vignette/apps/web/e2e/full-sweep-admin.spec.ts
Yun Chan 4511383cd9 전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
2026-07-27 14:25:24 +09:00

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(".ad-error");
await expect(inlineError).toBeVisible();
await expect(inlineError).toHaveAttribute("role", "alert");
await expect(inlineError).toContainText("헬스 저장소 접근 불가");
// 다른 데이터 소스는 정상이므로 화면 나머지는 렌더된다.
await expect(page.locator(".ad-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(".ad-user-table tbody tr");
await expect(rows).toHaveCount(3);
await expect(page.locator(".ad-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(".ad-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(".ad-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(".ad-user-table tbody tr")).toHaveCount(40);
await expect(page.locator(".ad-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(".ad-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(".ad-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(".ad-activity-table > div");
await expect(activityRows).toHaveCount(8);
await expect(activityRows.first()).toContainText("Activity User 1");
await expect(page.locator(".ad-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(".ad-user--skeleton").first()).toBeVisible();
// 실패 후 InlineError가 표시된다.
await expect(page.locator(".ad-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(".ad-user--skeleton").first()).toBeVisible();
await expect(page.locator(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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(".ad-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;
});
});