from __future__ import annotations import unittest from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch from uuid import UUID, uuid4 import asyncpg from fastapi import FastAPI, HTTPException from pydantic import SecretStr, ValidationError from .contracts.calibration_transfer import TransferSuiteInput from .deps import Principal, Role from .routes import calibration_transfer from .services import calibration_transfer_store REPO_ROOT = Path(__file__).resolve().parents[3] SQL = (REPO_ROOT / "infra" / "db" / "init" / "11_calibration_transfer.sql").read_text( encoding="utf-8" ) ACTUAL_SQL_PATH = ( REPO_ROOT / "infra" / "db" / "init" / "16_calibration_transfer_actual_execution.sql" ) def _principal(role: Role = Role.LEARNER) -> Principal: return Principal( user_id=str(uuid4()), role=role, cohort_ids=["g5-cohort"], ) class CalibrationTransferSchemaTests(unittest.TestCase): def test_schema_owns_append_only_g5_ledgers(self) -> None: tables = ( "calibration_prediction_history", "calibration_prediction_revision", "calibration_prediction_lock", "calibration_performance_observation", "calibration_assessment_snapshot", "calibration_metacognitive_prescription", "calibration_transfer_suite", "calibration_transfer_trial", "calibration_transfer_assessment", "calibration_subgroup_drift_report", "calibration_teacher_review_event", ) for table in tables: self.assertIn(f"CREATE TABLE IF NOT EXISTS app.{table}", SQL) self.assertIn(f"'{table}'", SQL) self.assertIn("audit.reject_measurement_mutation()", SQL) self.assertNotIn("FOR UPDATE", SQL) self.assertIn("'calibration-mirror-g5', '1.0.0'", SQL) self.assertIn("'unseen-transfer-g5', '1.0.0'", SQL) self.assertIn('"aggregate_total_forbidden":true', SQL) def test_prediction_revision_is_blocked_after_lock_or_reveal(self) -> None: self.assertIn( "self-prediction cannot be revised after lock or external reveal", SQL, ) self.assertIn("prediction lock must target the latest revision", SQL) self.assertIn("revealed_sequence <= lock_sequence", SQL) self.assertIn("trg_calibration_prediction_revision_contract", SQL) self.assertIn("trg_calibration_observation_reveal_contract", SQL) def test_rls_is_learner_self_teacher_cohort_and_admin(self) -> None: self.assertIn("learner_id = app.current_uid()", SQL) self.assertIn( "u.cohort = current_setting(''app.current_cohort'', true)", SQL ) self.assertIn("app.current_role_name() = 'admin'", SQL) self.assertIn("current_ai_view'', true) = ''evaluator''", SQL) def test_schema_rejects_transcript_and_aggregate_score_payloads(self) -> None: self.assertIn("raw_transcript", SQL) self.assertIn("total_score", SQL) self.assertIn( "calibration payload cannot store transcript text or aggregate score", SQL, ) self.assertNotRegex(SQL.lower(), r"\btotal_score\s+(double|numeric|real|int)") def test_actual_execution_migration_is_append_only_and_server_derived(self) -> None: actual_sql = ACTUAL_SQL_PATH.read_text(encoding="utf-8") self.assertIn( "CREATE TABLE IF NOT EXISTS app.calibration_transfer_execution_event", actual_sql, ) self.assertIn("audit.reject_measurement_mutation()", actual_sql) self.assertIn("trg_calibration_transfer_execution_contract", actual_sql) self.assertIn("original_transfer_trial_record_id", actual_sql) self.assertIn("practice_session_id", actual_sql) self.assertIn("normalized_evaluator_labels", actual_sql) self.assertIn("model_run_id", actual_sql) self.assertIn("source_kind", actual_sql) self.assertIn("perspective", actual_sql) self.assertIn("instrument_id", actual_sql) self.assertIn("instrument_version", actual_sql) self.assertIn("unseen-transfer-g5", actual_sql) self.assertIn("ADD COLUMN IF NOT EXISTS instrument_id", actual_sql) self.assertIn("ADD COLUMN IF NOT EXISTS instrument_version", actual_sql) self.assertIn("ALTER COLUMN instrument_id SET NOT NULL", actual_sql) self.assertIn("ALTER COLUMN instrument_version SET NOT NULL", actual_sql) self.assertIn("IF NOT EXISTS (", actual_sql) self.assertIn("calibration_transfer_execution_instrument_fkey", actual_sql) self.assertIn("calibration_transfer_execution_instrument_check", actual_sql) self.assertIn("learner_id = app.current_uid()", actual_sql) self.assertNotIn("raw_transcript", actual_sql) self.assertNotIn("text_masked", actual_sql) class CalibrationTransferOpenAPITests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: app = FastAPI() app.include_router(calibration_transfer.router) cls.schema = app.openapi() def test_standalone_openapi_has_all_role_boundaries(self) -> None: expected = { "/calibration/predictions/revisions", "/calibration/predictions/{history_id}/lock", "/internal/calibration/performance-observations", "/internal/sessions/{session_id}/calibration/assessments", "/internal/sessions/{session_id}/calibration/transfer-suites", "/calibration/reviews", "/calibration/learners/me", "/calibration/learners/{learner_id}", "/calibration/transfer-executions", } self.assertTrue(expected.issubset(self.schema["paths"])) def test_internal_paths_publish_dedicated_header(self) -> None: operation = self.schema["paths"][ "/internal/calibration/performance-observations" ]["post"] headers = { item["name"] for item in operation["parameters"] if item["in"] == "header" } self.assertIn( calibration_transfer.INTERNAL_TOKEN_HEADER, headers, ) def test_read_model_has_no_total_score_field(self) -> None: schema = self.schema["components"]["schemas"][ "CalibrationTransferReadModelResponse" ] properties = schema["properties"] self.assertNotIn("total", properties) self.assertNotIn("score", properties) self.assertEqual(properties["clinical_claim_allowed"]["const"], False) class CalibrationTransferRequestTests(unittest.TestCase): def test_observation_requires_compatible_model_provenance(self) -> None: with self.assertRaises(ValidationError): calibration_transfer.PerformanceObservationRequest( submission_id=uuid4(), observation_id=uuid4(), history_id=uuid4(), status="passed", source_kind="model_inferred", perspective="runtime_observation", instrument_id="g5-performance", instrument_version="1.0.0", uncertainty=0.2, evidence_turn_ids=[uuid4()], revealed_sequence=3, ) def test_teacher_correction_payload_rejects_raw_transcript(self) -> None: with self.assertRaises(ValidationError): calibration_transfer.TeacherReviewRequest( submission_id=uuid4(), review_id=uuid4(), target_kind="calibration_assessment", target_id=uuid4(), disposition="corrected", correction_payload={"raw_transcript": "do not persist"}, review_reason="근거를 다시 검토했다.", ) def test_non_correction_review_cannot_smuggle_payload(self) -> None: with self.assertRaises(ValidationError): calibration_transfer.TeacherReviewRequest( submission_id=uuid4(), review_id=uuid4(), target_kind="drift_report", target_id=uuid4(), disposition="confirmed", correction_payload={"status": "stable"}, review_reason="합성 subgroup 근거를 확인했다.", ) def test_actual_execution_request_accepts_only_server_identifiers(self) -> None: body = calibration_transfer.ActualTransferExecutionRequest( original_transfer_trial_record_id=uuid4(), practice_session_id=uuid4(), ) self.assertEqual( set(body.model_dump()), {"original_transfer_trial_record_id", "practice_session_id"}, ) with self.assertRaises(ValidationError): calibration_transfer.ActualTransferExecutionRequest( original_transfer_trial_record_id=uuid4(), practice_session_id=uuid4(), context_variant="browser-forged-context", ) class CalibrationTransferStoreTests(unittest.IsolatedAsyncioTestCase): async def test_transfer_suite_route_preserves_typed_nested_suite(self) -> None: session_id = uuid4() body = calibration_transfer.TransferSuiteSubmissionRequest( submission_id=uuid4(), transfer_suite_record_id=uuid4(), suite={ "suite_id": "oas-g5-suite-route-typed", "training_phrase_family_ids": ["training-route-typed"], "trials": [ { "trial_id": "oas-g5-transfer-route-typed", "competency_id": "competency.empathic_attunement", "scenario_variant_id": "unseen-route-typed", "variation": { "context_variant": "academic-transition", "relationship_style": "withdrawn", "difficulty_level": 3, "expression_variant": "indirect-emotion", "synthetic_subgroup": "synthetic-route-typed", "scenario_family_id": "family-academic-transition", "phrase_family_id": "novel-route-typed", }, "status": "passed", "uncertainty": 0.2, "evidence_refs": [str(uuid4())], "counterevidence": [], } ], }, model_run_id=uuid4(), instrument_id="unseen-transfer-g5", instrument_version="1.0.0", ) mocked = AsyncMock( return_value={ "submission_id": body.submission_id, "transfer_suite_record_id": body.transfer_suite_record_id, "trial_count": 1, "assessment_count": 1, "drift_report_count": 1, "idempotent_replay": False, } ) with patch.object( calibration_transfer.calibration_transfer_store, "append_transfer_suite", mocked, ): response = await calibration_transfer.create_transfer_suite( session_id=session_id, body=body, conn=AsyncMock(), ) self.assertEqual(response.trial_count, 1) self.assertIs(mocked.await_args.kwargs["suite"], body.suite) self.assertIsInstance(mocked.await_args.kwargs["suite"], TransferSuiteInput) async def test_transfer_suite_passes_json_object_to_registered_codec(self) -> None: learner_id = uuid4() session_id = uuid4() evidence_id = uuid4() conn = AsyncMock() conn.fetchrow.side_effect = [ {"id": session_id, "learner_id": learner_id, "case_id": uuid4()}, None, ] suite = TransferSuiteInput.model_validate( { "suite_id": "oas-g5-suite-json-codec", "training_phrase_family_ids": ["training-json-codec"], "trials": [ { "trial_id": "oas-g5-transfer-json-codec", "competency_id": "competency.empathic_attunement", "scenario_variant_id": "unseen-json-codec", "variation": { "context_variant": "academic-transition", "relationship_style": "withdrawn", "difficulty_level": 3, "expression_variant": "indirect-emotion", "synthetic_subgroup": "synthetic-json-codec", "scenario_family_id": "family-academic-transition", "phrase_family_id": "novel-json-codec", }, "status": "passed", "uncertainty": 0.2, "evidence_refs": [str(evidence_id)], "counterevidence": [], } ], } ) result = await calibration_transfer_store.append_transfer_suite( conn=conn, submission_id=uuid4(), transfer_suite_record_id=uuid4(), session_id=session_id, suite=suite, model_run_id=uuid4(), instrument_id="unseen-transfer-g5", instrument_version="1.0.0", ) assessment_insert = next( call for call in conn.execute.await_args_list if "INSERT INTO app.calibration_transfer_assessment" in call.args[0] ) self.assertIsInstance(assessment_insert.args[7], dict) self.assertEqual(result["trial_count"], 1) async def test_actual_execution_rejects_before_prediction_lock(self) -> None: principal = _principal(Role.LEARNER) now = datetime(2026, 8, 7, tzinfo=UTC) conn = AsyncMock() conn.fetchrow.return_value = { "transfer_trial_record_id": uuid4(), "transfer_suite_record_id": uuid4(), "session_id": uuid4(), "learner_id": UUID(principal.user_id), "competency_id": "competency.empathic-check", "created_at": now, "prediction_locked": False, } @asynccontextmanager async def fake_acquire(**kwargs): self.assertTrue(kwargs["ai_context"]) self.assertEqual(kwargs["ai_view"], "evaluator") self.assertEqual(kwargs["user_id"], principal.user_id) yield conn with patch.object(calibration_transfer_store.db, "acquire", fake_acquire): with self.assertRaisesRegex( calibration_transfer_store.CalibrationTransferStateError, "self-prediction", ): await calibration_transfer_store.append_actual_transfer_execution( principal=principal, original_transfer_trial_record_id=uuid4(), practice_session_id=uuid4(), ) self.assertEqual(conn.fetchrow.await_count, 1) self.assertIn("prediction_lock", conn.fetchrow.await_args.args[0]) async def test_actual_execution_requires_ended_ready_later_session(self) -> None: principal = _principal(Role.LEARNER) learner_id = UUID(principal.user_id) trial_id = uuid4() original_session_id = uuid4() practice_session_id = uuid4() now = datetime(2026, 8, 7, tzinfo=UTC) conn = AsyncMock() conn.fetchrow.side_effect = [ { "transfer_trial_record_id": trial_id, "transfer_suite_record_id": uuid4(), "session_id": original_session_id, "learner_id": learner_id, "competency_id": "competency.empathic-check", "created_at": now, "prediction_locked": True, }, None, { "id": practice_session_id, "learner_id": learner_id, "started_at": now + timedelta(minutes=1), "ended_at": None, "evaluation_status": "ready", "evaluation_scope": "session_end", }, ] @asynccontextmanager async def fake_acquire(**_kwargs): yield conn with patch.object(calibration_transfer_store.db, "acquire", fake_acquire): with self.assertRaisesRegex( calibration_transfer_store.CalibrationTransferStateError, "must be ended", ): await calibration_transfer_store.append_actual_transfer_execution( principal=principal, original_transfer_trial_record_id=trial_id, practice_session_id=practice_session_id, ) self.assertEqual(conn.fetch.await_count, 0) async def test_actual_execution_persists_and_reads_g0_instrument_provenance( self, ) -> None: principal = _principal(Role.LEARNER) learner_id = UUID(principal.user_id) trial_id = uuid4() suite_id = uuid4() source_session_id = uuid4() practice_session_id = uuid4() counselor_turn_id = uuid4() client_turn_id = uuid4() now = datetime(2026, 8, 7, tzinfo=UTC) original = { "transfer_trial_record_id": trial_id, "transfer_suite_record_id": suite_id, "session_id": source_session_id, "learner_id": learner_id, "competency_id": "competency.empathic-check", "scenario_variant_id": "actual-variant", "scenario_novelty": "unseen_transfer", "context_variant": "학업", "relationship_style": "withdrawn", "difficulty_level": 3, "expression_variant": "우회형", "synthetic_subgroup": "synthetic-a", "scenario_family_id": "family-school", "phrase_family_id": "actual-phrase", "training_phrase_family_ids": ["training-phrase"], "created_at": now, "prediction_locked": True, } session = { "id": practice_session_id, "learner_id": learner_id, "started_at": now + timedelta(minutes=1), "ended_at": now + timedelta(minutes=10), "evaluation_status": "ready", "evaluation_scope": "session_end", } execution_row = { "execution_event_id": uuid4(), "original_transfer_trial_record_id": trial_id, "transfer_suite_record_id": suite_id, "practice_session_id": practice_session_id, "learner_id": learner_id, "competency_id": "competency.empathic-check", "scenario_variant_id": "actual-variant", "scenario_novelty": "unseen_transfer", "context_variant": "학업", "relationship_style": "withdrawn", "difficulty_level": 3, "expression_variant": "우회형", "synthetic_subgroup": "synthetic-a", "scenario_family_id": "family-school", "phrase_family_id": "actual-phrase", "training_phrase_collision": False, "status": "passed", "uncertainty": 0.25, "evidence_turn_ids": [counselor_turn_id, client_turn_id], "normalized_evaluator_labels": { "technique_codes": ["reflection"], "client_state_codes": ["affect_contact"], "appropriateness": ["pos"], "intent_deviation_dimensions": [], "evaluator_error_count": 0, }, "counterevidence": [], "source_kind": "model_inferred", "perspective": "independent_observer", "model_run_id": uuid4(), "instrument_id": "unseen-transfer-g5", "instrument_version": "1.0.0", "observer_version": "calibration-actual-transfer-observer-v1", "created_at": now + timedelta(minutes=10), } conn = AsyncMock() conn.fetchrow.side_effect = [original, None, session, execution_row] conn.fetch.side_effect = [ [ { "counselor_turn_id": counselor_turn_id, "client_turn_id": client_turn_id, "technique_codes": ["reflection"], "client_state_codes": ["affect_contact"], "appropriateness": "pos", "intent_deviation_dimensions": [], "evaluator_error": False, } ], [execution_row], ] @asynccontextmanager async def fake_acquire(**_kwargs): yield conn with patch.object(calibration_transfer_store.db, "acquire", fake_acquire): result = await calibration_transfer_store.append_actual_transfer_execution( principal=principal, original_transfer_trial_record_id=trial_id, practice_session_id=practice_session_id, ) insert_call = conn.fetchrow.await_args_list[3] self.assertIn("instrument_id", insert_call.args[0]) self.assertIn("instrument_version", insert_call.args[0]) self.assertIsInstance(insert_call.args[19], dict) model_run_insert = next( call for call in conn.execute.await_args_list if "INSERT INTO audit.model_run" in call.args[0] ) self.assertIsInstance(model_run_insert.args[6], dict) self.assertEqual(insert_call.args[22], "model_inferred") self.assertEqual(insert_call.args[23], "independent_observer") self.assertEqual(insert_call.args[24], "unseen-transfer-g5") self.assertEqual(insert_call.args[25], "1.0.0") self.assertEqual( insert_call.args[26], "calibration-actual-transfer-observer-v1" ) self.assertEqual(result["execution"]["source_kind"], "model_inferred") self.assertEqual( result["execution"]["perspective"], "independent_observer" ) self.assertEqual(result["execution"]["instrument_id"], "unseen-transfer-g5") self.assertEqual(result["execution"]["instrument_version"], "1.0.0") self.assertEqual( result["execution"]["observer_version"], "calibration-actual-transfer-observer-v1", ) async def test_idempotent_same_content_returns_existing_identifier(self) -> None: identifier = uuid4() conn = AsyncMock() conn.fetchrow.return_value = { "observation_id": identifier, "content_hash": "a" * 64, } result = await calibration_transfer_store._existing_by_submission( conn, table="app.calibration_performance_observation", submission_id=uuid4(), content_hash="a" * 64, ) self.assertEqual(result, identifier) async def test_idempotent_changed_content_is_conflict(self) -> None: conn = AsyncMock() conn.fetchrow.return_value = { "observation_id": uuid4(), "content_hash": "a" * 64, } with self.assertRaises( calibration_transfer_store.CalibrationTransferConflictError ): await calibration_transfer_store._existing_by_submission( conn, table="app.calibration_performance_observation", submission_id=uuid4(), content_hash="b" * 64, ) async def test_post_lock_revision_db_guard_maps_to_state_error(self) -> None: principal = _principal(Role.LEARNER) learner_id = UUID(principal.user_id) session_id = uuid4() history_id = uuid4() prior_revision_id = uuid4() conn = AsyncMock() conn.fetchrow.side_effect = [ {"id": session_id, "learner_id": learner_id, "case_id": uuid4()}, None, { "history_id": history_id, "session_id": session_id, "learner_id": learner_id, "competency_id": "competency.empathic-check", "practice_block_id": "oas-g5-block-one", "scenario_variant_id": "variant-1", "phrase_family_id": "phrase-1", }, asyncpg.ObjectNotInPrerequisiteStateError( "self-prediction cannot be revised after lock or external reveal" ), ] @asynccontextmanager async def fake_acquire(**_kwargs): yield conn with patch.object(calibration_transfer_store.db, "acquire", fake_acquire): with self.assertRaises( calibration_transfer_store.CalibrationTransferStateError ): await calibration_transfer_store.append_prediction_revision( principal=principal, submission_id=uuid4(), prediction_revision_id=uuid4(), history_id=history_id, session_id=session_id, competency_id="competency.empathic-check", practice_block_id="oas-g5-block-one", scenario_variant_id="variant-1", phrase_family_id="phrase-1", revision_no=2, supersedes_prediction_revision_id=prior_revision_id, predicted_success_probability=0.8, confidence=0.8, recorded_sequence=2, revision_reason="잠금 뒤 수정 차단", instrument_id="calibration-mirror-g5", instrument_version="1.0.0", evidence_turn_ids=(), ) async def test_evidence_refs_are_uuid_only(self) -> None: self.assertEqual( calibration_transfer_store._uuid_evidence( ["30000000-0000-4000-8000-000000000001"], required=True ), (UUID("30000000-0000-4000-8000-000000000001"),), ) with self.assertRaises( calibration_transfer_store.CalibrationTransferStateError ): calibration_transfer_store._uuid_evidence( ["상담 축어록 본문"], required=True ) async def test_learner_cannot_append_teacher_review(self) -> None: with self.assertRaisesRegex( calibration_transfer_store.CalibrationTransferStateError, "teacher or admin", ): await calibration_transfer_store.append_teacher_review( principal=_principal(Role.LEARNER), submission_id=uuid4(), review_id=uuid4(), target_kind="calibration_assessment", target_id=uuid4(), disposition="confirmed", correction_payload={}, review_reason="확인", evidence_turn_ids=(), counterevidence=(), ) async def test_model_observation_is_appended_with_lock_provenance(self) -> None: session_id = uuid4() learner_id = uuid4() lock_id = uuid4() history_id = uuid4() observation_id = uuid4() conn = AsyncMock() conn.fetchrow.side_effect = [ { "history_id": history_id, "session_id": session_id, "learner_id": learner_id, "competency_id": "competency.empathic-check", "practice_block_id": "oas-g5-block-one", "scenario_variant_id": "variant-1", "phrase_family_id": "phrase-1", "lock_id": lock_id, }, None, {"observation_id": observation_id}, ] result = await calibration_transfer_store.append_performance_observation( conn=conn, submission_id=uuid4(), observation_id=observation_id, history_id=history_id, status="passed", source_kind="model_inferred", perspective="independent_observer", model_run_id=uuid4(), instrument_id="g5-performance", instrument_version="1.0.0", uncertainty=0.2, evidence_turn_ids=(uuid4(),), counterevidence=(), revealed_sequence=3, ) self.assertEqual(result["observation_id"], observation_id) insert = conn.fetchrow.await_args_list[-1] self.assertIn("prediction_lock_id", insert.args[0]) self.assertEqual(insert.args[5], lock_id) async def test_empty_read_is_role_safe_for_learner(self) -> None: principal = _principal(Role.LEARNER) conn = AsyncMock() conn.fetch.side_effect = [[], [], [], [], [], [], []] @asynccontextmanager async def fake_acquire(**kwargs): self.assertEqual(kwargs["role"], "learner") self.assertEqual(kwargs["user_id"], principal.user_id) yield conn with patch.object(calibration_transfer_store.db, "acquire", fake_acquire): result = await calibration_transfer_store.read_calibration_transfer( principal=principal ) self.assertEqual(result["requested_view"], "learner") self.assertEqual(result["prediction_histories"], []) self.assertEqual(result["transfer_suites"], []) self.assertFalse(result["clinical_claim_allowed"]) class CalibrationTransferInternalAuthenticationTests( unittest.IsolatedAsyncioTestCase ): TOKEN = "g5-calibration-transfer-token-at-least-32-characters" async def _assert_rejected_before_db( self, configured: str, presented: str | None, expected_status: int ) -> None: reached = False async def fake_provider(): nonlocal reached reached = True yield AsyncMock() settings = SimpleNamespace( calibration_transfer_internal_token=SecretStr(configured) ) with patch.object( calibration_transfer, "_evaluator_db_provider", fake_provider ): dependency = ( calibration_transfer.calibration_transfer_internal_evaluator_db( settings=settings, presented_token=presented ) ) with self.assertRaises(HTTPException) as captured: await anext(dependency) self.assertEqual(captured.exception.status_code, expected_status) self.assertFalse(reached) async def test_unconfigured_token_fails_closed(self) -> None: await self._assert_rejected_before_db("", None, 503) async def test_missing_token_is_401(self) -> None: await self._assert_rejected_before_db(self.TOKEN, None, 401) async def test_wrong_token_is_403(self) -> None: await self._assert_rejected_before_db(self.TOKEN, "wrong-token", 403) async def test_valid_token_acquires_evaluator_connection(self) -> None: conn = AsyncMock() async def fake_provider(): yield conn settings = SimpleNamespace( calibration_transfer_internal_token=SecretStr(self.TOKEN) ) with patch.object( calibration_transfer, "_evaluator_db_provider", fake_provider ): dependency = ( calibration_transfer.calibration_transfer_internal_evaluator_db( settings=settings, presented_token=self.TOKEN ) ) self.assertIs(await anext(dependency), conn) await dependency.aclose() if __name__ == "__main__": unittest.main()