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:
@@ -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}"
|
||||
)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user