vignette/apps/api/app/test_admin_ops.py
2026-06-28 12:18:20 +09:00

301 lines
12 KiB
Python

"""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)