음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
301
apps/api/app/test_admin_ops.py
Normal file
301
apps/api/app/test_admin_ops.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue