현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -10,7 +10,7 @@ from fastapi import HTTPException
from . import persona_repository
from .deps import Principal, Role
from .persona_repository import PersonaReviewItem
from .persona_repository import PersonaDraftRecord, PersonaReviewItem
from .routes import personas, sessions
from .services import persona as persona_service
@ -72,6 +72,7 @@ class _PersonaCardConn:
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]]:
@ -81,6 +82,36 @@ class _PersonaCardConn:
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 "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]),
"approved_by": None,
"approved_at": None,
}
)
return row
return None
persona_id = str(args[0])
next_status = str(args[1])
approved_by = args[2]
@ -92,12 +123,51 @@ class _PersonaCardConn:
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]),
"created_by": args[18],
"approved_by": None,
"created_at": "2026-01-04T00:00:00",
"approved_at": None,
}
self.rows.append(row)
return row
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 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"
@ -111,6 +181,31 @@ class _PersonaCardConn:
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),
source_provenance=card.source_provenance,
is_synthetic=card.is_synthetic,
submit_for_review=submit_for_review,
)
class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
async def test_catalog_repository_lists_only_approved_personas(self) -> None:
conn = _PersonaCardConn(
@ -249,6 +344,165 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
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_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_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,