1251 lines
52 KiB
Python
1251 lines
52 KiB
Python
"""Regression tests for persona approval and faculty review boundaries."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import unittest
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from . import persona_generation_contract, persona_read_model, persona_repository, session_persistence
|
|
from .deps import Principal, Role
|
|
from .engine_client import GenerateResponse
|
|
from .persona_repository import PersonaDraftRecord, PersonaReviewItem
|
|
from .routes import personas, sessions
|
|
from .services import persona as persona_service
|
|
|
|
|
|
def _principal(role: Role = Role.LEARNER) -> Principal:
|
|
return Principal(
|
|
user_id="00000000-0000-0000-0000-000000000901",
|
|
role=role,
|
|
cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
|
|
email=f"{role.value}@example.test",
|
|
display_name=role.value.title(),
|
|
consent_at=1.0 if role == Role.LEARNER else None,
|
|
profile_completed_at=1.0 if role == Role.LEARNER else None,
|
|
)
|
|
|
|
|
|
def _card_row(
|
|
card: persona_service.PersonaCard,
|
|
*,
|
|
persona_id: str,
|
|
status: str,
|
|
version: int = 1,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"persona_id": persona_id,
|
|
"code": card.code,
|
|
"version": version,
|
|
"status": status,
|
|
"display_name": card.display_name,
|
|
"difficulty": card.difficulty,
|
|
"theory_target": list(card.theory_target),
|
|
"demographics": dict(card.demographics),
|
|
"presenting": dict(card.presenting),
|
|
"history": dict(card.history),
|
|
"big5": dict(card.big5),
|
|
"resistance": dict(card.resistance),
|
|
"speech_style": dict(card.speech_style),
|
|
"affect_baseline": dict(card.affect_baseline),
|
|
"ccd": dict(card.ccd),
|
|
"dsm5_dimensional": dict(card.dsm5_dimensional),
|
|
"triggers": dict(card.triggers),
|
|
"source_provenance": card.source_provenance,
|
|
"is_synthetic": card.is_synthetic,
|
|
"created_at": "2026-01-01T00:00:00",
|
|
"approved_at": "2026-01-02T00:00:00" if status == "approved" else None,
|
|
}
|
|
|
|
|
|
class _Acquire:
|
|
def __init__(self, conn: "_PersonaCardConn") -> None:
|
|
self.conn = conn
|
|
|
|
async def __aenter__(self) -> "_PersonaCardConn":
|
|
return self.conn
|
|
|
|
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
return None
|
|
|
|
|
|
class _PersonaCardConn:
|
|
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
|
self.rows = rows
|
|
self.fetch_calls: list[tuple[str, tuple[Any, ...]]] = []
|
|
self.fetchrow_calls: list[tuple[str, tuple[Any, ...]]] = []
|
|
self.fetchval_calls: list[tuple[str, tuple[Any, ...]]] = []
|
|
self.execute_calls: list[tuple[str, tuple[Any, ...]]] = []
|
|
|
|
async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
|
|
self.fetch_calls.append((query, args))
|
|
return self._filter_rows(query, args)
|
|
|
|
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
|
|
self.fetchrow_calls.append((query, args))
|
|
if "UPDATE app.persona_card" in query:
|
|
if "archived AS" in query:
|
|
persona_id = str(args[0])
|
|
target = next(
|
|
(row for row in self.rows if row["persona_id"] == persona_id and row["status"] != "archived"),
|
|
None,
|
|
)
|
|
if target is None:
|
|
return None
|
|
code = str(target["code"]).upper()
|
|
returned: dict[str, Any] | None = None
|
|
for row in self.rows:
|
|
if str(row["code"]).upper() == code and row["status"] != "archived":
|
|
row["status"] = "archived"
|
|
row["approved_by"] = None
|
|
row["approved_at"] = None
|
|
if row["persona_id"] == persona_id:
|
|
returned = row
|
|
return returned
|
|
if "display_name = $4" in query:
|
|
persona_id = str(args[0])
|
|
next_status = str(args[2])
|
|
for row in self.rows:
|
|
if row["persona_id"] != persona_id or row["status"] not in {"draft", "review"}:
|
|
continue
|
|
row.update(
|
|
{
|
|
"code": str(args[1]).upper(),
|
|
"status": next_status,
|
|
"display_name": args[3],
|
|
"difficulty": args[4],
|
|
"theory_target": list(args[5]),
|
|
"demographics": dict(args[6]),
|
|
"presenting": dict(args[7]),
|
|
"history": dict(args[8]),
|
|
"big5": dict(args[9]),
|
|
"resistance": dict(args[10]),
|
|
"speech_style": dict(args[11]),
|
|
"affect_baseline": dict(args[12]),
|
|
"ccd": dict(args[13]),
|
|
"dsm5_dimensional": dict(args[14]),
|
|
"source_provenance": args[15],
|
|
"is_synthetic": bool(args[16]),
|
|
"triggers": dict(args[17]),
|
|
"approved_by": None,
|
|
"approved_at": None,
|
|
}
|
|
)
|
|
return row
|
|
return None
|
|
persona_id = str(args[0])
|
|
next_status = str(args[1])
|
|
approved_by = args[2]
|
|
for row in self.rows:
|
|
if row["persona_id"] != persona_id or row["status"] not in {"draft", "review"}:
|
|
continue
|
|
row["status"] = next_status
|
|
row["approved_by"] = approved_by
|
|
row["approved_at"] = "2026-01-03T00:00:00" if next_status == "approved" else None
|
|
return row
|
|
return None
|
|
if "INSERT INTO app.persona_card" in query:
|
|
row = {
|
|
"persona_id": str(args[0]),
|
|
"code": str(args[1]).upper(),
|
|
"version": int(args[2]),
|
|
"status": str(args[3]),
|
|
"display_name": args[4],
|
|
"difficulty": args[5],
|
|
"theory_target": list(args[6]),
|
|
"demographics": dict(args[7]),
|
|
"presenting": dict(args[8]),
|
|
"history": dict(args[9]),
|
|
"big5": dict(args[10]),
|
|
"resistance": dict(args[11]),
|
|
"speech_style": dict(args[12]),
|
|
"affect_baseline": dict(args[13]),
|
|
"ccd": dict(args[14]),
|
|
"dsm5_dimensional": dict(args[15]),
|
|
"source_provenance": args[16],
|
|
"is_synthetic": bool(args[17]),
|
|
"triggers": dict(args[18]),
|
|
"created_by": args[19],
|
|
"approved_by": None,
|
|
"created_at": "2026-01-04T00:00:00",
|
|
"approved_at": None,
|
|
}
|
|
self.rows.append(row)
|
|
return row
|
|
if "upper(code) = upper($1)" in query and "status IN ('draft', 'review')" in query:
|
|
code = str(args[0]).upper()
|
|
matches = [
|
|
row
|
|
for row in self.rows
|
|
if str(row["code"]).upper() == code and row["status"] in {"draft", "review"}
|
|
]
|
|
matches.sort(key=lambda row: int(row["version"]), reverse=True)
|
|
return matches[0] if matches else None
|
|
if "WHERE persona_id = $1::uuid" in query:
|
|
persona_id = str(args[0])
|
|
for row in self.rows:
|
|
if row["persona_id"] == persona_id and "status = 'approved'" in query:
|
|
if row["status"] == "approved":
|
|
return row
|
|
return None
|
|
if row["persona_id"] == persona_id and row["status"] in {"draft", "review"}:
|
|
return row
|
|
return None
|
|
rows = self._filter_rows(query, args)
|
|
code = str(args[0]).upper() if args else ""
|
|
matches = [row for row in rows if str(row["code"]).upper() == code]
|
|
matches.sort(key=lambda row: int(row["version"]), reverse=True)
|
|
return matches[0] if matches else None
|
|
|
|
async def fetchval(self, query: str, *args: Any) -> int:
|
|
self.fetchval_calls.append((query, args))
|
|
code = str(args[0]).upper()
|
|
versions = [int(row["version"]) for row in self.rows if str(row["code"]).upper() == code]
|
|
return (max(versions) if versions else 0) + 1
|
|
|
|
async def execute(self, query: str, *args: Any) -> str:
|
|
self.execute_calls.append((query, args))
|
|
return "INSERT 0 1"
|
|
|
|
def _filter_rows(self, query: str, args: tuple[Any, ...]) -> list[dict[str, Any]]:
|
|
if "WHERE status = 'approved'" in query:
|
|
return [row for row in self.rows if row["status"] == "approved"]
|
|
if "status = ANY($1::text[])" in query:
|
|
statuses = {str(status) for status in args[0]}
|
|
return [row for row in self.rows if row["status"] in statuses]
|
|
return list(self.rows)
|
|
|
|
|
|
def _draft_payload(
|
|
card: persona_service.PersonaCard,
|
|
*,
|
|
submit_for_review: bool = False,
|
|
) -> personas.PersonaDraftPayload:
|
|
return personas.PersonaDraftPayload(
|
|
code=card.code,
|
|
display_name=card.display_name,
|
|
difficulty=card.difficulty, # type: ignore[arg-type]
|
|
theory_target=list(card.theory_target),
|
|
demographics=dict(card.demographics),
|
|
presenting=dict(card.presenting),
|
|
history=dict(card.history),
|
|
big5=dict(card.big5),
|
|
resistance=dict(card.resistance),
|
|
speech_style=dict(card.speech_style),
|
|
affect_baseline=dict(card.affect_baseline),
|
|
ccd=dict(card.ccd),
|
|
dsm5_dimensional=dict(card.dsm5_dimensional),
|
|
triggers=dict(card.triggers),
|
|
source_provenance=card.source_provenance,
|
|
is_synthetic=card.is_synthetic,
|
|
submit_for_review=submit_for_review,
|
|
)
|
|
|
|
|
|
class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
|
|
def test_catalog_summary_prefers_complaint_over_surface_condition(self) -> None:
|
|
card = replace(
|
|
persona_service.SEED_PERSONAS["P1"],
|
|
presenting={
|
|
"surface": "상담자가 해결책을 서두르지 않을 때 깊은 이야기가 드러남",
|
|
"complaint": "학교에 가기 싫고 모든 일이 무의미하게 느껴짐",
|
|
},
|
|
)
|
|
entry = persona_repository.CatalogPersona(
|
|
card=card,
|
|
persona_id="00000000-0000-0000-0000-000000000031",
|
|
version=2,
|
|
source="database",
|
|
)
|
|
|
|
summary = persona_read_model.persona_summary(entry)
|
|
|
|
self.assertEqual(
|
|
summary.presenting_summary,
|
|
"학교에 가기 싫고 모든 일이 무의미하게 느껴짐",
|
|
)
|
|
|
|
def test_persona_seed_runner_reports_repo_manifest_without_db(self) -> None:
|
|
script_path = Path(__file__).resolve().parents[3] / "scripts" / "materialize-persona-seeds.py"
|
|
spec = importlib.util.spec_from_file_location("materialize_persona_seeds", script_path)
|
|
self.assertIsNotNone(spec)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
rows = module._manifest_rows()
|
|
|
|
self.assertEqual([row["code"] for row in rows], ["P1", "P2", "P3", "P4", "P5", "P6", "P7"])
|
|
self.assertEqual({row["version"] for row in rows}, {persona_repository.SEED_VERSION})
|
|
self.assertEqual(rows[0]["persona_id"], str(persona_repository.seed_persona_id("P1")))
|
|
self.assertIn("source_provenance", rows[0])
|
|
|
|
async def test_persona_seed_runner_apply_initializes_and_closes_pool(self) -> None:
|
|
script_path = Path(__file__).resolve().parents[3] / "scripts" / "materialize-persona-seeds.py"
|
|
spec = importlib.util.spec_from_file_location("materialize_persona_seeds_apply", script_path)
|
|
self.assertIsNotNone(spec)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
init_pool = AsyncMock()
|
|
materialize = AsyncMock(return_value=7)
|
|
close_pool = AsyncMock()
|
|
with (
|
|
patch.object(module, "init_pool", init_pool),
|
|
patch.object(module, "materialize_seed_personas", materialize),
|
|
patch.object(module, "close_pool", close_pool),
|
|
):
|
|
exit_code = await module._main_async(module.parse_args(["--apply"]))
|
|
|
|
self.assertEqual(exit_code, 0)
|
|
init_pool.assert_awaited_once()
|
|
materialize.assert_awaited_once()
|
|
close_pool.assert_awaited_once()
|
|
|
|
async def test_repository_loads_repo_persona_files_for_p4_to_p7(self) -> None:
|
|
cards = persona_repository.load_file_personas()
|
|
|
|
self.assertEqual([card.code for card in cards], ["P4", "P5", "P6", "P7"])
|
|
self.assertEqual(cards[0].display_name, "하늘(가명) · 고2 · 학업/시험 불안")
|
|
self.assertEqual(cards[0].difficulty, "easy")
|
|
self.assertIn("cbt", cards[0].theory_target)
|
|
|
|
async def test_seed_fallback_includes_repo_persona_files(self) -> None:
|
|
entry = persona_repository.seed_fallback_persona("p4")
|
|
|
|
self.assertIsNotNone(entry)
|
|
assert entry is not None
|
|
self.assertEqual(entry.card.code, "P4")
|
|
self.assertEqual(entry.source, "seed_fallback")
|
|
self.assertTrue(entry.degraded)
|
|
|
|
async def test_materialize_seed_personas_upserts_code_and_repo_file_cards(self) -> None:
|
|
conn = _PersonaCardConn([])
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
count = await persona_repository.materialize_seed_personas()
|
|
|
|
self.assertEqual(count, 7)
|
|
persona_card_calls = [
|
|
(query, args)
|
|
for query, args in conn.execute_calls
|
|
if "INSERT INTO app.persona_card" in query
|
|
]
|
|
voice_map_calls = [
|
|
(query, args)
|
|
for query, args in conn.execute_calls
|
|
if "INSERT INTO app.persona_voice_map" in query
|
|
]
|
|
inserted_codes = [args[1] for _, args in persona_card_calls]
|
|
self.assertEqual(inserted_codes, ["P1", "P2", "P3", "P4", "P5", "P6", "P7"])
|
|
self.assertEqual(len(voice_map_calls), 7)
|
|
self.assertEqual(voice_map_calls[0][1][2], "coral")
|
|
self.assertEqual(voice_map_calls[0][1][3]["preset"], "soft-young-fem")
|
|
|
|
async def test_materialize_seed_personas_does_not_overwrite_existing_rows(self) -> None:
|
|
conn = _PersonaCardConn([])
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
await persona_repository.materialize_seed_personas()
|
|
|
|
query, _ = conn.execute_calls[0]
|
|
self.assertIn("ON CONFLICT (code, version) DO NOTHING", query)
|
|
self.assertNotIn("DO UPDATE SET", query)
|
|
|
|
async def test_catalog_repository_lists_only_approved_personas(self) -> None:
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P1,
|
|
persona_id="00000000-0000-0000-0000-000000000001",
|
|
status="approved",
|
|
),
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id="00000000-0000-0000-0000-000000000002",
|
|
status="draft",
|
|
),
|
|
_card_row(
|
|
persona_service.P3,
|
|
persona_id="00000000-0000-0000-0000-000000000003",
|
|
status="review",
|
|
),
|
|
]
|
|
)
|
|
acquire_calls: list[dict[str, Any]] = []
|
|
|
|
def fake_acquire(**kwargs: Any) -> _Acquire:
|
|
acquire_calls.append(kwargs)
|
|
return _Acquire(conn)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", fake_acquire),
|
|
):
|
|
result = await persona_repository.list_approved_personas()
|
|
|
|
self.assertEqual([entry.card.code for entry in result], ["P1"])
|
|
self.assertEqual(acquire_calls, [{"ai_context": True}])
|
|
self.assertIn("WHERE status = 'approved'", conn.fetch_calls[0][0])
|
|
|
|
async def test_start_lookup_ignores_draft_or_review_persona_versions(self) -> None:
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id="00000000-0000-0000-0000-000000000102",
|
|
status="draft",
|
|
version=2,
|
|
),
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id="00000000-0000-0000-0000-000000000101",
|
|
status="review",
|
|
version=1,
|
|
),
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
result = await persona_repository.get_approved_persona("p2")
|
|
|
|
self.assertIsNone(result)
|
|
query, args = conn.fetchrow_calls[0]
|
|
self.assertIn("WHERE status = 'approved'", query)
|
|
self.assertEqual(args, ("P2",))
|
|
|
|
async def test_session_start_rejects_persona_without_approved_catalog_entry(self) -> None:
|
|
principal = _principal(Role.LEARNER)
|
|
|
|
with (
|
|
patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=None)) as get_persona,
|
|
patch.object(
|
|
sessions.session_persistence,
|
|
"create_session",
|
|
AsyncMock(side_effect=AssertionError("draft persona must not start a session")),
|
|
) as create_session,
|
|
):
|
|
with self.assertRaises(HTTPException) as caught:
|
|
await sessions.start_session(
|
|
sessions.SessionStartRequest(persona_code="P2"),
|
|
principal,
|
|
)
|
|
|
|
self.assertEqual(caught.exception.status_code, 404)
|
|
self.assertIn("unknown persona P2", caught.exception.detail)
|
|
get_persona.assert_awaited_once_with("P2")
|
|
create_session.assert_not_awaited()
|
|
|
|
async def test_session_joined_card_hydrates_triggers_for_runtime_prompt(self) -> None:
|
|
row = _card_row(
|
|
persona_service.P2,
|
|
persona_id="00000000-0000-0000-0000-000000000107",
|
|
status="approved",
|
|
)
|
|
row["triggers"] = {
|
|
"sore_spots": ["무능하다는 낙인"],
|
|
"forbidden": ["비웃음"],
|
|
"reaction": "말수가 급격히 줄고 방어한다.",
|
|
}
|
|
joined_row = {f"card_{key}": value for key, value in row.items()}
|
|
|
|
card = session_persistence._card_from_joined_session_row(joined_row)
|
|
|
|
self.assertIsNotNone(card)
|
|
assert card is not None
|
|
self.assertEqual(card.triggers["sore_spots"], ["무능하다는 낙인"])
|
|
self.assertEqual(card.triggers["forbidden"], ["비웃음"])
|
|
|
|
|
|
class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_review_queue_repository_fetches_draft_and_review_for_teacher(self) -> None:
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P1,
|
|
persona_id="00000000-0000-0000-0000-000000000201",
|
|
status="approved",
|
|
),
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id="00000000-0000-0000-0000-000000000202",
|
|
status="draft",
|
|
),
|
|
_card_row(
|
|
persona_service.P3,
|
|
persona_id="00000000-0000-0000-0000-000000000203",
|
|
status="review",
|
|
),
|
|
]
|
|
)
|
|
acquire_calls: list[dict[str, Any]] = []
|
|
|
|
def fake_acquire(**kwargs: Any) -> _Acquire:
|
|
acquire_calls.append(kwargs)
|
|
return _Acquire(conn)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", fake_acquire),
|
|
):
|
|
queue = await persona_repository.list_persona_review_queue(role="teacher")
|
|
|
|
self.assertEqual([item.code for item in queue], ["P2", "P3"])
|
|
self.assertEqual([item.status for item in queue], ["draft", "review"])
|
|
self.assertEqual(acquire_calls, [{"role": "teacher"}])
|
|
query, args = conn.fetch_calls[0]
|
|
self.assertIn("status = ANY($1::text[])", query)
|
|
self.assertEqual(args, (["draft", "review"],))
|
|
|
|
async def test_review_queue_repository_rejects_learner_role(self) -> None:
|
|
with patch.object(
|
|
persona_repository,
|
|
"get_pool",
|
|
side_effect=AssertionError("learner must be rejected before DB access"),
|
|
):
|
|
with self.assertRaises(ValueError):
|
|
await persona_repository.list_persona_review_queue(role="learner")
|
|
|
|
async def test_teacher_creates_persona_draft_version_and_audits(self) -> None:
|
|
author_id = "00000000-0000-0000-0000-000000000901"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id="00000000-0000-0000-0000-000000000501",
|
|
status="approved",
|
|
version=1,
|
|
),
|
|
]
|
|
)
|
|
acquire_calls: list[dict[str, Any]] = []
|
|
|
|
def fake_acquire(**kwargs: Any) -> _Acquire:
|
|
acquire_calls.append(kwargs)
|
|
return _Acquire(conn)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", fake_acquire),
|
|
):
|
|
created = await persona_repository.create_persona_draft(
|
|
card=persona_service.P2,
|
|
author_id=author_id,
|
|
role="teacher",
|
|
submit_for_review=True,
|
|
)
|
|
|
|
self.assertEqual(created.code, "P2")
|
|
self.assertEqual(created.version, 2)
|
|
self.assertEqual(created.status, "review")
|
|
self.assertEqual(acquire_calls, [{"role": "teacher", "user_id": author_id}])
|
|
self.assertEqual(conn.fetchval_calls[0][1], ("P2",))
|
|
audit_query, audit_args = conn.execute_calls[0]
|
|
self.assertIn("INSERT INTO audit.audit_log", audit_query)
|
|
self.assertEqual(audit_args[1], "persona_draft_create")
|
|
self.assertEqual(audit_args[4]["next_status"], "review")
|
|
|
|
async def test_teacher_clones_approved_persona_into_new_draft_version(self) -> None:
|
|
author_id = "00000000-0000-0000-0000-000000000901"
|
|
persona_id = "00000000-0000-0000-0000-000000000501"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id=persona_id,
|
|
status="approved",
|
|
version=1,
|
|
),
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
record = await persona_repository.create_persona_revision_from_existing(
|
|
persona_id=persona_id,
|
|
author_id=author_id,
|
|
role="teacher",
|
|
)
|
|
|
|
self.assertIsNotNone(record)
|
|
assert record is not None
|
|
self.assertEqual(record.review.persona_id, persona_id)
|
|
self.assertEqual(record.review.code, "P2")
|
|
self.assertEqual(record.review.version, 2)
|
|
self.assertEqual(record.review.status, "draft")
|
|
self.assertEqual(record.card.display_name, persona_service.P2.display_name)
|
|
self.assertEqual(conn.fetchval_calls[0][1], ("P2",))
|
|
_, audit_args = conn.execute_calls[0]
|
|
self.assertEqual(audit_args[1], "persona_revision_create")
|
|
self.assertEqual(audit_args[4]["version"], 2)
|
|
|
|
async def test_clone_existing_approved_persona_reuses_open_draft(self) -> None:
|
|
author_id = "00000000-0000-0000-0000-000000000901"
|
|
persona_id = "00000000-0000-0000-0000-000000000501"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=1),
|
|
_card_row(persona_service.P2, persona_id=persona_id, status="draft", version=2),
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
record = await persona_repository.create_persona_revision_from_existing(
|
|
persona_id=persona_id,
|
|
author_id=author_id,
|
|
role="admin",
|
|
)
|
|
|
|
self.assertIsNotNone(record)
|
|
assert record is not None
|
|
self.assertEqual(record.review.version, 2)
|
|
self.assertEqual(record.review.status, "draft")
|
|
self.assertEqual(conn.fetchval_calls, [])
|
|
self.assertEqual(conn.execute_calls, [])
|
|
|
|
async def test_teacher_updates_persona_draft_and_submits_review(self) -> None:
|
|
author_id = "00000000-0000-0000-0000-000000000901"
|
|
persona_id = "00000000-0000-0000-0000-000000000502"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P3,
|
|
persona_id=persona_id,
|
|
status="draft",
|
|
version=4,
|
|
),
|
|
]
|
|
)
|
|
edited_card = persona_service.P3
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
updated = await persona_repository.update_persona_draft(
|
|
persona_id=persona_id,
|
|
card=edited_card,
|
|
author_id=author_id,
|
|
role="admin",
|
|
submit_for_review=True,
|
|
)
|
|
|
|
self.assertIsNotNone(updated)
|
|
assert updated is not None
|
|
self.assertEqual(updated.status, "review")
|
|
self.assertEqual(updated.version, 4)
|
|
update_query, update_args = conn.fetchrow_calls[0]
|
|
self.assertIn("display_name = $4", update_query)
|
|
self.assertEqual(update_args[0], persona_id)
|
|
self.assertEqual(update_args[2], "review")
|
|
_, audit_args = conn.execute_calls[0]
|
|
self.assertEqual(audit_args[1], "persona_draft_update")
|
|
|
|
async def test_teacher_reads_persona_draft_detail_route(self) -> None:
|
|
record = PersonaDraftRecord(
|
|
review=PersonaReviewItem(
|
|
persona_id="00000000-0000-0000-0000-000000000503",
|
|
code="P3",
|
|
version=2,
|
|
status="draft",
|
|
display_name=persona_service.P3.display_name,
|
|
difficulty=persona_service.P3.difficulty,
|
|
theory_target=list(persona_service.P3.theory_target),
|
|
source_provenance=persona_service.P3.source_provenance,
|
|
is_synthetic=persona_service.P3.is_synthetic,
|
|
created_at="2026-01-04T00:00:00",
|
|
approved_at=None,
|
|
),
|
|
card=persona_service.P3,
|
|
)
|
|
|
|
with patch.object(
|
|
personas,
|
|
"get_persona_draft_record",
|
|
AsyncMock(return_value=record),
|
|
) as get_draft:
|
|
response = await personas.get_persona_draft_route(
|
|
"00000000-0000-0000-0000-000000000503",
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(response.code, "P3")
|
|
self.assertEqual(response.presenting, persona_service.P3.presenting)
|
|
get_draft.assert_awaited_once_with(
|
|
persona_id="00000000-0000-0000-0000-000000000503",
|
|
role="teacher",
|
|
)
|
|
|
|
async def test_teacher_create_draft_route_calls_repository(self) -> None:
|
|
created = PersonaReviewItem(
|
|
persona_id="00000000-0000-0000-0000-000000000504",
|
|
code="P2",
|
|
version=2,
|
|
status="review",
|
|
display_name=persona_service.P2.display_name,
|
|
difficulty=persona_service.P2.difficulty,
|
|
theory_target=list(persona_service.P2.theory_target),
|
|
source_provenance=persona_service.P2.source_provenance,
|
|
is_synthetic=persona_service.P2.is_synthetic,
|
|
created_at="2026-01-04T00:00:00",
|
|
approved_at=None,
|
|
)
|
|
|
|
with patch.object(
|
|
personas,
|
|
"create_persona_draft",
|
|
AsyncMock(return_value=created),
|
|
) as create_draft:
|
|
response = await personas.create_persona_draft_route(
|
|
_draft_payload(persona_service.P2, submit_for_review=True),
|
|
_principal(Role.ADMIN),
|
|
)
|
|
|
|
self.assertEqual(response.status, "review")
|
|
args = create_draft.await_args.kwargs
|
|
self.assertEqual(args["role"], "admin")
|
|
self.assertEqual(args["author_id"], "00000000-0000-0000-0000-000000000901")
|
|
self.assertTrue(args["submit_for_review"])
|
|
self.assertEqual(args["card"].code, "P2")
|
|
|
|
async def test_teacher_generates_persona_draft_from_masked_source_notes(self) -> None:
|
|
captured: list[Any] = []
|
|
evidence = [
|
|
personas.PersonaGenerationEvidence(
|
|
chunk_id=44,
|
|
source_id="persona_authoring_test",
|
|
score=0.88,
|
|
kb_kind="supervisor_pattern",
|
|
heading_path="상담 기록",
|
|
excerpt="내담자 연락처 [PHONE]. 최근 이별 뒤 관계 불안을 호소함.",
|
|
)
|
|
]
|
|
source_ref = personas.PersonaSourceDocumentResponse(
|
|
source_id="persona_authoring_test",
|
|
doc_id=9,
|
|
doc_uri="persona-authoring/test/inline.txt",
|
|
title="상담 기록",
|
|
source_kind="client_record",
|
|
kb_kind="diagnostic",
|
|
license_class="B",
|
|
external_llm_ok=True,
|
|
content_hash="hash",
|
|
chunk_count=1,
|
|
chunks_indexed=1,
|
|
embedded=True,
|
|
pii_entities_masked=["PHONE"],
|
|
)
|
|
|
|
async def fake_generate(req: Any) -> GenerateResponse:
|
|
captured.append(req)
|
|
return GenerateResponse(
|
|
text="",
|
|
model="test-model",
|
|
provider="test",
|
|
structured={
|
|
"draft": {
|
|
"code": "P8",
|
|
"display_name": "자료 기반 내담자",
|
|
"difficulty": "moderate",
|
|
"theory_target": ["humanistic"],
|
|
"demographics": {"age_band": "20s"},
|
|
"presenting": {"complaint": "관계 갈등"},
|
|
"history": {"precipitant": "최근 이별"},
|
|
"big5": {"O": 0.5, "C": 0.5, "E": 0.4, "A": 0.5, "N": 0.7},
|
|
"resistance": {
|
|
"base_resistance": 0.45,
|
|
"unlock_rate": 0.12,
|
|
"decay_floor": 0.05,
|
|
},
|
|
"speech_style": {"register": "polite"},
|
|
"affect_baseline": {
|
|
"negative_affect": 0.55,
|
|
"hopelessness": 0.2,
|
|
"anxiety": 0.5,
|
|
"suicide_ideation_stage": 1,
|
|
},
|
|
"ccd": {"core_belief": "나는 버려질 수 있다"},
|
|
"dsm5_dimensional": {"anxiety": 0.5},
|
|
"triggers": {
|
|
"sore_spots": ["버림받음"],
|
|
"forbidden": ["그냥 잊으라는 조언"],
|
|
"reaction": "말수가 줄어든다.",
|
|
},
|
|
"source_provenance": "masked client_record",
|
|
"is_synthetic": True,
|
|
},
|
|
"source_summary": "관계 상실 후 불안이 높아진 사례",
|
|
"warnings": ["임상팀 검수 필요"],
|
|
},
|
|
)
|
|
|
|
with (
|
|
patch.object(personas, "_register_persona_source_document", AsyncMock(return_value=source_ref)),
|
|
patch.object(personas, "_retrieve_persona_generation_evidence", AsyncMock(return_value=evidence)),
|
|
patch.object(personas.engine_client, "generate", fake_generate),
|
|
):
|
|
response = await personas.generate_persona_draft_route(
|
|
personas.PersonaDraftGenerateRequest(
|
|
source_text="내담자 휴대폰 010-1234-5678. 최근 이별 뒤 관계 불안을 호소함.",
|
|
source_kind="client_record",
|
|
code_hint="P8",
|
|
),
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(response.draft.code, "P8")
|
|
self.assertEqual(response.draft.triggers["sore_spots"], ["버림받음"])
|
|
self.assertEqual(response.source_summary, "관계 상실 후 불안이 높아진 사례")
|
|
self.assertIn("PHONE", response.pii_entities_masked)
|
|
self.assertEqual(response.source_references[0].source_id, "persona_authoring_test")
|
|
self.assertEqual(response.evidence_chunks[0].chunk_id, 44)
|
|
self.assertIn("RAG sources=persona_authoring_test", response.draft.source_provenance)
|
|
self.assertIn("chunks=44", response.draft.source_provenance)
|
|
self.assertIn("prompt=persona-draft-rag@2026-06-28.1#", response.draft.source_provenance)
|
|
self.assertEqual(len(captured), 1)
|
|
self.assertEqual(captured[0].metadata["prompt_bundle"]["id"], "persona-draft-rag")
|
|
self.assertEqual(captured[0].metadata["prompt_bundle"]["version"], "2026-06-28.1")
|
|
self.assertRegex(captured[0].metadata["prompt_bundle"]["hash"], r"^[0-9a-f]{12}$")
|
|
self.assertEqual(captured[0].structured_schema, persona_generation_contract.persona_generation_schema())
|
|
sent_text = captured[0].messages[-1].content
|
|
self.assertNotIn("010-1234-5678", sent_text)
|
|
self.assertIn("[PHONE]", sent_text)
|
|
self.assertIn("RAG 근거 청크", sent_text)
|
|
|
|
async def test_teacher_registers_persona_source_as_evaluator_only_kb_document(self) -> None:
|
|
conn = _PersonaCardConn([])
|
|
captured_index: list[Any] = []
|
|
|
|
async def fake_index_document(_conn: Any, req: Any) -> Any:
|
|
captured_index.append(req)
|
|
return personas.rag.IndexResult(
|
|
doc_id=42,
|
|
chunks_indexed=len(req.chunks),
|
|
skipped_unchanged=False,
|
|
embedded=False,
|
|
degraded=True,
|
|
)
|
|
|
|
with (
|
|
patch.object(personas, "acquire", lambda **_: _Acquire(conn)),
|
|
patch.object(personas.rag, "index_document", fake_index_document),
|
|
):
|
|
response = await personas.create_persona_source_route(
|
|
personas.PersonaSourceDocumentRequest(
|
|
filename="case-note.txt",
|
|
source_kind="client_record",
|
|
text=(
|
|
"내담자 전화번호 010-1234-5678. 관계 단절 이후 불안을 호소함.\n\n"
|
|
"상담 장면에서는 조언을 들으면 침묵이 늘어남."
|
|
),
|
|
),
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(response.doc_id, 42)
|
|
self.assertEqual(response.source_kind, "client_record")
|
|
self.assertEqual(response.kb_kind, "diagnostic")
|
|
self.assertEqual(response.license_class, "B")
|
|
self.assertTrue(response.external_llm_ok)
|
|
self.assertIn("PHONE", response.pii_entities_masked)
|
|
self.assertEqual(len(captured_index), 1)
|
|
index_req = captured_index[0]
|
|
self.assertTrue(index_req.source_id.startswith("persona_authoring_"))
|
|
self.assertNotIn("010-1234-5678", index_req.chunks[0]["chunk_text"])
|
|
self.assertIn("[PHONE]", index_req.chunks[0]["chunk_text"])
|
|
self.assertEqual(index_req.chunks[0]["visible_to"], ["evaluator"])
|
|
self.assertEqual(index_req.chunks[0]["sensitivity"], 2)
|
|
self.assertEqual(index_req.chunks[0]["meta"]["source_kind"], "client_record")
|
|
self.assertTrue(index_req.chunks[0]["meta"]["raw_source_not_indexed"])
|
|
self.assertEqual(index_req.chunks[0]["meta"]["raw_source_storage"], "hash_only")
|
|
self.assertTrue(index_req.chunks[0]["meta"]["raw_source_id"].endswith("_raw"))
|
|
self.assertRegex(index_req.chunks[0]["meta"]["raw_source_content_hash"], r"^[0-9a-f]{64}$")
|
|
self.assertNotIn("raw_source_not_stored", index_req.chunks[0]["meta"])
|
|
source_query, source_args = conn.execute_calls[0]
|
|
self.assertIn("INSERT INTO kb.source", source_query)
|
|
self.assertEqual(source_args[2], "diagnostic")
|
|
raw_query, raw_args = conn.execute_calls[1]
|
|
self.assertIn("INSERT INTO kb.raw_source_artifact", raw_query)
|
|
self.assertEqual(raw_args[0], index_req.chunks[0]["meta"]["raw_source_id"])
|
|
self.assertEqual(raw_args[1], index_req.source_id)
|
|
self.assertEqual(raw_args[2], "00000000-0000-0000-0000-000000000901")
|
|
self.assertEqual(raw_args[5], "B")
|
|
self.assertIsInstance(raw_args[6], dict)
|
|
raw_summary = raw_args[6]
|
|
self.assertEqual(raw_summary["derived_source_id"], index_req.source_id)
|
|
self.assertTrue(raw_summary["raw_text_not_indexed"])
|
|
self.assertEqual(raw_summary["storage"], "hash_only")
|
|
|
|
async def test_learner_cannot_create_persona_draft_route(self) -> None:
|
|
with patch.object(
|
|
personas,
|
|
"create_persona_draft",
|
|
AsyncMock(side_effect=AssertionError("learner must not reach draft repository")),
|
|
) as create_draft:
|
|
with self.assertRaises(HTTPException) as caught:
|
|
await personas.create_persona_draft_route(
|
|
_draft_payload(persona_service.P1),
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual(caught.exception.status_code, 403)
|
|
create_draft.assert_not_awaited()
|
|
|
|
async def test_learner_cannot_call_review_route(self) -> None:
|
|
with patch.object(
|
|
personas,
|
|
"list_persona_review_queue",
|
|
AsyncMock(side_effect=AssertionError("learner must not reach review repository")),
|
|
) as review_queue:
|
|
with self.assertRaises(HTTPException) as caught:
|
|
await personas.list_persona_reviews(_principal(Role.LEARNER))
|
|
|
|
self.assertEqual(caught.exception.status_code, 403)
|
|
review_queue.assert_not_awaited()
|
|
|
|
async def test_teacher_review_route_returns_state_metadata(self) -> None:
|
|
review_items = [
|
|
PersonaReviewItem(
|
|
persona_id="00000000-0000-0000-0000-000000000302",
|
|
code="P2",
|
|
version=2,
|
|
status="draft",
|
|
display_name="Draft Persona",
|
|
difficulty="moderate",
|
|
theory_target=["humanistic"],
|
|
source_provenance="faculty import",
|
|
is_synthetic=True,
|
|
created_at="2026-01-01T00:00:00",
|
|
approved_at=None,
|
|
),
|
|
PersonaReviewItem(
|
|
persona_id="00000000-0000-0000-0000-000000000303",
|
|
code="P3",
|
|
version=1,
|
|
status="review",
|
|
display_name="Review Persona",
|
|
difficulty="hard",
|
|
theory_target=["cbt"],
|
|
source_provenance="faculty import",
|
|
is_synthetic=True,
|
|
created_at="2026-01-02T00:00:00",
|
|
approved_at=None,
|
|
),
|
|
]
|
|
|
|
with patch.object(
|
|
personas,
|
|
"list_persona_review_queue",
|
|
AsyncMock(return_value=review_items),
|
|
) as review_queue:
|
|
response = await personas.list_persona_reviews(_principal(Role.TEACHER))
|
|
|
|
review_queue.assert_awaited_once_with(role="teacher")
|
|
self.assertEqual([item.code for item in response], ["P2", "P3"])
|
|
self.assertEqual([item.status for item in response], ["draft", "review"])
|
|
self.assertEqual(response[0].version, 2)
|
|
self.assertIsNone(response[0].approved_at)
|
|
|
|
async def test_admin_review_route_uses_admin_db_role(self) -> None:
|
|
with patch.object(
|
|
personas,
|
|
"list_persona_review_queue",
|
|
AsyncMock(return_value=[]),
|
|
) as review_queue:
|
|
response = await personas.list_persona_reviews(_principal(Role.ADMIN))
|
|
|
|
self.assertEqual(response, [])
|
|
review_queue.assert_awaited_once_with(role="admin")
|
|
|
|
async def test_teacher_approves_review_persona_and_audits_decision(self) -> None:
|
|
reviewer_id = "00000000-0000-0000-0000-000000000901"
|
|
persona_id = "00000000-0000-0000-0000-000000000401"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P2,
|
|
persona_id=persona_id,
|
|
status="review",
|
|
version=2,
|
|
),
|
|
]
|
|
)
|
|
acquire_calls: list[dict[str, Any]] = []
|
|
|
|
def fake_acquire(**kwargs: Any) -> _Acquire:
|
|
acquire_calls.append(kwargs)
|
|
return _Acquire(conn)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", fake_acquire),
|
|
):
|
|
updated = await persona_repository.update_persona_review_status(
|
|
persona_id=persona_id,
|
|
action="approve",
|
|
reviewer_id=reviewer_id,
|
|
role="teacher",
|
|
)
|
|
|
|
self.assertIsNotNone(updated)
|
|
assert updated is not None
|
|
self.assertEqual(updated.status, "approved")
|
|
self.assertEqual(updated.approved_at, "2026-01-03T00:00:00")
|
|
self.assertEqual(acquire_calls, [{"role": "teacher", "user_id": reviewer_id}])
|
|
update_query, update_args = conn.fetchrow_calls[0]
|
|
self.assertIn("UPDATE app.persona_card", update_query)
|
|
self.assertIn("status IN ('draft', 'review')", update_query)
|
|
self.assertEqual(update_args, (persona_id, "approved", reviewer_id))
|
|
audit_query, audit_args = conn.execute_calls[0]
|
|
self.assertIn("INSERT INTO audit.audit_log", audit_query)
|
|
self.assertEqual(audit_args[1], "persona_approve")
|
|
self.assertEqual(audit_args[2], "persona_card")
|
|
self.assertEqual(audit_args[3], persona_id)
|
|
self.assertEqual(audit_args[4]["next_status"], "approved")
|
|
|
|
async def test_reject_review_persona_returns_it_to_draft_and_audits(self) -> None:
|
|
reviewer_id = "00000000-0000-0000-0000-000000000901"
|
|
persona_id = "00000000-0000-0000-0000-000000000402"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P3,
|
|
persona_id=persona_id,
|
|
status="review",
|
|
version=1,
|
|
),
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
updated = await persona_repository.update_persona_review_status(
|
|
persona_id=persona_id,
|
|
action="reject",
|
|
reviewer_id=reviewer_id,
|
|
role="admin",
|
|
)
|
|
|
|
self.assertIsNotNone(updated)
|
|
assert updated is not None
|
|
self.assertEqual(updated.status, "draft")
|
|
self.assertIsNone(updated.approved_at)
|
|
_, update_args = conn.fetchrow_calls[0]
|
|
self.assertEqual(update_args, (persona_id, "draft", None))
|
|
_, audit_args = conn.execute_calls[0]
|
|
self.assertEqual(audit_args[1], "persona_reject")
|
|
self.assertEqual(audit_args[4]["next_status"], "draft")
|
|
|
|
async def test_archive_persona_family_archives_all_versions_and_audits(self) -> None:
|
|
archiver_id = "00000000-0000-0000-0000-000000000901"
|
|
persona_id = "00000000-0000-0000-0000-000000000501"
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=1),
|
|
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=2),
|
|
_card_row(
|
|
persona_service.P3,
|
|
persona_id="00000000-0000-0000-0000-000000000503",
|
|
status="approved",
|
|
version=1,
|
|
),
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
archived = await persona_repository.archive_persona_family(
|
|
persona_id=persona_id,
|
|
archiver_id=archiver_id,
|
|
role="teacher",
|
|
)
|
|
catalog = await persona_repository.list_approved_personas()
|
|
|
|
self.assertIsNotNone(archived)
|
|
assert archived is not None
|
|
self.assertEqual(archived.status, "archived")
|
|
self.assertTrue(
|
|
all(row["status"] == "archived" for row in conn.rows if str(row["code"]).upper() == "P2")
|
|
)
|
|
self.assertEqual([entry.card.code for entry in catalog], ["P3"])
|
|
_, audit_args = conn.execute_calls[0]
|
|
self.assertEqual(audit_args[1], "persona_archive")
|
|
self.assertEqual(audit_args[4]["scope"], "code_family")
|
|
|
|
async def test_archive_route_blocks_learner_before_repository_access(self) -> None:
|
|
with patch.object(
|
|
personas,
|
|
"archive_persona_family",
|
|
AsyncMock(side_effect=AssertionError("learner must not archive personas")),
|
|
) as archive:
|
|
with self.assertRaises(HTTPException) as caught:
|
|
await personas.archive_persona_route(
|
|
"00000000-0000-0000-0000-000000000501",
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual(caught.exception.status_code, 403)
|
|
archive.assert_not_awaited()
|
|
|
|
async def test_teacher_revision_route_returns_editable_draft_detail(self) -> None:
|
|
record = PersonaDraftRecord(
|
|
review=PersonaReviewItem(
|
|
persona_id="00000000-0000-0000-0000-000000000503",
|
|
code="P3",
|
|
version=2,
|
|
status="draft",
|
|
display_name=persona_service.P3.display_name,
|
|
difficulty=persona_service.P3.difficulty,
|
|
theory_target=list(persona_service.P3.theory_target),
|
|
source_provenance=persona_service.P3.source_provenance,
|
|
is_synthetic=persona_service.P3.is_synthetic,
|
|
created_at="2026-01-04T00:00:00",
|
|
approved_at=None,
|
|
),
|
|
card=persona_service.P3,
|
|
)
|
|
|
|
with patch.object(
|
|
personas,
|
|
"create_persona_revision_from_existing",
|
|
AsyncMock(return_value=record),
|
|
) as create_revision:
|
|
response = await personas.create_persona_revision_route(
|
|
"00000000-0000-0000-0000-000000000503",
|
|
personas.PersonaRevisionRequest(),
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(response.status, "draft")
|
|
self.assertEqual(response.presenting, persona_service.P3.presenting)
|
|
create_revision.assert_awaited_once_with(
|
|
persona_id="00000000-0000-0000-0000-000000000503",
|
|
author_id="00000000-0000-0000-0000-000000000901",
|
|
role="teacher",
|
|
submit_for_review=False,
|
|
)
|
|
|
|
async def test_review_update_ignores_already_approved_persona(self) -> None:
|
|
conn = _PersonaCardConn(
|
|
[
|
|
_card_row(
|
|
persona_service.P1,
|
|
persona_id="00000000-0000-0000-0000-000000000403",
|
|
status="approved",
|
|
),
|
|
]
|
|
)
|
|
|
|
with (
|
|
patch.object(persona_repository, "get_pool", return_value=object()),
|
|
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
|
|
):
|
|
updated = await persona_repository.update_persona_review_status(
|
|
persona_id="00000000-0000-0000-0000-000000000403",
|
|
action="approve",
|
|
reviewer_id="00000000-0000-0000-0000-000000000901",
|
|
role="teacher",
|
|
)
|
|
|
|
self.assertIsNone(updated)
|
|
self.assertEqual(conn.execute_calls, [])
|
|
|
|
async def test_learner_cannot_call_review_decision_route(self) -> None:
|
|
with patch.object(
|
|
personas,
|
|
"update_persona_review_status",
|
|
AsyncMock(side_effect=AssertionError("learner must not reach review update")),
|
|
) as update_review:
|
|
with self.assertRaises(HTTPException) as caught:
|
|
await personas.decide_persona_review(
|
|
"00000000-0000-0000-0000-000000000404",
|
|
personas.PersonaReviewDecisionRequest(action="approve"),
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual(caught.exception.status_code, 403)
|
|
update_review.assert_not_awaited()
|
|
|
|
async def test_review_decision_route_returns_404_for_non_pending_persona(self) -> None:
|
|
with patch.object(
|
|
personas,
|
|
"update_persona_review_status",
|
|
AsyncMock(return_value=None),
|
|
) as update_review:
|
|
with self.assertRaises(HTTPException) as caught:
|
|
await personas.decide_persona_review(
|
|
"00000000-0000-0000-0000-000000000405",
|
|
personas.PersonaReviewDecisionRequest(action="approve"),
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(caught.exception.status_code, 404)
|
|
update_review.assert_awaited_once_with(
|
|
persona_id="00000000-0000-0000-0000-000000000405",
|
|
action="approve",
|
|
reviewer_id="00000000-0000-0000-0000-000000000901",
|
|
role="teacher",
|
|
)
|
|
|
|
async def test_review_decision_route_returns_updated_summary(self) -> None:
|
|
updated_item = PersonaReviewItem(
|
|
persona_id="00000000-0000-0000-0000-000000000406",
|
|
code="P2",
|
|
version=3,
|
|
status="approved",
|
|
display_name="Approved Persona",
|
|
difficulty="moderate",
|
|
theory_target=["humanistic"],
|
|
source_provenance="faculty import",
|
|
is_synthetic=True,
|
|
created_at="2026-01-01T00:00:00",
|
|
approved_at="2026-01-03T00:00:00",
|
|
)
|
|
|
|
with patch.object(
|
|
personas,
|
|
"update_persona_review_status",
|
|
AsyncMock(return_value=updated_item),
|
|
) as update_review:
|
|
response = await personas.decide_persona_review(
|
|
"00000000-0000-0000-0000-000000000406",
|
|
personas.PersonaReviewDecisionRequest(action="approve"),
|
|
_principal(Role.ADMIN),
|
|
)
|
|
|
|
self.assertEqual(response.status, "approved")
|
|
self.assertEqual(response.approved_at, "2026-01-03T00:00:00")
|
|
update_review.assert_awaited_once_with(
|
|
persona_id="00000000-0000-0000-0000-000000000406",
|
|
action="approve",
|
|
reviewer_id="00000000-0000-0000-0000-000000000901",
|
|
role="admin",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|