vignette/apps/api/app/test_rupture_repair_store.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

560 lines
20 KiB
Python

from __future__ import annotations
import unittest
from contextlib import asynccontextmanager
from pathlib import Path
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
from fastapi import HTTPException
from fastapi import FastAPI
from pydantic import ValidationError
from .deps import Principal, Role
from .routes import rupture_repairs
from .services import rupture_repair_store
def _principal(role: Role = Role.LEARNER) -> Principal:
return Principal(
user_id=str(uuid4()),
role=role,
cohort_ids=["g3-cohort"],
)
class RuptureRepairStoreContractTests(unittest.TestCase):
def test_canonical_hash_is_order_independent(self) -> None:
self.assertEqual(
rupture_repair_store._canonical_hash({"b": 2, "a": 1}),
rupture_repair_store._canonical_hash({"a": 1, "b": 2}),
)
def test_human_views_keep_learner_and_supervisor_projections_separate(self) -> None:
self.assertEqual(
rupture_repair_store._human_view(_principal(Role.LEARNER)),
"counselor",
)
self.assertEqual(
rupture_repair_store._human_view(_principal(Role.TEACHER)),
"supervisor",
)
def test_evidence_must_be_nonempty_and_unique(self) -> None:
with self.assertRaises(rupture_repair_store.RuptureRepairStateError):
rupture_repair_store._ensure_unique_nonempty_evidence(())
evidence = uuid4()
with self.assertRaises(rupture_repair_store.RuptureRepairStateError):
rupture_repair_store._ensure_unique_nonempty_evidence((evidence, evidence))
def test_internal_request_requires_explicit_evaluator_model_provenance(self) -> None:
with self.assertRaises(ValidationError):
rupture_repairs.InternalRuptureObservationRequest(
episode_key="episode-1",
idempotency_key=uuid4(),
event_kind="rupture.detected",
from_state=None,
to_state="onset",
rupture_type="withdrawal",
source_kind="model_inferred",
perspective="independent_observer",
ai_view="evaluator",
confidence=0.9,
uncertainty=0.1,
evidence_turn_ids=[uuid4()],
)
def test_runtime_request_cannot_claim_independent_observer_perspective(self) -> None:
with self.assertRaises(ValidationError):
rupture_repairs.InternalRuptureObservationRequest(
episode_key="episode-1",
idempotency_key=uuid4(),
event_kind="rupture.detected",
from_state=None,
to_state="onset",
rupture_type="withdrawal",
source_kind="observed_runtime",
perspective="independent_observer",
ai_view="evaluator",
confidence=0.9,
uncertainty=0.1,
evidence_turn_ids=[uuid4()],
)
def test_reconciliation_disposition_is_typed_against_deep_status(self) -> None:
with self.assertRaises(ValidationError):
rupture_repairs.InternalReconciliationRequest(
idempotency_key=uuid4(),
fast_warning_observation_id=uuid4(),
fast_warning_id="warning-1",
provisional_status="missed",
deep_status="partial",
disposition="superseded_resolved",
uncertainty=0.1,
model_run_id=uuid4(),
ai_view="evaluator",
)
def test_response_has_no_total_score_and_keeps_safety_as_reference(self) -> None:
payload = {
"session_id": uuid4(),
"requested_view": "counselor",
"clinical_claim_allowed": False,
"episodes": [],
}
response = rupture_repairs.RuptureRepairReadModelResponse.model_validate(payload)
dumped = response.model_dump(mode="json")
self.assertNotIn("total", dumped)
self.assertNotIn("score", dumped)
self.assertFalse(dumped["clinical_claim_allowed"])
class RuptureRepairStoreAsyncTests(unittest.IsolatedAsyncioTestCase):
async def test_existing_idempotency_key_returns_same_id(self) -> None:
identifier = uuid4()
conn = AsyncMock()
conn.fetchrow.return_value = {
"observation_id": identifier,
"content_hash": "a" * 64,
}
result = await rupture_repair_store._existing_by_idempotency(
conn,
table="app.rupture_observation_event",
id_column="observation_id",
session_id=uuid4(),
idempotency_key=uuid4(),
content_hash="a" * 64,
)
self.assertEqual(result, identifier)
async def test_idempotency_key_reuse_with_different_content_is_conflict(self) -> None:
conn = AsyncMock()
conn.fetchrow.return_value = {
"observation_id": uuid4(),
"content_hash": "a" * 64,
}
with self.assertRaises(rupture_repair_store.RuptureRepairConflictError):
await rupture_repair_store._existing_by_idempotency(
conn,
table="app.rupture_observation_event",
id_column="observation_id",
session_id=uuid4(),
idempotency_key=uuid4(),
content_hash="b" * 64,
)
async def test_evaluator_observation_creates_episode_and_event(self) -> None:
session_id = uuid4()
case_id = uuid4()
learner_id = uuid4()
episode_id = uuid4()
observation_id = uuid4()
turn_id = uuid4()
conn = AsyncMock()
conn.fetchrow.side_effect = [
{"id": session_id, "case_id": case_id, "learner_id": learner_id},
{"episode_id": episode_id},
None,
{"observation_id": observation_id},
]
result = await rupture_repair_store.append_evaluator_observation(
conn=conn,
session_id=session_id,
episode_key="episode-1",
idempotency_key=uuid4(),
event_kind="rupture.detected",
from_state=None,
to_state="onset",
rupture_type="withdrawal",
source_kind="observed_runtime",
perspective="runtime_observation",
ai_view="evaluator",
confidence=0.8,
uncertainty=0.2,
evidence_turn_ids=(turn_id,),
counterevidence=(),
model_run_id=None,
)
self.assertEqual(
result,
{"episode_id": episode_id, "observation_id": observation_id},
)
self.assertEqual(conn.fetchrow.await_count, 4)
async def test_evaluator_store_rejects_non_evaluator_ai_view_before_db(self) -> None:
conn = AsyncMock()
with self.assertRaisesRegex(
rupture_repair_store.RuptureRepairStateError,
"ai_view=evaluator",
):
await rupture_repair_store.append_evaluator_observation(
conn=conn,
session_id=uuid4(),
episode_key="episode-1",
idempotency_key=uuid4(),
event_kind="rupture.detected",
from_state=None,
to_state="onset",
rupture_type="withdrawal",
source_kind="observed_runtime",
perspective="runtime_observation",
ai_view="supervisor",
confidence=0.8,
uncertainty=0.2,
evidence_turn_ids=(uuid4(),),
counterevidence=(),
model_run_id=None,
)
conn.execute.assert_not_awaited()
async def test_learner_cannot_append_human_correction(self) -> None:
with self.assertRaisesRegex(
rupture_repair_store.RuptureRepairStateError,
"teacher or admin",
):
await rupture_repair_store.append_human_correction(
principal=_principal(Role.LEARNER),
session_id=uuid4(),
episode_id=uuid4(),
idempotency_key=uuid4(),
supersedes_observation_id=uuid4(),
rupture_type="withdrawal",
corrected_status="partial",
uncertainty=0.1,
evidence_turn_ids=(uuid4(),),
counterevidence=(),
correction_reason="근거 재평가",
)
async def test_teacher_correction_appends_human_rated_supersession(self) -> None:
principal = _principal(Role.TEACHER)
session_id = uuid4()
episode_id = uuid4()
target_id = uuid4()
correction_id = uuid4()
conn = AsyncMock()
conn.fetchrow.side_effect = [
{"id": session_id, "case_id": uuid4(), "learner_id": uuid4()},
{"to_state": "partial"},
None,
{"observation_id": correction_id},
]
@asynccontextmanager
async def fake_acquire(**kwargs):
self.assertEqual(kwargs["role"], "teacher")
self.assertEqual(kwargs["cohort_ids"], ["g3-cohort"])
yield conn
with patch.object(rupture_repair_store.db, "acquire", fake_acquire):
result = await rupture_repair_store.append_human_correction(
principal=principal,
session_id=session_id,
episode_id=episode_id,
idempotency_key=uuid4(),
supersedes_observation_id=target_id,
rupture_type="withdrawal",
corrected_status="resolved",
uncertainty=0.1,
evidence_turn_ids=(uuid4(),),
counterevidence=(),
correction_reason="후속 반응 근거 확인",
)
self.assertEqual(result, correction_id)
insert_args = conn.fetchrow.await_args_list[-1].args
self.assertIn("human.corrected", insert_args[0])
self.assertIn("human_rated", insert_args[0])
self.assertEqual(insert_args[5], "partial")
self.assertEqual(insert_args[6], "resolved")
async def test_empty_read_model_is_role_safe_for_learner(self) -> None:
principal = _principal(Role.LEARNER)
session_id = uuid4()
conn = AsyncMock()
conn.fetchrow.return_value = {
"id": session_id,
"case_id": uuid4(),
"learner_id": UUID(principal.user_id),
}
conn.fetch.return_value = []
@asynccontextmanager
async def fake_acquire(**kwargs):
self.assertEqual(kwargs["role"], "learner")
yield conn
with patch.object(rupture_repair_store.db, "acquire", fake_acquire):
result = await rupture_repair_store.read_rupture_repairs(
principal=principal,
session_id=session_id,
)
self.assertEqual(result["requested_view"], "counselor")
self.assertEqual(result["episodes"], [])
self.assertFalse(result["clinical_claim_allowed"])
episode_query = conn.fetch.await_args.args
self.assertEqual(episode_query[-1], "counselor")
async def test_get_route_maps_not_found_to_404(self) -> None:
with patch.object(
rupture_repairs.rupture_repair_store,
"read_rupture_repairs",
AsyncMock(
side_effect=rupture_repair_store.RuptureRepairNotFoundError(
"not visible"
)
),
):
with self.assertRaises(HTTPException) as captured:
await rupture_repairs.get_rupture_repairs(
session_id=uuid4(),
principal=_principal(),
)
self.assertEqual(captured.exception.status_code, 404)
async def test_internal_route_forwards_explicit_ai_view(self) -> None:
episode_id = uuid4()
observation_id = uuid4()
body = rupture_repairs.InternalRuptureObservationRequest(
episode_key="episode-1",
idempotency_key=uuid4(),
event_kind="rupture.detected",
from_state=None,
to_state="onset",
rupture_type="withdrawal",
source_kind="observed_runtime",
perspective="runtime_observation",
ai_view="evaluator",
confidence=0.8,
uncertainty=0.2,
evidence_turn_ids=[uuid4()],
)
mocked = AsyncMock(
return_value={
"episode_id": episode_id,
"observation_id": observation_id,
}
)
with patch.object(
rupture_repairs.rupture_repair_store,
"append_evaluator_observation",
mocked,
):
response = await rupture_repairs.create_internal_rupture_observation(
session_id=uuid4(),
body=body,
conn=AsyncMock(),
)
self.assertEqual(response.observation_id, observation_id)
self.assertEqual(mocked.await_args.kwargs["ai_view"], "evaluator")
class RuptureInternalAuthenticationTests(unittest.IsolatedAsyncioTestCase):
TOKEN = "g3-test-token-with-at-least-32-characters-0001"
@staticmethod
def _settings(token: str):
return rupture_repairs.Settings(
_env_file=None,
rupture_internal_token=token,
)
async def test_unconfigured_token_disables_endpoint_before_db_acquire(self) -> None:
reached = False
async def fake_provider():
nonlocal reached
reached = True
yield AsyncMock()
dependency = rupture_repairs.rupture_internal_evaluator_db(
settings=self._settings(""),
presented_token=None,
)
with patch.object(
rupture_repairs, "_evaluator_db_provider", fake_provider
):
with self.assertRaises(HTTPException) as captured:
await anext(dependency)
self.assertEqual(captured.exception.status_code, 503)
self.assertEqual(
captured.exception.detail,
"internal rupture ingestion is unavailable",
)
self.assertFalse(reached)
async def test_missing_header_is_401_before_db_acquire(self) -> None:
reached = False
async def fake_provider():
nonlocal reached
reached = True
yield AsyncMock()
dependency = rupture_repairs.rupture_internal_evaluator_db(
settings=self._settings(self.TOKEN),
presented_token=None,
)
with patch.object(
rupture_repairs, "_evaluator_db_provider", fake_provider
):
with self.assertRaises(HTTPException) as captured:
await anext(dependency)
self.assertEqual(captured.exception.status_code, 401)
self.assertFalse(reached)
async def test_mismatched_header_is_403_and_uses_compare_digest(self) -> None:
reached = False
async def fake_provider():
nonlocal reached
reached = True
yield AsyncMock()
dependency = rupture_repairs.rupture_internal_evaluator_db(
settings=self._settings(self.TOKEN),
presented_token="wrong-token-that-must-not-be-reflected",
)
with (
patch.object(rupture_repairs, "_evaluator_db_provider", fake_provider),
patch.object(
rupture_repairs.secrets,
"compare_digest",
wraps=rupture_repairs.secrets.compare_digest,
) as compared,
):
with self.assertRaises(HTTPException) as captured:
await anext(dependency)
self.assertEqual(captured.exception.status_code, 403)
self.assertEqual(captured.exception.detail, "internal authentication failed")
compared.assert_called_once_with(
"wrong-token-that-must-not-be-reflected", self.TOKEN
)
self.assertNotIn("wrong-token", str(captured.exception.detail))
self.assertNotIn(self.TOKEN, str(captured.exception.detail))
self.assertFalse(reached)
async def test_valid_token_reaches_evaluator_db_provider(self) -> None:
connection = AsyncMock()
reached = 0
async def fake_provider():
nonlocal reached
reached += 1
yield connection
dependency = rupture_repairs.rupture_internal_evaluator_db(
settings=self._settings(self.TOKEN),
presented_token=self.TOKEN,
)
with patch.object(
rupture_repairs, "_evaluator_db_provider", fake_provider
):
result = await anext(dependency)
await dependency.aclose()
self.assertIs(result, connection)
self.assertEqual(reached, 1)
async def test_short_configured_token_is_fail_closed_as_unavailable(self) -> None:
dependency = rupture_repairs.rupture_internal_evaluator_db(
settings=self._settings("too-short"),
presented_token="too-short",
)
with self.assertRaises(HTTPException) as captured:
await anext(dependency)
self.assertEqual(captured.exception.status_code, 503)
async def test_secret_setting_repr_and_json_do_not_expose_token(self) -> None:
settings = self._settings(self.TOKEN)
self.assertNotIn(self.TOKEN, repr(settings))
self.assertNotIn(self.TOKEN, settings.model_dump_json())
self.assertEqual(
settings.rupture_internal_token.get_secret_value(),
self.TOKEN,
)
async def test_openapi_documents_header_only_on_internal_writes(self) -> None:
app = FastAPI()
app.include_router(rupture_repairs.router)
paths = app.openapi()["paths"]
for path in (
"/internal/sessions/{session_id}/ruptures/observations",
"/internal/sessions/{session_id}/ruptures/{episode_id}/reconciliations",
):
header_names = {
item["name"]
for item in paths[path]["post"].get("parameters", [])
if item["in"] == "header"
}
self.assertEqual(
header_names,
{rupture_repairs.INTERNAL_TOKEN_HEADER},
)
for path, method in (
("/sessions/{session_id}/ruptures", "get"),
("/sessions/{session_id}/ruptures/{episode_id}/corrections", "post"),
):
header_names = {
item["name"]
for item in paths[path][method].get("parameters", [])
if item["in"] == "header"
}
self.assertNotIn(rupture_repairs.INTERNAL_TOKEN_HEADER, header_names)
class RuptureRepairSchemaStaticTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.sql = (
Path(__file__).resolve().parents[3]
/ "infra"
/ "db"
/ "init"
/ "09_rupture_repair.sql"
).read_text(encoding="utf-8")
def test_schema_has_four_append_only_ledgers_and_rls(self) -> None:
for table in (
"app.rupture_episode",
"app.rupture_observation_event",
"app.rupture_reconciliation_revision",
"app.rupture_safety_reference",
):
self.assertIn(f"CREATE TABLE IF NOT EXISTS {table}", self.sql)
self.assertIn(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY", self.sql)
self.assertEqual(self.sql.count("audit.reject_measurement_mutation()"), 4)
def test_schema_requires_turn_ownership_model_run_and_idempotency(self) -> None:
self.assertIn("rupture evidence turns must belong to its session", self.sql)
self.assertIn("rupture model_run must belong to its session", self.sql)
self.assertIn("reconciliation model_run must belong to its session", self.sql)
self.assertGreaterEqual(self.sql.count("UNIQUE (session_id, idempotency_key)"), 2)
def test_safety_table_is_reference_only(self) -> None:
safety_sql = self.sql.split(
"CREATE TABLE IF NOT EXISTS app.rupture_safety_reference", 1
)[1].split(");", 1)[0]
self.assertIn("safety_event_id", safety_sql)
self.assertNotIn("confidence", safety_sql)
self.assertNotIn("status", safety_sql)
self.assertNotIn("score", safety_sql)
if __name__ == "__main__":
unittest.main()