vignette/apps/web/e2e/admin.spec.ts
2026-07-15 21:31:30 +09:00

1361 lines
47 KiB
TypeScript

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;
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;
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;
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<string, number>;
};
}
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;
} = {},
) {
const seenAdminEndpoints = new Set<string>();
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")) {
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,
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-config")) {
seenAdminEndpoints.add("engine-config");
await fulfillJson(
options.engineConfig ?? {
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_783_990_800,
},
);
return;
}
if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
seenAdminEndpoints.add("engine-config-patch");
const body = request.postDataJSON() as Partial<AdminEngineConfigResponse>;
await fulfillJson({
...(options.engineConfig ?? {
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
}),
...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(".ad-user-create");
await expect(form).toBeVisible();
const clippedControls = await form.evaluate((element) => {
const formRect = element.getBoundingClientRect();
const controls = Array.from(element.querySelectorAll<HTMLElement>("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<HTMLElement>(".ad-user,.ad-user-create,.ad-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(".ad-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("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: 32,
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 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();
await expect(page.locator(".aic-ledger")).toContainText("$6.6212");
await expect(page.locator(".aic-budget")).toContainText("93.8%");
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
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 기본 모델").fill("gpt-5.1-mini");
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({ model: "gpt-5.1-mini" });
await expect(page.getByText("저장됨")).toBeVisible();
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["engine-config", "engine-config-patch", "health", "usage"]);
await expectNoHorizontalOverflow(page);
});
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(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
});
expect(scrolled).toBeGreaterThan(0);
await page.locator(".vg-nav").getByRole("link", { name: "권한" }).click();
await expect(page).toHaveURL(/\/admin\/access$/);
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
await expect(page.locator(".ad-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(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
});
expect(scrolled).toBeGreaterThan(0);
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
});
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
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(".ad-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".ad-diagnostic")).toContainText("cohort_ids");
await expect(page.locator(".ad-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(".ad-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".ad-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(".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,
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(".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([
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(".ad-cost")).toContainText(costLabel(usage.cost_usd));
await expect(page.locator(".ad-cost")).toContainText(
usage.budget.status === "disabled"
? "예산 경고 비활성"
: usage.budget.status === "exceeded"
? "예산 초과"
: usage.budget.status === "warn"
? "예산 주의"
: "예산 정상",
);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"평가 캐시 hit-rate",
);
const cache = evaluatorCache(usage);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
cache.enabled
? `${rateLabel(cache.hit_rate)} hit`
: "캐시 비활성",
);
if (usageDailyCost(usage).length > 0) {
await expect(page.locator(".ad-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(".ad-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(".ad-approval").filter({ hasText: email });
await expect(approvalCard).toBeVisible();
await expect(approvalCard).toContainText("승인 대기");
await expectVisibleButtonsFit(page, ".ad-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(".ad-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, ".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",
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(".ad-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(".ad-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(".ad-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(".ad-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<HTMLElement>(".ad-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(".ad-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, ".ad-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);
}
});
});