1711 lines
61 KiB
TypeScript
1711 lines
61 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;
|
|
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<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;
|
|
authFailuresBeforeSuccess?: number;
|
|
} = {},
|
|
) {
|
|
const seenAdminEndpoints = new Set<string>();
|
|
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<AdminEngineConfigResponse>;
|
|
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 userSnapshots: AdminUsersResponse[] = [];
|
|
const collectUsersResponse = async (response: Response) => {
|
|
if (!isAdminUsersResponse(response) || !response.ok()) return;
|
|
userSnapshots.push((await response.json()) as AdminUsersResponse);
|
|
};
|
|
page.on("response", collectUsersResponse);
|
|
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 initialUsers = (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);
|
|
if (userSnapshots.length === 0) userSnapshots.push(initialUsers);
|
|
|
|
let users = initialUsers;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const rendered = await page.locator(".vgops-kpi b").allTextContents();
|
|
const matchingSnapshot = userSnapshots.find((snapshot) => {
|
|
const activeSessions = snapshot.users.reduce(
|
|
(sum, user) => sum + Math.max(0, user.active_sessions),
|
|
0,
|
|
);
|
|
const onlineUsers = snapshot.users.filter((user) => isOnline(user.last_seen_at)).length;
|
|
return (
|
|
rendered[0] === countLabel(activeSessions) &&
|
|
rendered[1] === countLabel(onlineUsers) &&
|
|
rendered[2] === countLabel(snapshot.users.length)
|
|
);
|
|
});
|
|
if (matchingSnapshot) users = matchingSnapshot;
|
|
return Boolean(matchingSnapshot);
|
|
},
|
|
{ message: "관리자 KPI가 실제 /admin/users 응답 중 하나를 반영해야 한다" },
|
|
)
|
|
.toBe(true);
|
|
page.off("response", collectUsersResponse);
|
|
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<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>(".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<HTMLElement>(".vg-main");
|
|
const root = document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".vg-main");
|
|
const root = document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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);
|
|
}
|
|
});
|
|
});
|