""" Phase 14.1 Plan 01 — RED contract tests for force re-analyze and single-item retry. Tests pin the contract for: 1. force=True on the analysis enqueue endpoint (D-11, ANALYZE-06) 2. Single-item retry job creation when no active job exists (D-12, ANALYZE-05) These tests will FAIL against the current codebase because: - AnalysisEnqueueRequest has no force field (Plan 02 adds it) - The single-item retry-with-no-job path may not exist yet Requirements: ANALYZE-05, ANALYZE-06, ANALYZE-07 Decisions: D-11, D-12, D-18, D-20 """ from __future__ import annotations import uuid as _uuid from unittest.mock import AsyncMock, MagicMock, patch import pytest pytestmark = pytest.mark.asyncio from tests.conftest import _TEST_USER_AGENT # ── Shared helpers ───────────────────────────────────────────────────────────── async def _create_user_and_token(session, role: str = "user"): from db.models import User, Quota from services.auth import hash_password, create_access_token user_id = _uuid.uuid4() user = User( id=user_id, handle=f"reana_user_{user_id.hex[:8]}", email=f"reana_{user_id.hex[:8]}@example.com", password_hash=hash_password("Testpassword123!"), role=role, is_active=True, password_must_change=False, ) quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) session.add(user) session.add(quota) await session.commit() await session.refresh(user) token = create_access_token(str(user_id), role, user_agent=_TEST_USER_AGENT) return { "user": user, "token": token, "headers": { "Authorization": f"Bearer {token}", "User-Agent": _TEST_USER_AGENT, }, } async def _create_cloud_connection(session, user_id, provider="google_drive", status="ACTIVE"): from db.models import CloudConnection from storage.cloud_utils import encrypt_credentials from config import settings master_key = settings.cloud_creds_key.encode() creds_enc = encrypt_credentials( master_key, str(user_id), {"access_token": "tok", "refresh_token": "ref"}, ) conn = CloudConnection( id=_uuid.uuid4(), user_id=user_id, provider=provider, display_name="Force-Analyze Test Connection", credentials_enc=creds_enc, status=status, ) session.add(conn) await session.commit() return conn async def _create_indexed_item(session, user_id, connection_id, etag="etag-indexed-v1"): """Create a CloudItem that is already indexed (already_current candidate).""" from db.models import CloudItem item = CloudItem( id=_uuid.uuid4(), user_id=user_id, connection_id=connection_id, provider_item_id=f"pitem-indexed-{_uuid.uuid4().hex[:8]}", name="already_current.pdf", kind="file", content_type="application/pdf", provider_size=102400, etag=etag, analysis_status="indexed", semantic_index_status="indexed", extracted_text="Previously extracted document text.", ) session.add(item) await session.commit() return item async def _create_failed_item(session, user_id, connection_id): """Create a CloudItem in 'failed' analysis status (retry candidate).""" from db.models import CloudItem item = CloudItem( id=_uuid.uuid4(), user_id=user_id, connection_id=connection_id, provider_item_id=f"pitem-failed-{_uuid.uuid4().hex[:8]}", name="failed_analysis.pdf", kind="file", content_type="application/pdf", provider_size=51200, etag="etag-fail-v1", analysis_status="failed", ) session.add(item) await session.commit() return item # ── D-11 / ANALYZE-06: Default enqueue skips already-current items ──────────── async def test_default_enqueue_skips_already_current_item(async_client, db_session): """ANALYZE-06 / D-11: Default enqueue marks unchanged indexed items as already_current. An item with matching version key (same etag/metadata) should yield: - already_current_count >= 1 - queued_count == 0 This confirms the baseline behavior that force=True overrides. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) item = await _create_indexed_item(db_session, auth["user"].id, conn.id) adapter = MagicMock() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post( f"/api/cloud/analysis/connections/{conn.id}/jobs", json={ "scope": "file", "provider_item_ids": [item.provider_item_id], # No force flag — default idempotent behavior }, headers=auth["headers"], ) assert resp.status_code in (200, 202), ( f"Enqueue must succeed — got {resp.status_code}: {resp.text}" ) body = resp.json() job_id = body.get("job_id") assert job_id is not None # Fetch job status to verify already_current classification status_resp = await async_client.get( f"/api/cloud/analysis/jobs/{job_id}", headers=auth["headers"], ) assert status_resp.status_code == 200 status_body = status_resp.json() assert status_body.get("already_current_count", 0) >= 1, ( "Default enqueue must classify unchanged indexed item as already_current" ) assert status_body.get("queued_count", 0) == 0, ( "Default enqueue must not queue an already-current item" ) # ── D-11 / ANALYZE-06: force=True enqueues an already-current item ──────────── async def test_force_enqueue_queues_already_current_item(async_client, db_session): """ANALYZE-06 / D-11: force=True bypasses already_current and enqueues the item. The same item that default enqueue skips as already_current must be queued when the request body includes force=true. This test WILL FAIL until Plan 02 adds force field to AnalysisEnqueueRequest and enqueue_analysis_job service. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) item = await _create_indexed_item(db_session, auth["user"].id, conn.id) adapter = MagicMock() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post( f"/api/cloud/analysis/connections/{conn.id}/jobs", json={ "scope": "file", "provider_item_ids": [item.provider_item_id], "force": True, # Plan 02 adds this field to AnalysisEnqueueRequest }, headers=auth["headers"], ) # Without Plan 02 this returns 422 (unknown field) or 200 (field ignored) # either way the assertions below enforce the contract assert resp.status_code in (200, 202), ( f"Force enqueue must succeed — got {resp.status_code}: {resp.text}. " "Plan 02 must add force field to AnalysisEnqueueRequest." ) body = resp.json() job_id = body.get("job_id") assert job_id is not None # Fetch job status status_resp = await async_client.get( f"/api/cloud/analysis/jobs/{job_id}", headers=auth["headers"], ) assert status_resp.status_code == 200 status_body = status_resp.json() # With force=True the item must be queued (not already_current) assert status_body.get("queued_count", 0) >= 1, ( "force=True must enqueue the already-current item for re-analysis (D-11)" ) assert status_body.get("already_current_count", 0) == 0, ( "force=True must not mark the item as already_current" ) # ── ANALYZE-06: force flag is present in the AnalysisEnqueueRequest schema ──── def test_force_field_exists_in_enqueue_request_schema(): """ANALYZE-06 / D-11: AnalysisEnqueueRequest must have a force field. Plan 02 adds force: bool = False to AnalysisEnqueueRequest. This test will FAIL until that schema change lands. """ from api.cloud.schemas import AnalysisEnqueueRequest # Check that the schema accepts a 'force' field fields = AnalysisEnqueueRequest.model_fields assert "force" in fields, ( "AnalysisEnqueueRequest must have a 'force' field (D-11 / Plan 02). " "This test fails until Plan 02 adds force: bool = False to the schema." ) # Default must be False (non-breaking) default = fields["force"].default assert default is False, ( f"force must default to False (non-breaking), got default={default!r}" ) # ── ANALYZE-07: force re-analyze routes through analysis path, no provider mutation async def test_force_reanalyze_does_not_mutate_provider(async_client, db_session): """ANALYZE-07 / D-18: Force re-analyze must not call any provider mutation method. Even with force=True, the re-analysis pathway must only read bytes (get_object) and write to the cache/database — it must not call upload, delete, rename, move, or create_folder on the provider adapter. This test will FAIL until Plan 02 adds force support to the enqueue endpoint. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) item = await _create_indexed_item(db_session, auth["user"].id, conn.id) mutation_tracker = MagicMock() # Adapter that tracks mutation calls — they must stay zero class NoMutationAdapter: get_object_calls = 0 mutation_calls = 0 async def get_object(self, *a, **kw): self.get_object_calls += 1 return b"document content bytes" async def upload(self, *a, **kw): self.mutation_calls += 1 raise AssertionError("upload must not be called during force re-analyze") async def delete(self, *a, **kw): self.mutation_calls += 1 raise AssertionError("delete must not be called during force re-analyze") async def rename(self, *a, **kw): self.mutation_calls += 1 raise AssertionError("rename must not be called during force re-analyze") async def move(self, *a, **kw): self.mutation_calls += 1 raise AssertionError("move must not be called during force re-analyze") async def create_folder(self, *a, **kw): self.mutation_calls += 1 raise AssertionError("create_folder must not be called during force re-analyze") adapter = NoMutationAdapter() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post( f"/api/cloud/analysis/connections/{conn.id}/jobs", json={ "scope": "file", "provider_item_ids": [item.provider_item_id], "force": True, }, headers=auth["headers"], ) assert resp.status_code in (200, 202), ( f"Force enqueue must succeed — got {resp.status_code}: {resp.text}" ) # The enqueue path must not have called any provider mutation assert adapter.mutation_calls == 0, ( f"ANALYZE-07: force enqueue called {adapter.mutation_calls} provider mutation(s)" ) # ── D-12 / ANALYZE-05: Retry with no active job creates a single-item retry job async def test_retry_failed_item_with_no_active_job_creates_single_item_job( async_client, db_session ): """D-12 / ANALYZE-05: Retry a failed item when no active job exists. If a cloud item has analysis_status='failed' and no active/queued job covers it, retrying via the detail or analysis retry endpoint must create a single-item job (or return a typed result that yields exactly one queued item). This test will FAIL until Plan 02 adds the owner-scoped single-item retry endpoint or extends the existing retry to create a new job when none exists. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) item = await _create_failed_item(db_session, auth["user"].id, conn.id) # No active job exists for this item — retry must create one # Target path: POST /api/cloud/analysis/connections/{conn_id}/items/{item_id}/retry # (Plan 02 adds this route or extends the existing retry semantics) retry_url = f"/api/cloud/analysis/connections/{conn.id}/items/{item.id}/retry" adapter = MagicMock() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post(retry_url, headers=auth["headers"]) assert resp.status_code in (200, 202), ( f"Single-item retry with no active job must succeed — " f"got {resp.status_code}: {resp.text}. " "Plan 02 must implement this route or extend retry semantics." ) body = resp.json() # The response must indicate exactly one item was queued # Either via a new job (job_id in response) or a typed retry result has_job_id = "job_id" in body has_queued = body.get("queued_count", 0) >= 1 assert has_job_id or has_queued, ( f"Single-item retry must return a job_id or queued_count >= 1, " f"got: {body}" ) if has_job_id: # If a job was created, verify it has exactly one queued item status_resp = await async_client.get( f"/api/cloud/analysis/jobs/{body['job_id']}", headers=auth["headers"], ) assert status_resp.status_code == 200 status_body = status_resp.json() assert status_body.get("queued_count", 0) >= 1, ( "Single-item retry job must have at least one queued item" ) # ── T-14.1-02: Force enqueue is owner-scoped ────────────────────────────────── async def test_force_enqueue_foreign_user_blocked(async_client, db_session): """T-14.1-02: Force re-analyze must be owner-scoped — foreign user gets 403/404.""" auth_owner = await _create_user_and_token(db_session) auth_foreign = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth_owner["user"].id) item = await _create_indexed_item(db_session, auth_owner["user"].id, conn.id) adapter = MagicMock() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post( f"/api/cloud/analysis/connections/{conn.id}/jobs", json={ "scope": "file", "provider_item_ids": [item.provider_item_id], "force": True, }, headers=auth_foreign["headers"], ) assert resp.status_code in (403, 404), ( f"T-14.1-02: Force enqueue by foreign user must be blocked — " f"got {resp.status_code}" ) async def test_force_enqueue_admin_blocked(async_client, db_session): """T-14.1-02: Admin accounts cannot force enqueue analysis (get_regular_user blocks).""" auth_user = await _create_user_and_token(db_session, role="user") auth_admin = await _create_user_and_token(db_session, role="admin") conn = await _create_cloud_connection(db_session, auth_user["user"].id) item = await _create_indexed_item(db_session, auth_user["user"].id, conn.id) adapter = MagicMock() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post( f"/api/cloud/analysis/connections/{conn.id}/jobs", json={ "scope": "file", "provider_item_ids": [item.provider_item_id], "force": True, }, headers=auth_admin["headers"], ) assert resp.status_code in (403, 404), ( f"T-14.1-02: Admin force enqueue must be blocked by get_regular_user — " f"got {resp.status_code}" ) # ── ANALYZE-06: force=false behaves identically to omitted force ─────────────── async def test_force_false_is_equivalent_to_default_enqueue(async_client, db_session): """ANALYZE-06: Explicit force=false is identical to default (non-forced) enqueue. force=False must preserve normal idempotent already-current behavior. """ auth = await _create_user_and_token(db_session) conn = await _create_cloud_connection(db_session, auth["user"].id) item = await _create_indexed_item(db_session, auth["user"].id, conn.id) adapter = MagicMock() with patch("services.cloud_analysis.get_adapter", return_value=adapter): resp = await async_client.post( f"/api/cloud/analysis/connections/{conn.id}/jobs", json={ "scope": "file", "provider_item_ids": [item.provider_item_id], "force": False, # Explicit false — same as default }, headers=auth["headers"], ) # Even if force field doesn't exist yet (422), the already-current behavior is tested # by test_default_enqueue_skips_already_current_item above. # This test verifies force=False is accepted (not rejected as unknown field) after Plan 02. assert resp.status_code in (200, 202, 422), ( f"force=False enqueue returned unexpected status: {resp.status_code}: {resp.text}" ) if resp.status_code in (200, 202): body = resp.json() job_id = body.get("job_id") if job_id: status_resp = await async_client.get( f"/api/cloud/analysis/jobs/{job_id}", headers=auth["headers"], ) if status_resp.status_code == 200: status_body = status_resp.json() # With force=False, already_current item should NOT be queued assert status_body.get("queued_count", 0) == 0, ( "force=False must not queue an already-current item" )