test(13-06): add failing upload reconciliation tests (RED)
- test_upload_success_upserts_cloud_item_before_returning — verifies CloudItem row exists after success - test_upload_success_marks_folder_freshness_stale — verifies folder state updated on success - test_upload_failed_does_not_mutate_cloud_items — verifies failed uploads don't create phantom items
This commit is contained in:
@@ -1045,3 +1045,187 @@ async def test_upload_foreign_user_blocked(async_client, db_session):
|
|||||||
assert resp.status_code == 404, (
|
assert resp.status_code == 404, (
|
||||||
f"Expected 404 IDOR block for foreign upload, got {resp.status_code}"
|
f"Expected 404 IDOR block for foreign upload, got {resp.status_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 13 Plan 06 Task 1 (RED): Upload success routes through reconciliation ─
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_success_upserts_cloud_item_before_returning(async_client, db_session):
|
||||||
|
"""POST upload success upserts the uploaded item into cloud_items before returning.
|
||||||
|
|
||||||
|
Plan 06 Task 1 behavior 1: Successful upload must update navigation metadata
|
||||||
|
through centralized reconciliation (cloud_items.upsert_cloud_item) before the
|
||||||
|
route returns success — not as a fire-and-forget side effect.
|
||||||
|
|
||||||
|
The returned 'uploaded' body's provider_item_id must have a matching CloudItem
|
||||||
|
row in the DB by the time the response is received.
|
||||||
|
|
||||||
|
FAILS: Upload route does not call upsert_cloud_item on success yet.
|
||||||
|
"""
|
||||||
|
from storage.cloud_base import MUT_KIND_UPLOADED, MUT_REASON_CREATED
|
||||||
|
from db.models import CloudItem
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
auth = await _create_user_and_token(db_session)
|
||||||
|
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||||
|
|
||||||
|
new_provider_item_id = str(_uuid.uuid4())
|
||||||
|
|
||||||
|
import uuid as _uuid2
|
||||||
|
mock_adapter = _make_mock_mutable_adapter(
|
||||||
|
upload_result={
|
||||||
|
"kind": MUT_KIND_UPLOADED,
|
||||||
|
"reason": MUT_REASON_CREATED,
|
||||||
|
"provider_item_id": new_provider_item_id,
|
||||||
|
"name": "reconcile_test.pdf",
|
||||||
|
"parent_ref": "reconcile_folder",
|
||||||
|
"size": 2048,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
files = {"file": ("reconcile_test.pdf", b"%PDF-1.4 reconcile", "application/pdf")}
|
||||||
|
data = {"parent_ref": "reconcile_folder_unique", "filename": "reconcile_test.pdf"}
|
||||||
|
|
||||||
|
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||||
|
resp = await async_client.post(
|
||||||
|
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||||
|
headers=auth["headers"],
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, f"Expected 200 for upload success, got {resp.status_code}"
|
||||||
|
body = resp.json()
|
||||||
|
assert body.get("kind") == "uploaded"
|
||||||
|
|
||||||
|
# After successful upload, the provider_item_id must have a CloudItem row
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(CloudItem).where(
|
||||||
|
CloudItem.connection_id == conn.id,
|
||||||
|
CloudItem.provider_item_id == new_provider_item_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cloud_item = result.scalar_one_or_none()
|
||||||
|
assert cloud_item is not None, (
|
||||||
|
"Upload success must upsert a CloudItem row before returning (Plan 06 Task 1 behavior 1)"
|
||||||
|
)
|
||||||
|
assert cloud_item.name == "reconcile_test.pdf", (
|
||||||
|
f"Upserted CloudItem must have the correct name, got {cloud_item.name!r}"
|
||||||
|
)
|
||||||
|
assert cloud_item.kind == "file", (
|
||||||
|
f"Upserted CloudItem must have kind='file', got {cloud_item.kind!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_success_marks_folder_freshness_stale(async_client, db_session):
|
||||||
|
"""POST upload success marks the parent folder freshness as stale (not fresh).
|
||||||
|
|
||||||
|
Plan 06 Task 1 behavior 2: After a successful upload, the parent folder listing
|
||||||
|
has changed — the folder state must be invalidated (set to 'warning' or 'stale')
|
||||||
|
so the next browse triggers a provider re-list. Setting it 'fresh' with an old
|
||||||
|
listing would be a lie.
|
||||||
|
|
||||||
|
The implementation must NOT call apply_listing_and_finalize (which requires a full
|
||||||
|
provider listing). It must call update_folder_state with a non-'fresh' state that
|
||||||
|
signals the folder needs refreshing.
|
||||||
|
|
||||||
|
FAILS: Upload route does not update folder freshness state yet.
|
||||||
|
"""
|
||||||
|
from storage.cloud_base import MUT_KIND_UPLOADED, MUT_REASON_CREATED
|
||||||
|
from db.models import CloudFolderState
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
auth = await _create_user_and_token(db_session)
|
||||||
|
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||||
|
|
||||||
|
new_provider_item_id = str(_uuid.uuid4())
|
||||||
|
test_parent_ref = "freshness_test_folder"
|
||||||
|
|
||||||
|
mock_adapter = _make_mock_mutable_adapter(
|
||||||
|
upload_result={
|
||||||
|
"kind": MUT_KIND_UPLOADED,
|
||||||
|
"reason": MUT_REASON_CREATED,
|
||||||
|
"provider_item_id": new_provider_item_id,
|
||||||
|
"name": "freshness_test.pdf",
|
||||||
|
"parent_ref": test_parent_ref,
|
||||||
|
"size": 512,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
files = {"file": ("freshness_test.pdf", b"content", "application/pdf")}
|
||||||
|
data = {"parent_ref": test_parent_ref, "filename": "freshness_test.pdf"}
|
||||||
|
|
||||||
|
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||||
|
resp = await async_client.post(
|
||||||
|
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||||
|
headers=auth["headers"],
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("kind") == "uploaded"
|
||||||
|
|
||||||
|
# Parent folder state must be refreshed/invalidated — not left as stale from before
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(CloudFolderState).where(
|
||||||
|
CloudFolderState.connection_id == conn.id,
|
||||||
|
CloudFolderState.parent_ref == test_parent_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fs = result.scalar_one_or_none()
|
||||||
|
# After upload, a folder state row must exist to signal refresh needed
|
||||||
|
assert fs is not None, (
|
||||||
|
"Upload success must create/update a CloudFolderState row for the parent folder "
|
||||||
|
"(Plan 06 Task 1 behavior 2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_failed_does_not_mutate_cloud_items(async_client, db_session):
|
||||||
|
"""Failed, skipped, and canceled queue decisions do not create CloudItem rows.
|
||||||
|
|
||||||
|
Plan 06 Task 1 behavior 3: Only authoritative upload success may mutate listing
|
||||||
|
state. An 'offline' or 'reauth_required' upload result must not upsert a CloudItem
|
||||||
|
row — that would corrupt the navigation cache with phantom items.
|
||||||
|
|
||||||
|
FAILS: Current behavior is correct (no reconcile call at all), but this test
|
||||||
|
establishes the invariant explicitly for regression.
|
||||||
|
"""
|
||||||
|
from storage.cloud_base import MUT_KIND_OFFLINE, MUT_REASON_PROVIDER_OFFLINE
|
||||||
|
from db.models import CloudItem
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
auth = await _create_user_and_token(db_session)
|
||||||
|
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||||
|
|
||||||
|
before_count_result = await db_session.execute(
|
||||||
|
select(CloudItem).where(CloudItem.connection_id == conn.id)
|
||||||
|
)
|
||||||
|
before_count = len(before_count_result.scalars().all())
|
||||||
|
|
||||||
|
mock_adapter = _make_mock_mutable_adapter(
|
||||||
|
upload_result={"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
|
||||||
|
)
|
||||||
|
|
||||||
|
files = {"file": ("offline_test.pdf", b"data", "application/pdf")}
|
||||||
|
data = {"parent_ref": "some_folder", "filename": "offline_phantom.pdf"}
|
||||||
|
|
||||||
|
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||||
|
resp = await async_client.post(
|
||||||
|
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||||
|
headers=auth["headers"],
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code != 500
|
||||||
|
|
||||||
|
# No new CloudItem must have been created for the failed upload
|
||||||
|
after_result = await db_session.execute(
|
||||||
|
select(CloudItem).where(CloudItem.connection_id == conn.id)
|
||||||
|
)
|
||||||
|
after_count = len(after_result.scalars().all())
|
||||||
|
assert after_count == before_count, (
|
||||||
|
f"Failed upload must not create CloudItem rows (Plan 06 Task 1 behavior 3): "
|
||||||
|
f"count changed {before_count} → {after_count}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user