운영 지표와 지원 요청 저장소 추가
This commit is contained in:
parent
50fa4ad432
commit
e7ebb38177
20 changed files with 3038 additions and 39 deletions
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .deps import Principal, Role
|
||||
from .routes import admin as admin_routes
|
||||
|
|
@ -26,6 +26,9 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
case = self
|
||||
|
||||
class Conn:
|
||||
def __init__(self) -> None:
|
||||
self.fetch_calls = 0
|
||||
|
||||
async def fetchrow(self, query, *args, **kwargs):
|
||||
return {
|
||||
"total_turns": 2,
|
||||
|
|
@ -36,6 +39,18 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
}
|
||||
|
||||
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,
|
||||
|
|
@ -52,7 +67,11 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
}
|
||||
]
|
||||
|
||||
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
|
||||
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)
|
||||
|
|
@ -60,12 +79,23 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
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,
|
||||
|
|
@ -113,6 +143,113 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
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)
|
||||
|
||||
|
|
@ -289,6 +426,108 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
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
|
||||
|
||||
|
|
@ -297,5 +536,19 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue