1232 lines
48 KiB
TypeScript
1232 lines
48 KiB
TypeScript
// full-sweep-admin.spec.ts — 관리자 콘솔(admin) 전수 점검 신규 스펙.
|
|
// docs/ops/e2e-full-sweep-2026-07-27.md §7 "관리자 콘솔 (admin)"에서
|
|
// "검증: 신규 spec 필요"로 표기된 체크리스트 항목을 검증한다.
|
|
// UI 상태 고정(가드·로딩·오류·정렬·필터 파라미터)은 page.route fixture로,
|
|
// 서버 왕복이 필요한 항목(보류/소속·코호트 저장/티켓 처리·해제)은 실 API로 검증한다.
|
|
// AI 엔진 턴 생성은 어디에서도 수행하지 않는다.
|
|
//
|
|
// 이 스위프에서 발견된 앱 결함 2건:
|
|
// 1) [프리즈] usersData=null(users API 실패) 상태 또는 실 DB 스케일의 티켓 검색 입력에서
|
|
// TanStack Table _autoResetPageIndex ↔ React setState 무한 마이크로태스크 루프로
|
|
// 관리자 콘솔 렌더러가 영구 프리즈된다. → test.fixme 재현 테스트 2건으로 격리.
|
|
// 2) [코호트 유실] 서버(POST/PATCH /admin/users)가 cohort_ids 배열의 첫 항목만 저장한다.
|
|
// → "persists every comma-separated cohort id" 테스트가 의도적 RED.
|
|
|
|
import { expect, test, type Page, type Request, type Response, type Route } from "@playwright/test";
|
|
import { signInAsAdmin, useRealApi } from "./support";
|
|
|
|
type AdminEndpointKey = "health" | "users" | "usage" | "uptime" | "tickets";
|
|
|
|
interface MockOverride {
|
|
status?: number;
|
|
detail?: string;
|
|
delayMs?: number;
|
|
body?: unknown;
|
|
}
|
|
|
|
interface AdminMockState {
|
|
auth: Record<string, unknown>;
|
|
users: Array<Record<string, unknown>>;
|
|
tickets: Array<Record<string, unknown>>;
|
|
overrides: Partial<Record<AdminEndpointKey, MockOverride>>;
|
|
calls: Record<AdminEndpointKey, string[]>;
|
|
userPatches: Array<{ userId: string; body: Record<string, unknown> }>;
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function makeMockUser(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
const id = String(overrides.user_id ?? "mock-user");
|
|
return {
|
|
user_id: id,
|
|
email: `${id}@hs.ac.kr`,
|
|
display_name: `Mock ${id}`,
|
|
role: "learner",
|
|
admin_access: false,
|
|
super_admin: false,
|
|
account_status: "approved",
|
|
affiliation: "",
|
|
cohort_ids: [],
|
|
active_sessions: 0,
|
|
created_at: 1_783_000_000,
|
|
last_seen_at: 1_783_000_000,
|
|
source: "database",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeMockTicket(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
const id = String(overrides.ticket_id ?? "mock-ticket");
|
|
return {
|
|
ticket_id: id,
|
|
reporter: {
|
|
email: "reporter@hs.ac.kr",
|
|
display_name: "Mock Reporter",
|
|
role: "learner",
|
|
},
|
|
category: "other",
|
|
priority: "normal",
|
|
status: "open",
|
|
subject: `Mock 티켓 ${id}`,
|
|
body: "fixture ticket body",
|
|
source_path: "/__e2e__/full-sweep",
|
|
assigned_group: "",
|
|
parent_ticket_id: null,
|
|
duplicate_count: 0,
|
|
duplicate_parent_candidate_id: null,
|
|
child_ticket_count: 0,
|
|
event_count: 0,
|
|
resolved_at: null,
|
|
created_at: 1_783_000_000,
|
|
updated_at: 1_783_100_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockTicketsBody(state: AdminMockState): Record<string, unknown> {
|
|
const active = state.tickets.filter(
|
|
(ticket) => ticket.status !== "resolved" && ticket.status !== "closed",
|
|
);
|
|
const byCategory: Record<string, number> = {};
|
|
for (const ticket of active) {
|
|
const category = String(ticket.category);
|
|
byCategory[category] = (byCategory[category] ?? 0) + 1;
|
|
}
|
|
return {
|
|
source: "database",
|
|
durable: true,
|
|
generated_at: 1_783_990_800,
|
|
tickets: state.tickets,
|
|
summary: {
|
|
total: state.tickets.length,
|
|
open_count: active.length,
|
|
high_priority_count: active.filter(
|
|
(ticket) => ticket.priority === "high" || ticket.priority === "urgent",
|
|
).length,
|
|
stale_count: 0,
|
|
by_category: byCategory,
|
|
by_priority: {},
|
|
by_status: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
const MOCK_HEALTH_BODY = {
|
|
status: "ok",
|
|
environment: "dev",
|
|
engine_mode: "openai",
|
|
services: [
|
|
{
|
|
key: "engine",
|
|
name: "AI 엔진",
|
|
status: "ok",
|
|
detail: "응답 정상",
|
|
metric: "120ms",
|
|
load: 0.2,
|
|
},
|
|
],
|
|
};
|
|
|
|
const MOCK_USAGE_BODY = {
|
|
source: "database",
|
|
durable: true,
|
|
generated_at: 1_783_990_800,
|
|
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: [],
|
|
};
|
|
|
|
const MOCK_UPTIME_BODY = {
|
|
source: "database",
|
|
durable: true,
|
|
window_hours: 24,
|
|
sample_count: 0,
|
|
ok_ratio: 0,
|
|
degraded_events: 0,
|
|
down_events: 0,
|
|
last_down_at: null,
|
|
};
|
|
|
|
/**
|
|
* 관리자 콘솔 전용 route fixture.
|
|
* state를 반환하며, 테스트가 state.auth / state.users / state.tickets / state.overrides를
|
|
* 수정하면 다음 요청부터 즉시 반영된다(재라우팅 불필요).
|
|
*/
|
|
async function installAdminMock(
|
|
page: Page,
|
|
authOverrides: Record<string, unknown> = {},
|
|
): Promise<AdminMockState> {
|
|
const state: AdminMockState = {
|
|
auth: {
|
|
user_id: "full-sweep-admin",
|
|
email: "full-sweep-admin@twentyoz.kr",
|
|
display_name: "Full Sweep Admin",
|
|
role: "admin",
|
|
admin_access: true,
|
|
super_admin: true,
|
|
account_status: "approved",
|
|
approval_required: false,
|
|
cohort_ids: [],
|
|
consent_at: null,
|
|
onboarding_completed_at: 1_782_900_000,
|
|
nickname: "",
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
...authOverrides,
|
|
},
|
|
users: [],
|
|
tickets: [],
|
|
overrides: {},
|
|
calls: { health: [], users: [], usage: [], uptime: [], tickets: [] },
|
|
userPatches: [],
|
|
};
|
|
|
|
const fulfillJson = (route: Route, body: unknown, status = 200) =>
|
|
route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const respond = async (route: Route, key: AdminEndpointKey, defaultBody: () => unknown) => {
|
|
const override = state.overrides[key];
|
|
if (override?.delayMs) await sleep(override.delayMs);
|
|
if (override?.status && override.status >= 400) {
|
|
await fulfillJson(
|
|
route,
|
|
{ detail: override.detail ?? `${key} mock failure` },
|
|
override.status,
|
|
);
|
|
return;
|
|
}
|
|
await fulfillJson(route, override?.body ?? defaultBody());
|
|
};
|
|
|
|
await page.route("**/api/**", async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const method = request.method();
|
|
const path = url.pathname;
|
|
|
|
if (method === "GET" && path.endsWith("/auth/me")) {
|
|
await fulfillJson(route, state.auth);
|
|
return;
|
|
}
|
|
if (method === "GET" && path.endsWith("/admin/health")) {
|
|
state.calls.health.push(url.toString());
|
|
await respond(route, "health", () => MOCK_HEALTH_BODY);
|
|
return;
|
|
}
|
|
if (method === "GET" && path.endsWith("/admin/users")) {
|
|
state.calls.users.push(url.toString());
|
|
await respond(route, "users", () => ({
|
|
source: "database",
|
|
durable: true,
|
|
users: state.users,
|
|
}));
|
|
return;
|
|
}
|
|
if (method === "GET" && path.endsWith("/admin/usage")) {
|
|
state.calls.usage.push(url.toString());
|
|
await respond(route, "usage", () => MOCK_USAGE_BODY);
|
|
return;
|
|
}
|
|
if (method === "GET" && path.endsWith("/admin/uptime")) {
|
|
state.calls.uptime.push(url.toString());
|
|
await respond(route, "uptime", () => MOCK_UPTIME_BODY);
|
|
return;
|
|
}
|
|
if (method === "GET" && path.endsWith("/admin/tickets")) {
|
|
state.calls.tickets.push(url.toString());
|
|
await respond(route, "tickets", () => mockTicketsBody(state));
|
|
return;
|
|
}
|
|
if (method === "PATCH" && /\/admin\/users\/[^/]+$/.test(path)) {
|
|
const userId = decodeURIComponent(path.split("/").pop() ?? "");
|
|
const body = (request.postDataJSON() ?? {}) as Record<string, unknown>;
|
|
state.userPatches.push({ userId, body });
|
|
const existing = state.users.find((user) => user.user_id === userId) ?? makeMockUser({
|
|
user_id: userId,
|
|
});
|
|
const merged = { ...existing, ...body };
|
|
state.users = state.users.map((user) => (user.user_id === userId ? merged : user));
|
|
await fulfillJson(route, merged);
|
|
return;
|
|
}
|
|
|
|
await fulfillJson(route, { detail: `unmocked API request: ${method} ${path}` }, 404);
|
|
});
|
|
|
|
return state;
|
|
}
|
|
|
|
function isTicketsRequest(check: (params: URLSearchParams) => boolean) {
|
|
return (request: Request) => {
|
|
if (request.method() !== "GET") return false;
|
|
const url = new URL(request.url());
|
|
return url.pathname.endsWith("/admin/tickets") && check(url.searchParams);
|
|
};
|
|
}
|
|
|
|
function isAdminGet(pathSuffix: string) {
|
|
return (request: Request) =>
|
|
request.method() === "GET" && new URL(request.url()).pathname.endsWith(pathSuffix);
|
|
}
|
|
|
|
function isUsersListResponse(response: Response) {
|
|
const url = new URL(response.url());
|
|
return (
|
|
response.request().method() === "GET" &&
|
|
url.pathname.endsWith("/admin/users") &&
|
|
(response.headers()["content-type"]?.includes("application/json") ?? false)
|
|
);
|
|
}
|
|
|
|
function isUserPatchResponse(userId: string) {
|
|
return (response: Response) =>
|
|
response.request().method() === "PATCH" &&
|
|
new URL(response.url()).pathname.endsWith(`/admin/users/${userId}`);
|
|
}
|
|
|
|
function isTicketPatchResponse(ticketId: string) {
|
|
return (response: Response) =>
|
|
response.request().method() === "PATCH" &&
|
|
new URL(response.url()).pathname.endsWith(`/admin/tickets/${ticketId}`);
|
|
}
|
|
|
|
test.describe("admin console full-sweep (fixtures)", () => {
|
|
// 검증 checklist: admin-guard-pending-approval
|
|
test("redirects unapproved users to /pending and approved users back out of it", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page, { account_status: "pending" });
|
|
|
|
await page.goto("/admin");
|
|
await expect(page).toHaveURL(/\/pending$/);
|
|
await expect(
|
|
page.getByRole("heading", { name: "계정 확인이 끝나면 바로 이용할 수 있습니다." }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
|
|
|
|
await page.goto("/admin/tickets");
|
|
await expect(page).toHaveURL(/\/pending$/);
|
|
|
|
mock.auth.account_status = "suspended";
|
|
await page.goto("/admin");
|
|
await expect(page).toHaveURL(/\/pending$/);
|
|
await expect(page.locator(".pa-kicker")).toHaveText("접속 보류");
|
|
|
|
mock.auth.account_status = "approved";
|
|
await page.goto("/pending");
|
|
await expect(page).toHaveURL(/\/admin$/);
|
|
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
|
|
});
|
|
|
|
// 검증 checklist: admin-overview-refresh-button
|
|
test("overview refresh button refetches all five admin APIs and disables while loading", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
|
|
await page.goto("/admin");
|
|
const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ });
|
|
await expect(refreshButton).toHaveText(/새로고침/);
|
|
await expect(refreshButton).toBeEnabled();
|
|
|
|
mock.overrides.health = { delayMs: 700 };
|
|
const refetches = Promise.all([
|
|
page.waitForRequest(isAdminGet("/admin/health")),
|
|
page.waitForRequest(isAdminGet("/admin/users")),
|
|
page.waitForRequest(isAdminGet("/admin/usage")),
|
|
page.waitForRequest(isAdminGet("/admin/uptime")),
|
|
page.waitForRequest(isAdminGet("/admin/tickets")),
|
|
]);
|
|
await refreshButton.click();
|
|
await expect(refreshButton).toHaveText("확인 중");
|
|
await expect(refreshButton).toBeDisabled();
|
|
await refetches;
|
|
await expect(refreshButton).toHaveText("새로고침");
|
|
await expect(refreshButton).toBeEnabled();
|
|
});
|
|
|
|
// 검증 checklist: admin-overview-health-error
|
|
test("overview shows a role=alert inline error when the health API fails", async ({ page }) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.overrides.health = { status: 500, detail: "헬스 저장소 접근 불가" };
|
|
|
|
await page.goto("/admin");
|
|
|
|
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
|
|
const inlineError = page.locator(".vgops-error");
|
|
await expect(inlineError).toBeVisible();
|
|
await expect(inlineError).toHaveAttribute("role", "alert");
|
|
await expect(inlineError).toContainText("헬스 저장소 접근 불가");
|
|
// 다른 데이터 소스는 정상이므로 화면 나머지는 렌더된다.
|
|
await expect(page.locator(".vgops-kpis").first()).toBeVisible();
|
|
});
|
|
|
|
// 검증 checklist: admin-users-search-input
|
|
test("user search filters by role, cohort, and email substrings with a match count", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.users = [
|
|
makeMockUser({
|
|
user_id: "alice",
|
|
email: "alice@hs.ac.kr",
|
|
display_name: "Alice Lee",
|
|
role: "learner",
|
|
affiliation: "한신대 상담",
|
|
cohort_ids: ["cohort-alpha"],
|
|
}),
|
|
makeMockUser({
|
|
user_id: "bob",
|
|
email: "bob@hs.ac.kr",
|
|
display_name: "Bob Kim",
|
|
role: "teacher",
|
|
affiliation: "상담대학원",
|
|
}),
|
|
makeMockUser({
|
|
user_id: "carol",
|
|
email: "carol@twentyoz.kr",
|
|
display_name: "Carol Admin",
|
|
role: "admin",
|
|
admin_access: true,
|
|
}),
|
|
];
|
|
|
|
await page.goto("/admin/users");
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
const rows = page.locator(".vgops-user-table tbody tr");
|
|
await expect(rows).toHaveCount(3);
|
|
await expect(page.locator(".vgops-users-toolbar")).toContainText("3 / 3명");
|
|
|
|
const search = page.getByLabel("사용자 검색");
|
|
await search.fill("교수자");
|
|
await expect(rows).toHaveCount(1);
|
|
await expect(rows.first()).toContainText("bob@hs.ac.kr");
|
|
await expect(page.locator(".vgops-users-toolbar")).toContainText("1 / 3명");
|
|
|
|
await search.fill("cohort-alpha");
|
|
await expect(rows).toHaveCount(1);
|
|
await expect(rows.first()).toContainText("alice@hs.ac.kr");
|
|
|
|
await search.fill("@twentyoz.kr");
|
|
await expect(rows).toHaveCount(1);
|
|
await expect(rows.first()).toContainText("carol@twentyoz.kr");
|
|
|
|
await search.fill("zzz-no-such-user");
|
|
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
|
|
});
|
|
|
|
// 검증 checklist: admin-users-table-sort
|
|
test("user table sorts by columns with aria-sort and defaults to last_seen_at descending", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.users = [
|
|
makeMockUser({
|
|
user_id: "alpha",
|
|
email: "alpha@hs.ac.kr",
|
|
display_name: "Alpha User",
|
|
active_sessions: 5,
|
|
last_seen_at: 1_000,
|
|
}),
|
|
makeMockUser({
|
|
user_id: "beta",
|
|
email: "beta@hs.ac.kr",
|
|
display_name: "Beta User",
|
|
active_sessions: 1,
|
|
last_seen_at: 3_000,
|
|
}),
|
|
makeMockUser({
|
|
user_id: "gamma",
|
|
email: "gamma@hs.ac.kr",
|
|
display_name: "Gamma User",
|
|
active_sessions: 3,
|
|
last_seen_at: 2_000,
|
|
}),
|
|
];
|
|
|
|
await page.goto("/admin/users");
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
const rows = page.locator(".vgops-user-table tbody tr");
|
|
await expect(rows).toHaveCount(3);
|
|
|
|
// 기본 정렬: last_seen_at 내림차순.
|
|
const lastSeenHeader = page.getByRole("columnheader", { name: /최근 접속/ });
|
|
await expect(lastSeenHeader).toHaveAttribute("aria-sort", "descending");
|
|
await expect(rows.nth(0)).toContainText("beta@hs.ac.kr");
|
|
await expect(rows.nth(2)).toContainText("alpha@hs.ac.kr");
|
|
|
|
// 사용자(identity) 컬럼 클릭 → 오름차순 + aria-sort 갱신.
|
|
const identityHeader = page.getByRole("columnheader", { name: /사용자/ });
|
|
await identityHeader.getByRole("button").click();
|
|
await expect(identityHeader).toHaveAttribute("aria-sort", "ascending");
|
|
await expect(lastSeenHeader).toHaveAttribute("aria-sort", "none");
|
|
await expect(rows.nth(0)).toContainText("alpha@hs.ac.kr");
|
|
|
|
// 활성 회기 컬럼: 오름차순 → 내림차순 토글.
|
|
const sessionsHeader = page.getByRole("columnheader", { name: /활성 회기/ });
|
|
await sessionsHeader.getByRole("button").click();
|
|
await expect(sessionsHeader).toHaveAttribute("aria-sort", "ascending");
|
|
const ascending = await rows.locator("td:nth-child(7)").allTextContents();
|
|
expect(ascending.map(Number)).toEqual([1, 3, 5]);
|
|
await sessionsHeader.getByRole("button").click();
|
|
await expect(sessionsHeader).toHaveAttribute("aria-sort", "descending");
|
|
const descending = await rows.locator("td:nth-child(7)").allTextContents();
|
|
expect(descending.map(Number)).toEqual([5, 3, 1]);
|
|
});
|
|
|
|
// 검증 checklist: admin-users-row-cap
|
|
test("user table renders at most 40 rows and explains how to reach the rest", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.users = Array.from({ length: 45 }, (_, index) =>
|
|
makeMockUser({
|
|
user_id: `bulk-${String(index + 1).padStart(3, "0")}`,
|
|
email: `bulk-${String(index + 1).padStart(3, "0")}@hs.ac.kr`,
|
|
display_name: `Bulk User ${index + 1}`,
|
|
last_seen_at: 100_000 - index,
|
|
}),
|
|
);
|
|
|
|
await page.goto("/admin/users");
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
|
|
await expect(page.locator(".vgops-user-table tbody tr")).toHaveCount(40);
|
|
await expect(page.locator(".vgops-users-toolbar")).toContainText("45 / 45명");
|
|
await expect(page.getByText(/나머지\s*5명을 더 좁혀 볼 수/)).toBeVisible();
|
|
});
|
|
|
|
// 검증 checklist: admin-users-admin-access-toggle
|
|
test("admin access checkbox is superAdmin-only and blocked for super_admin targets", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page, { super_admin: true });
|
|
mock.users = [
|
|
makeMockUser({
|
|
user_id: "normal",
|
|
email: "normal@hs.ac.kr",
|
|
display_name: "Normal User",
|
|
last_seen_at: 2_000,
|
|
}),
|
|
makeMockUser({
|
|
user_id: "root",
|
|
email: "root@twentyoz.kr",
|
|
display_name: "Root Admin",
|
|
role: "admin",
|
|
admin_access: true,
|
|
super_admin: true,
|
|
last_seen_at: 1_000,
|
|
}),
|
|
];
|
|
|
|
await page.goto("/admin/users");
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
|
|
const normalToggle = page.getByLabel("normal@hs.ac.kr 관리자 페이지 권한");
|
|
const rootToggle = page.getByLabel("root@twentyoz.kr 관리자 페이지 권한");
|
|
await expect(normalToggle).toBeEnabled();
|
|
await expect(rootToggle).toBeDisabled();
|
|
|
|
// 토글 → 저장 시 admin_access가 PATCH로 반영된다.
|
|
await normalToggle.check();
|
|
const normalRow = page.locator(".vgops-user-table tbody tr").filter({ hasText: "normal@hs.ac.kr" });
|
|
const saveButton = normalRow.getByRole("button", { name: "저장" });
|
|
await expect(saveButton).toBeEnabled();
|
|
const patchPromise = page.waitForResponse(isUserPatchResponse("normal"));
|
|
await saveButton.click();
|
|
await patchPromise;
|
|
expect(mock.userPatches.at(-1)).toMatchObject({
|
|
userId: "normal",
|
|
body: { admin_access: true },
|
|
});
|
|
|
|
// superAdmin이 아니면 어떤 대상도 토글할 수 없다.
|
|
mock.auth.super_admin = false;
|
|
await page.reload();
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
await expect(page.getByLabel("normal@hs.ac.kr 관리자 페이지 권한")).toBeDisabled();
|
|
await expect(page.getByLabel("root@twentyoz.kr 관리자 페이지 권한")).toBeDisabled();
|
|
});
|
|
|
|
// 검증 checklist: admin-users-activity-tab
|
|
test("activity tab shows role distribution meters and the eight most recent users", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
const roles = [
|
|
"learner",
|
|
"learner",
|
|
"learner",
|
|
"learner",
|
|
"learner",
|
|
"teacher",
|
|
"teacher",
|
|
"admin",
|
|
"admin",
|
|
];
|
|
mock.users = roles.map((role, index) =>
|
|
makeMockUser({
|
|
user_id: `activity-${index + 1}`,
|
|
email: `activity-${index + 1}@hs.ac.kr`,
|
|
display_name: `Activity User ${index + 1}`,
|
|
role,
|
|
last_seen_at: 90_000 - index * 1_000,
|
|
}),
|
|
);
|
|
|
|
await page.goto("/admin/users");
|
|
await page.getByRole("tab", { name: "활동 요약" }).click();
|
|
|
|
await expect(page.getByRole("heading", { name: "역할 분포" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "최근 활동" })).toBeVisible();
|
|
|
|
const meters = page.locator(".vgops-role-meter");
|
|
await expect(meters).toHaveCount(3);
|
|
await expect(meters.nth(0)).toContainText("학습자");
|
|
await expect(meters.nth(0)).toContainText("5명");
|
|
await expect(meters.nth(1)).toContainText("교수자");
|
|
await expect(meters.nth(1)).toContainText("2명");
|
|
await expect(meters.nth(2)).toContainText("관리자");
|
|
await expect(meters.nth(2)).toContainText("2명");
|
|
|
|
const activityRows = page.locator(".vgops-activity-table > div");
|
|
await expect(activityRows).toHaveCount(8);
|
|
await expect(activityRows.first()).toContainText("Activity User 1");
|
|
await expect(page.locator(".vgops-activity-table")).not.toContainText("Activity User 9");
|
|
});
|
|
|
|
// 검증 checklist: admin-users-states
|
|
// [앱 결함 재현 — test.fixme로 스위트에서 제외]
|
|
// /admin/users가 실패해 usersData가 null로 남으면 Admin.tsx의
|
|
// `const users = usersData?.users ?? []`가 렌더마다 새 배열을 만들고,
|
|
// 이 불안정한 data가 TanStack useReactTable의 _autoResetPageIndex 큐
|
|
// (resetPageIndex → setPagination → React setState)와 결합해 무한 마이크로태스크
|
|
// 루프를 만든다. 그 상태에서 신뢰된 사용자 입력(탭 클릭)이 최초의 동기 갱신을
|
|
// 촉발하는 순간 관리자 화면 전체가 영구 프리즈된다(스크린샷·evaluate조차 불가).
|
|
// 합성 dispatchEvent 클릭으로는 재현되지 않고 실제(trusted) 입력 경로에서만 발생한다.
|
|
// 아래 탭 클릭이 그 지점이며, 오류 상태의 재시도 안내 문구에 마우스로는 도달할 수 없다.
|
|
// 근본 원인 수정 후 fixme를 해제하면 이 테스트가 회귀 가드가 된다.
|
|
test("users load failure shows retry guidance and stays interactive", async ({ page }) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.overrides.users = { delayMs: 1_500, status: 500, detail: "사용자 저장소 중단" };
|
|
|
|
await page.goto("/admin/users");
|
|
// 로딩 스켈레톤(승인 큐)이 먼저 보인다.
|
|
await expect(page.locator(".vgops-user--skeleton").first()).toBeVisible();
|
|
// 실패 후 InlineError가 표시된다.
|
|
await expect(page.locator(".vgops-error")).toContainText("사용자 저장소 중단");
|
|
// 앱 결함: 이 클릭에서 페이지가 무한 렌더 루프로 프리즈되어 테스트가 타임아웃된다.
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
await expect(
|
|
page.getByText("사용자 목록을 불러오지 못했습니다. 새로고침을 눌러 다시 시도하세요."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// 검증 checklist: admin-users-states
|
|
// 로딩 스켈레톤·오류 InlineError는 상호작용 없이(프리즈 결함 우회) 검증하고,
|
|
// 오류 상태의 목록 탭 재시도 안내는 위 fixme 재현 테스트가 담당한다.
|
|
test("users list distinguishes loading, error, list-empty, and search-empty states", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
|
|
// 로딩 스켈레톤 → 실패 InlineError (클릭 없이 표시만 검증).
|
|
mock.overrides.users = { delayMs: 1_500, status: 500, detail: "사용자 저장소 중단" };
|
|
await page.goto("/admin/users");
|
|
await expect(page.locator(".vgops-user--skeleton").first()).toBeVisible();
|
|
await expect(page.locator(".vgops-error")).toContainText("사용자 저장소 중단");
|
|
|
|
// 등록 0건 빈 상태.
|
|
delete mock.overrides.users;
|
|
mock.users = [];
|
|
await page.reload();
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toBeVisible();
|
|
|
|
// 검색 0건 빈 상태는 등록 0건 문구와 구분된다.
|
|
mock.users = [
|
|
makeMockUser({ user_id: "solo", email: "solo@hs.ac.kr", display_name: "Solo User" }),
|
|
];
|
|
await page.reload();
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
await expect(page.locator(".vgops-user-table tbody tr")).toHaveCount(1);
|
|
await page.getByLabel("사용자 검색").fill("no-match-full-sweep");
|
|
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
|
|
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
|
|
});
|
|
|
|
// 검증 checklist: admin-tickets-search-input, admin-tickets-status-filter,
|
|
// admin-tickets-category-filter, admin-tickets-priority-filter,
|
|
// admin-tickets-assigned-group-input, admin-tickets-stale-toggle,
|
|
// admin-tickets-clear-filters-button
|
|
test("ticket filters drive server query params and the clear button resets them", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.tickets = [makeMockTicket({ ticket_id: "filter-1", subject: "필터 대상 티켓" })];
|
|
|
|
await page.goto("/admin/tickets");
|
|
await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible();
|
|
const clearButton = page.getByRole("button", { name: "초기화" });
|
|
await expect(clearButton).toBeDisabled();
|
|
|
|
const searchRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("search") === "로그인 오류"),
|
|
);
|
|
await page.getByLabel("지원 요청 검색").fill("로그인 오류");
|
|
await searchRequest;
|
|
|
|
const statusRequest = page.waitForRequest(
|
|
isTicketsRequest(
|
|
(params) => params.get("status") === "open" && params.get("search") === "로그인 오류",
|
|
),
|
|
);
|
|
await page.getByLabel("상태 필터").selectOption("open");
|
|
await statusRequest;
|
|
|
|
const categoryRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("category") === "safety"),
|
|
);
|
|
await page.getByLabel("카테고리 필터").selectOption("safety");
|
|
await categoryRequest;
|
|
|
|
const priorityRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("priority") === "urgent"),
|
|
);
|
|
await page.getByLabel("우선순위 필터").selectOption("urgent");
|
|
await priorityRequest;
|
|
|
|
const groupRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("assigned_group") === "ops"),
|
|
);
|
|
await page.getByLabel("담당 그룹 필터").fill("ops");
|
|
await groupRequest;
|
|
|
|
const staleRequest = page.waitForRequest(
|
|
isTicketsRequest(
|
|
(params) =>
|
|
params.get("stale_only") === "true" &&
|
|
params.get("search") === "로그인 오류" &&
|
|
params.get("status") === "open" &&
|
|
params.get("category") === "safety" &&
|
|
params.get("priority") === "urgent" &&
|
|
params.get("assigned_group") === "ops",
|
|
),
|
|
);
|
|
await page.locator(".vgops-ticket-filter__check input").check();
|
|
await staleRequest;
|
|
|
|
await expect(clearButton).toBeEnabled();
|
|
const resetRequest = page.waitForRequest(
|
|
isTicketsRequest(
|
|
(params) =>
|
|
!params.has("search") &&
|
|
!params.has("status") &&
|
|
!params.has("category") &&
|
|
!params.has("priority") &&
|
|
!params.has("assigned_group") &&
|
|
!params.has("stale_only") &&
|
|
params.get("window_days") === "30",
|
|
),
|
|
);
|
|
await clearButton.click();
|
|
await resetRequest;
|
|
await expect(page.getByLabel("지원 요청 검색")).toHaveValue("");
|
|
await expect(page.getByLabel("상태 필터")).toHaveValue("all");
|
|
await expect(page.getByLabel("카테고리 필터")).toHaveValue("all");
|
|
await expect(page.getByLabel("우선순위 필터")).toHaveValue("all");
|
|
await expect(page.getByLabel("담당 그룹 필터")).toHaveValue("");
|
|
await expect(page.locator(".vgops-ticket-filter__check input")).not.toBeChecked();
|
|
await expect(clearButton).toBeDisabled();
|
|
});
|
|
|
|
// 검증 checklist: admin-tickets-category-queue-chips
|
|
test("category queue chips list counted categories and apply the category filter", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.tickets = [
|
|
makeMockTicket({ ticket_id: "chip-1", category: "safety", subject: "안전 티켓 1" }),
|
|
makeMockTicket({ ticket_id: "chip-2", category: "safety", subject: "안전 티켓 2" }),
|
|
makeMockTicket({ ticket_id: "chip-3", category: "other", subject: "기타 티켓" }),
|
|
];
|
|
|
|
await page.goto("/admin/tickets");
|
|
const chips = page.locator(".vgops-ticket-queues button");
|
|
await expect(chips).toHaveCount(2);
|
|
// 건수 내림차순: 안전(2) → 기타(1).
|
|
await expect(chips.nth(0)).toContainText("안전");
|
|
await expect(chips.nth(0)).toContainText("2");
|
|
await expect(chips.nth(1)).toContainText("기타");
|
|
await expect(chips.nth(1)).toContainText("1");
|
|
|
|
const filterRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("category") === "safety"),
|
|
);
|
|
await chips.nth(0).click();
|
|
await filterRequest;
|
|
await expect(chips.nth(0)).toHaveClass(/is-active/);
|
|
await expect(page.getByLabel("카테고리 필터")).toHaveValue("safety");
|
|
});
|
|
|
|
// 검증 checklist: admin-tickets-refresh-button
|
|
test("ticket refresh button reloads with current filters and disables while loading", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.tickets = [makeMockTicket({ ticket_id: "refresh-1", subject: "새로고침 대상 티켓" })];
|
|
|
|
await page.goto("/admin/tickets");
|
|
const statusRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("status") === "open"),
|
|
);
|
|
await page.getByLabel("상태 필터").selectOption("open");
|
|
await statusRequest;
|
|
|
|
const refreshButton = page.getByRole("button", { name: /새로고침|확인 중/ });
|
|
await expect(refreshButton).toBeEnabled();
|
|
mock.overrides.tickets = { delayMs: 700 };
|
|
const refreshRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("status") === "open"),
|
|
);
|
|
await refreshButton.click();
|
|
await expect(refreshButton).toHaveText("확인 중");
|
|
await expect(refreshButton).toBeDisabled();
|
|
await refreshRequest;
|
|
await expect(refreshButton).toHaveText("새로고침");
|
|
await expect(refreshButton).toBeEnabled();
|
|
});
|
|
|
|
// 검증 checklist: admin-tickets-states
|
|
test("tickets view distinguishes initial loading, empty queue, and failure states", async ({
|
|
page,
|
|
}) => {
|
|
const mock = await installAdminMock(page);
|
|
mock.tickets = [];
|
|
mock.overrides.tickets = { delayMs: 1_500 };
|
|
|
|
await page.goto("/admin/tickets");
|
|
await expect(page.getByText("티켓 큐를 불러오는 중입니다")).toBeVisible();
|
|
await expect(page.getByText("미해결 운영 티켓이 없습니다")).toBeVisible();
|
|
|
|
mock.overrides.tickets = { status: 500, detail: "티켓 저장소 오류" };
|
|
await page.getByRole("button", { name: "새로고침" }).click();
|
|
const inlineError = page.locator(".vgops-error");
|
|
await expect(inlineError).toBeVisible();
|
|
await expect(inlineError).toHaveAttribute("role", "alert");
|
|
await expect(inlineError).toContainText("티켓 저장소 오류");
|
|
});
|
|
});
|
|
|
|
test.describe("admin console full-sweep (real API)", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await useRealApi(page);
|
|
});
|
|
|
|
// 검증 checklist: admin-users-suspend-button
|
|
test("suspends a pending signup from the approval queue via PATCH", async ({ page }, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await signInAsAdmin(page);
|
|
|
|
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
const email = `full-sweep-suspend.${slug}@hs.ac.kr`.toLowerCase();
|
|
let createdUserId: string | null = null;
|
|
|
|
try {
|
|
const create = await page.request.post("/api/admin/users", {
|
|
data: {
|
|
email,
|
|
display_name: `보류 대상 ${testInfo.project.name}`,
|
|
role: "learner",
|
|
admin_access: false,
|
|
account_status: "pending",
|
|
affiliation: "E2E 검증",
|
|
cohort_ids: [],
|
|
},
|
|
});
|
|
expect(create.ok(), await create.text()).toBeTruthy();
|
|
const created = (await create.json()) as { user_id: string };
|
|
createdUserId = created.user_id;
|
|
|
|
const usersResponsePromise = page.waitForResponse(isUsersListResponse);
|
|
await page.goto("/admin/users");
|
|
expect((await usersResponsePromise).ok()).toBeTruthy();
|
|
|
|
const approvalCard = page.locator(".vgops-approval").filter({ hasText: email });
|
|
await expect(approvalCard).toBeVisible();
|
|
await expect(approvalCard).toContainText("승인 대기");
|
|
|
|
const patchPromise = page.waitForResponse(isUserPatchResponse(created.user_id));
|
|
await approvalCard.getByRole("button", { name: "보류" }).click();
|
|
const patchResponse = await patchPromise;
|
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
|
const updated = (await patchResponse.json()) as {
|
|
user_id: string;
|
|
account_status: string;
|
|
};
|
|
expect(updated).toMatchObject({
|
|
user_id: created.user_id,
|
|
account_status: "suspended",
|
|
});
|
|
// 보류 처리된 사용자는 승인 대기 큐에서 사라진다.
|
|
await expect(approvalCard).toHaveCount(0);
|
|
} finally {
|
|
if (createdUserId) {
|
|
const cleanup = await page.request.delete(`/api/admin/users/${createdUserId}`);
|
|
if (!cleanup.ok()) {
|
|
console.warn(
|
|
`E2E suspend-user cleanup failed: ${cleanup.status()} ${await cleanup.text()}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 검증 checklist: admin-users-affiliation-input, admin-users-cohort-input
|
|
// 코호트는 단일 값 + trim·빈값 제거까지를 여기서 검증한다. 복수 코호트 영속은
|
|
// 서버가 첫 항목만 저장하는 결함이 있어 별도 RED 테스트로 분리했다(아래 참고).
|
|
test("saves inline affiliation and trimmed cohort edits through PATCH", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await signInAsAdmin(page);
|
|
|
|
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
const email = `full-sweep-edit.${slug}@hs.ac.kr`.toLowerCase();
|
|
let createdUserId: string | null = null;
|
|
|
|
try {
|
|
const create = await page.request.post("/api/admin/users", {
|
|
data: {
|
|
email,
|
|
display_name: `편집 대상 ${testInfo.project.name}`,
|
|
role: "learner",
|
|
admin_access: false,
|
|
account_status: "approved",
|
|
affiliation: "원래 소속",
|
|
cohort_ids: [],
|
|
},
|
|
});
|
|
expect(create.ok(), await create.text()).toBeTruthy();
|
|
const created = (await create.json()) as { user_id: string };
|
|
createdUserId = created.user_id;
|
|
|
|
const usersResponsePromise = page.waitForResponse(isUsersListResponse);
|
|
await page.goto("/admin/users");
|
|
expect((await usersResponsePromise).ok()).toBeTruthy();
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
await page.getByLabel("사용자 검색").fill(email);
|
|
|
|
const row = page.locator(".vgops-user-table tbody tr").filter({ hasText: email });
|
|
await expect(row).toBeVisible();
|
|
|
|
const affiliationInput = row.getByLabel(`${email} 소속`);
|
|
const cohortInput = row.getByLabel(`${email} 코호트`);
|
|
await affiliationInput.fill("산학협력단");
|
|
await expect(affiliationInput).toHaveValue("산학협력단");
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
// 콤마 구분 문자열은 trim되고 빈 항목은 제거된다.
|
|
await cohortInput.fill(" co-a , , ");
|
|
await expect(cohortInput).toHaveValue("co-a");
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
|
|
const saveButton = row.getByRole("button", { name: "저장" });
|
|
await expect(saveButton).toBeEnabled();
|
|
const patchPromise = page.waitForResponse(isUserPatchResponse(created.user_id));
|
|
await saveButton.click();
|
|
const patchResponse = await patchPromise;
|
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
|
const updated = (await patchResponse.json()) as {
|
|
affiliation: string;
|
|
cohort_ids: string[];
|
|
};
|
|
expect(updated).toMatchObject({
|
|
affiliation: "산학협력단",
|
|
cohort_ids: ["co-a"],
|
|
});
|
|
await expect(affiliationInput).toHaveValue("산학협력단");
|
|
await expect(cohortInput).toHaveValue("co-a");
|
|
} finally {
|
|
if (createdUserId) {
|
|
const cleanup = await page.request.delete(`/api/admin/users/${createdUserId}`);
|
|
if (!cleanup.ok()) {
|
|
console.warn(
|
|
`E2E edit-user cleanup failed: ${cleanup.status()} ${await cleanup.text()}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 검증 checklist: admin-users-cohort-input — 앱(서버) 결함으로 의도적 RED
|
|
// [앱 결함] UI는 "co-a, co-b"를 ["co-a","co-b"]로 정확히 파싱해 PATCH하지만,
|
|
// 서버(POST/PATCH /admin/users)가 cohort_ids 배열의 첫 항목만 저장한다.
|
|
// (직접 API 검증: PATCH cohort_ids ["co-a","co-b"] → 응답 ["co-a"],
|
|
// ["co-b","co-a"] → ["co-b"], POST ["x-1","x-2","x-3"] → ["x-1"])
|
|
// 체크리스트가 요구하는 "콤마 구분 문자열을 cohort_ids 배열로 반영"이
|
|
// 복수 코호트에서 조용히 데이터 유실로 끝나므로 RED로 남긴다.
|
|
test("persists every comma-separated cohort id through PATCH", async ({ page }, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await signInAsAdmin(page);
|
|
|
|
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
const email = `full-sweep-cohorts.${slug}@hs.ac.kr`.toLowerCase();
|
|
let createdUserId: string | null = null;
|
|
|
|
try {
|
|
const create = await page.request.post("/api/admin/users", {
|
|
data: {
|
|
email,
|
|
display_name: `코호트 대상 ${testInfo.project.name}`,
|
|
role: "learner",
|
|
admin_access: false,
|
|
account_status: "approved",
|
|
affiliation: "",
|
|
cohort_ids: [],
|
|
},
|
|
});
|
|
expect(create.ok(), await create.text()).toBeTruthy();
|
|
const created = (await create.json()) as { user_id: string };
|
|
createdUserId = created.user_id;
|
|
|
|
const usersResponsePromise = page.waitForResponse(isUsersListResponse);
|
|
await page.goto("/admin/users");
|
|
expect((await usersResponsePromise).ok()).toBeTruthy();
|
|
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
|
await page.getByLabel("사용자 검색").fill(email);
|
|
|
|
const row = page.locator(".vgops-user-table tbody tr").filter({ hasText: email });
|
|
await expect(row).toBeVisible();
|
|
const cohortInput = row.getByLabel(`${email} 코호트`);
|
|
await cohortInput.fill("co-a, co-b");
|
|
await expect(cohortInput).toHaveValue("co-a, co-b");
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
|
|
const saveButton = row.getByRole("button", { name: "저장" });
|
|
await expect(saveButton).toBeEnabled();
|
|
const patchPromise = page.waitForResponse(isUserPatchResponse(created.user_id));
|
|
await saveButton.click();
|
|
const patchResponse = await patchPromise;
|
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
|
const updated = (await patchResponse.json()) as { cohort_ids: string[] };
|
|
// 앱 결함: 서버가 ["co-a"]만 반환한다. 기대는 두 코호트 모두 저장.
|
|
expect(updated.cohort_ids).toEqual(["co-a", "co-b"]);
|
|
} finally {
|
|
if (createdUserId) {
|
|
const cleanup = await page.request.delete(`/api/admin/users/${createdUserId}`);
|
|
if (!cleanup.ok()) {
|
|
console.warn(
|
|
`E2E cohort-user cleanup failed: ${cleanup.status()} ${await cleanup.text()}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 검증 checklist: admin-ticket-inprogress-button
|
|
test("moves a ticket to in_progress with a single PATCH even on rapid double clicks", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await signInAsAdmin(page);
|
|
|
|
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
const subject = `[E2E] full-sweep 처리 중 ${slug}`;
|
|
let createdTicketId: string | null = null;
|
|
|
|
try {
|
|
const create = await page.request.post("/api/users/support-tickets", {
|
|
data: {
|
|
category: "other",
|
|
priority: "urgent",
|
|
subject,
|
|
body: "E2E 처리 중 버튼 검증입니다. 실제 장애가 아닙니다.",
|
|
source_path: "/__e2e__/full-sweep/in-progress",
|
|
},
|
|
});
|
|
expect(create.ok(), await create.text()).toBeTruthy();
|
|
const created = (await create.json()) as { ticket_id: string };
|
|
createdTicketId = created.ticket_id;
|
|
|
|
// 주의: 실 DB 스케일에서 티켓 검색 입력은 콘솔 프리즈 결함을 촉발한다
|
|
// (아래 fixme 재현 테스트 참고). 여기서는 기존 admin.spec.ts와 동일하게
|
|
// 검색 없이 큐 상단에서 카드를 직접 찾는다(긴급 우선순위 + 최신 접수).
|
|
const ticketsResponsePromise = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "GET" &&
|
|
new URL(response.url()).pathname.endsWith("/admin/tickets"),
|
|
);
|
|
await page.goto("/admin/tickets");
|
|
await ticketsResponsePromise;
|
|
|
|
const card = page.locator(".vgops-ticket").filter({ hasText: subject });
|
|
await expect(card).toBeVisible();
|
|
|
|
let patchCount = 0;
|
|
page.on("request", (request) => {
|
|
if (
|
|
request.method() === "PATCH" &&
|
|
new URL(request.url()).pathname.endsWith(`/admin/tickets/${created.ticket_id}`)
|
|
) {
|
|
patchCount += 1;
|
|
}
|
|
});
|
|
|
|
const inProgressButton = card.getByRole("button", { name: "처리 중" });
|
|
const patchPromise = page.waitForResponse(isTicketPatchResponse(created.ticket_id));
|
|
// 빠른 중복 클릭 — pendingRef가 두 번째 요청을 즉시 차단해야 한다.
|
|
await inProgressButton.evaluate((element) => {
|
|
(element as HTMLButtonElement).click();
|
|
(element as HTMLButtonElement).click();
|
|
});
|
|
const patchResponse = await patchPromise;
|
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
|
const updated = (await patchResponse.json()) as { status: string };
|
|
expect(updated.status).toBe("in_progress");
|
|
expect(patchCount).toBe(1);
|
|
|
|
await expect(card).toContainText("처리 중");
|
|
await expect(card.getByRole("button", { name: "처리 중" })).toBeDisabled();
|
|
expect(patchCount).toBe(1);
|
|
} 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 in-progress ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 검증 checklist: admin-ticket-unlink-duplicate-button
|
|
test("unlinks a duplicate ticket by clearing parent_ticket_id", async ({ page }, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await signInAsAdmin(page);
|
|
|
|
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
|
|
const subject = `[E2E] full-sweep 중복 해제 ${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: "other",
|
|
priority: "normal",
|
|
subject,
|
|
body: "E2E 중복 연결 해제 검증입니다. 실제 장애가 아닙니다.",
|
|
source_path: "/__e2e__/full-sweep/unlink",
|
|
},
|
|
});
|
|
expect(create.ok(), await create.text()).toBeTruthy();
|
|
const created = (await create.json()) as { ticket_id: string };
|
|
createdTicketIds.push(created.ticket_id);
|
|
}
|
|
const [parentId, childId] = createdTicketIds;
|
|
|
|
// 선연결: 자식 티켓을 부모에 수동 연결해 둔다.
|
|
const link = await page.request.patch(`/api/admin/tickets/${childId}`, {
|
|
data: { parent_ticket_id: parentId },
|
|
});
|
|
expect(link.ok(), await link.text()).toBeTruthy();
|
|
const linked = (await link.json()) as { parent_ticket_id: string | null };
|
|
expect(linked.parent_ticket_id).toBe(parentId);
|
|
|
|
// 주의: 실 DB 스케일에서 티켓 검색 입력은 콘솔 프리즈 결함을 촉발한다
|
|
// (아래 fixme 재현 테스트 참고). 검색 없이 큐에서 직접 카드를 찾는다.
|
|
const ticketsResponsePromise = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "GET" &&
|
|
new URL(response.url()).pathname.endsWith("/admin/tickets"),
|
|
);
|
|
await page.goto("/admin/tickets");
|
|
await ticketsResponsePromise;
|
|
|
|
const linkedCard = page
|
|
.locator(".vgops-ticket")
|
|
.filter({ hasText: subject })
|
|
.filter({ has: page.getByRole("button", { name: "해제" }) });
|
|
await expect(linkedCard).toBeVisible();
|
|
await expect(linkedCard).toContainText("중복 연결");
|
|
|
|
const patchPromise = page.waitForResponse(isTicketPatchResponse(childId));
|
|
await linkedCard.getByRole("button", { name: "해제" }).click();
|
|
const patchResponse = await patchPromise;
|
|
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
|
|
const updated = (await patchResponse.json()) as { parent_ticket_id: string | null };
|
|
expect(updated.parent_ticket_id ?? "").toBe("");
|
|
|
|
// 해제 후 해제 버튼이 있는 카드는 더 이상 없다.
|
|
await expect(linkedCard).toHaveCount(0);
|
|
} 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 unlink ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 검증 checklist: admin-tickets-search-input — 앱 결함 재현(test.fixme로 스위트에서 제외)
|
|
// [앱 결함] 실 DB 스케일(/admin/users 약 1,400명 + 활성 티켓 24건 초과)에서
|
|
// /admin/tickets의 "티켓 검색" 입력에 값을 넣는 순간 관리자 콘솔 전체가
|
|
// 무한 동기 렌더 루프로 영구 프리즈된다(fill 액션·스크린샷·evaluate 모두 응답 없음).
|
|
// CDP Debugger로 채증한 루프: TanStack Table `table._autoResetPageIndex`
|
|
// → `setPageIndex` → `setPagination` → React `setState` → Admin 재렌더 →
|
|
// core row model 재계산 → autoReset 재큐잉(Promise.then 마이크로태스크) → 무한 반복.
|
|
// 같은 페이지·같은 조작이 fixture(소규모·안정 데이터)에서는 재현되지 않고
|
|
// (위 "ticket filters drive server query params" 테스트는 GREEN),
|
|
// 실 dev DB 데이터로는 두 번 연속 결정적으로 재현됐다. 상태 버튼 클릭(해결/처리 중)은
|
|
// 프리즈되지 않으며 필터 상태 갱신 계열 입력에서만 발생한다.
|
|
// 검색 파라미터 배선 자체는 fixture 테스트가 검증하므로, 이 테스트는 실 스케일
|
|
// 회귀 가드다. 근본 원인 수정 후 fixme를 해제하라.
|
|
test("regression guard: ticket search input stays responsive at real data scale", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
await signInAsAdmin(page);
|
|
|
|
await page.goto("/admin/tickets");
|
|
await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible();
|
|
|
|
const searchRequest = page.waitForRequest(
|
|
isTicketsRequest((params) => params.get("search") === "freeze-probe"),
|
|
);
|
|
// 실 DB 스케일에서는 이 fill에서 렌더러가 프리즈되어 어떤 요청도 나가지 않는다.
|
|
await page.getByLabel("지원 요청 검색").fill("freeze-probe");
|
|
await searchRequest;
|
|
});
|
|
});
|