vignette/apps/web/e2e/full-sweep-admin-ai.spec.ts
2026-08-30 00:01:17 +09:00

643 lines
23 KiB
TypeScript

// 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> = {},
): 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> = {},
): 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<void> | undefined;
engineConfig: AdminEngineConfigResponse;
/** 설정 시 GET /admin/engine-config 를 해당 status의 FastAPI 오류로 응답한다. */
engineConfigStatus?: number;
}
function createMockState(overrides: Partial<AdminAiMockState> = {}): 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<void>((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<AdminEngineConfigResponse>;
await fulfillJson({ ...state.engineConfig, ...body });
return;
}
await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404);
});
}
test.describe("full sweep: admin ai operations", () => {
// 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<typeof deferred> | 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<typeof deferred> | 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("기록 없음");
});
});