from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from fastapi import APIRouter from k1link.compute.e30_human_review import ( E30HumanReviewConflictError, E30HumanReviewIntegrityError, E30HumanReviewStore, E30HumanReviewValidationError, E30ReviewSubject, E30ReviewSubstrate, ) from k1link.web import e30_human_review_api from k1link.web.e30_human_review_api import build_e30_human_review_router def _endpoint(router: APIRouter, path: str, method: str) -> Any: for route in router.routes: if ( getattr(route, "path", None) == path and method in getattr(route, "methods", set()) ): return route.endpoint raise AssertionError(f"{method} endpoint {path} not found") def _substrate() -> E30ReviewSubstrate: return E30ReviewSubstrate( materialization_id=f"e30-materialization-{'a' * 64}", materialization_identity_sha256="a" * 64, review_pack_id=f"e30-review-pack-{'b' * 64}", review_items_sha256="c" * 64, reason_taxonomy=(), subjects=( E30ReviewSubject( item_id=f"e30-review-item-{'d' * 64}", sequence=0, source_stratum="geometry-only", ), E30ReviewSubject( item_id=f"e30-review-item-{'e' * 64}", sequence=1, source_stratum="unknown", ), ), engineering_generation_id=f"e30-engineering-generation-{'f' * 64}", ) def _store(tmp_path: Path) -> E30HumanReviewStore: return E30HumanReviewStore( draft_root=tmp_path / "drafts", generation_root=tmp_path / "generations", ) def test_exception_review_is_generation_bound_and_resumable( tmp_path: Path, ) -> None: substrate = _substrate() store = _store(tmp_path) created = store.create_or_resume(substrate=substrate, reviewer_id="DC") resumed = store.create_or_resume(substrate=substrate, reviewer_id="DC") assert resumed["draft_id"] == created["draft_id"] assert resumed["engineering_generation_id"] == ( substrate.engineering_generation_id ) assert resumed["item_count"] == 2 assert resumed["reviewed_item_count"] == 0 def test_decisions_are_append_only_idempotent_and_superseding( tmp_path: Path, ) -> None: substrate = _substrate() store = _store(tmp_path) draft_id = str( store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"] ) subject = substrate.subjects[0] first = store.record_decision( draft_id=draft_id, substrate=substrate, item_id=subject.item_id, expected_revision=0, idempotency_key="decision-001", disposition="object-present", notes=None, ) replay = store.record_decision( draft_id=draft_id, substrate=substrate, item_id=subject.item_id, expected_revision=0, idempotency_key="decision-001", disposition="object-present", notes=None, ) changed = store.record_decision( draft_id=draft_id, substrate=substrate, item_id=subject.item_id, expected_revision=1, idempotency_key="decision-002", disposition="background-or-noise", notes="Static façade points.", ) assert first["revision"] == replay["revision"] == 1 assert changed["revision"] == 2 assert changed["reviewed_item_count"] == 1 assert changed["disposition_distribution"] == { "background-or-noise": 1 } assert len( ( tmp_path / "drafts" / draft_id / "events.jsonl" ).read_text().splitlines() ) == 2 def test_decision_rejects_unknown_disposition(tmp_path: Path) -> None: substrate = _substrate() store = _store(tmp_path) draft_id = str( store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"] ) with pytest.raises(E30HumanReviewValidationError): store.record_decision( draft_id=draft_id, substrate=substrate, item_id=substrate.subjects[0].item_id, expected_revision=0, idempotency_key="decision-invalid", disposition="invented", # type: ignore[arg-type] notes=None, ) def test_finalization_requires_every_exception_and_freezes_generation( tmp_path: Path, ) -> None: substrate = _substrate() store = _store(tmp_path) draft_id = str( store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"] ) first = store.record_decision( draft_id=draft_id, substrate=substrate, item_id=substrate.subjects[0].item_id, expected_revision=0, idempotency_key="decision-001", disposition="object-present", notes=None, ) with pytest.raises(E30HumanReviewConflictError, match="coverage is incomplete"): store.finalize( draft_id=draft_id, substrate=substrate, expected_revision=int(first["revision"]), ) complete = store.record_decision( draft_id=draft_id, substrate=substrate, item_id=substrate.subjects[1].item_id, expected_revision=1, idempotency_key="decision-002", disposition="insufficient-evidence", notes="Occluded.", ) generation = store.finalize( draft_id=draft_id, substrate=substrate, expected_revision=int(complete["revision"]), ) assert generation["human_review_complete"] is True assert generation["lab_published"] is False assert generation["engineering_generation_id"] == ( substrate.engineering_generation_id ) assert generation["disposition_distribution"] == { "insufficient-evidence": 1, "object-present": 1, } generation_id = str(generation["generation_id"]) decision_rows = [ json.loads(line) for line in ( tmp_path / "generations" / generation_id / "review-decisions.jsonl" ).read_text().splitlines() ] assert [row["disposition"] for row in decision_rows] == [ "object-present", "insufficient-evidence", ] repeated = store.finalize( draft_id=draft_id, substrate=substrate, expected_revision=2, ) assert repeated["generation_id"] == generation_id with pytest.raises(E30HumanReviewConflictError, match="finalized"): store.record_decision( draft_id=draft_id, substrate=substrate, item_id=substrate.subjects[0].item_id, expected_revision=2, idempotency_key="decision-after-finalize", disposition="object-present", notes=None, ) def test_event_and_generation_tampering_fail_closed(tmp_path: Path) -> None: substrate = _substrate() store = _store(tmp_path) draft_id = str( store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"] ) store.record_decision( draft_id=draft_id, substrate=substrate, item_id=substrate.subjects[0].item_id, expected_revision=0, idempotency_key="decision-001", disposition="object-present", notes=None, ) events_path = tmp_path / "drafts" / draft_id / "events.jsonl" event = json.loads(events_path.read_text()) event["disposition"] = "background-or-noise" events_path.write_text(json.dumps(event) + "\n") with pytest.raises(E30HumanReviewIntegrityError): store.get(draft_id=draft_id, substrate=substrate) def test_http_lifecycle_is_bound_to_the_engineering_exception_queue( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: source_substrate = _substrate() generation_id = str(source_substrate.engineering_generation_id) monkeypatch.setattr( e30_human_review_api, "load_verified_e30_review", lambda **_: ({}, (), source_substrate), ) monkeypatch.setattr( e30_human_review_api, "load_verified_e30_engineering_generation", lambda **_: ( { "human_exceptions": [ {"item_id": subject.item_id} for subject in source_substrate.subjects ] }, (), ), ) router = build_e30_human_review_router( materialization_root_provider=lambda: tmp_path / "materializations", review_pack_root_provider=lambda: tmp_path / "review-packs", engineering_generation_root_provider=lambda: ( tmp_path / "engineering-generations" ), draft_root_provider=lambda: tmp_path / "drafts", generation_root_provider=lambda: tmp_path / "generations", ) create = _endpoint( router, "/api/v1/laboratory/e30/reviews/{result_id}/human-review", "POST", ) decide = _endpoint( router, ( "/api/v1/laboratory/e30/reviews/{result_id}/human-review/" "{draft_id}/decisions/{item_id}" ), "PUT", ) finalize = _endpoint( router, ( "/api/v1/laboratory/e30/reviews/{result_id}/human-review/" "{draft_id}/finalize" ), "POST", ) created = create( result_id=source_substrate.materialization_id, request=e30_human_review_api.E30HumanReviewCreateRequest( reviewer_id="DC", engineering_generation_id=generation_id, ), ) draft = created for revision, subject in enumerate(source_substrate.subjects): draft = decide( result_id=source_substrate.materialization_id, draft_id=draft["draft_id"], item_id=subject.item_id, engineering_generation_id=generation_id, request=e30_human_review_api.E30HumanReviewDecisionRequest( expected_revision=revision, idempotency_key=f"http-{revision}", disposition=( "object-present" if revision == 0 else "insufficient-evidence" ), notes=None, ), ) finalized = finalize( result_id=source_substrate.materialization_id, draft_id=draft["draft_id"], engineering_generation_id=generation_id, request=e30_human_review_api.E30HumanReviewFinalizeRequest( expected_revision=draft["revision"], confirm_generation=True, ), ) assert finalized["draft"]["state"] == "finalized"