vignette/apps/api/app/test_user_support_tickets.py

262 lines
10 KiB
Python

"""Current-user support ticket visibility tests."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from .routes import users as user_routes
from .services.support_tickets import support_ticket_fingerprint
class _Acquire:
def __init__(self, conn):
self.conn = conn
async def __aenter__(self):
return self.conn
async def __aexit__(self, exc_type, exc, tb):
return None
class UserSupportTicketTest(unittest.IsolatedAsyncioTestCase):
async def test_create_support_ticket_stores_fingerprint_without_audit_body(self) -> None:
ticket_id = "00000000-0000-0000-0000-000000000101"
audit_calls: list[tuple[str, tuple[object, ...]]] = []
class Conn:
async def fetchrow(self, query, *args, **kwargs):
self.query = query
self.args = args
return {
"id": ticket_id,
"category": args[4],
"priority": args[5],
"subject": args[6],
"created_at": 1234.0,
}
async def execute(self, query, *args, **kwargs):
audit_calls.append((query, args))
principal = Principal(
user_id="00000000-0000-0000-0000-000000000201",
role=Role.LEARNER,
email="learner@hs.ac.kr",
display_name="Learner",
)
body = user_routes.UserSupportTicketRequest(
category="voice_browser",
priority="high",
subject="마이크 권한 오류",
body="브라우저에서 마이크 권한을 눌렀는데 다시 실패합니다.",
source_path="/learn/session/voice",
)
expected_fingerprint = support_ticket_fingerprint(
category=body.category,
subject=body.subject,
body=body.body,
source_path=body.source_path,
)
profile = user_routes.UserProfileResponse(
user_id=principal.user_id,
email=principal.email,
display_name="Learner",
role="learner",
cohort_ids=[],
affiliation="한신대학교",
)
conn = Conn()
with (
patch.object(user_routes, "acquire", return_value=_Acquire(conn)),
patch.object(user_routes, "_profile_for", AsyncMock(return_value=profile)),
):
created = await user_routes.create_support_ticket(body, principal)
self.assertEqual(created.ticket_id, ticket_id)
self.assertIn("fingerprint", conn.query)
self.assertEqual(conn.args[9], expected_fingerprint)
self.assertEqual(len(audit_calls), 1)
detail = audit_calls[0][1][4]
self.assertEqual(detail["fingerprint"], expected_fingerprint)
self.assertNotIn("body", detail)
self.assertNotIn("subject", detail)
async def test_user_ticket_list_scopes_to_current_reporter_without_body(self) -> None:
now = datetime.now(timezone.utc)
class Conn:
async def fetch(self, query, *args, **kwargs):
self.query = query
self.args = args
return [
{
"id": "00000000-0000-0000-0000-000000000101",
"category": "session_review",
"priority": "high",
"status": "resolved",
"subject": "리뷰 생성 지연",
"source_path": "/learn/session/1/review",
"assigned_group": "서비스 운영자",
"resolution_note": "재처리 완료",
"created_at": (now - timedelta(days=1)).timestamp(),
"updated_at": now.timestamp(),
"resolved_at": now.timestamp(),
}
]
conn = Conn()
principal = Principal(
user_id="00000000-0000-0000-0000-000000000201",
role=Role.LEARNER,
email="learner@hs.ac.kr",
display_name="Learner",
)
with patch.object(user_routes, "acquire", return_value=_Acquire(conn)):
tickets = await user_routes._support_tickets_for_user(principal)
self.assertIn("WHERE reporter_id = $1::uuid", conn.query)
self.assertNotIn(" body", conn.query.lower())
self.assertEqual(conn.args, (principal.user_id, 20))
self.assertEqual(tickets.source, "database")
self.assertTrue(tickets.durable)
self.assertEqual(len(tickets.tickets), 1)
ticket = tickets.tickets[0]
self.assertEqual(ticket.status, "resolved")
self.assertEqual(ticket.resolution_note, "재처리 완료")
self.assertFalse(hasattr(ticket, "body"))
class UserPrepostMeasureTest(unittest.IsolatedAsyncioTestCase):
async def test_upsert_prepost_measure_scopes_to_current_user_without_score_in_audit(self) -> None:
measure_id = "00000000-0000-0000-0000-000000000301"
audit_calls: list[tuple[str, tuple[object, ...]]] = []
class Conn:
async def fetchrow(self, query, *args, **kwargs):
self.query = query
self.args = args
return {
"id": measure_id,
"pilot_id": args[1],
"measure_name": args[2],
"timepoint": args[3],
"raw_score": args[4],
"min_score": args[5],
"max_score": args[6],
"instrument_version": args[7],
"item_count": args[8],
"collected_at": 1234.0,
"updated_at": 1235.0,
}
async def execute(self, query, *args, **kwargs):
audit_calls.append((query, args))
principal = Principal(
user_id="00000000-0000-0000-0000-000000000201",
role=Role.LEARNER,
email="learner@hs.ac.kr",
display_name="Learner",
)
body = user_routes.UserPrepostMeasureRequest(
pilot_id="phase3-pilot-local",
measure_name="self_efficacy",
timepoint="pre",
raw_score=4.0,
min_score=1.0,
max_score=5.0,
instrument_version="draft-scale-v1",
item_count=6,
)
conn = Conn()
with patch.object(user_routes, "acquire", return_value=_Acquire(conn)):
saved = await user_routes.upsert_my_prepost_measure(body, principal)
self.assertIn("learner_prepost_measure", conn.query)
self.assertEqual(conn.args[0], principal.user_id)
self.assertEqual(conn.args[1], "phase3-pilot-local")
self.assertEqual(conn.args[2], "self_efficacy")
self.assertEqual(conn.args[3], "pre")
self.assertEqual(saved.normalized_score, 75.0)
self.assertEqual(len(audit_calls), 1)
detail = audit_calls[0][1][4]
self.assertEqual(detail["measure_name"], "self_efficacy")
self.assertEqual(detail["timepoint"], "pre")
self.assertTrue(detail["score_recorded"])
self.assertNotIn("raw_score", detail)
self.assertNotIn("score", detail)
async def test_prepost_measure_list_scopes_to_current_user_and_counts_pairs(self) -> None:
class Conn:
async def fetch(self, query, *args, **kwargs):
self.query = query
self.args = args
return [
{
"id": "00000000-0000-0000-0000-000000000301",
"pilot_id": "phase3-pilot-local",
"measure_name": "self_efficacy",
"timepoint": "pre",
"raw_score": 3.0,
"min_score": 1.0,
"max_score": 5.0,
"instrument_version": "draft-scale-v1",
"item_count": 6,
"collected_at": 1234.0,
"updated_at": 1234.0,
},
{
"id": "00000000-0000-0000-0000-000000000302",
"pilot_id": "phase3-pilot-local",
"measure_name": "self_efficacy",
"timepoint": "post",
"raw_score": 4.0,
"min_score": 1.0,
"max_score": 5.0,
"instrument_version": "draft-scale-v1",
"item_count": 6,
"collected_at": 2234.0,
"updated_at": 2234.0,
},
{
"id": "00000000-0000-0000-0000-000000000303",
"pilot_id": "phase3-pilot-local",
"measure_name": "skill_proficiency",
"timepoint": "pre",
"raw_score": 2.0,
"min_score": 1.0,
"max_score": 5.0,
"instrument_version": "draft-scale-v1",
"item_count": 6,
"collected_at": 3234.0,
"updated_at": 3234.0,
},
]
principal = Principal(
user_id="00000000-0000-0000-0000-000000000201",
role=Role.LEARNER,
email="learner@hs.ac.kr",
display_name="Learner",
)
conn = Conn()
with patch.object(user_routes, "acquire", return_value=_Acquire(conn)):
response = await user_routes._prepost_measures_for_user(
principal,
pilot_id="phase3-pilot-local",
)
self.assertIn("WHERE learner_id = $1::uuid", conn.query)
self.assertEqual(conn.args, (principal.user_id, "phase3-pilot-local"))
self.assertEqual(response.source, "database")
self.assertTrue(response.durable)
self.assertEqual(response.complete_measure_pairs, 1)
self.assertEqual(len(response.measures), 3)
self.assertEqual(response.measures[0].normalized_score, 50.0)