import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; import { 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"; affiliation: string; cohort_ids: string[]; } interface AdminUsersResponse { source: "database" | "server_session_registry"; durable: boolean; users: AdminManagedUser[]; } async function expectResponseOk(response: APIResponse | Response) { if (!response.ok()) { expect(response.ok(), await response.text()).toBeTruthy(); } } async function signInAsAdmin(page: Page) { const res = await page.request.post("/api/auth/dev-login", { data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin", }, }); await expectResponseOk(res); } function isAdminHealthResponse(response: Response) { const url = new URL(response.url()); return response.request().method() === "GET" && url.pathname.endsWith("/admin/health"); } function isAdminUsersResponse(response: Response) { const url = new URL(response.url()); return response.request().method() === "GET" && url.pathname.endsWith("/admin/users"); } function isAdminUserCreate(response: Response) { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname.endsWith("/admin/users"); } function isAdminUserPatch(userId: string) { return (response: Response) => { const url = new URL(response.url()); return ( response.request().method() === "PATCH" && url.pathname.endsWith(`/admin/users/${userId}`) ); }; } function isAdminUserDelete(userId: string) { return (response: Response) => { const url = new URL(response.url()); return ( response.request().method() === "DELETE" && url.pathname.endsWith(`/admin/users/${userId}`) ); }; } 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; } async function openAdminAndReadHealth(page: Page) { const healthResponsePromise = page.waitForResponse(isAdminHealthResponse); await page.goto("/admin"); const healthResponse = await healthResponsePromise; await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as AdminHealthResponse; expect(health.services.length).toBeGreaterThan(0); return health; } async function openAdminAndReadUsers(page: Page) { const usersResponsePromise = page.waitForResponse(isAdminUsersResponse); await page.goto("/admin"); const usersResponse = await usersResponsePromise; await expectResponseOk(usersResponse); return (await usersResponse.json()) as AdminUsersResponse; } async function expectCreateUserControlsFit(page: Page, viewportWidth: number) { const form = page.locator(".ad-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(".ad-user,.ad-user-create") ?? 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", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); }); test("allows an admin to open the live health dashboard", async ({ page }) => { await signInAsAdmin(page); await withGlobalEngineConfigLock("admin-health-dashboard", async () => { const health = await openAdminAndReadHealth(page); const serviceCards = page.locator(".ad-service:not(.ad-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, }; await expect(page).toHaveURL(/\/admin$/); await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment)); await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode)); await expect(page.locator(".ad-kpi b")).toHaveText([ String(health.services.length), String(counts.ok), String(counts.degraded), String(counts.down), ]); 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("allows an admin to manage real server-known users", async ({ page }, testInfo) => { 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.getByLabel("사용자 검색").fill(`no-match-${slug}`); await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible(); await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0); await page.getByLabel("사용자 검색").fill(""); await page.getByLabel("새 사용자 이메일").fill(email); await page.getByLabel("새 사용자 표시 이름").fill(displayName); await page.getByLabel("새 사용자 역할").selectOption("learner"); 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", cohort_ids: [`created-${testInfo.project.name}`], }); await page.getByLabel("사용자 검색").fill(email); const card = page.locator(".ad-user").filter({ hasText: email }); await expect(card).toBeVisible(); await expect(card).toContainText(displayName); await expectVisibleButtonsFit(page, ".ad-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", cohort_ids: [nextCohort], }); await expect(card).toContainText(nextName); await expect(card).toContainText("교수자"); await expect(card).toContainText(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); const blockedLogin = await page.request.post("/api/auth/dev-login", { data: { email, role: "teacher", display_name: nextName, }, }); expect(blockedLogin.status(), await blockedLogin.text()).toBe(403); }); 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); await page.goto("/admin"); await expect(page).toHaveURL(/\/learn$/); await expect(page.locator(".ad-root")).toHaveCount(0); }); test("keeps admin controls usable at a mobile viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await signInAsAdmin(page); const users = await openAdminAndReadUsers(page); const layout = await page.evaluate(() => { const workspace = document.querySelector(".ad-user-workspace"); const form = document.querySelector(".ad-user-create"); if (!workspace || !form) throw new Error("admin user workspace was not rendered"); return { workspaceColumns: window.getComputedStyle(workspace).gridTemplateColumns.split(" ").length, formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length, }; }); await expectNoHorizontalOverflow(page); await expectCreateUserControlsFit(page, 390); expect(layout.workspaceColumns).toBe(1); expect(layout.formColumns).toBe(1); if (users.users.length > 0) { await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions"); } }); test("keeps the create-user form contained at tablet widths", async ({ page }) => { await signInAsAdmin(page); for (const width of [861, 900, 1024]) { await page.setViewportSize({ width, height: 900 }); await openAdminAndReadUsers(page); await expectNoHorizontalOverflow(page); await expectCreateUserControlsFit(page, width); } }); });