8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
861 lines
34 KiB
Python
861 lines
34 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 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):
|
|
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
|
|
if self.fetch_calls == 2:
|
|
case.assertIn("date_trunc('day', created_at)", query)
|
|
return [
|
|
{
|
|
"day": "2026-06-28",
|
|
"turns": 1,
|
|
"token_metered_turns": 1,
|
|
"token_unmetered_turns": 0,
|
|
"tokens_in": 11,
|
|
"tokens_out": 13,
|
|
"cost_usd": 0.0042,
|
|
"unpriced_turns": 0,
|
|
"unpriced_tokens_in": 0,
|
|
"unpriced_tokens_out": 0,
|
|
"provider": "claude_cli",
|
|
"model": "gateway-default",
|
|
}
|
|
]
|
|
case.assertIn("unpriced_tokens_in", query)
|
|
case.assertNotIn("LIMIT 12", query)
|
|
return [
|
|
{
|
|
"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,
|
|
"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.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:
|
|
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
|
|
row = {
|
|
"provider": "agy_cli",
|
|
"model": "gemini-3.6-flash-high",
|
|
"turns": 5,
|
|
"token_metered_turns": 5,
|
|
"token_unmetered_turns": 0,
|
|
"tokens_in": 35_703,
|
|
"tokens_out": 1_129,
|
|
"cost_usd": 0,
|
|
"unpriced_turns": 5,
|
|
"unpriced_tokens_in": 35_703,
|
|
"unpriced_tokens_out": 1_129,
|
|
}
|
|
if self.fetch_calls == 2:
|
|
return [{"day": "2026-07-31", **row}]
|
|
return [row]
|
|
|
|
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_rate")
|
|
self.assertIn("Gemini 3.6 Flash", breakdown.rate_label or "")
|
|
|
|
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)
|