vignette/apps/api/app/test_admin_ops.py
2026-08-29 23:59:17 +09:00

1156 lines
47 KiB
Python

"""Admin operations persistence model tests."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .contracts.engine_gateway import EngineCapabilitiesResponse, EngineModelOption
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 CohortSerializationTest(unittest.TestCase):
"""2026-07-27 전수 순회 결함 #20: 서버가 cohort_ids 첫 항목만 저장하던 유실 회귀 가드."""
def test_multi_cohort_round_trip_preserves_every_id(self) -> None:
from .auth_sessions import _cohort_ids, _cohort_value
self.assertEqual(_cohort_value(["co-a", "co-b"]), "co-a,co-b")
self.assertEqual(_cohort_ids("co-a,co-b"), ["co-a", "co-b"])
self.assertEqual(_cohort_ids(_cohort_value(["x-1", "x-2", "x-3"])), ["x-1", "x-2", "x-3"])
def test_cohort_value_trims_and_drops_empty_entries(self) -> None:
from .auth_sessions import _cohort_ids, _cohort_value
self.assertEqual(_cohort_value([" co-a ", "", " ", "co-b"]), "co-a,co-b")
self.assertIsNone(_cohort_value([]))
self.assertIsNone(_cohort_value(None))
self.assertEqual(_cohort_ids(None), [])
self.assertEqual(_cohort_ids(" co-a , ,co-b "), ["co-a", "co-b"])
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):
case.assertIn("LOWER(BTRIM(COALESCE(llm_provider, ''))) = 'e2e'", query)
case.assertIn("LOWER(BTRIM(COALESCE(model, ''))) = 'fake-client'", query)
case.assertIn("COALESCE(tokens_in, 0) = 1", query)
case.assertIn("COALESCE(tokens_out, 0) = 1", query)
case.assertIn("COALESCE(cost_usd, 0) = 0", query)
return {
"total_turns": 2,
"metered_turns": 1,
"token_metered_turns": 1,
"token_unmetered_turns": 0,
"tokens_in": 11,
"tokens_out": 13,
"cost_usd": 0.0042,
}
async def fetch(self, query, *args, **kwargs):
self.fetch_calls += 1
case.assertEqual(self.fetch_calls, 1)
case.assertIn("date_trunc('day', created_at AT TIME ZONE 'UTC')", query)
case.assertNotIn("ORDER BY created_at, id", query)
case.assertIn("COUNT(*)::bigint AS matching_turns", query)
case.assertIn("GROUP BY 1, 2, 3, 4, 5, 6", query)
case.assertNotIn("LIMIT 12", query)
return [
{
"day": "2026-06-28",
"provider": "claude_cli",
"model": "gateway-default",
"turns": 1,
"token_metered_turns": 1,
"token_unmetered_turns": 0,
"tokens_in": 11,
"tokens_out": 13,
"cost_usd": 0.0042,
"matching_turns": 1,
"unpriced_turns": 0,
"unpriced_tokens_in": 0,
"unpriced_tokens_out": 0,
}
]
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.token_metered_turns, 1)
self.assertEqual(usage.token_unmetered_turns, 0)
self.assertEqual(usage.by_provider[0].provider, "claude_cli")
self.assertEqual(usage.by_provider[0].cost_basis, "provider_estimate")
self.assertEqual(usage.cost_basis, "provider_estimate")
self.assertEqual(usage.daily_cost[0].cost_basis, "provider_estimate")
self.assertEqual(usage.recorded_cost_usd, 0.0042)
self.assertEqual(usage.estimated_cost_usd, 0)
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_usage_from_database_backfills_agy_zero_cost_with_reference_rate(self) -> None:
case = self
class Conn:
def __init__(self) -> None:
self.fetch_calls = 0
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 5,
"metered_turns": 5,
"token_metered_turns": 5,
"token_unmetered_turns": 0,
"tokens_in": 35_703,
"tokens_out": 1_129,
"cost_usd": 0,
}
async def fetch(self, query, *args, **kwargs):
self.fetch_calls += 1
case.assertEqual(self.fetch_calls, 1)
token_pairs = [(7_141, 226)] * 4 + [(7_139, 225)]
return [
{
"day": "2026-07-31",
"provider": "agy_cli",
"model": "gemini-3.6-flash-high",
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"cost_usd": 0,
"matching_turns": 1,
}
for tokens_in, tokens_out in token_pairs
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
usage = await admin_routes._usage_from_database(window_days=30)
self.assertEqual(usage.cost_usd, 0.062022)
self.assertEqual(usage.recorded_cost_usd, 0)
self.assertEqual(usage.estimated_cost_usd, 0.062022)
self.assertEqual(usage.daily_cost[0].cost_usd, 0.062022)
breakdown = usage.by_provider[0]
self.assertEqual(breakdown.cost_usd, 0.062022)
self.assertEqual(breakdown.cost_basis, "reference_upper_bound")
self.assertIn("Gemini 3.6 Flash", breakdown.rate_label or "")
async def test_usage_from_database_prices_gemini_37_on_its_usage_date(self) -> None:
class Conn:
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 21,
"metered_turns": 21,
"token_metered_turns": 21,
"token_unmetered_turns": 0,
"tokens_in": 115_950,
"tokens_out": 4_407,
"cost_usd": 0,
}
async def fetch(self, query, *args, **kwargs):
token_pairs = [(5_521, 210)] * 20 + [(5_530, 207)]
return [
{
"day": "2026-08-28",
"provider": "agy_cli",
"model": "gemini-3.7-flash-high",
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"cost_usd": 0,
"matching_turns": 1,
}
for tokens_in, tokens_out in token_pairs
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
usage = await admin_routes._usage_from_database(window_days=30)
self.assertEqual(usage.cost_usd, 0.103489)
self.assertEqual(usage.recorded_cost_usd, 0)
self.assertEqual(usage.estimated_cost_usd, 0.103489)
self.assertEqual(usage.daily_cost[0].cost_usd, 0.103489)
self.assertEqual(usage.cost_basis, "reference_upper_bound")
self.assertEqual(usage.daily_cost[0].cost_basis, "reference_upper_bound")
breakdown = usage.by_provider[0]
self.assertEqual(breakdown.cost_usd, 0.103489)
self.assertEqual(breakdown.cost_basis, "reference_upper_bound")
self.assertIn("Gemini 3.7 Flash", breakdown.rate_label or "")
async def test_usage_from_database_keeps_effective_rates_across_date_boundary(self) -> None:
class Conn:
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 2,
"metered_turns": 2,
"token_metered_turns": 2,
"token_unmetered_turns": 0,
"tokens_in": 2_000_000,
"tokens_out": 2_000_000,
"cost_usd": 0,
"matching_turns": 1,
}
async def fetch(self, query, *args, **kwargs):
base = {
"provider": "agy_cli",
"model": "gemini-3.6-flash-high",
"tokens_in": 1_000_000,
"tokens_out": 1_000_000,
"cost_usd": 0,
"matching_turns": 1,
}
return [
{"day": "2026-08-12", **base},
{"day": "2026-08-13", **base},
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
usage = await admin_routes._usage_from_database(window_days=30)
self.assertEqual(usage.cost_usd, 13.5)
self.assertEqual(usage.estimated_cost_usd, 13.5)
self.assertEqual([item.cost_usd for item in usage.daily_cost], [9.0, 4.5])
self.assertEqual(len(usage.by_provider), 1)
self.assertEqual(usage.by_provider[0].cost_usd, 13.5)
self.assertIn("기간별", usage.by_provider[0].rate_label or "")
async def test_usage_from_database_prices_request_tiers_before_aggregation(self) -> None:
class Conn:
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 2,
"metered_turns": 2,
"token_metered_turns": 2,
"token_unmetered_turns": 0,
"tokens_in": 300_000,
"tokens_out": 20_000,
"cost_usd": 0,
}
async def fetch(self, query, *args, **kwargs):
return [
{
"day": "2026-08-28",
"provider": "agy_cli",
"model": "gemini-3.1-pro-high",
"tokens_in": 150_000,
"tokens_out": 10_000,
"cost_usd": 0,
"matching_turns": 2,
},
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
database_usage = await admin_routes._usage_from_database(window_days=30)
observed_at = datetime(2026, 8, 28, 12, tzinfo=timezone.utc).timestamp()
runtime_turns = [
SimpleNamespace(
speaker="client",
created_at=observed_at,
llm_provider="agy_cli",
model="gemini-3.1-pro-high",
tokens_in=150_000,
tokens_out=10_000,
cost_usd=0,
)
for _ in range(2)
]
with (
patch.object(
admin_routes.store,
"list",
return_value=[SimpleNamespace(turns=runtime_turns)],
),
patch.object(admin_routes.time, "time", return_value=observed_at + 1),
):
runtime_usage = admin_routes._usage_from_runtime_store(window_days=30)
self.assertEqual(database_usage.cost_usd, 0.84)
self.assertEqual(database_usage.by_provider[0].cost_usd, 0.84)
self.assertEqual(database_usage.daily_cost[0].cost_usd, 0.84)
self.assertEqual(runtime_usage.cost_usd, database_usage.cost_usd)
self.assertEqual(runtime_usage.by_provider[0].cost_usd, 0.84)
async def test_usage_from_database_marks_mixed_pricing_as_partial(self) -> None:
class Conn:
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 2,
"metered_turns": 2,
"token_metered_turns": 2,
"token_unmetered_turns": 0,
"tokens_in": 2_000_000,
"tokens_out": 2_000_000,
"cost_usd": 0,
"matching_turns": 1,
}
async def fetch(self, query, *args, **kwargs):
base = {
"provider": "agy_cli",
"model": "gemini-3.7-flash-high",
"tokens_in": 1_000_000,
"tokens_out": 1_000_000,
"cost_usd": 0,
"matching_turns": 1,
}
return [
{"day": "2026-08-12", **base},
{"day": "2026-08-13", **base},
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
usage = await admin_routes._usage_from_database(window_days=30)
self.assertEqual(usage.cost_usd, 4.5)
self.assertEqual(usage.cost_basis, "partial_upper_bound")
self.assertEqual(usage.budget.cost_basis, "partial_upper_bound")
self.assertEqual(usage.by_provider[0].cost_basis, "partial_upper_bound")
self.assertEqual(
usage.by_provider[0].rate_label,
"일부 호출 미산정 · 산정된 부분도 상한 추정",
)
self.assertEqual(usage.daily_cost[0].cost_basis, "unavailable")
self.assertEqual(usage.daily_cost[1].cost_basis, "reference_upper_bound")
def test_cost_basis_preserves_missing_and_upper_bound_dimensions(self) -> None:
self.assertEqual(
admin_routes._aggregate_cost_basis(
["reference_upper_bound", "unavailable"]
),
"partial_upper_bound",
)
self.assertEqual(
admin_routes._aggregate_cost_basis(["reference_rate", "unavailable"]),
"partial",
)
self.assertEqual(
admin_routes._aggregate_cost_basis(
["provider_reported", "reference_upper_bound"]
),
"reference_upper_bound",
)
def test_budget_is_indeterminate_when_cost_is_not_bounded_both_ways(self) -> None:
with patch.object(admin_routes.settings, "admin_usage_budget_usd", 10.0):
partial = admin_routes._usage_budget(4.5, "partial")
partial_upper = admin_routes._usage_budget(
4.5, "partial_upper_bound"
)
safe_upper = admin_routes._usage_budget(7.5, "reference_upper_bound")
self.assertEqual(partial.status, "indeterminate")
self.assertEqual(partial.remaining_usd, 5.5)
self.assertEqual(partial_upper.status, "indeterminate")
self.assertIsNone(partial_upper.remaining_usd)
self.assertEqual(safe_upper.status, "ok")
self.assertEqual(safe_upper.remaining_usd, 2.5)
def test_synthetic_e2e_signature_is_not_reportable_usage(self) -> None:
self.assertFalse(admin_routes._is_reportable_usage("e2e", "fake-client", 1, 1, 0))
self.assertFalse(
admin_routes._is_reportable_usage(" E2E ", " FAKE-CLIENT ", 1, 1, 0)
)
self.assertTrue(admin_routes._is_reportable_usage("e2e", "fake-client", 2, 1, 0))
self.assertTrue(admin_routes._is_reportable_usage("e2e", "real-client", 1, 1, 0))
self.assertTrue(admin_routes._is_reportable_usage("agy_cli", "fake-client", 1, 1, 0))
def test_stored_reference_cost_does_not_claim_a_recomputed_rate(self) -> None:
breakdown = admin_routes._usage_breakdown(
provider="agy_cli",
model="gemini-3.6-flash-high",
turns=1,
token_metered_turns=1,
token_unmetered_turns=0,
tokens_in=1_000_000,
tokens_out=0,
stored_cost_usd=1.5,
unpriced_tokens_in=0,
unpriced_tokens_out=0,
priced_at="2026-08-28",
)
self.assertEqual(breakdown.cost_usd, 1.5)
self.assertEqual(breakdown.cost_basis, "reference_rate")
self.assertEqual(breakdown.rate_label, "호출 시점에 저장된 참조단가 추정값")
self.assertIsNone(breakdown.rate_source_url)
def test_legacy_reference_fallback_is_labeled_as_an_upper_bound(self) -> None:
breakdown = admin_routes._usage_breakdown(
provider="agy_cli",
model="gemini-3.7-flash-high",
turns=1,
token_metered_turns=1,
token_unmetered_turns=0,
tokens_in=115_950,
tokens_out=4_407,
stored_cost_usd=0,
unpriced_tokens_in=115_950,
unpriced_tokens_out=4_407,
priced_at="2026-08-28",
)
self.assertEqual(breakdown.cost_basis, "reference_upper_bound")
self.assertIn("캐시 미보존", breakdown.rate_label or "")
def test_runtime_usage_prices_by_turn_date_and_excludes_synthetic_provider(self) -> None:
observed_at = datetime(2026, 8, 28, 12, tzinfo=timezone.utc).timestamp()
turns = [
SimpleNamespace(
speaker="client",
created_at=observed_at,
llm_provider="agy_cli",
model="gemini-3.7-flash-high",
tokens_in=115_950,
tokens_out=4_407,
cost_usd=0,
),
SimpleNamespace(
speaker="client",
created_at=observed_at,
llm_provider="e2e",
model="fake-client",
tokens_in=1,
tokens_out=1,
cost_usd=0,
),
]
with (
patch.object(
admin_routes.store,
"list",
return_value=[SimpleNamespace(turns=turns)],
),
patch.object(admin_routes.time, "time", return_value=observed_at + 1),
):
usage = admin_routes._usage_from_runtime_store(window_days=30)
self.assertEqual(usage.total_turns, 1)
self.assertEqual(usage.metered_turns, 1)
self.assertEqual(usage.tokens_in, 115_950)
self.assertEqual(usage.cost_usd, 0.103489)
self.assertEqual(len(usage.by_provider), 1)
self.assertEqual(usage.by_provider[0].model, "gemini-3.7-flash-high")
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)
class AdminEngineConfigTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.previous_config = admin_routes._ENGINE_CONFIG
admin_routes._ENGINE_CONFIG = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
reasoning_effort="high",
durable=True,
source="database",
)
self.principal = Principal(
user_id="00000000-0000-0000-0000-000000000099",
role=Role.ADMIN,
cohort_ids=[],
email="admin@twentyoz.kr",
display_name="Admin",
)
async def asyncTearDown(self) -> None:
admin_routes._ENGINE_CONFIG = self.previous_config
async def test_capabilities_endpoint_uses_requested_gateway_url(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[
EngineModelOption(
id="gpt-5.6-terra",
label="GPT-5.6-Terra",
reasoning_efforts=["medium"],
default_reasoning_effort="medium",
)
],
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
fetched_at=1,
)
with patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
) as lookup:
result = await admin_routes.get_engine_capabilities(
self.principal,
engine_mode="codex_cli",
engine_url="http://127.0.0.1:9199/",
force=True,
)
self.assertEqual(result.default_model, "gpt-5.6-terra")
lookup.assert_awaited_once_with(
provider="codex_cli",
base_url="http://127.0.0.1:9199",
force=True,
)
with patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
) as current_lookup:
await admin_routes.get_engine_capabilities(
self.principal,
engine_mode="codex_cli",
engine_url=None,
force=False,
)
current_lookup.assert_awaited_once_with(
provider="codex_cli",
base_url="http://127.0.0.1:9099",
force=False,
)
async def test_patch_rejects_model_missing_from_live_catalog(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[],
fetched_at=1,
)
with patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
):
with self.assertRaises(HTTPException) as caught:
await admin_routes.patch_engine_config(
admin_routes.AdminEngineConfigPatch(
engine_mode="codex_cli",
model="made-up-model",
reasoning_effort="medium",
),
self.principal,
)
self.assertEqual(caught.exception.status_code, 422)
self.assertIn("사용할 수 없는 모델", caught.exception.detail)
async def test_patch_persists_and_applies_reasoning_effort(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="codex_cli",
available=True,
source="live_cli",
models=[
EngineModelOption(
id="gpt-5.6-terra",
label="GPT-5.6-Terra",
reasoning_efforts=["low", "medium", "high"],
default_reasoning_effort="medium",
)
],
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
fetched_at=1,
)
class ConfigConn:
def __init__(self):
self.query = ""
self.args = ()
async def fetchrow(self, query, *args):
self.query = query
self.args = args
return {
"engine_mode": args[0],
"engine_url": args[1],
"model": args[2],
"reasoning_effort": args[3],
"updated_by": args[4],
"updated_at": None,
}
conn = ConfigConn()
class ConfigPool:
def acquire(self):
return _Acquire(conn)
with (
patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(admin_routes, "get_pool", return_value=ConfigPool()),
patch.object(
admin_routes.engine_client,
"configure",
AsyncMock(),
) as configure,
):
result = await admin_routes.patch_engine_config(
admin_routes.AdminEngineConfigPatch(
engine_mode="codex_cli",
engine_url="http://127.0.0.1:9099",
model="gpt-5.6-terra",
reasoning_effort="medium",
),
self.principal,
)
self.assertEqual(result.reasoning_effort, "medium")
self.assertIn("reasoning_effort", conn.query)
self.assertEqual(conn.args[3], "medium")
configure.assert_awaited_once_with(
base_url="http://127.0.0.1:9099",
engine_mode="codex_cli",
default_model="gpt-5.6-terra",
default_reasoning_effort="medium",
)
async def test_patch_can_explicitly_clear_reasoning_effort(self) -> None:
capabilities = EngineCapabilitiesResponse(
provider="claude_api",
available=True,
source="live_api",
models=[EngineModelOption(id="claude-legacy", label="Claude Legacy")],
default_model="claude-legacy",
fetched_at=1,
)
class ConfigConn:
async def fetchrow(self, _query, *args):
return {
"engine_mode": args[0],
"engine_url": args[1],
"model": args[2],
"reasoning_effort": args[3],
"updated_by": args[4],
"updated_at": None,
}
class ConfigPool:
def acquire(self):
return _Acquire(ConfigConn())
with (
patch.object(
admin_routes.engine_client,
"capabilities",
AsyncMock(return_value=capabilities),
),
patch.object(admin_routes, "get_pool", return_value=ConfigPool()),
patch.object(admin_routes.engine_client, "configure", AsyncMock()),
):
result = await admin_routes.patch_engine_config(
admin_routes.AdminEngineConfigPatch(
engine_mode="claude_api",
model="claude-legacy",
reasoning_effort=None,
),
self.principal,
)
self.assertIsNone(result.reasoning_effort)