"""Focused G2 persistence adapter and HTTP boundary tests.""" from __future__ import annotations import unittest from datetime import UTC, datetime from pathlib import Path from unittest.mock import AsyncMock, patch from uuid import UUID from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from pydantic import ValidationError from .contracts.outcome_trajectory import LongitudinalOutcomeInput from .deps import Principal, Role, get_current_principal from .routes import outcome_trajectories from .services import outcome_trajectory_store from .services.outcome_trajectory import ( assess_longitudinal_outcome, load_trajectory_benchmark, ) SESSION_ID = UUID("00000000-0000-0000-0000-00000000b101") MEASUREMENT_ID = UUID("00000000-0000-0000-0000-00000000b102") TURN_ID = UUID("00000000-0000-0000-0000-00000000b103") REVISION_ID = UUID("00000000-0000-0000-0000-00000000b104") SUBMISSION_ID = UUID("00000000-0000-0000-0000-00000000b108") MEMORY_EVENT_ID = UUID("00000000-0000-0000-0000-00000000b109") BENCHMARK_PATH = ( Path(__file__).resolve().parent / "data" / "outcome_trajectory_benchmark_g2.v1.json" ) def _principal(role: Role = Role.LEARNER) -> Principal: return Principal( user_id="00000000-0000-0000-0000-00000000b105", role=role, cohort_ids=["e2e-hanshin"], ) def _measurement(**overrides: object) -> dict[str, object]: row: dict[str, object] = { "measurement_id": MEASUREMENT_ID, "status": "ready", "error_code": None, "value": 6.0, "scale_min": 0.0, "scale_max": 10.0, "confidence": 0.8, "source_kind": "simulated_state", "perspective": "client_simulation", "instrument_id": "vignette-session-outcome", "instrument_version": "1.0.0", "model_run_id": None, "evidence_turn_ids": (TURN_ID,), "created_at": datetime(2026, 8, 6, 8, 0, tzinfo=UTC), } row.update(overrides) return row class OutcomeObservationAdapterTest(unittest.TestCase): def test_ready_measurement_is_normalized_with_full_provenance(self) -> None: observation, snapshot = outcome_trajectory_store._observation_from_measurement( session_no=1, axis="distress_load", measurement=_measurement(), ) self.assertEqual(observation.status, "observed") self.assertEqual(observation.value, 0.6) self.assertEqual(observation.evidence_refs, (str(TURN_ID),)) self.assertEqual(snapshot["raw_value"], 6.0) self.assertEqual(snapshot["measurement_id"], MEASUREMENT_ID) def test_simulated_state_without_turn_ids_uses_measurement_provenance(self) -> None: observation, snapshot = outcome_trajectory_store._observation_from_measurement( session_no=1, axis="daily_functioning", measurement=_measurement(evidence_turn_ids=()), ) self.assertEqual(observation.status, "observed") self.assertEqual(observation.evidence_refs, (f"measurement:{MEASUREMENT_ID}",)) self.assertEqual(snapshot["raw_value"], 6.0) def test_learner_self_report_is_its_own_evidence_without_turn_ids(self) -> None: observation, snapshot = outcome_trajectory_store._observation_from_measurement( session_no=1, axis="daily_functioning", measurement=_measurement( source_kind="learner_reported", perspective="learner_self_report", instrument_id="vignette-session-outcome-checkin", evidence_turn_ids=(), ), ) self.assertEqual(observation.status, "observed") self.assertEqual( observation.evidence_refs, (f"measurement:{MEASUREMENT_ID}",), ) self.assertEqual(snapshot["evidence_refs"], observation.evidence_refs) def test_model_observation_still_requires_transcript_evidence(self) -> None: observation, _ = outcome_trajectory_store._observation_from_measurement( session_no=1, axis="daily_functioning", measurement=_measurement( source_kind="model_inferred", perspective="independent_observer", model_run_id=UUID("00000000-0000-0000-0000-00000000b110"), evidence_turn_ids=(), ), ) self.assertEqual(observation.status, "missing") self.assertEqual(observation.missing_reason, "measurement_evidence_missing") def test_error_measurement_never_reuses_its_stale_value(self) -> None: observation, snapshot = outcome_trajectory_store._observation_from_measurement( session_no=1, axis="learning_engagement", measurement=_measurement(status="error", error_code="engine_timeout"), ) self.assertEqual(observation.status, "error") self.assertIsNone(observation.value) self.assertEqual( observation.missing_reason, "measurement_error:engine_timeout" ) self.assertIsNone(snapshot["value"]) def test_fingerprint_changes_with_source_measurement_revision(self) -> None: _, snapshot = outcome_trajectory_store._observation_from_measurement( session_no=1, axis="distress_load", measurement=_measurement(), ) changed = dict(snapshot) changed["measurement_id"] = UUID( "00000000-0000-0000-0000-00000000b106" ) first = outcome_trajectory_store._evidence_fingerprint( expected_arc_hash="a" * 64, snapshots=[snapshot] ) second = outcome_trajectory_store._evidence_fingerprint( expected_arc_hash="a" * 64, snapshots=[changed] ) self.assertNotEqual(first, second) def test_human_view_keeps_learner_and_supervisor_memory_separate(self) -> None: self.assertEqual(outcome_trajectory_store._human_view(_principal()), "counselor") self.assertEqual( outcome_trajectory_store._human_view(_principal(Role.TEACHER)), "supervisor", ) def test_submission_idempotency_returns_same_three_measurements(self) -> None: submission_hash = outcome_trajectory_store._submission_hash( submission_id=SUBMISSION_ID, scores={axis: 0.5 for axis in ("distress_load", "daily_functioning", "learning_engagement")}, confidences={axis: 0.8 for axis in ("distress_load", "daily_functioning", "learning_engagement")}, evidence_turn_ids=(), ) ids = [ UUID("00000000-0000-0000-0000-00000000b11" + str(index)) for index in range(1, 4) ] rows = [ { "measurement_id": measurement_id, "dimension": axis, "metadata": {"submission_hash": submission_hash}, } for axis, measurement_id in zip( ("distress_load", "daily_functioning", "learning_engagement"), ids, strict=True, ) ] self.assertEqual( outcome_trajectory_store._existing_submission_measurement_ids( rows, submission_hash=submission_hash ), ids, ) with self.assertRaises(outcome_trajectory_store.OutcomeTrajectoryConflictError): outcome_trajectory_store._existing_submission_measurement_ids( rows, submission_hash="c" * 64 ) class OutcomeTrajectoryRouteTest(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: pack = load_trajectory_benchmark(BENCHMARK_PATH) assessment = assess_longitudinal_outcome( LongitudinalOutcomeInput( expected_arc=pack.expected_arc, sessions=(pack.cases[0].sessions[0],), ) ).model_dump(mode="json") assessment["sessions"][0]["safety_signals"] = [] cls.payload = { "session_id": SESSION_ID, "revision_id": REVISION_ID, "revision_no": 1, "supersedes_revision_id": None, "source_fingerprint": "b" * 64, "recompute_reason": "initial_computation", "computed_at": "2026-08-06T08:00:00+00:00", "notice_ko": outcome_trajectory_store.NON_CLINICAL_NOTICE_KO, "expected_arc": { "schema_version": pack.expected_arc.schema_version, "arc_id": pack.expected_arc.arc_id, "title_ko": pack.expected_arc.title_ko, "data_classification": "synthetic_educational", "clinical_claim_allowed": False, "provenance_note": pack.expected_arc.provenance_note, "session_count": 5, "distributions": [ item.model_dump(mode="json") for item in pack.expected_arc.distributions ], }, "assessment": assessment, "next_questions": [], "observations": [ { "measurement_id": MEASUREMENT_ID, "session_id": SESSION_ID, "session_no": 1, "axis": axis, "status": "observed", "value": 0.5, "raw_value": 0.5, "scale_min": 0.0, "scale_max": 1.0, "confidence": 0.8, "source_kind": "simulated_state", "perspective": "client_simulation", "instrument_id": "vignette-session-outcome", "instrument_version": "1.0.0", "model_run_id": None, "evidence_refs": [str(TURN_ID)], "missing_reason": None, "occurred_at": "2026-08-06T07:59:00+00:00", } for axis in ( "distress_load", "daily_functioning", "learning_engagement", ) ], "safety_signals": [ { "safety_event_id": "44", "session_no": 1, "risk_level": "high", "escalated": True, "evidence_refs": [str(TURN_ID)], } ], "relationship_memory": [], } async def test_get_exposes_synthetic_nonclinical_label_and_separate_safety(self) -> None: with patch.object( outcome_trajectories.outcome_trajectory_store, "read_outcome_trajectory", AsyncMock(return_value=self.payload), ) as read: response = await outcome_trajectories.get_outcome_trajectory( SESSION_ID, _principal() ) self.assertFalse(response.expected_arc.clinical_claim_allowed) self.assertEqual( response.expected_arc.data_classification, "synthetic_educational" ) self.assertEqual(len(response.safety_signals), 1) self.assertEqual(response.assessment.sessions[0].safety_signals, ()) read.assert_awaited_once_with( principal=unittest.mock.ANY, session_id=SESSION_ID, ) async def test_recompute_always_requests_new_revision(self) -> None: changed = dict(self.payload) changed.update( { "revision_no": 2, "supersedes_revision_id": REVISION_ID, "revision_id": UUID("00000000-0000-0000-0000-00000000b107"), "recompute_reason": "교수자 재검토", } ) with patch.object( outcome_trajectories.outcome_trajectory_store, "read_outcome_trajectory", AsyncMock(return_value=changed), ) as read: response = await outcome_trajectories.recompute_outcome_trajectory( SESSION_ID, outcome_trajectories.OutcomeTrajectoryRecomputeRequest( reason=" 교수자 재검토 " ), _principal(Role.TEACHER), ) self.assertEqual(response.revision_no, 2) self.assertEqual(response.supersedes_revision_id, REVISION_ID) read.assert_awaited_once_with( principal=unittest.mock.ANY, session_id=SESSION_ID, force_recompute=True, recompute_reason="교수자 재검토", ) async def test_hidden_session_maps_to_404(self) -> None: with patch.object( outcome_trajectories.outcome_trajectory_store, "read_outcome_trajectory", AsyncMock( side_effect=outcome_trajectory_store.OutcomeTrajectoryNotFoundError( "session not found or not visible" ) ), ): with self.assertRaises(HTTPException) as raised: await outcome_trajectories.get_outcome_trajectory( SESSION_ID, _principal(Role.TEACHER) ) self.assertEqual(raised.exception.status_code, 404) async def test_outcome_submission_returns_latest_trajectory_and_event_ids(self) -> None: payload = dict(self.payload) payload.update( { "submission_id": SUBMISSION_ID, "submitted_measurement_ids": [ UUID("00000000-0000-0000-0000-00000000b111"), UUID("00000000-0000-0000-0000-00000000b112"), UUID("00000000-0000-0000-0000-00000000b113"), ], } ) body = outcome_trajectories.OutcomeObservationSubmissionRequest( submission_id=SUBMISSION_ID, scores=outcome_trajectories.OutcomeAxisValues( distress_load=0.7, daily_functioning=0.4, learning_engagement=0.8, ), confidences=outcome_trajectories.OutcomeAxisValues( distress_load=0.9, daily_functioning=0.8, learning_engagement=0.85, ), ) with patch.object( outcome_trajectories.outcome_trajectory_store, "submit_outcome_observations", AsyncMock(return_value=payload), ) as submit: response = await outcome_trajectories.create_outcome_observations( SESSION_ID, body, _principal() ) self.assertEqual(response.submission_id, SUBMISSION_ID) self.assertEqual(len(response.submitted_measurement_ids), 3) self.assertEqual(response.revision_id, REVISION_ID) submit.assert_awaited_once_with( principal=unittest.mock.ANY, session_id=SESSION_ID, submission_id=SUBMISSION_ID, scores={ "distress_load": 0.7, "daily_functioning": 0.4, "learning_engagement": 0.8, }, confidences={ "distress_load": 0.9, "daily_functioning": 0.8, "learning_engagement": 0.85, }, evidence_turn_ids=(), ) async def test_submission_content_conflict_maps_to_409(self) -> None: body = outcome_trajectories.OutcomeObservationSubmissionRequest( submission_id=SUBMISSION_ID, scores=outcome_trajectories.OutcomeAxisValues( distress_load=0.5, daily_functioning=0.5, learning_engagement=0.5, ), confidences=outcome_trajectories.OutcomeAxisValues( distress_load=0.8, daily_functioning=0.8, learning_engagement=0.8, ), ) with patch.object( outcome_trajectories.outcome_trajectory_store, "submit_outcome_observations", AsyncMock( side_effect=outcome_trajectory_store.OutcomeTrajectoryConflictError( "submission_id was already used" ) ), ): with self.assertRaises(HTTPException) as raised: await outcome_trajectories.create_outcome_observations( SESSION_ID, body, _principal() ) self.assertEqual(raised.exception.status_code, 409) async def test_relationship_memory_route_preserves_resolve_and_evidence(self) -> None: body = outcome_trajectories.RelationshipMemoryCreateRequest( event_type="repair_confirmed", summaries={ "supervisor": "과제 부담을 재확인하고 더 작은 연습으로 합의했다." }, evidence_turn_ids=(TURN_ID,), resolves_event_id=MEMORY_EVENT_ID, ) created_id = UUID("00000000-0000-0000-0000-00000000b114") with patch.object( outcome_trajectories.outcome_trajectory_store, "append_relationship_memory_event", AsyncMock(return_value=created_id), ) as append: response = await outcome_trajectories.create_relationship_memory_event( SESSION_ID, body, _principal(Role.TEACHER) ) self.assertEqual(response.memory_event_id, created_id) append.assert_awaited_once_with( principal=unittest.mock.ANY, session_id=SESSION_ID, event_type="repair_confirmed", summaries={ "supervisor": "과제 부담을 재확인하고 더 작은 연습으로 합의했다." }, evidence_turn_ids=(TURN_ID,), resolves_event_id=MEMORY_EVENT_ID, ) def test_relationship_request_rejects_invalid_resolve_contract(self) -> None: with self.assertRaises(ValidationError): outcome_trajectories.RelationshipMemoryCreateRequest( event_type="goal_agreement", summaries={"supervisor": "목표 합의"}, evidence_turn_ids=(TURN_ID,), resolves_event_id=MEMORY_EVENT_ID, ) with self.assertRaises(ValidationError): outcome_trajectories.RelationshipMemoryCreateRequest( event_type="repair_confirmed", summaries={"supervisor": "복구 확인"}, evidence_turn_ids=(TURN_ID,), ) async def test_learner_cannot_author_relationship_memory(self) -> None: with self.assertRaises(outcome_trajectory_store.OutcomeTrajectoryStateError): await outcome_trajectory_store.append_relationship_memory_event( principal=_principal(Role.LEARNER), session_id=SESSION_ID, event_type="goal_agreement", summaries={"counselor": "목표 합의"}, evidence_turn_ids=(TURN_ID,), ) class OutcomeTrajectoryHttpRoleTest(unittest.TestCase): def _client(self, principal: Principal) -> TestClient: app = FastAPI() app.include_router(outcome_trajectories.router) app.dependency_overrides[get_current_principal] = lambda: principal return TestClient(app) def test_learner_is_denied_relationship_memory_authoring(self) -> None: with patch.object( outcome_trajectories.outcome_trajectory_store, "append_relationship_memory_event", AsyncMock(), ) as append: response = self._client(_principal(Role.LEARNER)).post( f"/sessions/{SESSION_ID}/relationship-memory-events", json={ "event_type": "goal_agreement", "summaries": {"counselor": "회기 목표를 합의했다."}, "evidence_turn_ids": [str(TURN_ID)], }, ) self.assertEqual(response.status_code, 403) append.assert_not_awaited() def test_teacher_is_denied_learner_outcome_submission(self) -> None: with patch.object( outcome_trajectories.outcome_trajectory_store, "submit_outcome_observations", AsyncMock(), ) as submit: response = self._client(_principal(Role.TEACHER)).post( f"/sessions/{SESSION_ID}/outcome-observations", json={ "submission_id": str(SUBMISSION_ID), "scores": { "distress_load": 0.5, "daily_functioning": 0.5, "learning_engagement": 0.5, }, "confidences": { "distress_load": 0.8, "daily_functioning": 0.8, "learning_engagement": 0.8, }, }, ) self.assertEqual(response.status_code, 403) submit.assert_not_awaited() if __name__ == "__main__": unittest.main()