Files
kite/backend/tests/test_cloud_reanalyze_force.py
T
curo1305 a76854e003 test(14.1-01): RED contract tests for cloud detail parity + force re-analyze
- backend/tests/test_cloud_detail_parity.py: RED tests asserting GET
  /api/cloud/connections/{id}/items/{item_id}/detail returns extracted_text,
  analysis_status, semantic_index_status, topics, provider metadata, and
  excludes credentials_enc/object_key; owner gets 200, foreign user gets 404,
  admin gets 403 via get_regular_user; stale items retain prior data; detail
  endpoint must not call hydrate_and_cache_bytes (D-05, D-07, D-18, T-14.1-01/02)
- backend/tests/test_cloud_reanalyze_force.py: RED tests asserting force=True
  on AnalysisEnqueueRequest bypasses already_current for explicit re-analyze;
  default enqueue still skips unchanged indexed items; single-item retry with
  no active job creates a new one; force flag is owner-scoped and admin-blocked
  (D-11, D-12, ANALYZE-05, ANALYZE-06, ANALYZE-07, T-14.1-02)
- frontend/src/views/__tests__/CloudDetailParity.test.js: RED tests asserting
  cloud-file-detail named route exists in router; paired local+cloud route
  parity; DocumentView does not contain Re-classify (D-09 regression); route
  prerequisite for navigation (D-01, D-19)
- frontend/src/components/storage/__tests__/StorageBrowser.parity.test.js: RED
  tests asserting cloud rows render TopicBadge and analysis-status indicator in
  same slot as local rows; Analyze/Re-analyze/Retry action slot by state; no
  Re-classify copy in cloud rows (D-06, D-08, D-09, D-10)

All four files collect cleanly. 11 backend failures and 7 frontend failures are
tied exclusively to the missing detail endpoint, force flag, route, and Re-analyze
copy — not to fixture or infrastructure errors.
2026-06-26 21:47:44 +02:00

477 lines
18 KiB
Python

"""
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"
)