현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue