- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
501 lines
18 KiB
TypeScript
501 lines
18 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";
|
|
|
|
interface AdminUsageBreakdown {
|
|
provider: string;
|
|
model: string;
|
|
turns: number;
|
|
tokens_in: number;
|
|
tokens_out: number;
|
|
cost_usd: number;
|
|
}
|
|
|
|
interface AdminUsageDailyCost {
|
|
day: string;
|
|
turns: number;
|
|
tokens_in: number;
|
|
tokens_out: number;
|
|
cost_usd: number;
|
|
}
|
|
|
|
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;
|
|
tokens_in: number;
|
|
tokens_out: number;
|
|
cost_usd: number;
|
|
budget: {
|
|
limit_usd: number;
|
|
used_ratio: number;
|
|
remaining_usd: number | null;
|
|
status: "disabled" | "ok" | "warn" | "exceeded";
|
|
};
|
|
evaluator_cache?: AdminUsageEvaluatorCache;
|
|
by_provider: AdminUsageBreakdown[];
|
|
daily_cost?: AdminUsageDailyCost[];
|
|
}
|
|
|
|
interface AdminEngineConfigResponse {
|
|
engine_mode: string;
|
|
engine_url: string;
|
|
model: string;
|
|
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,
|
|
tokens_in: 0,
|
|
tokens_out: 0,
|
|
cost_usd: 0,
|
|
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
|
|
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",
|
|
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 };
|
|
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 },
|
|
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-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 });
|
|
});
|
|
|
|
// checklist: admin-ai-aria-busy, admin-ai-refresh-button
|
|
test("marks the page aria-busy while loading and reloads all three data sets 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: "새로고침" });
|
|
|
|
// 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 });
|
|
});
|
|
|
|
// 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: "새로고침" }).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.3300");
|
|
// 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.1100");
|
|
await expect(ledger).toContainText("7일 DB 집계");
|
|
|
|
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
|
|
slowWindow.resolve();
|
|
await staleResponse;
|
|
await expect(ledger).toContainText("$1.1100");
|
|
await expect(ledger).not.toContainText("$9.9900");
|
|
|
|
// 기간 변경은 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);
|
|
});
|
|
|
|
// 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,
|
|
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,
|
|
tokens_in: 125_000,
|
|
tokens_out: 18_500,
|
|
cost_usd: 6.6212,
|
|
},
|
|
],
|
|
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");
|
|
|
|
// DB 계량 커버리지 트랙: metered/total 비율과 미계량 턴 안내.
|
|
const budgetPanel = page.locator(".aic-budget");
|
|
await expect(budgetPanel).toContainText("DB 계량 커버리지");
|
|
await expect(budgetPanel).toContainText("75%");
|
|
await expect(budgetPanel.locator('[aria-label="DB 계량 커버리지 75%"]')).toHaveCount(1);
|
|
await expect(budgetPanel).toContainText(
|
|
"10개 턴은 provider·model·token·cost 계량 필드가 없습니다.",
|
|
);
|
|
|
|
// 일별 비용 차트: 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.4200",
|
|
);
|
|
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);
|
|
});
|
|
|
|
// 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: "새로고침" }).click();
|
|
await expect(rowValue("저장 원천")).toHaveText("런타임 적용");
|
|
await expect(rowValue("현재 소스")).toHaveText("runtime_default");
|
|
await expect(rowValue("최근 변경")).toHaveText("기록 없음");
|
|
await expect(rowValue("변경자")).toHaveText("기록 없음");
|
|
});
|
|
});
|