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.
This commit is contained in:
curo1305
2026-06-26 21:47:44 +02:00
parent 37a7174623
commit a76854e003
4 changed files with 1724 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
"""
Phase 14.1 Plan 01 — RED contract tests for cloud detail endpoint parity.
Tests pin the contract for the cloud detail endpoint before implementation exists.
All tests will fail (404 or missing field assertions) until Plan 02 adds:
- GET /api/cloud/connections/{connection_id}/items/{item_id:path}/detail
- CloudItemDetailOut schema with extracted_text, analysis_status,
semantic_index_status, topics, provider, display_name, parent_ref/location,
modified_at, size, content_type, capabilities, unsupported_analysis_reason
Requirements: CLOUD-02, ANALYZE-01, CACHE-03, CACHE-05
Threats: T-14.1-01 (information disclosure), T-14.1-02 (privilege escalation)
Decisions: D-05, D-07, 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
# ── Target endpoint path (Plan 02 implements this) ────────────────────────────
_DETAIL_PATH_TEMPLATE = "/api/cloud/connections/{connection_id}/items/{item_id}/detail"
# ── Shared helpers — reused from test_cloud_security.py pattern ──────────────
async def _create_user_and_token(session, role: str = "user"):
"""Create User + Quota + JWT. Mirrors the pattern from test_cloud_security.py."""
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"det_user_{user_id.hex[:8]}",
email=f"det_{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: str = "google_drive",
name: str = "Test Drive",
status: str = "ACTIVE",
):
"""Create a CloudConnection owned by user_id."""
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=name,
credentials_enc=creds_enc,
status=status,
)
session.add(conn)
await session.commit()
return conn
async def _create_cloud_item(
session,
user_id,
connection_id,
*,
name: str = "report.pdf",
kind: str = "file",
analysis_status: str = "pending",
extracted_text: str | None = None,
etag: str = "etag-v1",
):
"""Create a CloudItem with optional analysis fields."""
from db.models import CloudItem
item = CloudItem(
id=_uuid.uuid4(),
user_id=user_id,
connection_id=connection_id,
provider_item_id=f"pitem-det-{_uuid.uuid4().hex[:8]}",
name=name,
kind=kind,
content_type="application/pdf",
provider_size=102400,
etag=etag,
analysis_status=analysis_status,
semantic_index_status="none" if analysis_status == "pending" else "indexed",
extracted_text=extracted_text,
)
session.add(item)
await session.commit()
return item
async def _create_analyzed_item_with_topics(
session,
user_id,
connection_id,
topic_names: list[str] | None = None,
):
"""Create a CloudItem in 'indexed' state with CloudItemTopic links."""
from db.models import CloudItem, CloudItemTopic, Topic
from sqlalchemy import select
item = CloudItem(
id=_uuid.uuid4(),
user_id=user_id,
connection_id=connection_id,
provider_item_id=f"pitem-analyzed-{_uuid.uuid4().hex[:8]}",
name="analyzed.pdf",
kind="file",
content_type="application/pdf",
provider_size=204800,
etag="etag-analyzed-v1",
analysis_status="indexed",
semantic_index_status="indexed",
extracted_text="Extracted text from the analyzed document.",
)
session.add(item)
await session.flush()
topic_names = topic_names or ["finance", "contracts"]
for tname in topic_names:
# Find or create the topic
result = await session.execute(
select(Topic).where(Topic.name == tname)
)
topic = result.scalars().first()
if topic is None:
topic = Topic(id=_uuid.uuid4(), name=tname)
session.add(topic)
await session.flush()
link = CloudItemTopic(
cloud_item_id=item.id,
topic_id=topic.id,
)
session.add(link)
await session.commit()
return item
# ── T-14.1-01: Owner GET returns analysis fields ──────────────────────────────
async def test_owner_detail_returns_extracted_text_and_topics(async_client, db_session):
"""CLOUD-02 / D-05: Owner GET of the cloud detail endpoint returns analysis fields.
After analysis completes, the detail response must include:
extracted_text, analysis_status, semantic_index_status, topics (list of names),
provider, display_name, parent_ref, modified_at, size, content_type,
capabilities, and unsupported_analysis_reason.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_analyzed_item_with_topics(
db_session, auth["user"].id, conn.id, topic_names=["finance", "contracts"]
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
# Plan 02 implements this endpoint — currently returns 404 or 405
assert resp.status_code == 200, (
f"Expected 200 from cloud detail endpoint — "
f"got {resp.status_code}: {resp.text}. "
"Plan 02 must add GET /connections/{id}/items/{item_id}/detail."
)
body = resp.json()
# Analysis fields that must be present (D-05)
assert "extracted_text" in body, "Cloud detail must include extracted_text"
assert body["extracted_text"], "extracted_text must be non-empty for indexed item"
assert "analysis_status" in body, "Cloud detail must include analysis_status"
assert body["analysis_status"] == "indexed"
assert "semantic_index_status" in body, "Cloud detail must include semantic_index_status"
# Topics as a list of names
assert "topics" in body, "Cloud detail must include topics list"
topic_names = body["topics"]
assert isinstance(topic_names, list), "topics must be a list"
assert len(topic_names) >= 1, "topics must contain at least one name"
assert "finance" in topic_names or "contracts" in topic_names, (
f"Expected topic names in {topic_names}"
)
# Source metadata fields (D-04)
assert "provider" in body, "Cloud detail must include provider"
assert "display_name" in body, "Cloud detail must include display_name"
assert "size" in body, "Cloud detail must include size"
assert "content_type" in body, "Cloud detail must include content_type"
async def test_owner_detail_returns_capabilities_and_unsupported_reason(async_client, db_session):
"""D-05 / D-16: Cloud detail includes capabilities and unsupported_analysis_reason.
The detail endpoint must expose capability information alongside content so
frontend action slots know whether Analyze/Re-analyze is available.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_cloud_item(
db_session, auth["user"].id, conn.id, analysis_status="pending"
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 from cloud detail endpoint, got {resp.status_code}: {resp.text}"
)
body = resp.json()
# Capabilities must be present (D-05)
assert "capabilities" in body, (
"Cloud detail must expose capabilities dict so frontend can render action slots"
)
# unsupported_analysis_reason is optional but must be present as a field (D-16)
# It may be None for supported items
assert "unsupported_analysis_reason" in body, (
"Cloud detail must include unsupported_analysis_reason (D-16)"
)
# ── T-14.1-01: Response excludes forbidden credential/key fields ──────────────
async def test_detail_response_excludes_credentials_and_keys(async_client, db_session):
"""T-14.1-01 / D-18: Cloud detail response must not expose forbidden fields.
The full serialized response body must NOT contain:
- credentials_enc (cloud credential ciphertext)
- object_key (MinIO object path — internal cache detail)
- version_key (internal cache version discriminator)
- password (user credential field)
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_analyzed_item_with_topics(
db_session, auth["user"].id, conn.id
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 from cloud detail endpoint, got {resp.status_code}: {resp.text}"
)
# Serialize the full body to string for token presence checks (D-18, T-14.1-01)
body_text = resp.text
# Forbidden tokens — CLAUDE.md allowlist-schema rule
forbidden_tokens = [
"credentials_enc", # cloud credential ciphertext
"object_key", # MinIO internal object path
"version_key", # internal cache version discriminator
]
for token in forbidden_tokens:
assert token not in body_text, (
f"T-14.1-01: Forbidden field '{token}' must not appear in cloud detail response"
)
# Provider URLs must not appear in the body (raw https:// pointing at the provider host)
# A provider URL for google_drive would typically be https://drive.google.com or
# https://www.googleapis.com — the response must use DocuVault-scoped relative URLs only
# (or omit provider URLs entirely).
# We assert no raw http(s) URL pointing to an external provider appears in the body.
# The test checks for the absence of common provider URL substrings that would
# indicate credential-bearing URLs leaking through.
assert "googleapis.com" not in body_text, (
"T-14.1-01: Raw Google API URL must not appear in cloud detail response"
)
assert "graph.microsoft.com" not in body_text, (
"T-14.1-01: Raw Microsoft Graph URL must not appear in cloud detail response"
)
# ── T-14.1-02: IDOR — foreign user gets 404 ──────────────────────────────────
async def test_foreign_user_detail_returns_404(async_client, db_session):
"""T-14.1-02 / D-18: Foreign user GET of another user's cloud item returns 404.
The ownership check (connection_id scoped to current_user.id) must fire before
any content is served. 404 protects against IDOR.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
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_cloud_item(db_session, auth_owner["user"].id, conn.id)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth_foreign["headers"])
assert resp.status_code == 404, (
f"T-14.1-02: Foreign user must get 404 (IDOR protection) — "
f"got {resp.status_code}: {resp.text}"
)
# ── T-14.1-02: Admin is blocked from cloud detail endpoint ───────────────────
async def test_admin_detail_returns_403(async_client, db_session):
"""T-14.1-02 / D-18: Admin token is blocked from cloud detail by get_regular_user.
The cloud detail endpoint must use get_regular_user, which rejects admin accounts.
Admin accounts must not access user document content.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
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_cloud_item(db_session, auth_user["user"].id, conn.id)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth_admin["headers"])
assert resp.status_code == 403, (
f"T-14.1-02: Admin must be blocked from cloud detail — "
f"expected 403 from get_regular_user, got {resp.status_code}: {resp.text}"
)
# ── D-07: Stale item returns prior extracted_text and topics ─────────────────
async def test_stale_item_returns_prior_analysis_data(async_client, db_session):
"""D-07: Stale cloud analysis keeps prior extracted text and topics visible.
When analysis_status is 'stale', the detail response must still include
the previously extracted_text and topics — it must not clear them.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
# Create an item that was indexed but has become stale
item = await _create_analyzed_item_with_topics(
db_session, auth["user"].id, conn.id, topic_names=["legal"]
)
# Update analysis_status to stale (provider changed the file)
from db.models import CloudItem
from sqlalchemy import select as sa_select
result = await db_session.execute(
sa_select(CloudItem).where(CloudItem.id == item.id)
)
item_row = result.scalars().first()
item_row.analysis_status = "stale"
await db_session.commit()
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 for stale item, got {resp.status_code}: {resp.text}"
)
body = resp.json()
# D-07: stale item must still return prior extracted_text and topics
assert "extracted_text" in body, "Stale item must still include extracted_text"
assert body["extracted_text"], "Stale item must retain prior extracted_text"
assert "topics" in body, "Stale item must still include topics"
assert len(body["topics"]) >= 1, "Stale item must retain prior topic links"
# analysis_status must reflect stale state
assert body.get("analysis_status") == "stale", (
f"Stale item must report analysis_status='stale', got {body.get('analysis_status')}"
)
# ── D-05 / D-07: Pending item returns empty analysis fields ──────────────────
async def test_pending_item_returns_empty_analysis_fields(async_client, db_session):
"""D-02 / D-05: Cloud detail exists before analysis and shows empty-state fields.
A cloud file that has not been analyzed yet must return 200 with:
analysis_status = 'pending', extracted_text = None/empty, topics = [].
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_cloud_item(
db_session, auth["user"].id, conn.id, analysis_status="pending"
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 for pending item (detail exists before analysis), "
f"got {resp.status_code}: {resp.text}"
)
body = resp.json()
# analysis_status should be pending or none
assert body.get("analysis_status") in ("pending", None, ""), (
f"Expected pending analysis_status, got {body.get('analysis_status')}"
)
# topics must be an empty list (not None — frontend expects a list)
topics = body.get("topics", [])
assert isinstance(topics, list), "topics must be a list even for unanalyzed items"
assert topics == [], f"Unanalyzed item must have empty topics list, got {topics}"
# ── D-18: Detail does not hydrate bytes (no get_object call during detail) ────
async def test_detail_does_not_download_bytes(async_client, db_session):
"""D-18: Cloud detail endpoint must not call adapter.get_object or MinIO.
The detail endpoint returns only metadata from cloud_items — it must not
trigger byte download through the cache or provider adapter.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_analyzed_item_with_topics(db_session, auth["user"].id, conn.id)
get_object_tracker = MagicMock(
side_effect=AssertionError("get_object must not be called from cloud detail")
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
with patch("services.cloud_cache.hydrate_and_cache_bytes", side_effect=AssertionError(
"hydrate_and_cache_bytes must not be called from detail endpoint"
)):
resp = await async_client.get(url, headers=auth["headers"])
# If the endpoint exists, it must not have triggered byte hydration
assert resp.status_code == 200, (
f"Expected 200 from cloud detail (metadata-only), got {resp.status_code}: {resp.text}"
)
+476
View File
@@ -0,0 +1,476 @@
"""
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"
)
@@ -0,0 +1,424 @@
/**
* Phase 14.1 Plan 01 — RED frontend tests for paired local/cloud row parity.
*
* Tests pin the contract for StorageBrowser parity between local and cloud modes:
* 1. Both local and cloud file rows render topic badges in the name cell (D-06)
* 2. Both render an analysis-status indicator in the same relative slot (D-06)
* 3. Analysis action slot holds Analyze/Re-analyze/Retry by state (D-08, D-10)
* 4. Re-analyze copy appears in cloud rows (not Re-classify) — D-09
* 5. Cloud file rows show analysis status using translateAnalysisStatus from store
*
* These tests FAIL because:
* - StorageBrowser may not yet render topics/status for cloud items in the same
* position as local items
* - Re-analyze copy may not be wired in cloud rows
*
* Patterns: Vitest + @vue/test-utils, same stubs as StorageBrowser.capabilities.test.js.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import StorageBrowser from '../StorageBrowser.vue'
// ── Common stubs ──────────────────────────────────────────────────────────────
const globalStubs = {
BreadcrumbBar: true,
SearchBar: true,
SortControls: true,
DropZone: true,
UploadProgress: true,
AppIcon: { template: '<span class="app-icon-stub" />' },
EmptyState: true,
// TopicBadge is NOT stubbed so we can test topic rendering
TopicBadge: {
template: '<span class="topic-badge-stub" :data-topic-name="name">{{ name }}</span>',
props: ['name', 'color'],
},
}
beforeEach(() => {
setActivePinia(createPinia())
})
// ── Fixture data ──────────────────────────────────────────────────────────────
/** Local file with topics and analysis_status for parity test. */
const LOCAL_FILE_WITH_TOPICS = {
id: 'local-d1',
original_name: 'report.pdf',
size_bytes: 102400,
created_at: '2026-06-01T00:00:00Z',
analysis_status: 'indexed',
topics: [
{ id: 't1', name: 'finance', color: '#2563EB' },
{ id: 't2', name: 'contracts', color: '#7C3AED' },
],
}
/** Cloud file with topics and analysis_status matching local file shape. */
const CLOUD_FILE_WITH_TOPICS = {
id: 'cloud-item-uuid-1',
provider_item_id: 'pitem-cloud-abc123',
name: 'report.pdf',
kind: 'file',
size: 102400,
content_type: 'application/pdf',
modified_at: '2026-06-01T00:00:00Z',
analysis_status: 'indexed',
topics: ['finance', 'contracts'], // Cloud items use string list per CloudItemDetailOut
capabilities: {},
}
/** Cloud file in pending state — shows Analyze action. */
const CLOUD_FILE_PENDING = {
id: 'cloud-item-uuid-2',
provider_item_id: 'pitem-cloud-pending',
name: 'unanalyzed.pdf',
kind: 'file',
size: 51200,
content_type: 'application/pdf',
modified_at: '2026-06-01T00:00:00Z',
analysis_status: 'pending',
topics: [],
capabilities: {},
}
/** Cloud file in failed state — shows Retry action. */
const CLOUD_FILE_FAILED = {
id: 'cloud-item-uuid-3',
provider_item_id: 'pitem-cloud-failed',
name: 'failed.pdf',
kind: 'file',
size: 20480,
content_type: 'application/pdf',
modified_at: '2026-06-01T00:00:00Z',
analysis_status: 'failed',
topics: [],
capabilities: {},
}
// Full cloud capabilities (no restrictions)
const CAPS_FULL = {
share: 'supported',
move: 'supported',
delete: 'supported',
rename_folder: 'supported',
delete_folder: 'supported',
create_folder: 'supported',
drag_move: 'supported',
}
// ── Task 2.6: Topic badges in name cell — paired local/cloud assertion ─────────
describe('StorageBrowser topic badge parity — local vs cloud (D-06)', () => {
it('local file row renders topic badges in the name cell', () => {
/**
* Baseline: local file rows already render topic badges.
* This test documents the local behavior that cloud rows must match.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'local',
folders: [],
files: [LOCAL_FILE_WITH_TOPICS],
rootFolders: [],
},
global: { stubs: globalStubs },
})
// Topic badges must be rendered in the file name cell
const topicBadges = w.findAll('.topic-badge-stub')
expect(topicBadges.length).toBeGreaterThanOrEqual(1), (
'Local file rows must render TopicBadge components'
)
// Verify at least one badge contains 'finance' in any form
// StorageBrowser passes topic objects to TopicBadge in local mode;
// checking the serialized text or attributes for 'finance'
const badgeHtml = topicBadges.map((b) => b.html() + b.text()).join(' ')
expect(badgeHtml).toContain('finance'), (
'Local file row must render the "finance" topic badge'
)
})
it('cloud file row renders topic badges in the name cell (D-06) — fails until Plan 04', () => {
/**
* D-06: StorageBrowser cloud rows must render topic badges in the same
* relative position as local rows when topics are present.
*
* This test WILL FAIL until Plan 04 extends StorageBrowser to render
* topics for cloud items (which return topics as string arrays from the API).
*/
const w = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_WITH_TOPICS],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
// Cloud file rows must also render TopicBadge components
const topicBadges = w.findAll('.topic-badge-stub')
expect(topicBadges.length).toBeGreaterThanOrEqual(1), (
'D-06: Cloud file rows must render TopicBadge components when topics are present. ' +
'Plan 04 must extend StorageBrowser cloud row rendering to include topics.'
)
const topicTexts = topicBadges.map((b) => b.text())
expect(topicTexts).toContain('finance'), (
'D-06: Cloud file row must render the "finance" topic badge'
)
})
it('paired: both local and cloud rows render topic badges in name cell (D-06)', () => {
/**
* D-06: Paired assertion — both local and cloud rows show topics in same slot.
*
* Mount StorageBrowser twice: local mode and cloud mode.
* Both must render TopicBadge children for files with topics.
*
* The cloud assertion FAILS until Plan 04 adds topic rendering to cloud rows.
*/
const wLocal = mount(StorageBrowser, {
props: {
mode: 'local',
folders: [],
files: [LOCAL_FILE_WITH_TOPICS],
rootFolders: [],
},
global: { stubs: globalStubs },
})
const wCloud = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_WITH_TOPICS],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
const localBadges = wLocal.findAll('.topic-badge-stub')
const cloudBadges = wCloud.findAll('.topic-badge-stub')
expect(localBadges.length).toBeGreaterThanOrEqual(1), 'Local: at least one topic badge'
// The local HTML must contain 'finance' somewhere in badges
const localHtml = localBadges.map((b) => b.html() + b.text()).join(' ')
expect(localHtml).toContain('finance'), 'Local: topic badge must reference finance'
expect(cloudBadges.length).toBeGreaterThanOrEqual(1), (
'D-06: Cloud: at least one topic badge — FAILS until Plan 04 adds cloud topic rendering'
)
})
})
// ── Task 2.7: Analysis status indicator in name cell — paired parity ──────────
describe('StorageBrowser analysis-status indicator parity (D-06)', () => {
it('local file row renders an analysis status indicator', () => {
/**
* Baseline: local file rows show analysis_status in the name cell.
* StorageBrowser must render an element that communicates analysis status.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'local',
folders: [],
files: [LOCAL_FILE_WITH_TOPICS],
rootFolders: [],
},
global: { stubs: globalStubs },
})
const html = w.html()
// The status 'indexed' or equivalent indicator must appear in rendered output
expect(
html.includes('indexed') ||
html.includes('analysis') ||
html.includes('Analysis') ||
html.includes('status')
).toBe(true), 'Local file row must show an analysis status indicator'
})
it('cloud file row renders an analysis-status indicator (D-06) — may fail until Plan 04', () => {
/**
* D-06: Cloud file rows must show an analysis status indicator in the same
* relative slot as local rows.
*
* This test may FAIL until Plan 04 adds cloud analysis status indicators
* to StorageBrowser cloud row rendering.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_WITH_TOPICS],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
const html = w.html()
expect(
html.includes('indexed') ||
html.includes('analysis') ||
html.includes('Analysis') ||
html.includes('current')
).toBe(true), (
'D-06: Cloud file row must show analysis status indicator. ' +
'FAILS until Plan 04 adds cloud status rendering to StorageBrowser.'
)
})
})
// ── Task 2.8: Analyze/Re-analyze/Retry action slot by state ─────────────────
describe('StorageBrowser analysis action slot — Analyze/Re-analyze/Retry by state (D-08, D-10)', () => {
it('cloud pending file row shows Analyze action in name cell (D-02)', () => {
/**
* D-02: A cloud file that has not been analyzed must show an Analyze action.
* StorageBrowser must render an analyze-file trigger in the name cell or actions.
*
* This test may FAIL until Plan 04 wires the analyze-file action for cloud rows.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_PENDING],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
const html = w.html()
// StorageBrowser must render an Analyze trigger for pending cloud items
expect(
html.includes('Analyze') || html.includes('analyze')
).toBe(true), (
'D-02: Cloud pending file must show Analyze action in StorageBrowser. ' +
'FAILS until Plan 04 adds analyze-file action for cloud rows.'
)
})
it('cloud indexed file row shows Re-analyze action (not Re-classify) — D-09', () => {
/**
* D-09: After analysis, cloud rows must show Re-analyze (not Re-classify).
*
* This test WILL FAIL until Plan 04:
* 1. Adds Re-analyze action to cloud file rows in StorageBrowser
* 2. Uses "Re-analyze" copy (not "Re-classify")
*/
const w = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_WITH_TOPICS],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
const html = w.html()
// D-09: Re-analyze must appear
expect(html).toContain('Re-analyze'), (
'D-09: Cloud indexed file must show "Re-analyze" in StorageBrowser row. ' +
'FAILS until Plan 04 adds Re-analyze copy to cloud rows.'
)
// D-09: Re-classify must NOT appear
expect(html).not.toContain('Re-classify'), (
'D-09: "Re-classify" must not appear in StorageBrowser cloud rows.'
)
})
it('cloud failed file row shows Retry action (D-08, D-12)', () => {
/**
* D-08: Failed analysis shows a Retry action.
* D-12: Retry from row uses retry_job_item or creates a single-item job.
*
* This test WILL FAIL until Plan 04 adds Retry action to failed cloud rows.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_FAILED],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
const html = w.html()
expect(
html.includes('Retry') || html.includes('retry')
).toBe(true), (
'D-08: Cloud failed file must show a Retry action in StorageBrowser row. ' +
'FAILS until Plan 04 wires retry action for failed cloud rows.'
)
})
})
// ── Task 2.9: Re-analyze copy in local rows too (D-09 regression) ────────────
describe('Re-classify → Re-analyze copy regression (D-09)', () => {
it('StorageBrowser local indexed file rows do not contain Re-classify text', () => {
/**
* D-09: "Re-classify" must be replaced with "Re-analyze" everywhere.
* This test verifies local rows no longer use the old "Re-classify" copy.
*
* This test may already pass if local rows never showed "Re-classify".
* If it fails, it documents the regression that Plan 04 must fix.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'local',
folders: [],
files: [LOCAL_FILE_WITH_TOPICS],
rootFolders: [],
},
global: { stubs: globalStubs },
})
expect(w.html()).not.toContain('Re-classify'), (
'D-09: "Re-classify" must not appear in local file rows. ' +
'Plan 04 must rename all visible "Re-classify" copy to "Re-analyze".'
)
})
it('StorageBrowser cloud rows do not contain Re-classify text (D-09)', () => {
/**
* D-09: Cloud rows must use "Re-analyze" copy.
*/
const w = mount(StorageBrowser, {
props: {
mode: 'cloud',
folders: [],
files: [CLOUD_FILE_WITH_TOPICS],
rootFolders: [],
capabilities: CAPS_FULL,
},
global: { stubs: globalStubs },
})
expect(w.html()).not.toContain('Re-classify'), (
'D-09: "Re-classify" must not appear in cloud StorageBrowser rows.'
)
})
})
@@ -0,0 +1,319 @@
/**
* Phase 14.1 Plan 01 — RED frontend tests for cloud detail route + parity.
*
* Tests pin the contract for:
* 1. cloud-file-detail named route existence under /cloud/:connectionId/item/:itemId(.*)
* 2. Router-level parity between local and cloud detail routes
* 3. Cloud file row click navigates to cloud-file-detail (not auto-download)
* 4. Re-analyze copy (not Re-classify) in cloud detail (D-09)
* 5. Section order parity contract (Header → Status → Topics → Extracted Text)
*
* These tests FAIL until Plan 03 adds:
* - frontend/src/views/CloudDetailView.vue
* - frontend/src/components/storage/DocumentDetailSurface.vue
* - Named route 'cloud-file-detail' in router/index.js
*
* Note: Dynamic imports of non-existent files fail at Vite analysis time.
* Tests use route introspection and rendered behavior of EXISTING components
* to assert parity contracts. Import of Plan 03 components is deferred to
* the implementation tests (Plans 03/04) so this file collects cleanly.
*
* Patterns: Vitest + @vue/test-utils, same mock approach as CloudFolderView.test.js.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
// ── Mocks ─────────────────────────────────────────────────────────────────────
// Cloud API barrel mock — prevents real HTTP calls (Phase 13 P07 pattern)
vi.mock('../../api/cloud.js', () => ({
getCloudItemDetail: vi.fn().mockResolvedValue({
id: 'cloud-item-uuid-1',
provider_item_id: 'pitem-abc123',
display_name: 'report.pdf',
name: 'report.pdf',
analysis_status: 'indexed',
semantic_index_status: 'indexed',
extracted_text: 'Extracted text from the cloud document.',
topics: ['finance', 'contracts'],
provider: 'google_drive',
size: 102400,
content_type: 'application/pdf',
modified_at: '2026-06-20T10:00:00Z',
capabilities: {},
unsupported_analysis_reason: null,
}),
openCloudFile: vi.fn().mockResolvedValue({ kind: 'open', url: '/api/cloud/preview/tok' }),
downloadCloudFile: vi.fn().mockResolvedValue({ kind: 'download', url: '/api/cloud/download/tok' }),
enqueueCloudAnalysis: vi.fn().mockResolvedValue({ job_id: 'job-uuid-1', queued_count: 1 }),
}))
// Local document API mock
vi.mock('../../api/client.js', () => ({
getDocument: vi.fn().mockResolvedValue({
id: 'local-doc-uuid-1',
original_name: 'local_report.pdf',
extracted_text: 'Extracted text from local document.',
topics: [{ id: 't1', name: 'finance', color: '#333' }],
analysis_status: 'indexed',
size_bytes: 51200,
content_type: 'application/pdf',
created_at: '2026-06-19T10:00:00Z',
}),
listDocuments: vi.fn().mockResolvedValue({ items: [] }),
listFolders: vi.fn().mockResolvedValue([]),
}))
// cloudConnections store mock
vi.mock('../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [
{ id: 'conn-uuid-1', provider: 'google_drive', display_name: 'My Drive' },
],
loading: false,
translateAnalysisStatus: (s) => s,
fetchConnections: vi.fn().mockResolvedValue(undefined),
selectConnection: vi.fn(),
}),
saveLastFolder: vi.fn(),
loadLastFolder: vi.fn(() => null),
}))
// Auth store mock
vi.mock('../../stores/auth.js', () => ({
useAuthStore: () => ({
user: { id: 'user-uuid-1', email: 'test@example.com', role: 'user' },
accessToken: 'fake-access-token',
refresh: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(null),
}),
}))
// Toast mock
vi.mock('../../stores/toast.js', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
beforeEach(() => {
setActivePinia(createPinia())
})
// ── Task 2.1: Named route cloud-file-detail must exist in the real router ──────
describe('cloud-file-detail route', () => {
it('router includes a named route cloud-file-detail (fails until Plan 03 adds it)', async () => {
/**
* UI-SPEC Route Contract: A named route 'cloud-file-detail' must exist.
* This test imports the REAL router from the project and checks route names.
* It will FAIL until Plan 03 adds the cloud-file-detail route to router/index.js.
*/
const routerModule = await import('../../router/index.js')
const router = routerModule.default
const routes = router.getRoutes()
const routeNames = routes.map((r) => r.name).filter(Boolean)
expect(routeNames).toContain('cloud-file-detail')
})
it('cloud-file-detail route resolves with connectionId and itemId params (fails until Plan 03)', async () => {
/**
* A resolved cloud-file-detail route must accept connectionId + itemId params.
* This test will FAIL until Plan 03 adds the route.
*/
const routerModule = await import('../../router/index.js')
const router = routerModule.default
const routes = router.getRoutes()
const routeNames = routes.map((r) => r.name).filter(Boolean)
// Must exist before we can resolve it
expect(routeNames).toContain('cloud-file-detail')
const resolved = router.resolve({
name: 'cloud-file-detail',
params: { connectionId: 'conn-uuid-1', itemId: 'pitem-abc123' },
})
expect(resolved.matched.length).toBeGreaterThan(0)
})
})
// ── Task 2.2: Both /document/:id and cloud-file-detail routes exist (route parity)
describe('Route parity — both local and cloud detail routes exist', () => {
it('local /document/:id route exists (baseline)', async () => {
/**
* Baseline: the local document detail route must still exist after Phase 14.1.
* This test is currently PASSING and verifies the route is preserved.
*/
const routerModule = await import('../../router/index.js')
const router = routerModule.default
const routes = router.getRoutes()
const docRoute = routes.find((r) => r.path === '/document/:id')
expect(docRoute).toBeTruthy()
})
it('paired: cloud-file-detail route exists alongside local document detail (D-19)', async () => {
/**
* D-19: Route-level parity — a cloud detail route opens from browser rows and
* renders the same core sections/actions as local detail.
* Both /document/:id and cloud-file-detail must exist simultaneously.
* The cloud route assertion FAILS until Plan 03.
*/
const routerModule = await import('../../router/index.js')
const router = routerModule.default
const routes = router.getRoutes()
const routeNames = routes.map((r) => r.name).filter(Boolean)
// Local route
const docRoute = routes.find((r) => r.path === '/document/:id')
expect(docRoute).toBeTruthy()
// Cloud detail route — FAILS until Plan 03
expect(routeNames).toContain('cloud-file-detail')
})
})
// ── Task 2.3: Cloud file row click navigates to cloud-file-detail (D-01) ─────
describe('Browser row navigation — cloud file opens detail view (D-01)', () => {
it('cloud-file-detail route is the prerequisite for cloud row navigation (D-01)', async () => {
/**
* D-01: Cloud file rows/cards open into a cloud detail view — not a direct
* preview/download.
*
* This test asserts the prerequisite: the cloud-file-detail route must exist.
* Without it, CloudFolderView cannot push to the detail route.
*
* The navigation behavior test lives in CloudFolderView.test.js (Plan 04).
* This test will FAIL until Plan 03 adds the cloud-file-detail route.
*/
const routerModule = await import('../../router/index.js')
const router = routerModule.default
const routes = router.getRoutes()
const routeNames = routes.map((r) => r.name).filter(Boolean)
// Prerequisite: cloud-file-detail must exist for row navigation to work
expect(routeNames).toContain('cloud-file-detail')
})
})
// ── Task 2.4: DocumentView still shows local detail behavior (regression) ─────
describe('Local DocumentView regression (parity baseline)', () => {
it('DocumentView renders extracted-text and topics sections (local baseline)', async () => {
/**
* Baseline regression: after Phase 14.1 changes, DocumentView must still
* render the core sections: extracted text, topics.
*
* This test is PASSING (DocumentView exists) and documents the local
* section layout that cloud detail must match (D-17).
*/
const { default: DocumentView } = await import('../DocumentView.vue')
const stubs = {
AppIcon: { template: '<span />' },
TopicBadge: { template: '<span />', props: ['name', 'color'] },
PreviewModal: { template: '<div />' },
SuggestTopicsModal: { template: '<div />' },
}
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/document/:id', component: DocumentView },
],
})
await router.push('/document/local-doc-uuid-1')
await router.isReady()
const w = mount(DocumentView, {
global: {
plugins: [createPinia(), router],
stubs,
},
})
await flushPromises()
const html = w.html()
// DocumentView must render some form of extracted text and topics section
// (exact structure depends on current implementation)
const hasExtractedText = html.includes('Extracted') || html.includes('extracted_text') || html.includes('text')
const hasTopics = html.includes('Topic') || html.includes('topic') || html.includes('classify')
// At least one of these sections must be present
expect(hasExtractedText || hasTopics).toBe(true)
})
it('DocumentView does not contain Re-classify button text (D-09 regression)', async () => {
/**
* D-09: "Re-classify" must be replaced with "Re-analyze" everywhere.
* This test verifies that after Phase 14.1 completes, DocumentView no longer
* shows "Re-classify" visible copy.
*
* This test MAY FAIL initially (DocumentView currently shows "Re-classify")
* and is fixed by Plan 04 which renames all occurrences.
*/
const { default: DocumentView } = await import('../DocumentView.vue')
const stubs = {
AppIcon: { template: '<span />' },
TopicBadge: { template: '<span />', props: ['name', 'color'] },
PreviewModal: { template: '<div />' },
SuggestTopicsModal: { template: '<div />' },
}
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/document/:id', component: DocumentView },
],
})
await router.push('/document/local-doc-uuid-1')
await router.isReady()
const w = mount(DocumentView, {
global: {
plugins: [createPinia(), router],
stubs,
},
})
await flushPromises()
// D-09: "Re-classify" must not appear in rendered DocumentView
// This test documents the state BEFORE Plan 04 renames the copy.
// It FAILS if DocumentView still shows "Re-classify".
expect(w.html()).not.toContain('Re-classify')
})
})
// ── Task 2.5: Re-analyze copy assertion (D-09, Copywriting Contract) ──────────
describe('Copywriting Contract — Re-analyze copy in cloud-file-detail (D-09)', () => {
it('cloud-file-detail route must exist for Re-analyze copy test to be meaningful (D-09)', async () => {
/**
* D-09: The visible copy "Re-analyze" must appear in the cloud detail view.
* This test first asserts the route exists (prerequisite) before the copy test.
*
* This test FAILS until Plan 03 adds the cloud-file-detail route.
* The Re-analyze copy test runs in Plans 03/04 once the view exists.
*/
const routerModule = await import('../../router/index.js')
const router = routerModule.default
const routes = router.getRoutes()
const routeNames = routes.map((r) => r.name).filter(Boolean)
// Prerequisite: cloud-file-detail route must exist
expect(routeNames).toContain('cloud-file-detail')
})
})