// 2026-07-27 E2E 전수 순회 — 관리자 AI 설정(/admin/ai) 영역의 "신규 spec 필요" 항목 검증. // docs/ops/e2e-full-sweep-2026-07-27.md §8 (admin-ai) 체크리스트 중 기존 spec이 없는 항목을 // route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다. import { expect, test, type Page } from "@playwright/test"; type UsageCostBasis = | "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable"; interface AdminUsageBreakdown { provider: string; model: string; turns: number; tokens_in: number; tokens_out: number; cost_usd: number; recorded_cost_usd: number; estimated_cost_usd: number; token_metered_turns: number; token_unmetered_turns: number; cost_basis: UsageCostBasis; rate_label?: string | null; rate_source_url?: string | null; } interface AdminUsageDailyCost { day: string; turns: number; tokens_in: number; tokens_out: number; cost_usd: number; cost_basis?: UsageCostBasis; } interface AdminUsageEvaluatorCache { enabled: boolean; entries: number; hits: number; misses: number; stores: number; evictions: number; requests: number; hit_rate: number; } interface AdminUsageResponse { source: "database" | "server_session_registry"; durable: boolean; generated_at: number; window_days: number; total_turns: number; metered_turns: number; token_metered_turns: number; token_unmetered_turns: number; tokens_in: number; tokens_out: number; cost_usd: number; recorded_cost_usd: number; estimated_cost_usd: number; cost_basis?: UsageCostBasis; budget: { limit_usd: number; used_ratio: number; remaining_usd: number | null; status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate"; cost_basis?: UsageCostBasis; }; evaluator_cache?: AdminUsageEvaluatorCache; by_provider: AdminUsageBreakdown[]; daily_cost?: AdminUsageDailyCost[]; } interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; reasoning_effort: string | null; source: "database" | "runtime_cache" | "runtime_default"; durable: boolean; updated_by: string | null; updated_at: number | null; } function usageFixture( days: number, overrides: Partial = {}, ): AdminUsageResponse { return { source: "database", durable: true, generated_at: 1_785_142_800, window_days: days, total_turns: 0, metered_turns: 0, token_metered_turns: 0, token_unmetered_turns: 0, tokens_in: 0, tokens_out: 0, cost_usd: 0, cost_basis: "provider_reported", // 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다. recorded_cost_usd: overrides.cost_usd ?? 0, estimated_cost_usd: 0, budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled", cost_basis: "provider_reported", }, evaluator_cache: { enabled: false, entries: 0, hits: 0, misses: 0, stores: 0, evictions: 0, requests: 0, hit_rate: 0, }, by_provider: [], daily_cost: [], ...overrides, }; } function engineConfigFixture( overrides: Partial = {}, ): AdminEngineConfigResponse { return { engine_mode: "openai", engine_url: "http://127.0.0.1:9099", model: "gateway-default", reasoning_effort: "medium", source: "database", durable: true, updated_by: "admin@twentyoz.kr", updated_at: 1_785_142_800, ...overrides, }; } interface AdminAiMockState { counts: { usage: number; health: number; engine: number; capabilities: number }; authUser: { account_status?: "pending" | "approved" | "suspended"; onboarding_completed_at?: number | null; }; usageForWindow: (days: number) => AdminUsageResponse; /** usage 응답을 지연시키는 게이트. 반환된 promise가 resolve될 때까지 응답을 보류한다. */ usageGate?: (days: number) => Promise | undefined; engineConfig: AdminEngineConfigResponse; /** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */ engineConfigStatus?: number; } function createMockState(overrides: Partial = {}): AdminAiMockState { return { counts: { usage: 0, health: 0, engine: 0, capabilities: 0 }, authUser: {}, usageForWindow: (days) => usageFixture(days), engineConfig: engineConfigFixture(), ...overrides, }; } function deferred() { let resolve!: () => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } async function mockAdminAiSession(page: Page, state: AdminAiMockState) { await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const method = request.method(); const path = url.pathname; const fulfillJson = (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); if (method === "GET" && path.endsWith("/auth/me")) { await fulfillJson({ user_id: "sweep-ai-admin", email: "sweep-ai-admin@twentyoz.kr", display_name: "Sweep AI Admin", role: "admin", admin_access: true, super_admin: true, account_status: state.authUser.account_status ?? "approved", approval_required: false, cohort_ids: [], consent_at: null, onboarding_completed_at: state.authUser.onboarding_completed_at ?? 1_782_900_000, nickname: "", self_introduction: "", avatar_url: "", }); return; } if (method === "GET" && path.endsWith("/admin/usage")) { state.counts.usage += 1; const days = Number(url.searchParams.get("window_days") ?? 30); const gate = state.usageGate?.(days); if (gate) await gate; await fulfillJson(state.usageForWindow(days)); return; } if (method === "GET" && path.endsWith("/admin/health")) { state.counts.health += 1; await fulfillJson({ status: "ok", environment: "dev", engine_mode: "openai", services: [ { key: "engine", name: "응답 엔진", status: "ok", detail: "엔진 응답 정상", metric: "120ms", load: 0.2, }, ], }); return; } if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { state.counts.capabilities += 1; const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode; await fulfillJson({ provider, available: true, source: "live_cli", models: [ { id: "gateway-default", label: "게이트웨이 기본 모델", description: "테스트 기본 모델", reasoning_efforts: ["medium"], default_reasoning_effort: "medium", is_default: true, }, ], default_model: "gateway-default", default_reasoning_effort: "medium", detail: "테스트 모델 목록", fetched_at: 1_785_142_800, }); return; } if (method === "GET" && path.endsWith("/admin/engine-config")) { state.counts.engine += 1; if (state.engineConfigStatus) { await fulfillJson({ detail: "engine config unavailable" }, state.engineConfigStatus); return; } await fulfillJson(state.engineConfig); return; } if (method === "PATCH" && path.endsWith("/admin/engine-config")) { const body = request.postDataJSON() as Partial; await fulfillJson({ ...state.engineConfig, ...body }); return; } await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404); }); } test.describe("full sweep: admin ai operations", () => { test("제공자 조회 실패를 표시하고 다시 확인할 수 있다", async ({ page }) => { await mockAdminAiSession(page, createMockState()); let attempts = 0; let recovered = false; await page.route("**/api/admin/providers", async (route) => { attempts += 1; await route.fulfill({ status: recovered ? 200 : 500, contentType: "application/json", body: JSON.stringify(!recovered ? { detail: "제공자 상태 조회 실패" } : { providers: [], gateway_sync: { synced: false, detail: "연결 설정 대기" } }), }); }); await page.goto("/admin/ai"); const panel = page.getByTestId("provider-connections-panel"); await expect(panel.locator(".aic-runtime")).toHaveText("연결 상태 확인 실패"); const failedAttempts = attempts; recovered = true; await panel.getByRole("button", { name: "다시 확인" }).click(); await expect(panel.locator(".aic-runtime")).toHaveText("적용 대기"); await expect(panel.getByRole("alert")).toHaveCount(0); expect(attempts).toBeGreaterThan(failedAttempts); }); // checklist: admin-ai-guard-pending-approval test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => { const state = createMockState({ authUser: { account_status: "pending", onboarding_completed_at: 1_782_900_000 }, }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); await expect(page).toHaveURL(/\/pending$/); await expect(page.getByText("승인 대기")).toBeVisible(); await expect( page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }), ).toBeVisible(); await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0); // 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다. expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0, capabilities: 0 }); }); // checklist: admin-ai-aria-busy, admin-ai-refresh-button test("marks the page aria-busy while loading and reloads all operations data on refresh", async ({ page, }) => { let gate: ReturnType | null = deferred(); const state = createMockState({ usageGate: () => gate?.promise, }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); const container = page.locator('[data-testid="admin-ai-page"]'); const refreshButton = page.getByRole("button", { name: "새로고침", exact: true }); // usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성. await expect(container).toHaveAttribute("aria-busy", "true"); await expect(refreshButton).toBeDisabled(); gate.resolve(); gate = null; await expect(container).toHaveAttribute("aria-busy", "false"); await expect(refreshButton).toBeEnabled(); // React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다. const base = { ...state.counts }; expect(base.health).toBe(1); expect(base.engine).toBe(1); // 새로고침 클릭 → 세 엔드포인트 모두 재조회, 로딩 동안 다시 비활성. gate = deferred(); await refreshButton.click(); await expect(container).toHaveAttribute("aria-busy", "true"); await expect(refreshButton).toBeDisabled(); gate.resolve(); gate = null; await expect(container).toHaveAttribute("aria-busy", "false"); await expect(refreshButton).toBeEnabled(); await expect .poll(() => state.counts) .toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1, capabilities: base.capabilities + 1, }); }); // checklist: admin-ai-error-alert, admin-ai-engine-loading-empty test("shows a role=alert banner with the engine panel placeholder on load failure and clears it on refresh", async ({ page, }) => { const state = createMockState({ engineConfigStatus: 500 }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); // engine-config 로드 실패 → role=alert 배너에 오류 메시지 노출. const alert = page.locator(".aic-alert[role='alert']"); await expect(alert).toBeVisible(); await expect(alert).toContainText("engine config unavailable"); // 엔진 패널은 폼 대신 로딩/빈 상태 문구를 표시한다. await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toBeVisible(); await expect(page.getByLabel("AI 연결 주소")).toHaveCount(0); // 다음 요청이 시작되면 오류 배너가 초기화된다: 응답을 보류한 채 새로고침. state.engineConfigStatus = undefined; let gate: ReturnType | null = deferred(); state.usageGate = () => gate?.promise; await page.getByRole("button", { name: "새로고침", exact: true }).click(); await expect(alert).toHaveCount(0); gate.resolve(); gate = null; // 성공 응답 이후에도 배너는 없고 엔진 설정 폼이 렌더된다. await expect(page.getByLabel("AI 연결 주소")).toHaveValue("http://127.0.0.1:9099"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); await expect(alert).toHaveCount(0); }); // checklist: admin-ai-window-toggle test("refetches only usage on window change and ignores a stale late response", async ({ page, }) => { const slowWindow = deferred(); const state = createMockState({ usageForWindow: (days) => usageFixture(days, { total_turns: 10, metered_turns: 10, cost_usd: days === 90 ? 9.99 : days === 7 ? 1.11 : 3.33, }), usageGate: (days) => (days === 90 ? slowWindow.promise : undefined), }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); const ledger = page.locator(".aic-ledger"); await expect(ledger).toContainText("$3.33"); // React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다. const base = { ...state.counts }; expect(base.health).toBe(1); expect(base.engine).toBe(1); // 90일 클릭(응답 보류) 후 곧바로 7일 클릭 → 7일 데이터가 반영된다. const staleResponse = page.waitForResponse((response) => response.url().includes("/api/admin/usage?window_days=90"), ); await page.getByRole("button", { name: "90일" }).click(); await expect(page.getByRole("button", { name: "90일" })).toHaveAttribute( "aria-pressed", "true", ); await page.getByRole("button", { name: "7일" }).click(); await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true"); await expect(ledger).toContainText("$1.11"); // 재설계된 원장 카드: 데이터 로드 후에는 "N일 DB 집계" 대신 기록·참조 분해를 표시한다. await expect(ledger).toContainText("기록 $1.11 · 참조 $0"); // 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다. slowWindow.resolve(); await staleResponse; await expect(ledger).toContainText("$1.11"); await expect(ledger).not.toContainText("$9.99"); // 기간 변경은 usage만 재조회한다(health·engine-config는 초기 1회 그대로). await expect.poll(() => state.counts.usage).toBe(base.usage + 2); expect(state.counts.health).toBe(1); expect(state.counts.engine).toBe(1); expect(state.counts.capabilities).toBe(1); }); // checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel test("renders the coverage track, daily cost chart, and evaluator cache panel from metered usage", async ({ page, }) => { const state = createMockState({ usageForWindow: (days) => usageFixture(days, { total_turns: 40, metered_turns: 30, token_metered_turns: 24, token_unmetered_turns: 6, tokens_in: 125_000, tokens_out: 18_500, cost_usd: 6.6212, budget: { limit_usd: 20, used_ratio: 0.33106, remaining_usd: 13.3788, status: "ok" }, evaluator_cache: { enabled: true, entries: 12, hits: 8, misses: 2, stores: 2, evictions: 1, requests: 10, hit_rate: 0.8, }, by_provider: [ { provider: "openai", model: "gpt-5-mini", turns: 30, token_metered_turns: 24, token_unmetered_turns: 6, tokens_in: 125_000, tokens_out: 18_500, cost_usd: 6.6212, recorded_cost_usd: 6.6212, estimated_cost_usd: 0, cost_basis: "provider_reported", rate_label: null, }, ], daily_cost: [ { day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 }, { day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 }, { day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 }, ], }), }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); // 토큰 계량 커버리지 트랙: token_metered/metered 비율과 토큰 미수집·DB 원장 미연결 안내. const budgetPanel = page.locator(".aic-budget"); await expect(budgetPanel).toContainText("토큰 계량 커버리지"); await expect(budgetPanel).toContainText("80%"); await expect(budgetPanel.locator('[aria-label="토큰 계량 커버리지 80%"]')).toHaveCount(1); await expect(budgetPanel).toContainText( "6개 원장 호출은 과거 토큰 미수집 건이라 복원 없이 미계량으로 남깁니다.", ); await expect(budgetPanel).toContainText("DB 원장 미연결 10턴"); // 일별 비용 차트: role=img aria-label, 날짜·비용 툴팁, 막대 개수. const chart = page.locator(".aic-chart[role='img']"); await expect(chart).toBeVisible(); await expect(chart).toHaveAttribute("aria-label", "30일 일별 AI 비용 막대 차트"); await expect(chart.locator(".aic-chart__day")).toHaveCount(3); await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute( "title", "2026-07-13 $1.42 · Provider 보고", ); await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴"); // 평가 캐시 패널: 사용 중 배지, hit-rate, 6개 카운트, 원문 미저장 안내. const cachePanel = page.locator(".aic-panel").filter({ hasText: "평가 캐시 효율" }); await expect(cachePanel).toContainText("사용 중"); await expect(cachePanel.locator(".aic-cache-score")).toContainText("80%"); await expect(cachePanel.locator(".aic-cache-score")).toContainText("hit-rate"); const miniGrid = cachePanel.locator(".aic-mini-grid"); await expect(miniGrid).toContainText("요청"); await expect(miniGrid.locator("div").filter({ hasText: "요청" }).locator("dd")).toHaveText("10"); await expect(miniGrid.locator("div").filter({ hasText: "적중" }).locator("dd")).toHaveText("8"); await expect(miniGrid.locator("div").filter({ hasText: "미스" }).locator("dd")).toHaveText("2"); await expect(miniGrid.locator("div").filter({ hasText: "저장" }).locator("dd")).toHaveText("2"); await expect( miniGrid.locator("div").filter({ hasText: "현재 엔트리" }).locator("dd"), ).toHaveText("12"); await expect(miniGrid.locator("div").filter({ hasText: "축출" }).locator("dd")).toHaveText("1"); await expect(cachePanel).toContainText( "캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.", ); }); // checklist: admin-ai-daily-chart-empty, admin-ai-provider-table-empty test("shows empty states for the daily chart and the provider ledger", async ({ page }) => { const state = createMockState({ usageForWindow: (days) => usageFixture(days, { by_provider: [], daily_cost: [] }), }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); await expect(page.getByText("선택한 기간에 비용이 계량된 AI 호출이 없습니다.")).toBeVisible(); await expect(page.getByText("모델별 계량 행이 아직 없습니다.")).toBeVisible(); await expect(page.locator(".aic-chart")).toHaveCount(0); await expect(page.locator(".aic-table")).toHaveCount(0); }); test("does not present a partial upper bound as an exact cost", async ({ page }) => { const state = createMockState({ usageForWindow: (days) => usageFixture(days, { total_turns: 2, metered_turns: 2, token_metered_turns: 2, tokens_in: 2_000_000, tokens_out: 2_000_000, cost_usd: 4.5, recorded_cost_usd: 0, estimated_cost_usd: 4.5, cost_basis: "partial_upper_bound", budget: { limit_usd: 20, used_ratio: 0.225, remaining_usd: null, status: "indeterminate", cost_basis: "partial_upper_bound", }, by_provider: [ { provider: "agy_cli", model: "gemini-3.7-flash-high", turns: 2, token_metered_turns: 2, token_unmetered_turns: 0, tokens_in: 2_000_000, tokens_out: 2_000_000, cost_usd: 4.5, recorded_cost_usd: 0, estimated_cost_usd: 4.5, cost_basis: "partial_upper_bound", rate_label: "일부 호출 미산정 · 산정된 부분도 상한 추정", }, ], daily_cost: [ { day: "2026-08-13", turns: 2, tokens_in: 2_000_000, tokens_out: 2_000_000, cost_usd: 4.5, cost_basis: "partial_upper_bound", }, ], }), }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); await expect(page.locator(".aic-ledger b").first()).toHaveText("미산정"); await expect(page.locator(".aic-ledger article").last().locator("b")).toHaveText("—"); await expect(page.locator(".aic-budget")).toContainText("예산 미확정"); await expect(page.locator(".aic-budget")).toContainText("잔여 미산정 · 사용률 미산정"); await expect(page.locator(".aic-chart__value")).toHaveText("미산정"); const row = page.locator(".aic-table tbody tr"); await expect(row.locator("td").nth(4)).toContainText("미산정"); await expect(row.locator("td").nth(4)).toContainText("일부 상한"); await expect(row.locator("td").nth(5)).toHaveText("—"); await expect(row.locator("td").nth(6)).toHaveText("—"); }); // checklist: admin-ai-engine-config-meta test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => { const state = createMockState({ engineConfig: engineConfigFixture({ source: "database", durable: true, updated_by: "admin@twentyoz.kr", updated_at: 1_785_142_800, }), }); await mockAdminAiSession(page, state); await page.goto("/admin/ai"); const meta = page.locator(".aic-config-meta"); await expect(meta).toBeVisible(); const rowValue = (label: string) => meta.locator("div").filter({ hasText: label }).locator("dd"); await expect(rowValue("저장 원천")).toHaveText("DB 영구 저장"); await expect(rowValue("현재 소스")).toHaveText("database"); await expect(rowValue("최근 변경")).not.toHaveText("기록 없음"); await expect(rowValue("변경자")).toHaveText("admin@twentyoz.kr"); // 런타임 적용 변형: durable=false·기록 없음 표시. state.engineConfig = engineConfigFixture({ source: "runtime_default", durable: false, updated_by: null, updated_at: null, }); await page.getByRole("button", { name: "새로고침", exact: true }).click(); await expect(rowValue("저장 원천")).toHaveText("런타임 적용"); await expect(rowValue("현재 소스")).toHaveText("runtime_default"); await expect(rowValue("최근 변경")).toHaveText("기록 없음"); await expect(rowValue("변경자")).toHaveText("기록 없음"); }); });