"""Admin operations persistence model tests.""" from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone from unittest.mock import patch from .deps import Principal, Role from .routes import admin as admin_routes 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 AdminOpsTest(unittest.IsolatedAsyncioTestCase): async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None: case = self class Conn: async def fetchrow(self, query, *args, **kwargs): return { "total_turns": 2, "metered_turns": 1, "tokens_in": 11, "tokens_out": 13, "cost_usd": 0.0042, } async def fetch(self, query, *args, **kwargs): case.assertIn( "COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC", query, ) case.assertNotIn("tokens_in + tokens_out DESC", query) return [ { "provider": "claude_cli", "model": "gateway-default", "turns": 1, "tokens_in": 11, "tokens_out": 13, "cost_usd": 0.0042, } ] with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())): usage = await admin_routes._usage_from_database(window_days=7) self.assertTrue(usage.durable) self.assertEqual(usage.source, "database") self.assertEqual(usage.total_turns, 2) self.assertEqual(usage.metered_turns, 1) self.assertEqual(usage.by_provider[0].provider, "claude_cli") async def test_uptime_from_database_aggregates_health_samples(self) -> None: now = datetime.now(timezone.utc) class Conn: async def fetch(self, *args, **kwargs): return [ { "id": 3, "observed_at": now, "overall_status": "ok", "service_key": "db", "service_name": "영구 저장소", "service_status": "ok", "detail": "사용자, 세션, 리뷰 저장", "metric": "풀 1/10", "load": 0.1, }, { "id": 2, "observed_at": now - timedelta(minutes=5), "overall_status": "down", "service_key": "engine", "service_name": "응답 생성", "service_status": "down", "detail": "Engine readiness failed", "metric": "로그인/설정 필요", "load": 0.0, }, { "id": 1, "observed_at": now - timedelta(minutes=10), "overall_status": "degraded", "service_key": "voice", "service_name": "음성 입력", "service_status": "degraded", "detail": "음성 입력과 재생", "metric": "설정 필요", "load": 0.0, }, ] with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())): uptime = await admin_routes._uptime_from_database(window_hours=24) self.assertTrue(uptime.durable) self.assertEqual(uptime.sample_count, 3) self.assertEqual(uptime.down_events, 1) self.assertEqual(uptime.degraded_events, 1) self.assertAlmostEqual(uptime.ok_ratio, 1 / 3, places=4) self.assertIsNotNone(uptime.last_down_at) self.assertEqual({item.service_key for item in uptime.services}, {"db", "engine", "voice"}) async def test_tickets_from_database_returns_summary_without_synthetic_rows(self) -> None: now = datetime.now(timezone.utc) class Conn: async def fetch(self, *args, **kwargs): return [ { "id": "00000000-0000-0000-0000-000000000101", "reporter_id": "00000000-0000-0000-0000-000000000201", "reporter_email": "learner@hs.ac.kr", "reporter_name": "Learner", "reporter_role": "learner", "category": "session_review", "priority": "high", "status": "open", "subject": "리뷰 지연", "body": "회기 리뷰가 생성되지 않습니다.", "source_path": "/learn/session/1/review", "assigned_group": "", "resolution_note": "", "created_at": now - timedelta(days=2), "updated_at": now - timedelta(days=2), "resolved_at": None, }, { "id": "00000000-0000-0000-0000-000000000102", "reporter_id": None, "reporter_email": "teacher@hs.ac.kr", "reporter_name": "Teacher", "reporter_role": "teacher", "category": "voice_browser", "priority": "high", "status": "resolved", "subject": "마이크 권한", "body": "브라우저 권한 안내가 필요합니다.", "source_path": "/learn/session/2", "assigned_group": "서비스 운영자", "resolution_note": "안내 완료", "created_at": now, "updated_at": now, "resolved_at": now, }, ] with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())): tickets = await admin_routes._tickets_from_database( ticket_status=None, category=None, priority=None, assigned_group=None, source_path=None, stale_only=False, search="", window_days=30, ) self.assertTrue(tickets.durable) self.assertEqual(tickets.source, "database") self.assertEqual(len(tickets.tickets), 2) self.assertEqual(tickets.summary.total, 2) self.assertEqual(tickets.summary.open_count, 1) self.assertEqual(tickets.summary.high_priority_count, 1) self.assertEqual(tickets.summary.stale_count, 1) self.assertEqual(tickets.summary.by_category["session_review"], 1) async def test_tickets_from_database_applies_queue_filters(self) -> None: class Conn: async def fetch(self, query, *args, **kwargs): self.query = query self.args = args return [] conn = Conn() with patch.object(admin_routes, "acquire", return_value=_Acquire(conn)): tickets = await admin_routes._tickets_from_database( ticket_status="open", category="voice_browser", priority="urgent", assigned_group="서비스 운영자", source_path="/learn/session/1", stale_only=True, search="마이크", window_days=7, ) self.assertTrue(tickets.durable) self.assertIn("assigned_group = $4", conn.query) self.assertIn("lower(subject)", conn.query) self.assertEqual( conn.args, ( "open", "voice_browser", "urgent", "서비스 운영자", "/learn/session/1", True, "마이크", 7, ), ) async def test_patch_ticket_records_metadata_only_audit(self) -> None: now = datetime.now(timezone.utc) ticket_id = "00000000-0000-0000-0000-000000000101" audit_calls: list[tuple[str, tuple[object, ...]]] = [] class Conn: def __init__(self): self.fetchrow_calls = 0 async def fetchrow(self, query, *args, **kwargs): self.fetchrow_calls += 1 if self.fetchrow_calls == 1: return { "id": ticket_id, "category": "session_review", "priority": "high", "status": "open", "source_path": "/learn/session/1/review", "assigned_group": "", "resolution_note": "", } return { "id": ticket_id, "reporter_id": "00000000-0000-0000-0000-000000000201", "reporter_email": "learner@hs.ac.kr", "reporter_name": "Learner", "reporter_role": "learner", "category": "session_review", "priority": "urgent", "status": "in_progress", "subject": "리뷰 지연", "body": "회기 리뷰가 생성되지 않습니다.", "source_path": "/learn/session/1/review", "assigned_group": "서비스 운영자", "resolution_note": "", "created_at": now, "updated_at": now, "resolved_at": None, "event_count": 1, "last_event_at": now, } async def execute(self, query, *args, **kwargs): audit_calls.append((query, args)) principal = Principal( user_id="00000000-0000-0000-0000-000000000301", role=Role.ADMIN, admin_access=True, email="admin@hs.ac.kr", display_name="Admin", ) with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())): updated = await admin_routes.patch_ticket( ticket_id, admin_routes.AdminTicketPatch( status="in_progress", priority="urgent", assigned_group="서비스 운영자", ), principal, ) self.assertEqual(updated.status, "in_progress") self.assertEqual(updated.event_count, 1) self.assertEqual(len(audit_calls), 1) query, args = audit_calls[0] self.assertIn("INSERT INTO audit.audit_log", query) self.assertEqual(args[1], "support_ticket_update") detail = args[4] self.assertEqual(set(detail["changed_fields"]), {"status", "priority", "assigned_group"}) self.assertNotIn("body", detail) self.assertNotIn("subject", detail) def test_admin_ops_schema_enables_rls(self) -> None: from pathlib import Path root = Path(__file__).resolve().parents[3] schema = (root / "infra" / "db" / "init" / "05_runtime_auth.sql").read_text( encoding="utf-8" ) self.assertIn("ALTER TABLE app.admin_health_event ENABLE ROW LEVEL SECURITY", schema) self.assertIn("ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY", schema) self.assertIn("CREATE POLICY p_support_ticket_insert", schema)