"""Admin operations persistence model 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 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: def __init__(self) -> None: self.fetch_calls = 0 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): self.fetch_calls += 1 if self.fetch_calls == 2: case.assertIn("date_trunc('day', created_at)", query) return [ { "day": "2026-06-28", "turns": 1, "tokens_in": 11, "tokens_out": 13, "cost_usd": 0.0042, } ] 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())), patch.object( admin_routes.evaluator, "evaluator_semantic_cache_stats", return_value={"hits": 3, "misses": 1, "stores": 2, "evictions": 0, "entries": 2}, ): 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") self.assertTrue(usage.evaluator_cache.enabled) self.assertEqual(usage.evaluator_cache.requests, 4) self.assertEqual(usage.evaluator_cache.hit_rate, 0.75) self.assertEqual(usage.daily_cost[0].day, "2026-06-28") self.assertEqual(usage.daily_cost[0].cost_usd, 0.0042) async def test_uptime_from_database_aggregates_health_samples(self) -> None: now = datetime.now(timezone.utc) class Conn: def __init__(self): self.calls = 0 async def fetch(self, *args, **kwargs): self.calls += 1 if self.calls == 2: return [] 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_uptime_from_database_combines_raw_events_and_daily_rollups(self) -> None: now = datetime.now(timezone.utc) class Conn: def __init__(self): self.calls = 0 async def fetch(self, *args, **kwargs): self.calls += 1 if self.calls == 1: return [ { "id": 10, "observed_at": now, "overall_status": "ok", "service_key": "engine", "service_name": "응답 생성", "service_status": "ok", "detail": "ready", "metric": "21ms", "load": 0.02, } ] return [ { "service_key": "engine", "service_name": "응답 생성", "sample_count": 3, "ok_samples": 1, "degraded_samples": 1, "down_samples": 1, "latest_status": "down", "last_observed_at": now - timedelta(days=2), "last_down_at": now - timedelta(days=2, hours=1), } ] with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())): uptime = await admin_routes._uptime_from_database(window_hours=168) self.assertEqual(uptime.sample_count, 4) self.assertEqual(uptime.down_events, 1) self.assertEqual(uptime.degraded_events, 1) self.assertAlmostEqual(uptime.ok_ratio, 0.5, places=4) self.assertIsNotNone(uptime.last_down_at) engine = next(service for service in uptime.services if service.service_key == "engine") self.assertEqual(engine.samples, 4) self.assertEqual(engine.latest_status, "ok") self.assertEqual(len(uptime.events), 1) async def test_record_health_events_allows_synthetic_capture_without_actor(self) -> None: calls = [] class Conn: async def executemany(self, query, rows, **kwargs): calls.append((query, rows)) service = admin_routes.AdminServiceHealth( key="engine", name="응답 생성", status="ok", detail="ready", metric="12ms", load=0.01, ) with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())): recorded = await admin_routes._record_health_events( principal=None, overall_status="ok", environment="dev", engine_mode="claude_cli", services=[service], ) self.assertEqual(recorded, 1) query, rows = calls[0] self.assertIn("INSERT INTO app.admin_health_event", query) self.assertIn("captured_by", query) self.assertIsNone(rows[0][-1]) async def test_record_admin_health_sample_reuses_health_contract(self) -> None: engine_config = admin_routes.AdminEngineConfigResponse( engine_mode="claude_cli", engine_url="http://127.0.0.1:9099", model="gateway-default", durable=True, source="database", ) recorder = AsyncMock(return_value=5) with ( patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)), patch.object(admin_routes, "healthcheck", AsyncMock(return_value=True)), patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})), patch.object(admin_routes.voice_service, "is_available", return_value=True), patch.object(admin_routes, "_runtime_health_metrics", AsyncMock(return_value=admin_routes.RuntimeHealthMetrics())), patch.object(admin_routes, "_record_health_events", recorder), ): health, recorded = await admin_routes.record_admin_health_sample(principal=None) self.assertEqual(recorded, 5) self.assertEqual(health.status, "ok") self.assertEqual(health.engine_mode, "claude_cli") recorder.assert_awaited_once() self.assertIsNone(recorder.await_args.kwargs["principal"]) self.assertEqual(len(recorder.await_args.kwargs["services"]), 5) 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) async def test_patch_ticket_links_manual_duplicate_parent(self) -> None: now = datetime.now(timezone.utc) ticket_id = "00000000-0000-0000-0000-000000000101" parent_id = "00000000-0000-0000-0000-000000000102" 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": "voice_browser", "priority": "normal", "status": "open", "source_path": "/learn/session/voice", "assigned_group": "", "resolution_note": "", "parent_ticket_id": None, } if self.fetchrow_calls == 2: case.assertIn("WITH RECURSIVE ancestors", query) return {"parent_exists": True, "creates_cycle": False} if self.fetchrow_calls == 3: case.assertIn("parent_ticket_id = CASE", query) return { "id": ticket_id, "reporter_id": "00000000-0000-0000-0000-000000000201", "reporter_email": "learner@hs.ac.kr", "reporter_name": "Learner", "reporter_role": "learner", "category": "voice_browser", "priority": "normal", "status": "open", "subject": "마이크 권한 오류", "body": "브라우저 마이크 권한 실패", "source_path": "/learn/session/voice", "fingerprint": "dup-fingerprint", "parent_ticket_id": parent_id, "assigned_group": "", "resolution_note": "", "created_at": now, "updated_at": now, "resolved_at": None, } return { "id": ticket_id, "reporter_id": "00000000-0000-0000-0000-000000000201", "reporter_email": "learner@hs.ac.kr", "reporter_name": "Learner", "reporter_role": "learner", "category": "voice_browser", "priority": "normal", "status": "open", "subject": "마이크 권한 오류", "body": "브라우저 마이크 권한 실패", "source_path": "/learn/session/voice", "fingerprint": "dup-fingerprint", "parent_ticket_id": parent_id, "duplicate_count": 1, "duplicate_parent_candidate_id": parent_id, "child_ticket_count": 0, "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)) case = self 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(parent_ticket_id=parent_id), principal, ) self.assertEqual(updated.parent_ticket_id, parent_id) self.assertEqual(updated.duplicate_count, 1) self.assertEqual(updated.event_count, 1) self.assertEqual(len(audit_calls), 1) detail = audit_calls[0][1][4] self.assertEqual(detail["changed_fields"], ["parent_ticket_id"]) self.assertEqual(detail["parent_ticket_id"]["to"], parent_id) 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("CREATE TABLE IF NOT EXISTS app.admin_health_daily_rollup", schema) self.assertIn("last_down_at TIMESTAMPTZ", schema) self.assertIn("ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY", schema) self.assertIn("CREATE POLICY p_admin_health_event_delete", schema) self.assertIn("CREATE POLICY p_admin_health_daily_rollup_select", schema) self.assertIn("CREATE POLICY p_admin_health_daily_rollup_update", schema) self.assertNotIn("CREATE POLICY p_admin_health_daily_rollup_delete", schema) self.assertIn("CREATE POLICY p_support_ticket_insert", schema) self.assertIn("reporter_id = app.current_uid()", schema) self.assertIn("fingerprint TEXT NOT NULL DEFAULT ''", schema) self.assertIn("parent_ticket_id UUID REFERENCES app.support_ticket(id)", schema) self.assertIn("idx_support_ticket_fingerprint", schema) self.assertIn("CREATE TABLE IF NOT EXISTS app.learner_prepost_measure", schema) self.assertIn("measure_name IN ('self_efficacy','skill_proficiency','training_satisfaction')", schema) self.assertIn("ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY", schema) self.assertIn("CREATE POLICY p_learner_prepost_measure_insert", schema)