import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; import { completeOnboarding, expectNoHorizontalOverflow, signInAsLearner, useRealApi, withGlobalEngineConfigLock, } from "./support"; type AdminHealthStatus = "ok" | "degraded" | "down"; interface AdminServiceHealth { key: string; name: string; status: AdminHealthStatus; detail: string; metric: string; load: number; } interface AdminHealthResponse { status: AdminHealthStatus; environment: string; engine_mode: string; services: AdminServiceHealth[]; } interface AdminManagedUser { user_id: string; email: string; display_name: string; role: "learner" | "teacher" | "admin"; account_status: "pending" | "approved" | "suspended"; affiliation: string; cohort_ids: string[]; active_sessions: number; last_seen_at: number; created_at: number; } interface AdminUsersResponse { source: "database" | "server_session_registry"; durable: boolean; users: AdminManagedUser[]; } interface AdminUsageBreakdown { provider: string; model: string; turns: number; token_metered_turns: number; token_unmetered_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 AdminUsageBudget { limit_usd: number; used_ratio: number; remaining_usd: number | null; status: "disabled" | "ok" | "warn" | "exceeded"; } 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; budget: AdminUsageBudget; 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; } interface AdminUptimeResponse { source: "database" | "unavailable"; durable: boolean; window_hours: number; sample_count: number; ok_ratio: number; degraded_events: number; down_events: number; last_down_at: number | null; } interface AdminTicketReporter { email: string; display_name: string; role: string; } interface AdminSupportTicket { ticket_id: string; reporter: AdminTicketReporter; category: string; priority: "low" | "normal" | "high" | "urgent"; status: "open" | "triaged" | "in_progress" | "resolved" | "closed"; subject: string; body: string; source_path: string; parent_ticket_id?: string | null; duplicate_count?: number; duplicate_parent_candidate_id?: string | null; child_ticket_count?: number; created_at: number; updated_at: number; } interface AdminTicketsResponse { source: "database" | "unavailable"; durable: boolean; tickets: AdminSupportTicket[]; summary: { total: number; open_count: number; high_priority_count: number; stale_count: number; by_category: Record; }; } async function expectResponseOk(response: APIResponse | Response) { if (!response.ok()) { expect(response.ok(), await response.text()).toBeTruthy(); } } async function mockAdminSession( page: Page, authUser: Partial<{ user_id: string; email: string; display_name: string; role: "learner" | "teacher" | "admin"; admin_access: boolean; super_admin: boolean; onboarding_completed_at: number | null; }> = {}, options: { adminUsers?: unknown[]; adminTickets?: unknown; adminUsage?: AdminUsageResponse; engineConfig?: AdminEngineConfigResponse; authFailuresBeforeSuccess?: number; } = {}, ) { const seenAdminEndpoints = new Set(); let authAttempts = 0; const json = (body: unknown) => JSON.stringify(body); 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(body), }); if (method === "GET" && path.endsWith("/auth/me")) { authAttempts += 1; if (authAttempts <= (options.authFailuresBeforeSuccess ?? 0)) { await fulfillJson({ detail: "public runtime is starting" }, 503); return; } await fulfillJson({ user_id: authUser.user_id ?? "stale-admin", email: authUser.email ?? "stale-admin@twentyoz.kr", display_name: authUser.display_name ?? "Stale Admin", role: authUser.role ?? "admin", admin_access: authUser.admin_access ?? false, super_admin: authUser.super_admin ?? false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: null, onboarding_completed_at: authUser.onboarding_completed_at ?? null, nickname: "", self_introduction: "", avatar_url: "", }); return; } if (method === "GET" && path.endsWith("/admin/health")) { seenAdminEndpoints.add("health"); await fulfillJson({ status: "degraded", environment: "dev", engine_mode: "openai", services: [], }); return; } if (method === "GET" && path.endsWith("/admin/users")) { seenAdminEndpoints.add("users"); await fulfillJson({ source: "database", durable: true, users: options.adminUsers ?? [], }); return; } if (method === "GET" && path.endsWith("/admin/usage")) { seenAdminEndpoints.add("usage"); await fulfillJson( options.adminUsage ?? { source: "database", durable: true, generated_at: 1_783_990_800, window_days: Number(url.searchParams.get("window_days") ?? 7), total_turns: 0, metered_turns: 0, token_metered_turns: 0, token_unmetered_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: [], }, ); return; } if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { seenAdminEndpoints.add("engine-capabilities"); const provider = url.searchParams.get("engine_mode") ?? "openai"; const models = provider === "codex_cli" ? [ { id: "gpt-5.6-terra", label: "GPT-5.6 Terra", description: "Codex 기본 모델", reasoning_efforts: ["low", "medium", "high"], default_reasoning_effort: "medium", is_default: true, }, ] : provider === "agy_cli" ? [ { id: "gemini-3.6-flash-high", label: "Gemini 3.6 Flash (High)", description: "Agy 기본 모델", reasoning_efforts: ["high"], default_reasoning_effort: "high", is_default: true, }, ] : [ { id: "gateway-default", label: "게이트웨이 기본 모델", description: "테스트 기본 모델", reasoning_efforts: ["medium"], default_reasoning_effort: "medium", is_default: true, }, { id: "gpt-5.1-mini", label: "GPT-5.1 Mini", description: "테스트 선택 모델", reasoning_efforts: ["low", "medium", "high"], default_reasoning_effort: "medium", is_default: false, }, ]; const defaultModel = models[0]; await fulfillJson({ provider, available: true, source: "live_cli", models, default_model: defaultModel.id, default_reasoning_effort: defaultModel.default_reasoning_effort, detail: "테스트 모델 목록", fetched_at: 1_783_990_800, }); return; } if (method === "GET" && path.endsWith("/admin/engine-config")) { seenAdminEndpoints.add("engine-config"); await fulfillJson( options.engineConfig ?? { 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_783_990_800, }, ); return; } if (method === "PATCH" && path.endsWith("/admin/engine-config")) { seenAdminEndpoints.add("engine-config-patch"); const body = request.postDataJSON() as Partial; await fulfillJson({ ...(options.engineConfig ?? { engine_mode: "openai", engine_url: "http://127.0.0.1:9099", model: "gateway-default", reasoning_effort: "medium", }), ...body, source: "database", durable: true, updated_by: "stale-admin@twentyoz.kr", updated_at: 1_783_990_900, }); return; } if (method === "GET" && path.endsWith("/admin/uptime")) { seenAdminEndpoints.add("uptime"); await fulfillJson({ source: "database", durable: true, window_hours: 24, sample_count: 0, ok_ratio: 0, degraded_events: 0, down_events: 0, last_down_at: null, }); return; } if (method === "GET" && path.endsWith("/admin/tickets")) { seenAdminEndpoints.add("tickets"); await fulfillJson( options.adminTickets ?? { source: "database", durable: true, tickets: [], summary: { total: 0, open_count: 0, high_priority_count: 0, stale_count: 0, by_category: {}, }, }, ); return; } await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404); }); return seenAdminEndpoints; } async function mockApprovedAdminWithoutOnboarding(page: Page) { return mockAdminSession(page); } async function signInAsAdmin(page: Page) { let res: APIResponse | null = null; for (let attempt = 0; attempt < 3; attempt += 1) { res = await page.request.post("/api/auth/dev-login", { data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin", }, }); if (res.ok()) break; await new Promise((resolve) => setTimeout(resolve, 300)); } if (!res) throw new Error("admin dev-login did not return a response"); await expectResponseOk(res); await completeOnboarding(page, { legal_name: "E2E Admin", affiliation: "한신대학교", department: "운영", grade_level: "관리자", phone: "010-2222-2222", contact_address: "경기도 오산시 한신대학교", }); } function isJsonResponse(response: Response) { return response.headers()["content-type"]?.includes("application/json") ?? false; } function isAdminHealthResponse(response: Response) { const url = new URL(response.url()); return ( response.request().method() === "GET" && url.pathname.endsWith("/admin/health") && isJsonResponse(response) ); } function isAdminUsersResponse(response: Response) { const url = new URL(response.url()); return ( response.request().method() === "GET" && url.pathname.endsWith("/admin/users") && isJsonResponse(response) ); } function isAdminUsageResponse(response: Response) { const url = new URL(response.url()); return ( response.request().method() === "GET" && url.pathname.endsWith("/admin/usage") && isJsonResponse(response) ); } function isAdminUptimeResponse(response: Response) { const url = new URL(response.url()); return ( response.request().method() === "GET" && url.pathname.endsWith("/admin/uptime") && isJsonResponse(response) ); } function isAdminTicketsResponse(response: Response) { const url = new URL(response.url()); return ( response.request().method() === "GET" && url.pathname.endsWith("/admin/tickets") && isJsonResponse(response) ); } function isAdminTicketPatch(ticketId: string) { return (response: Response) => { const url = new URL(response.url()); return ( response.request().method() === "PATCH" && url.pathname.endsWith(`/admin/tickets/${ticketId}`) && isJsonResponse(response) ); }; } function isAdminUserCreate(response: Response) { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith("/admin/users") && isJsonResponse(response) ); } function isAdminUserPatch(userId: string) { return (response: Response) => { const url = new URL(response.url()); return ( response.request().method() === "PATCH" && url.pathname.endsWith(`/admin/users/${userId}`) && isJsonResponse(response) ); }; } function isAdminUserDelete(userId: string) { return (response: Response) => { const url = new URL(response.url()); return ( response.request().method() === "DELETE" && url.pathname.endsWith(`/admin/users/${userId}`) && isJsonResponse(response) ); }; } function environmentLabel(value: string) { if (value === "prod") return "운영"; if (value === "staging") return "스테이징"; if (value === "dev") return "개발"; return value; } function engineModeLabel(value: string) { if (value === "claude_cli") return "Claude CLI 게이트웨이"; if (value === "claude_api" || value === "messages_api") return "Anthropic API"; if (value === "openai") return "OpenAI 호환"; if (value === "solar") return "Solar"; return value; } function countLabel(value: number) { if (!Number.isFinite(value)) return "0"; return Math.round(value).toLocaleString("ko-KR"); } function costLabel(value: number) { if (!Number.isFinite(value) || value <= 0) return "$0"; return `$${value.toFixed(value < 0.01 ? 6 : 4)}`; } function rateLabel(value: number) { if (!Number.isFinite(value) || value <= 0) return "0%"; return `${Math.round(value * 1000) / 10}%`; } function evaluatorCache(response: AdminUsageResponse): AdminUsageEvaluatorCache { return ( response.evaluator_cache ?? { enabled: false, entries: 0, hits: 0, misses: 0, stores: 0, evictions: 0, requests: 0, hit_rate: 0, } ); } function usageDailyCost(response: AdminUsageResponse): AdminUsageDailyCost[] { return response.daily_cost ?? []; } function isOnline(seconds: number) { return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60; } async function openAdminAndReadHealth(page: Page) { const healthResponsePromise = page.waitForResponse(isAdminHealthResponse); const usageResponsePromise = page.waitForResponse(isAdminUsageResponse); const usersResponsePromise = page.waitForResponse(isAdminUsersResponse); const uptimeResponsePromise = page.waitForResponse(isAdminUptimeResponse); const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse); await page.goto("/admin"); const [healthResponse, usageResponse, usersResponse, uptimeResponse, ticketsResponse] = await Promise.all([ healthResponsePromise, usageResponsePromise, usersResponsePromise, uptimeResponsePromise, ticketsResponsePromise, ]); await expectResponseOk(healthResponse); await expectResponseOk(usageResponse); await expectResponseOk(usersResponse); await expectResponseOk(uptimeResponse); await expectResponseOk(ticketsResponse); const health = (await healthResponse.json()) as AdminHealthResponse; const usage = (await usageResponse.json()) as AdminUsageResponse; const users = (await usersResponse.json()) as AdminUsersResponse; const uptime = (await uptimeResponse.json()) as AdminUptimeResponse; const tickets = (await ticketsResponse.json()) as AdminTicketsResponse; expect(health.services.length).toBeGreaterThan(0); return { health, usage, users, uptime, tickets }; } async function openAdminAndReadUsers(page: Page) { const usersResponsePromise = page.waitForResponse(isAdminUsersResponse); await page.goto("/admin/users"); const usersResponse = await usersResponsePromise; await expectResponseOk(usersResponse); return (await usersResponse.json()) as AdminUsersResponse; } async function openAdminTickets(page: Page) { const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse); await page.goto("/admin/tickets"); const ticketsResponse = await ticketsResponsePromise; await expectResponseOk(ticketsResponse); return (await ticketsResponse.json()) as AdminTicketsResponse; } async function expectCreateUserControlsFit(page: Page, viewportWidth: number) { const form = page.locator(".vgops-user-create"); await expect(form).toBeVisible(); const clippedControls = await form.evaluate((element) => { const formRect = element.getBoundingClientRect(); const controls = Array.from(element.querySelectorAll("input, select, button")); return controls .map((control) => { const rect = control.getBoundingClientRect(); const style = window.getComputedStyle(control); const tag = control.tagName.toLowerCase(); const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; const outsideForm = rect.left < formRect.left - 1 || rect.right > formRect.right + 1 || rect.top < formRect.top - 1 || rect.bottom > formRect.bottom + 1; const contentClipped = tag === "button" && (control.scrollWidth > control.clientWidth + 1 || control.scrollHeight > control.clientHeight + 1); return { tag, label: control.getAttribute("aria-label") ?? control.textContent?.replace(/\s+/g, " ").trim(), left: Math.floor(rect.left), right: Math.ceil(rect.right), width: Math.ceil(rect.width), outsideForm, contentClipped, visible, }; }) .filter((control) => control.visible && (control.outsideForm || control.contentClipped)); }); expect( clippedControls, `Create-user controls clipped at ${viewportWidth}px: ${JSON.stringify(clippedControls)}`, ).toEqual([]); } async function expectVisibleButtonsFit(page: Page, selector: string, context: string) { const clippedButtons = await page.locator(selector).evaluateAll((buttons) => buttons .map((button) => { const rect = button.getBoundingClientRect(); const owner = button.closest(".vgops-user,.vgops-user-create,.vgops-user-table tbody tr") ?? button.parentElement; const ownerRect = owner?.getBoundingClientRect(); const style = window.getComputedStyle(button); const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; const contentClipped = button.scrollWidth > button.clientWidth + 1 || button.scrollHeight > button.clientHeight + 1; const outsideOwner = ownerRect ? rect.left < ownerRect.left - 1 || rect.right > ownerRect.right + 1 || rect.top < ownerRect.top - 1 || rect.bottom > ownerRect.bottom + 1 : false; return { text: button.textContent?.replace(/\s+/g, " ").trim(), width: Math.ceil(rect.width), contentClipped, outsideOwner, visible, }; }) .filter((button) => button.visible && (button.contentClipped || button.outsideOwner)), ); expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]); } test.describe("admin route guards", () => { test("allows an approved admin without onboarding to open the admin console", async ({ page }) => { const seenAdminEndpoints = await mockApprovedAdminWithoutOnboarding(page); await page.goto("/admin"); await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.locator(".vgops-status")).toContainText("개발"); await expect .poll(() => Array.from(seenAdminEndpoints).sort()) .toEqual(["health", "tickets", "uptime", "usage", "users"]); }); test("routes admin-entitled primary-role users from the root to the admin console", async ({ page }) => { const seenAdminEndpoints = await mockAdminSession(page, { user_id: "learner-admin", email: "learner-admin@twentyoz.kr", display_name: "Learner Admin", role: "learner", admin_access: true, super_admin: false, onboarding_completed_at: 1_782_900_000, }); await page.goto("/"); await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible(); await expect .poll(() => Array.from(seenAdminEndpoints).sort()) .toEqual(["health", "tickets", "uptime", "usage", "users"]); }); test("restores every admin page for admin, super-admin, and delegated admin sessions", async ({ page, }) => { const adminRoutes = [ { path: "/admin", heading: "현재 서비스 상태" }, { path: "/admin/ai", heading: "AI 운영과 DB 계량" }, { path: "/admin/users", heading: "가입 승인과 권한 관리" }, { path: "/admin/access", heading: "역할, 그룹, 접근 범위" }, { path: "/admin/tickets", heading: "사용자 문제 큐" }, ] as const; const sessions = [ { user_id: "role-admin", email: "role-admin@twentyoz.kr", display_name: "Role Admin", role: "admin" as const, admin_access: false, super_admin: false, }, { user_id: "super-admin", email: "super-admin@twentyoz.kr", display_name: "Super Admin", role: "learner" as const, admin_access: false, super_admin: true, }, { user_id: "delegated-admin", email: "delegated-admin@hs.ac.kr", display_name: "Delegated Admin", role: "teacher" as const, admin_access: true, super_admin: false, }, ] as const; for (const session of sessions) { await page.unrouteAll({ behavior: "wait" }); await mockAdminSession(page, { ...session, onboarding_completed_at: 1_782_900_000, }); for (const route of adminRoutes) { await page.goto(route.path); await expect(page).toHaveURL(new RegExp(`${route.path.replace("/", "\\/")}$`)); await expect(page.getByRole("heading", { name: route.heading })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "사용자" })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "권한" })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "티켓" })).toBeVisible(); } } }); test("keeps the admin entry visible after a primary-role super-admin leaves the admin workspace", async ({ page, }) => { await mockAdminSession(page, { user_id: "primary-role-super-admin", email: "primary-role-super-admin@twentyoz.kr", display_name: "Primary Role Super Admin", role: "learner", admin_access: false, super_admin: true, onboarding_completed_at: 1_782_900_000, }); await page.goto("/admin"); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click(); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" })).toBeVisible(); await page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" }).click(); await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); }); test("keeps the session recoverable when auth restore starts during a server restart", async ({ page, }) => { await mockAdminSession( page, { user_id: "restart-admin", email: "restart-admin@twentyoz.kr", display_name: "Restart Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }, { authFailuresBeforeSuccess: 3 }, ); await page.goto("/admin"); await expect(page.getByTestId("auth-restore-failed")).toBeVisible(); await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible(); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); await page.getByTestId("auth-restore-retry").click(); await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.getByTestId("auth-restore-failed")).toHaveCount(0); }); test("shows detailed AI metering and saves the real engine configuration", async ({ page }) => { const seenAdminEndpoints = await mockAdminSession( page, { user_id: "ai-admin", email: "ai-admin@twentyoz.kr", display_name: "AI Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }, { adminUsage: { source: "database", durable: true, generated_at: 1_783_990_800, window_days: 30, total_turns: 37, metered_turns: 37, token_metered_turns: 35, token_unmetered_turns: 2, tokens_in: 160_703, tokens_out: 19_629, cost_usd: 7.023222, recorded_cost_usd: 6.9612, estimated_cost_usd: 0.062022, budget: { limit_usd: 20, used_ratio: 0.3512, remaining_usd: 12.976778, 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: 30, token_unmetered_turns: 0, 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", }, { provider: "claude_cli", model: "claude-opus-4-8", turns: 2, token_metered_turns: 0, token_unmetered_turns: 2, tokens_in: 0, tokens_out: 0, cost_usd: 0.34, recorded_cost_usd: 0.34, estimated_cost_usd: 0, cost_basis: "provider_estimate", }, { 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.062022, recorded_cost_usd: 0, estimated_cost_usd: 0.062022, cost_basis: "reference_rate", rate_label: "Google Gemini 3.6 Flash 표준 단가 · 입력 $1.50/M · 캐시 $0.15/M · 출력 $7.50/M", }, ], 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: 17, tokens_in: 87_703, tokens_out: 8_729, cost_usd: 3.183222 }, ], }, }, ); await page.goto("/admin/ai"); await expect(page).toHaveURL(/\/admin\/ai$/); await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toHaveAttribute( "aria-current", "page", ); await expect(page.getByText("운영 DB 원장").first()).toBeVisible(); // 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다. await expect(page.locator(".aic-ledger")).toContainText("$7.02"); await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /7\.023222/); await expect(page.locator(".aic-budget")).toContainText("94.6%"); await expect(page.locator(".aic-table")).toContainText("gpt-5-mini"); await expect(page.locator(".aic-table")).toContainText("gemini-3.6-flash-high"); await expect(page.locator(".aic-table")).toContainText("참조단가"); await expect(page.locator(".aic-table")).toContainText("SDK 추정"); await expect(page.locator(".aic-table")).toContainText("미계량"); await expect(page.locator(".aic-table")).toContainText("$0.06"); await expect(page.locator(".aic-table")).toContainText("0.9%"); await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 $0.06"); await expect(page.locator(".aic-cache-score")).toContainText("80%"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); const usageRequest = page.waitForRequest((request) => request.url().includes("/api/admin/usage?window_days=7"), ); await page.getByRole("button", { name: "7일" }).click(); await usageRequest; await page.getByLabel("AI 엔진 공급자").selectOption("codex_cli"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gpt-5.6-terra"); await expect(page.getByLabel("AI 추론 강도")).toHaveValue("medium"); await page.getByLabel("AI 엔진 공급자").selectOption("agy_cli"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gemini-3.6-flash-high"); await expect(page.getByLabel("AI 추론 강도")).toHaveValue("high"); await page.getByLabel("AI 연결 주소").fill("http://127.0.0.1:9199"); await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled(); const catalogRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return ( url.pathname.endsWith("/api/admin/engine-capabilities") && url.searchParams.get("engine_url") === "http://127.0.0.1:9199" ); }); await page.getByRole("button", { name: "목록 새로고침" }).click(); await catalogRequest; await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gemini-3.6-flash-high"); const patchRequest = page.waitForRequest((request) => request.method() === "PATCH" && request.url().endsWith("/api/admin/engine-config"), ); await page.getByRole("button", { name: "운영 설정 저장" }).click(); const request = await patchRequest; expect(request.postDataJSON()).toMatchObject({ engine_mode: "agy_cli", engine_url: "http://127.0.0.1:9199", model: "gemini-3.6-flash-high", reasoning_effort: "high", }); await expect(page.getByText("저장됨")).toBeVisible(); await expect .poll(() => Array.from(seenAdminEndpoints).sort()) .toEqual(["engine-capabilities", "engine-config", "engine-config-patch", "health", "usage"]); await expectNoHorizontalOverflow(page); }); test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => { await mockAdminSession(page, { user_id: "ai-watchdog-admin", email: "ai-watchdog-admin@twentyoz.kr", display_name: "AI Watchdog Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }); await page.goto("/admin/ai"); await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible(); // index.html watchdog은 로드 후 3.5초/8초에 부트 진단을 판정한다. // 정상 렌더된 화면에서는 두 판정 창이 지난 뒤에도 진단 오버레이가 없어야 한다. await page.waitForTimeout(8_600); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible(); }); test("resets scroll when moving from long admin pages to access policy", async ({ page }) => { await mockAdminSession(page, { user_id: "scroll-admin", email: "scroll-admin@twentyoz.kr", display_name: "Scroll Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }); await page.goto("/admin/users"); await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); const scrolled = await page.evaluate(() => { const main = document.querySelector(".vg-main"); const root = document.querySelector(".vgops-root"); if (!main || !root) throw new Error("admin scroll container missing"); root.style.minHeight = "2200px"; main.scrollTop = 900; return main.scrollTop; }); expect(scrolled).toBeGreaterThan(0); await page.locator(".vg-nav").getByRole("link", { name: "권한" }).click(); await expect(page).toHaveURL(/\/admin\/access$/); await expect .poll(() => page.evaluate( () => document.querySelector(".vg-main")?.scrollTop ?? -1, ), ) .toBe(0); await expect(page.locator(".vgops-root")).toBeInViewport(); await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeVisible(); }); test("resets scroll when the browser restores an existing admin tab", async ({ page }) => { await mockAdminSession(page, { user_id: "restored-tab-admin", email: "restored-tab-admin@twentyoz.kr", display_name: "Restored Tab Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }); await page.goto("/admin"); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); const scrolled = await page.evaluate(() => { const main = document.querySelector(".vg-main"); const root = document.querySelector(".vgops-root"); if (!main || !root) throw new Error("admin scroll container missing"); root.style.minHeight = "2200px"; main.scrollTop = 900; return main.scrollTop; }); expect(scrolled).toBeGreaterThan(0); await page.evaluate(() => { window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true })); }); await expect .poll(() => page.evaluate( () => document.querySelector(".vg-main")?.scrollTop ?? -1, ), ) .toBe(0); await expect(page.locator(".vgops-root")).toBeInViewport(); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); }); test("keeps the admin console visible under EasyList cosmetic filters", async ({ page }) => { await mockAdminSession(page, { user_id: "cosmetic-filter-admin", email: "cosmetic-filter-admin@twentyoz.kr", display_name: "Cosmetic Filter Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }); await page.goto("/admin"); await page.addStyleTag({ // EasyList general cosmetic rules contain both selectors. They used to // hide the whole operations console while every admin API still returned 200. content: ".ad-root,.ad-section{display:none!important}", }); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.locator("[data-vignette-admin-root]")).toBeVisible(); await expect(page.locator('[class^="ad-"],[class*=" ad-"]')).toHaveCount(0); // 첫 watchdog 판정(3.5초)이 실제 픽셀 가시성을 확인한 뒤에도 진단 화면이 // 뜨지 않아야 한다. DOM 존재 여부만 확인하면 이번 장애를 재현하지 못한다. await page.waitForTimeout(4_000); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); }); test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => { await mockAdminSession( page, { user_id: "diagnostic-admin", email: "diagnostic-admin@twentyoz.kr", display_name: "Diagnostic Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }, { adminUsers: [ { user_id: "bad-user", email: "bad-user@hs.ac.kr", display_name: "Bad User", role: "learner", admin_access: false, super_admin: false, account_status: "pending", affiliation: null, cohort_ids: null, active_sessions: null, created_at: null, last_seen_at: null, source: "database", }, ], }, ); await page.goto("/admin/users"); await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); await expect(page.locator(".vgops-diagnostic")).toContainText("관리자 데이터 진단"); await expect(page.locator(".vgops-diagnostic")).toContainText("cohort_ids"); await expect(page.locator(".vgops-diagnostic")).toContainText("active_sessions"); await expect(page.getByText("Bad User")).toBeVisible(); }); test("shows ticket payload diagnostics instead of a white page", async ({ page }) => { await mockAdminSession( page, { user_id: "ticket-diagnostic-admin", email: "ticket-diagnostic-admin@twentyoz.kr", display_name: "Ticket Diagnostic Admin", role: "admin", admin_access: true, super_admin: true, onboarding_completed_at: 1_782_900_000, }, { adminTickets: { source: "database", durable: true, tickets: [], summary: null, }, }, ); await page.goto("/admin/users"); await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); await expect(page.locator(".vgops-diagnostic")).toContainText("관리자 데이터 진단"); await expect(page.locator(".vgops-diagnostic")).toContainText("admin.tickets.summary"); await expect(page.locator("body")).not.toHaveText(/^$/); }); }); test.describe("admin route", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); }); test("allows an admin to open the live health dashboard", async ({ page }) => { test.setTimeout(60_000); await signInAsAdmin(page); await withGlobalEngineConfigLock("admin-health-dashboard", async () => { const { health, usage, users, uptime, tickets } = await openAdminAndReadHealth(page); const serviceCards = page.locator(".vgops-service:not(.vgops-service--skeleton)"); const counts = { ok: health.services.filter((service) => service.status === "ok").length, degraded: health.services.filter((service) => service.status === "degraded").length, down: health.services.filter((service) => service.status === "down").length, total: health.services.length, }; const activeSessions = users.users.reduce( (sum, user) => sum + Math.max(0, user.active_sessions), 0, ); const onlineUsers = users.users.filter((user) => isOnline(user.last_seen_at)).length; await expect(page).toHaveURL(/\/admin$/); await expect(page.locator(".vgops-status")).toContainText(environmentLabel(health.environment)); await expect(page.locator(".vgops-status")).toContainText(engineModeLabel(health.engine_mode)); await expect(page.locator(".vgops-kpi b")).toHaveText([ countLabel(activeSessions), countLabel(onlineUsers), countLabel(users.users.length), counts.total ? `${counts.ok}/${counts.total}` : "-", ]); await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible(); await expect(page.locator(".vgops-cost")).toContainText(costLabel(usage.cost_usd)); await expect(page.locator(".vgops-cost")).toContainText( usage.budget.status === "disabled" ? "예산 경고 비활성" : usage.budget.status === "exceeded" ? "예산 초과" : usage.budget.status === "warn" ? "예산 주의" : "예산 정상", ); await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText( "평가 캐시 hit-rate", ); const cache = evaluatorCache(usage); await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText( cache.enabled ? `${rateLabel(cache.hit_rate)} hit` : "캐시 비활성", ); if (usageDailyCost(usage).length > 0) { await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText( "일별 비용 추이", ); } await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible(); await expect(page.getByText("샘플 정상률")).toBeVisible(); expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0); expect(uptime.ok_ratio).toBeLessThanOrEqual(1); await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible(); expect(tickets.summary.open_count).toBeGreaterThanOrEqual(0); await expect(page.locator(".vgops-panel").filter({ hasText: "운영 티켓" })).toContainText( /(\d+건 미해결|미해결 티켓이 없습니다)/, ); await expect(serviceCards).toHaveCount(health.services.length); for (const service of health.services) { const card = serviceCards.filter({ hasText: service.name }); await expect(card).toBeVisible(); await expect(card).toContainText(service.detail); expect(service.load, `${service.key} load must be normalized`).toBeGreaterThanOrEqual(0); expect(service.load, `${service.key} load must be normalized`).toBeLessThanOrEqual(1); if (service.key === "engine" && service.status === "ok") { await expect(card).toContainText(/\d+ms/); } else if (service.key === "db" && service.status === "ok") { await expect(card).toContainText(/풀 \d+\/\d+/); } else if (service.key === "evaluation") { await expect(card).toContainText(/대기 \d+건/); } else if (service.key === "kb" && service.status === "ok") { await expect(card).toContainText(/활성 세션 \d+건/); } else { await expect(card).toContainText(service.metric); } } const byKey = Object.fromEntries(health.services.map((service) => [service.key, service])); if (byKey.engine?.status === "ok") { expect(byKey.engine.metric).toMatch(/^\d+ms$/); } if (byKey.db?.status === "ok") { expect(byKey.db.metric).toMatch(/^풀 \d+\/\d+$/); } expect(byKey.evaluation?.metric).toMatch(/^대기 \d+건$/); if (byKey.kb?.status === "ok") { expect(byKey.kb.metric).toMatch(/^활성 세션 \d+건$/); } }); }); test("shows every workspace entry in the admin navigation", async ({ page }) => { test.setTimeout(60_000); await signInAsAdmin(page); await page.goto("/admin"); const nav = page.locator(".vg-nav"); await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible(); await expect(nav.getByRole("link", { name: "AI 운영" })).toBeVisible(); await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible(); await expect(nav.getByRole("link", { name: "권한" })).toBeVisible(); await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible(); await expect(nav.getByRole("link", { name: "교수 콘솔" })).toBeVisible(); await expect(nav.getByRole("link", { name: "페르소나" })).toBeVisible(); await expect(nav.getByRole("link", { name: "학습자 홈" })).toBeVisible(); await expect(nav.getByRole("link", { name: "학습", exact: true })).toBeVisible(); await expect(nav.getByRole("link", { name: "기록" })).toBeVisible(); await nav.getByRole("link", { name: "교수 콘솔" }).click(); await expect(page).toHaveURL(/\/teach$/); await expect(page.locator(".pf-root")).toBeVisible(); await page.goto("/admin"); await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click(); await expect(page).toHaveURL(/\/learn$/); await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible(); await expectNoHorizontalOverflow(page); }); test("allows an admin to manage real server-known users", async ({ page }, testInfo) => { test.setTimeout(60_000); await signInAsAdmin(page); const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; const email = `admin-users.${slug}@hs.ac.kr`.toLowerCase(); const displayName = `관리 대상 ${testInfo.project.name}`; const users = await openAdminAndReadUsers(page); expect(users.source).toBe("database"); expect(users.durable).toBe(true); await page.getByRole("tab", { name: "사용자 목록" }).click(); await page.getByLabel("사용자 검색").fill(`no-match-${slug}`); await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible(); await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0); await page.getByLabel("사용자 검색").fill(""); const userTable = page.getByRole("table"); await expect(userTable).toBeVisible(); const sessionHeader = page.getByRole("columnheader", { name: /활성 회기/ }); await sessionHeader.getByRole("button").click(); await expect(sessionHeader).toHaveAttribute("aria-sort", "ascending"); const ascendingSessions = await userTable.locator("tbody tr td:nth-child(7)").allTextContents(); expect(ascendingSessions.map(Number)).toEqual( ascendingSessions.map(Number).slice().sort((a, b) => a - b), ); await sessionHeader.getByRole("button").click(); await expect(sessionHeader).toHaveAttribute("aria-sort", "descending"); await page.getByRole("tab", { name: "사용자 등록" }).click(); await page.getByLabel("새 사용자 이메일").fill(email); await page.getByLabel("새 사용자 표시 이름").fill(displayName); await page.getByLabel("새 사용자 역할").selectOption("learner"); await page.getByLabel("새 사용자 승인 상태").selectOption("pending"); await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`); const createPromise = page.waitForResponse(isAdminUserCreate); const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse); await page.getByRole("button", { name: "사용자 등록" }).click(); const createResponse = await createPromise; await expectResponseOk(createResponse); await expectResponseOk(await reloadAfterCreatePromise); const created = (await createResponse.json()) as AdminManagedUser; expect(created).toMatchObject({ email, display_name: displayName, role: "learner", account_status: "pending", cohort_ids: [`created-${testInfo.project.name}`], }); await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute( "aria-selected", "true", ); const approvalTab = page.getByRole("tab", { name: /가입 승인/ }); await approvalTab.click(); await expect(approvalTab).toHaveAttribute("aria-selected", "true"); const approvalCard = page.locator(".vgops-approval").filter({ hasText: email }); await expect(approvalCard).toBeVisible(); await expect(approvalCard).toContainText("승인 대기"); await expectVisibleButtonsFit(page, ".vgops-approval__actions .vg-btn", "admin approval buttons"); const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id)); await approvalCard.getByRole("button", { name: "승인" }).click(); const approveResponse = await approvePromise; await expectResponseOk(approveResponse); const approved = (await approveResponse.json()) as AdminManagedUser; expect(approved).toMatchObject({ user_id: created.user_id, email, account_status: "approved", }); await expect(approvalCard).toHaveCount(0); await page.getByRole("tab", { name: "사용자 목록" }).click(); await page.getByLabel("사용자 검색").fill(email); const card = page.locator(".vgops-user-table tbody tr").filter({ hasText: email }); await expect(card).toBeVisible(); await expect(card.getByLabel(`${email} 표시 이름`)).toHaveValue(displayName); await expect(card).toContainText("승인됨"); await expectVisibleButtonsFit(page, ".vgops-user__actions .vg-btn", "admin user action buttons"); const nextName = `교수자 ${testInfo.project.name}`; const nameInput = card.getByLabel(`${email} 표시 이름`); const roleSelect = card.getByLabel(`${email} 역할`); const cohortInput = card.getByLabel(`${email} 코호트`); const saveButton = card.getByRole("button", { name: "저장" }); const nextCohort = `cohort-${testInfo.project.name}`; await nameInput.fill(nextName); await expect(nameInput).toHaveValue(nextName); await page.evaluate(() => new Promise(requestAnimationFrame)); await roleSelect.selectOption("teacher"); await expect(roleSelect).toHaveValue("teacher"); await cohortInput.fill(nextCohort); await expect(cohortInput).toHaveValue(nextCohort); await page.evaluate(() => new Promise(requestAnimationFrame)); await expect(saveButton).toBeEnabled(); const patchPromise = page.waitForResponse(isAdminUserPatch(created.user_id)); await saveButton.click(); const patchResponse = await patchPromise; await expectResponseOk(patchResponse); const updated = (await patchResponse.json()) as AdminManagedUser; expect(updated).toMatchObject({ user_id: created.user_id, email, display_name: nextName, role: "teacher", account_status: "approved", cohort_ids: [nextCohort], }); await expect(nameInput).toHaveValue(nextName); await expect(card).toContainText("교수자"); await expect(cohortInput).toHaveValue(nextCohort); const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id)); await card.getByRole("button", { name: "비활성화" }).click(); const deleteResponse = await deletePromise; await expectResponseOk(deleteResponse); await expect(card).toHaveCount(0); }); test("shows and resolves user-submitted operation tickets", async ({ page }, testInfo) => { await signInAsAdmin(page); const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; const subject = `[E2E] 운영 티켓 ${slug}`; let createdTicketId: string | null = null; try { const create = await page.request.post("/api/users/support-tickets", { data: { category: "session_review", priority: "high", subject, body: "E2E 운영 티켓 처리 흐름 검증입니다. 실제 리뷰 생성 지연이 아닙니다.", source_path: "/__e2e__/learn/session/test/review", }, }); await expectResponseOk(create); const created = (await create.json()) as { ticket_id: string }; createdTicketId = created.ticket_id; const tickets = await openAdminTickets(page); expect(tickets.source).toBe("database"); expect(tickets.durable).toBe(true); expect(tickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBeTruthy(); const card = page.locator(".vgops-ticket").filter({ hasText: subject }); await expect(card).toBeVisible(); await expect(card).toContainText("높음"); await expect(card).toContainText("미해결"); const patchPromise = page.waitForResponse(isAdminTicketPatch(created.ticket_id)); await card.getByRole("button", { name: "해결" }).click(); const patchResponse = await patchPromise; await expectResponseOk(patchResponse); const updated = (await patchResponse.json()) as AdminSupportTicket; expect(updated.status).toBe("resolved"); await expect(card).toContainText("해결"); await expectNoHorizontalOverflow(page); } finally { if (createdTicketId) { const cleanup = await page.request.patch(`/api/admin/tickets/${createdTicketId}`, { data: { status: "resolved", resolution_note: "E2E cleanup", }, }); if (!cleanup.ok()) { console.warn(`E2E ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`); } } } }); test("manually links duplicate operation tickets", async ({ page }, testInfo) => { await signInAsAdmin(page); const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; const subject = `[E2E] 중복 운영 티켓 ${slug}`; const createdTicketIds: string[] = []; try { for (let i = 0; i < 2; i += 1) { const create = await page.request.post("/api/users/support-tickets", { data: { category: "voice_browser", priority: "normal", subject, body: "E2E 중복 티켓 수동 연결 검증입니다. 실제 음성 장애가 아닙니다.", source_path: "/__e2e__/learn/session/duplicate", }, }); await expectResponseOk(create); const created = (await create.json()) as { ticket_id: string }; createdTicketIds.push(created.ticket_id); } const tickets = await openAdminTickets(page); const createdTickets = tickets.tickets.filter((ticket) => createdTicketIds.includes(ticket.ticket_id)); expect(createdTickets).toHaveLength(2); expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy(); const linkableCard = page .locator(".vgops-ticket") .filter({ hasText: subject }) .filter({ has: page.getByRole("button", { name: "연결" }) }); await expect(linkableCard).toBeVisible(); const patchPromise = page.waitForResponse( (response) => response.request().method() === "PATCH" && createdTicketIds.some((ticketId) => response.url().endsWith(`/api/admin/tickets/${ticketId}`)), ); await linkableCard.getByRole("button", { name: "연결" }).click(); const patchResponse = await patchPromise; await expectResponseOk(patchResponse); const updated = (await patchResponse.json()) as AdminSupportTicket; expect(updated.parent_ticket_id).toBeTruthy(); await expect(page.locator(".vgops-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible(); await expectNoHorizontalOverflow(page); } finally { for (const ticketId of createdTicketIds) { const cleanup = await page.request.patch(`/api/admin/tickets/${ticketId}`, { data: { status: "resolved", parent_ticket_id: "", resolution_note: "E2E cleanup", }, }); if (!cleanup.ok()) { console.warn(`E2E duplicate ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`); } } } }); test("denies learner access to the admin API and UI", async ({ page }) => { await signInAsLearner(page); const denied = await page.request.get("/api/admin/health"); expect(denied.status(), await denied.text()).toBe(403); const deniedUsers = await page.request.get("/api/admin/users"); expect(deniedUsers.status(), await deniedUsers.text()).toBe(403); const deniedUptime = await page.request.get("/api/admin/uptime"); expect(deniedUptime.status(), await deniedUptime.text()).toBe(403); const deniedTickets = await page.request.get("/api/admin/tickets"); expect(deniedTickets.status(), await deniedTickets.text()).toBe(403); await page.goto("/admin"); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".vgops-root")).toHaveCount(0); }); test("keeps admin controls usable at a mobile viewport", async ({ page }) => { test.setTimeout(60_000); await page.setViewportSize({ width: 390, height: 844 }); await signInAsAdmin(page); const users = await openAdminAndReadUsers(page); await page.getByRole("tab", { name: "사용자 등록" }).click(); const layout = await page.evaluate(() => { const form = document.querySelector(".vgops-user-create"); if (!form) throw new Error("admin user create form was not rendered"); return { formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length, }; }); await expectNoHorizontalOverflow(page); await expectCreateUserControlsFit(page, 390); expect(layout.formColumns).toBe(1); if (users.users.length > 0) { await page.getByRole("tab", { name: "사용자 목록" }).click(); const scrollRegion = page.locator(".vgops-user-table-scroll"); const scrollContract = await scrollRegion.evaluate((element) => { const before = element.scrollLeft; element.scrollLeft = element.scrollWidth; return { before, after: element.scrollLeft, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, }; }); expect(scrollContract.scrollWidth).toBeGreaterThan(scrollContract.clientWidth); expect(scrollContract.after).toBeGreaterThan(scrollContract.before); await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible(); await expectVisibleButtonsFit(page, ".vgops-user__actions .vg-btn", "mobile admin user actions"); } }); test("keeps the create-user form contained at tablet widths", async ({ page }) => { test.setTimeout(60_000); await signInAsAdmin(page); for (const width of [861, 900, 1024]) { await page.setViewportSize({ width, height: 900 }); await openAdminAndReadUsers(page); await page.getByRole("tab", { name: "사용자 등록" }).click(); await expectNoHorizontalOverflow(page); await expectCreateUserControlsFit(page, width); } }); });