// 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; users: Array>; tickets: Array>; overrides: Partial>; calls: Record; userPatches: Array<{ userId: string; body: Record }>; } function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function makeMockUser(overrides: Record = {}): Record { 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 = {}): Record { 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 { const active = state.tickets.filter( (ticket) => ticket.status !== "resolved" && ticket.status !== "closed", ); const byCategory: Record = {}; 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 = {}, ): Promise { 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; 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; }); });