554 lines
23 KiB
Python
554 lines
23 KiB
Python
"""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)
|