/* ===================================================================== full-sweep-settings.spec.ts — 2026-07-27 E2E 전수 순회, 설정(/settings) 영역. docs/ops/e2e-full-sweep-2026-07-27.md §9에서 "검증: 신규 spec 필요"로 표기된 체크리스트 항목을 검증한다. 각 test() 위 주석에 담당 checklist id를 적는다. - 실 API(dev-login, P1~P7 시드)를 기본으로 쓰고, 오류·빈 상태 고정이 필요한 경우에만 page.route() fixture를 쓴다. AI 엔진 턴 생성은 호출하지 않는다. ===================================================================== */ import { expect, test, type Page, type Response, type TestInfo } from "@playwright/test"; import { completeOnboarding, useRealApi } from "./support"; /** 1x1 PNG — 아바타 업로드 e2e용 최소 픽셀. */ const PNG_1X1_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg=="; function slugFor(testInfo: TestInfo) { let hash = 0; for (const char of testInfo.title) { hash = (hash * 31 + char.charCodeAt(0)) >>> 0; } const project = testInfo.project.name.includes("mobile") ? "mob" : "desk"; return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`; } function isApiResponse(method: string, pathnameSuffix: string) { return (response: Response) => { const url = new URL(response.url()); return response.request().method() === method && url.pathname.endsWith(pathnameSuffix); }; } async function devLogin( page: Page, role: "learner" | "admin", testInfo: TestInfo, displayName: string, options: { onboarding?: boolean } = {}, ) { const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr"; const email = `sweep.settings.${role}.${slugFor(testInfo)}@${domain}`; const res = await page.request.post("/api/auth/dev-login", { data: { email, role, display_name: displayName }, }); expect(res.ok(), await res.text()).toBeTruthy(); if (options.onboarding !== false) { await completeOnboarding(page, { legal_name: displayName, affiliation: "한신대학교", department: role === "admin" ? "운영팀" : "상담심리학과", grade_level: role === "admin" ? "관리자" : "3학년", phone: "010-4444-4444", contact_address: "경기도 오산시 한신대학교", nickname: displayName, self_introduction: `${displayName} 설정 전수 순회 E2E 사용자입니다.`, }); } return email; } /** /settings 진입 후 프로필·환경설정이 도착해 저장 버튼들이 렌더될 때까지 대기. */ async function openLoadedSettings(page: Page) { await page.goto("/settings"); await expect(page).toHaveURL(/\/settings$/); await expect(page.locator(".vg-set")).toBeVisible(); // preferences 도착 → 알림 저장 버튼 렌더, profile 도착 → 계정 저장 버튼 활성화 await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible({ timeout: 10_000 }); await expect( page.locator("#set-account .vg-set__foot .vg-btn"), ).toBeEnabled({ timeout: 10_000 }); } function pendingMeResponse(overrides: Record = {}) { return { user_id: "e2e-pending-user", email: "sweep.settings.pending@hs.ac.kr", display_name: "승인 대기 학습자", role: "learner", admin_access: false, super_admin: false, account_status: "pending", approval_required: true, avatar_url: "", cohort_ids: [], consent_at: null, nickname: "승인대기", self_introduction: "", onboarding_completed_at: null, ...overrides, }; } test.describe("full-sweep settings", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); }); // checklist: settings-guard-require-auth test("redirects an unauthenticated visitor from /settings to /login", async ({ page }) => { await page.goto("/settings"); await expect(page).toHaveURL(/\/login$/); // 설정 화면 본문이 렌더되지 않아야 한다. await expect(page.locator(".vg-set")).toHaveCount(0); }); // checklist: settings-guard-pending-approval test("redirects a pending-approval user from /settings to /pending", async ({ page }) => { await page.route("**/api/auth/me", async (route) => { await route.fulfill({ json: pendingMeResponse() }); }); await page.goto("/settings"); await expect(page).toHaveURL(/\/pending$/); await expect(page.locator(".pa-kicker")).toHaveText("승인 대기"); await expect(page.locator(".pa-status")).toContainText("접속 권한 확인 중"); await expect(page.locator(".vg-set")).toHaveCount(0); }); // checklist: settings-guard-onboarding test("redirects a signed-in user without onboarding from /settings to /onboarding", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Onboarding Gate", { onboarding: false }); await page.goto("/settings"); await expect(page).toHaveURL(/\/onboarding$/); await expect(page.locator(".vg-set")).toHaveCount(0); }); // checklist: settings-load-error-callout test("shows a role=alert warning callout when the settings load fails", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Load Error"); await page.route("**/api/users/me/preferences", async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "preferences unavailable (e2e fixture)" }), }); }); await page.goto("/settings"); const callout = page.locator(".vg-set__callout--warn"); await expect(callout).toBeVisible({ timeout: 10_000 }); await expect(callout).toHaveAttribute("role", "alert"); await expect(callout.locator(".vg-set__callout-text")).not.toHaveText(""); }); // checklist: settings-support-error-state test("keeps the page usable with an inline alert when only support tickets fail", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Ticket Error"); await page.route("**/api/users/support-tickets", async (route) => { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "support tickets unavailable (e2e fixture)" }), }); }); await openLoadedSettings(page); // 지원 요청 섹션 안에만 role=alert 경고가 뜨고, 페이지 전체 오류 콜아웃은 없다. const supportAlert = page.locator("#set-support .vg-set__state--warn"); await expect(supportAlert).toBeVisible(); await expect(supportAlert).toHaveAttribute("role", "alert"); await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0); // 다른 섹션은 살아 있다: 계정 저장·알림 저장 동작 가능 상태. await expect(page.locator("#set-account .vg-set__foot .vg-btn")).toBeEnabled(); await expect(page.getByRole("button", { name: "알림 저장" })).toBeEnabled(); }); // checklist: settings-support-empty-state test("shows distinct support empty states for zero tickets and a null payload", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Ticket Empty"); // 1) 실 API — 방금 만든 학습자는 접수 티켓 0건. await openLoadedSettings(page); const emptyState = page.getByTestId("settings-support-empty"); await expect(emptyState).toBeVisible(); await expect(emptyState).toContainText("접수한 지원 요청이 없습니다"); // 2) fixture — 저장소가 null 페이로드를 반환하면 별도 문구를 보여준다. await page.route("**/api/users/support-tickets", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: "null" }); }); await openLoadedSettings(page); await expect(emptyState).toBeVisible(); await expect(emptyState).toContainText("지원 요청 저장소에 표시할 항목이 없습니다"); }); // checklist: settings-rail-nav-buttons test("rail nav buttons scroll to their section and expose aria-current", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Rail Nav"); await openLoadedSettings(page); const nav = page.getByRole("navigation", { name: "설정 섹션" }); // 학습자에게는 관리자 전용 AI 운영 항목이 없다(계정·지원 요청·동의·테마·알림·음성 6개). await expect(nav.getByRole("button")).toHaveCount(6); await expect(nav.getByRole("button", { name: "AI 운영" })).toHaveCount(0); const accountItem = nav.getByRole("button", { name: "계정" }); const voiceItem = nav.getByRole("button", { name: "음성" }); await expect(accountItem).toHaveAttribute("aria-current", "location"); await voiceItem.click(); await expect(page.locator("#set-voice")).toBeInViewport(); await expect(voiceItem).toHaveAttribute("aria-current", "location"); await expect(accountItem).not.toHaveAttribute("aria-current", "location"); await accountItem.click(); await expect(page.locator("#set-account")).toBeInViewport(); await expect(accountItem).toHaveAttribute("aria-current", "location"); }); // checklist: settings-rail-nav-scrollspy test("scroll spy syncs the active rail item to the section scrolled into view", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Scrollspy"); await openLoadedSettings(page); const nav = page.getByRole("navigation", { name: "설정 섹션" }); await expect(nav.getByRole("button", { name: "계정" })).toHaveAttribute( "aria-current", "location", ); // 클릭 없이 스크롤만으로 IntersectionObserver가 활성 섹션을 동기화해야 한다. await page.evaluate(() => { document.getElementById("set-voice")?.scrollIntoView({ block: "start" }); }); await expect(nav.getByRole("button", { name: "음성" })).toHaveAttribute( "aria-current", "location", { timeout: 10_000 }, ); await expect(nav.getByRole("button", { name: "계정" })).not.toHaveAttribute( "aria-current", "location", ); }); // checklist: settings-theme-dark-toggle test("dark mode switch flips data-theme immediately and persists to localStorage", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Theme Toggle"); await openLoadedSettings(page); const readTheme = () => page.evaluate(() => ({ applied: document.documentElement.getAttribute("data-theme"), stored: window.localStorage.getItem("vignette.theme"), })); const initialDark = (await readTheme()).applied === "dark"; const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" }); await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark)); await darkSwitch.click(); const flipped = !initialDark; await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped)); await expect.poll(readTheme).toEqual({ applied: flipped ? "dark" : "light", stored: flipped ? "dark" : "light", }); // 되돌리기도 즉시 반영된다. await darkSwitch.click(); await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark)); await expect.poll(readTheme).toEqual({ applied: initialDark ? "dark" : "light", stored: initialDark ? "dark" : "light", }); }); // checklist: settings-theme-save-button, settings-saved-flash-indicator test("theme save button PATCHes the current theme and flashes then clears 저장됨", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Theme Save"); await openLoadedSettings(page); const appearance = page.locator("#set-appearance"); const darkSwitch = appearance.getByRole("switch", { name: "다크 모드" }); const isDark = (await darkSwitch.getAttribute("aria-checked")) === "true"; // 한 번 토글해 저장할 값을 명시적으로 만든다. await darkSwitch.click(); const nextTheme = !isDark ? "dark" : "light"; await expect(darkSwitch).toHaveAttribute("aria-checked", String(!isDark)); const patchPromise = page.waitForResponse( isApiResponse("PATCH", "/users/me/preferences"), { timeout: 10_000 }, ); await appearance.locator(".vg-set__foot .vg-btn").click(); const patchResponse = await patchPromise; expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); const requestBody = patchResponse.request().postDataJSON() as { theme?: string }; expect(requestBody.theme).toBe(nextTheme); const saved = (await patchResponse.json()) as { theme: string }; expect(saved.theme).toBe(nextTheme); // '저장됨' 플래시: 표시됐다가 2.4초 뒤 자동으로 사라진다. const savedFlash = appearance.locator(".vg-set__saved"); await expect(savedFlash).toBeVisible(); await expect(savedFlash).toContainText("저장됨"); await expect(savedFlash).toHaveCount(0, { timeout: 6_000 }); }); // checklist: settings-voice-empty-state test("voice section shows an empty state and hides slider/save without presets", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Voice Empty"); await page.route("**/api/users/me/voice-presets", async (route) => { await route.fulfill({ json: [] }); }); await openLoadedSettings(page); const voiceEmpty = page.getByTestId("settings-voice-empty"); await expect(voiceEmpty).toBeVisible(); await expect(voiceEmpty).toContainText("서버에 등록된 음성 프리셋이 없습니다"); await expect(page.getByLabel("말하기 속도")).toHaveCount(0); await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0); await expect(page.locator("#set-voice").getByRole("radio")).toHaveCount(0); }); // checklist: settings-topbar-theme-toggle test("topbar theme button drives the same global store as the settings switch", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Topbar Theme"); await openLoadedSettings(page); const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" }); const initialDark = (await page.evaluate(() => document.documentElement.getAttribute("data-theme"))) === "dark"; await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark)); const topbarToggle = page.getByRole("button", { name: initialDark ? "라이트 모드로" : "다크 모드로", }); await topbarToggle.click(); const flipped = !initialDark; await expect .poll(() => page.evaluate(() => document.documentElement.getAttribute("data-theme"))) .toBe(flipped ? "dark" : "light"); // 설정 섹션의 스위치가 같은 스토어를 구독해 함께 뒤집힌다. await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped)); // 버튼 라벨도 반대 방향으로 갱신된다. await expect( page.getByRole("button", { name: flipped ? "라이트 모드로" : "다크 모드로" }), ).toBeVisible(); }); // checklist: settings-topbar-logout test("topbar logout ends the server session and returns to /login", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Logout"); await openLoadedSettings(page); const logoutPromise = page.waitForResponse(isApiResponse("POST", "/auth/logout"), { timeout: 10_000, }); await page.getByRole("button", { name: "로그아웃" }).click(); const logoutResponse = await logoutPromise; expect(logoutResponse.ok(), await logoutResponse.text()).toBeTruthy(); await expect(page).toHaveURL(/\/login$/); // 서버 세션이 실제로 종료됐는지 확인 — 쿠키로 /auth/me 재조회 시 401. const meAfter = await page.request.get("/api/auth/me"); expect(meAfter.status()).toBe(401); }); // checklist: settings-avatar-upload-missing // 소유자 결정 D1: 계정 섹션에서 온보딩과 같은 업로드 컨트롤로 아바타를 교체한다. // 업로드 성공 → PATCH /users/me 저장 → 미리보기·레일 아바타가 이미지로 전환된다. test("settings exposes an avatar upload control (briefing requirement)", async ({ page, }, testInfo) => { await devLogin(page, "learner", testInfo, "Sweep Avatar Upload"); await openLoadedSettings(page); // 업로드 전에는 이니셜 폴백만 렌더된다. const account = page.locator("#set-account"); await expect(account.locator(".vg-set__avatar img")).toHaveCount(0); const fileInput = account.locator("input[type='file']"); await expect(fileInput).toHaveAttribute("accept", "image/png,image/jpeg,image/webp"); const uploadPromise = page.waitForResponse(isApiResponse("POST", "/users/me/avatar"), { timeout: 10_000, }); const patchPromise = page.waitForResponse(isApiResponse("PATCH", "/users/me"), { timeout: 10_000, }); await fileInput.setInputFiles({ name: "sweep-settings-avatar.png", mimeType: "image/png", buffer: Buffer.from(PNG_1X1_BASE64, "base64"), }); const uploadResponse = await uploadPromise; expect(uploadResponse.ok(), await uploadResponse.text()).toBeTruthy(); const uploaded = (await uploadResponse.json()) as { avatar_url: string }; expect(uploaded.avatar_url.length).toBeGreaterThan(0); // 업로드 성공 직후 avatar_url 이 프로필 PATCH 로 저장된다. const patchResponse = await patchPromise; expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy(); // 계정 요약 미리보기가 업로드된 이미지를 가리키고, 레일 아바타도 이미지로 전환된다. const preview = account.locator(".vg-set__avatar img"); await expect(preview).toBeVisible(); expect(await preview.getAttribute("src")).toContain(uploaded.avatar_url); await expect(page.locator(".vg-set__rail-avatar img")).toHaveCount(1); // 서버 프로필 재조회로 avatar_url 영속 저장을 확인한다. const meRes = await page.request.get("/api/users/me"); expect(meRes.ok(), await meRes.text()).toBeTruthy(); const me = (await meRes.json()) as { avatar_url: string }; expect(me.avatar_url).toBe(uploaded.avatar_url); }); // checklist: settings-consent-management-missing // 소유자 결정 D2: 동의 섹션 — 실습 동의 상태·약관 버전 표시, learner 철회(2단계 확인) // → 차단 안내 → 재동의 복원까지 전체 흐름을 검증한다. test("settings exposes consent management and withdrawal UI (briefing requirement)", async ({ page, }, testInfo) => { // 주의: 공유 learner@hs.ac.kr 의 동의 상태를 파괴하지 않도록 반드시 테스트가 // 새로 만든 고유 dev-login 계정으로만 철회·재동의를 수행한다. await devLogin(page, "learner", testInfo, "Sweep Consent Mgmt"); const consentSeed = await page.request.post("/api/auth/consent", { data: { accepted: true }, }); expect(consentSeed.ok(), await consentSeed.text()).toBeTruthy(); await openLoadedSettings(page); // 레일 내비에 동의 항목이 있고 클릭하면 섹션으로 스크롤된다. const nav = page.getByRole("navigation", { name: "설정 섹션" }); await nav.getByRole("button", { name: "동의" }).click(); const consent = page.locator("#set-consent"); await expect(consent).toBeInViewport(); // 동의됨 상태: 실습 동의 시각 + 약관·개인정보 동의 버전이 표시된다. await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각"); await expect(consent.getByText("이용약관")).toBeVisible(); await expect(consent.getByText("개인정보 처리방침")).toBeVisible(); // 철회는 2단계: 첫 클릭은 인라인 확인만 띄우고, 확정 클릭에서만 DELETE 가 나간다. await consent.getByRole("button", { name: "실습 동의 철회" }).click(); const confirmButton = consent.getByRole("button", { name: "철회 확정" }); await expect(confirmButton).toBeVisible(); const withdrawPromise = page.waitForResponse(isApiResponse("DELETE", "/auth/consent"), { timeout: 10_000, }); await confirmButton.click(); const withdrawResponse = await withdrawPromise; expect(withdrawResponse.ok(), await withdrawResponse.text()).toBeTruthy(); // 차단 안내가 뜨고 서버 consent_at 이 null 로 비워진다. await expect( consent.getByText("새 회기 시작과 음성 연습이 차단됩니다."), ).toBeVisible(); const meAfterWithdraw = await page.request.get("/api/auth/me"); expect(meAfterWithdraw.ok(), await meAfterWithdraw.text()).toBeTruthy(); expect( ((await meAfterWithdraw.json()) as { consent_at: number | null }).consent_at, ).toBeNull(); // 다시 동의 → consent_at 복원 + 화면도 동의 시각 표기로 되돌아간다. const reacceptPromise = page.waitForResponse(isApiResponse("POST", "/auth/consent"), { timeout: 10_000, }); await consent.getByRole("button", { name: "다시 동의" }).click(); const reacceptResponse = await reacceptPromise; expect(reacceptResponse.ok(), await reacceptResponse.text()).toBeTruthy(); await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각"); const meAfterAccept = await page.request.get("/api/auth/me"); expect(meAfterAccept.ok(), await meAfterAccept.text()).toBeTruthy(); expect( ((await meAfterAccept.json()) as { consent_at: number | null }).consent_at, ).not.toBeNull(); }); });