/* ===================================================================== uc-settings-consent.spec.ts — 설정·동의 관리 유스케이스 여정. 목적: 학습자가 /settings 에서 실습 동의를 관리(2단계 철회 확인 → 확정, 철회 후 재동의), 아바타 교체, 표시 이름 수정(빈값·과도한 길이 거절), 음성 프리셋 변경·저장, 지원 티켓 확인·작성까지 실제 사용자 행동 단위로 검증한다. 저장 실패 시 기존 값 보존도 함께 본다. 근거: apps/web/src/pages/Settings.tsx 의 실제 UI 계약(버튼 문구·testid· role)과 apps/web/src/lib/api.ts / api.gen.ts 의 응답 타입. 중복 회피: settings.spec.ts(실 API 저장 왕복), full-sweep-settings.spec.ts (가드·빈 상태·테마·실 API 아바타 최초 업로드·실 API 동의 왕복)와 달리 여기서는 전부 route fixture 로 각 단계의 UI 계약을 고립 검증한다. 실 API 없이 route fixture 만으로 동작하며, 실제 AI 엔진 턴 생성은 없다. ===================================================================== */ import { test, expect, type Page, type Route } from "@playwright/test"; const CONSENT_AT = 1785542400; // 2026-08-01T00:00:00Z (초 단위 epoch) const REACCEPT_AT = 1787000000; const TERMS_AT = 1780000000; /** 1x1 PNG — 아바타 업로드용 최소 픽셀. */ const PNG_1X1_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg=="; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body) }; } async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); } interface FixtureOptions { role?: "learner" | "teacher"; consentAt?: number | null; avatarUrl?: string; tickets?: Array>; /** PATCH /users/me 응답 상태 — 422 등 거절 시나리오용. */ profilePatchStatus?: number; /** PATCH /users/me/preferences 응답 상태. */ preferencesPatchStatus?: number; /** POST /users/me/avatar 응답 상태. */ avatarUploadStatus?: number; /** DELETE /auth/consent 응답 상태. */ consentWithdrawStatus?: number; } interface FixtureState { consentDeletes: number; consentPosts: number; } function buildProfile(opts: FixtureOptions) { return { user_id: "e2e-uc-settings-1", email: "uc.settings@hs.ac.kr", display_name: "설정 유스케이스 학습자", legal_name: "설정 유스케이스 학습자", nickname: "설정학습자", self_introduction: "", role: opts.role ?? "learner", cohort_ids: [], affiliation: "한신대학교", department: "상담심리학과", grade_level: "3학년", phone: "010-0000-0000", contact_address: "경기도 오산시", avatar_url: opts.avatarUrl ?? "/static/avatars/uc-old-avatar.png", onboarding_required: false, onboarding_completed_at: TERMS_AT, terms_version: "2026-06-01", terms_agreed_at: TERMS_AT, privacy_version: "2026-06-01", privacy_agreed_at: TERMS_AT, }; } const VOICE_PRESETS = [ { id: "soft-young-fem", name: "차분한 목소리", voice_id: "ko-soft-01", desc: "낮은 톤의 안정적인 발화", persona_hint: "차분한 20대 내담자", }, { id: "bright-mid-fem", name: "밝은 목소리", voice_id: "ko-bright-02", desc: "밝고 빠른 발화", persona_hint: "활발한 30대 내담자", }, ]; function buildPreferences() { return { theme: "light", voice_preset_id: VOICE_PRESETS[0].id, voice_rate: 1, notifications: { session_done: true, safety_signal: true, learner_progress: true, account_approval: true, product_news: false, }, }; } function buildTicket(overrides: Record) { return { ticket_id: "uc-ticket-1", status: "open", category: "session_review", priority: "normal", subject: "리뷰 결과가 열리지 않아요", resolution_note: "", resolved_at: null, assigned_group: "", source_path: "/settings", created_at: TERMS_AT, updated_at: TERMS_AT, ...overrides, }; } /** catch-all → 개별 fixture(나중 등록이 우선) 순서로 설정 화면 전체를 목킹한다. */ async function mountSettingsFixtures( page: Page, opts: FixtureOptions = {}, ): Promise { const state: FixtureState = { consentDeletes: 0, consentPosts: 0 }; const consentAt = opts.consentAt === undefined ? CONSENT_AT : opts.consentAt; const role = opts.role ?? "learner"; const profile = buildProfile(opts); await routeUnmockedApi(page); await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: profile.user_id, email: profile.email, role, admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: consentAt, onboarding_completed_at: TERMS_AT, nickname: profile.nickname, display_name: profile.display_name, self_introduction: "", avatar_url: profile.avatar_url, }), ), ); await page.route("**/api/auth/consent", (route: Route) => { const method = route.request().method(); if (method === "DELETE") { state.consentDeletes += 1; const status = opts.consentWithdrawStatus ?? 200; if (status >= 400) { return route.fulfill(jsonRoute({ detail: "동의 철회 처리에 실패했습니다. (e2e fixture)" }, status)); } return route.fulfill(jsonRoute({ consent_at: null })); } if (method === "POST") { state.consentPosts += 1; return route.fulfill(jsonRoute({ consent_at: REACCEPT_AT })); } return route.fulfill(jsonRoute({ detail: "method not mocked" }, 405)); }); await page.route("**/api/users/me", (route: Route) => { const method = route.request().method(); if (method === "GET") return route.fulfill(jsonRoute(profile)); if (method === "PATCH") { const status = opts.profilePatchStatus ?? 200; if (status >= 400) { return route.fulfill( jsonRoute({ detail: "표시 이름이 유효하지 않습니다. (e2e fixture)" }, status), ); } const body = route.request().postDataJSON() as Record; return route.fulfill(jsonRoute({ ...profile, ...body })); } return route.fulfill(jsonRoute({ detail: "method not mocked" }, 405)); }); await page.route("**/api/users/me/preferences", (route: Route) => { const method = route.request().method(); if (method === "GET") return route.fulfill(jsonRoute(buildPreferences())); if (method === "PATCH") { const status = opts.preferencesPatchStatus ?? 200; if (status >= 400) { return route.fulfill( jsonRoute({ detail: "환경 설정 저장에 실패했습니다. (e2e fixture)" }, status), ); } const body = route.request().postDataJSON() as Record; return route.fulfill(jsonRoute({ ...buildPreferences(), ...body })); } return route.fulfill(jsonRoute({ detail: "method not mocked" }, 405)); }); await page.route("**/api/users/me/voice-presets", (route) => route.fulfill(jsonRoute(VOICE_PRESETS)), ); await page.route("**/api/users/support-tickets", (route) => route.fulfill( jsonRoute({ source: "database", durable: true, generated_at: TERMS_AT, tickets: opts.tickets ?? [], }), ), ); await page.route("**/api/users/me/avatar", (route: Route) => { const status = opts.avatarUploadStatus ?? 200; if (status >= 400) { return route.fulfill(jsonRoute({ detail: "업로드 실패 (e2e fixture)" }, status)); } return route.fulfill(jsonRoute({ avatar_url: "/static/avatars/uc-new-avatar.png" })); }); return state; } /** /settings 진입 후 프로필·환경설정 도착(저장 버튼 렌더)까지 대기. */ async function openLoadedSettings(page: Page) { await page.goto("/settings"); await expect(page).toHaveURL(/\/settings$/); await expect(page.locator(".vg-set")).toBeVisible(); 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, }); } /** React 상태 반영 두 프레임 대기 — 임의 sleep 대신 rAF 2회. */ async function waitTwoFrames(page: Page) { await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); } test.describe("설정·동의 관리 유스케이스", () => { // usecase: 학습자가 동의 섹션에서 실습 동의 시각과 약관·개인정보 동의 이력을 확인한다 test("학습자가 동의 섹션에서 실습 동의 시각과 약관·개인정보 동의 이력을 확인한다", async ({ page, }) => { await mountSettingsFixtures(page); await openLoadedSettings(page); const consent = page.locator("#set-consent"); // 실습 동의: 연도까지 포함한 동의 시각이 표시된다(감사 추적 계약). const practice = consent.getByTestId("settings-consent-practice"); await expect(practice).toContainText("동의 시각"); await expect(practice).toContainText("2026"); // 약관·개인정보: 버전과 동의 시각이 함께 표시된다. await expect(consent.getByText("이용약관")).toBeVisible(); await expect(consent.getByText("개인정보 처리방침")).toBeVisible(); await expect(consent.getByText(/버전 2026-06-01/).first()).toBeVisible(); // 동의된 상태에서는 철회 진입 버튼이 노출된다. await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toBeVisible(); }); // usecase: 학습자가 실습 동의 철회 버튼을 눌러 1단계 확인 안내를 연다(아직 철회 요청은 전송되지 않는다) test("철회 1단계 — 철회 버튼 클릭 시 확인 안내만 뜨고 철회 요청은 전송되지 않는다", async ({ page, }) => { const state = await mountSettingsFixtures(page); await openLoadedSettings(page); const consent = page.locator("#set-consent"); await consent.getByRole("button", { name: "실습 동의 철회" }).click(); // 인라인 확인 블록: 경고 문구 + 철회 확정/취소. await expect(consent.getByText("정말 철회할까요?")).toBeVisible(); await expect(consent.getByRole("button", { name: "철회 확정" })).toBeVisible(); await expect(consent.getByRole("button", { name: "취소" })).toBeVisible(); // 1단계에서는 DELETE /auth/consent 가 나가지 않고 동의 시각도 유지된다. expect(state.consentDeletes).toBe(0); await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각"); }); // usecase: 학습자가 철회 확인 단계에서 취소를 눌러 동의를 유지한다 test("철회 확인 단계에서 취소하면 동의가 유지되고 확인 블록이 닫힌다", async ({ page }) => { const state = await mountSettingsFixtures(page); await openLoadedSettings(page); const consent = page.locator("#set-consent"); await consent.getByRole("button", { name: "실습 동의 철회" }).click(); await expect(consent.getByRole("button", { name: "철회 확정" })).toBeVisible(); await consent.getByRole("button", { name: "취소" }).click(); await expect(consent.getByRole("button", { name: "철회 확정" })).toHaveCount(0); await expect(consent.getByText("정말 철회할까요?")).toHaveCount(0); // 동의 상태 그대로: 철회 진입 버튼과 동의 시각이 다시 보인다. await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toBeVisible(); await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각"); expect(state.consentDeletes).toBe(0); }); // usecase: 학습자가 철회를 확정해 실습 동의를 실제로 철회한다 test("철회 확정 시 철회 요청이 전송되고 차단 안내와 다시 동의 버튼이 표시된다", async ({ page, }) => { const state = await mountSettingsFixtures(page); await openLoadedSettings(page); const consent = page.locator("#set-consent"); await consent.getByRole("button", { name: "실습 동의 철회" }).click(); const deletePromise = page.waitForResponse( (response) => response.request().method() === "DELETE" && new URL(response.url()).pathname.endsWith("/auth/consent"), { timeout: 10_000 }, ); await consent.getByRole("button", { name: "철회 확정" }).click(); await deletePromise; expect(state.consentDeletes).toBe(1); // 철회됨: 미동의 표기 + 차단 안내(role=status) + 재동의 진입 버튼. await expect(consent.getByTestId("settings-consent-practice")).toContainText( "미동의 상태입니다", ); const blockedNotice = consent.getByRole("status"); await expect(blockedNotice).toContainText("새 회기 시작과 음성 연습이 차단됩니다"); await expect(consent.getByRole("button", { name: "다시 동의" })).toBeVisible(); await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toHaveCount(0); }); // usecase: 철회한 학습자가 설정 화면에서 다시 동의해 실습 상태를 복원한다 test("철회 후 다시 동의를 누르면 동의가 복원되어 동의 시각이 다시 표시된다", async ({ page, }) => { const state = await mountSettingsFixtures(page); await openLoadedSettings(page); const consent = page.locator("#set-consent"); await consent.getByRole("button", { name: "실습 동의 철회" }).click(); await consent.getByRole("button", { name: "철회 확정" }).click(); await expect(consent.getByRole("button", { name: "다시 동의" })).toBeVisible(); const postPromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname.endsWith("/auth/consent"), { timeout: 10_000 }, ); await consent.getByRole("button", { name: "다시 동의" }).click(); await postPromise; expect(state.consentPosts).toBe(1); // 복원: 동의 시각 표기와 철회 진입 버튼이 돌아온다. await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각"); await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toBeVisible(); await expect(consent.getByRole("button", { name: "다시 동의" })).toHaveCount(0); }); // usecase: 철회 요청이 서버에서 거절되면 학습자는 오류 안내를 보고 동의 상태를 유지한다 test("철회 요청이 실패하면 오류 안내가 뜨고 동의 상태가 보존된다", async ({ page }) => { await mountSettingsFixtures(page, { consentWithdrawStatus: 500 }); await openLoadedSettings(page); const consent = page.locator("#set-consent"); await consent.getByRole("button", { name: "실습 동의 철회" }).click(); await consent.getByRole("button", { name: "철회 확정" }).click(); // 실패: role=alert 경고가 뜨고, 동의 시각 표기는 그대로다. const errorState = consent.locator(".vg-set__state--warn"); await expect(errorState).toBeVisible(); await expect(errorState).toHaveAttribute("role", "alert"); await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각"); // 확인 블록이 유지되어 재시도 또는 취소를 이어갈 수 있다. await expect(consent.getByRole("button", { name: "철회 확정" })).toBeVisible(); await expect(consent.getByRole("button", { name: "취소" })).toBeVisible(); }); // usecase: 교수자가 동의 섹션을 열면 실습 동의가 학습자 전용임을 확인한다 test("교수자 계정에는 철회 버튼 없이 학습자 전용 안내가 표시된다", async ({ page }) => { await mountSettingsFixtures(page, { role: "teacher", consentAt: null }); await openLoadedSettings(page); const consent = page.locator("#set-consent"); await expect(consent.getByTestId("settings-consent-practice")).toContainText( "학습자 전용 항목입니다", ); await expect(consent.getByText("학습자 전용", { exact: true })).toBeVisible(); await expect(consent.getByRole("button", { name: "실습 동의 철회" })).toHaveCount(0); // 비학습자에게는 재동의 블록도 노출되지 않는다. await expect(consent.getByRole("button", { name: "다시 동의" })).toHaveCount(0); }); // usecase: 학습자가 아바타 변경 버튼으로 새 이미지를 선택해 저장한다 test("아바타 변경 — 파일 선택 시 업로드·프로필 저장 후 미리보기가 새 이미지로 바뀐다", async ({ page, }) => { await mountSettingsFixtures(page); await openLoadedSettings(page); const account = page.locator("#set-account"); // 기존 아바타가 있어 버튼 라벨은 '아바타 변경'이다. await expect(account.getByRole("button", { name: "아바타 변경" })).toBeVisible(); const fileInput = account.locator("input[type='file']"); await expect(fileInput).toHaveAttribute("accept", "image/png,image/jpeg,image/webp"); const patchPromise = page.waitForResponse( (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname.endsWith("/users/me"), { timeout: 10_000 }, ); await fileInput.setInputFiles({ name: "uc-settings-avatar.png", mimeType: "image/png", buffer: Buffer.from(PNG_1X1_BASE64, "base64"), }); const patchResponse = await patchPromise; // 업로드 성공 직후 avatar_url 이 프로필 PATCH 로 저장된다. const patchBody = patchResponse.request().postDataJSON() as { avatar_url?: string }; expect(patchBody.avatar_url).toBe("/static/avatars/uc-new-avatar.png"); // 계정 미리보기가 새 이미지로 전환되고 저장됨 플래시가 뜬다. const preview = account.locator(".vg-set__avatar img"); await expect(preview).toBeVisible(); expect(await preview.getAttribute("src")).toContain("uc-new-avatar.png"); await expect(account.locator(".vg-set__saved")).toContainText("저장됨"); }); // usecase: 아바타 업로드가 실패하면 학습자는 오류 안내를 보고 기존 아바타를 유지한다 test("아바타 업로드 실패 시 오류 안내가 뜨고 기존 아바타가 유지된다", async ({ page }) => { await mountSettingsFixtures(page, { avatarUploadStatus: 500 }); await openLoadedSettings(page); const account = page.locator("#set-account"); const preview = account.locator(".vg-set__avatar img"); const originalSrc = await preview.getAttribute("src"); expect(originalSrc).toContain("uc-old-avatar.png"); await account.locator("input[type='file']").setInputFiles({ name: "uc-settings-avatar-fail.png", mimeType: "image/png", buffer: Buffer.from(PNG_1X1_BASE64, "base64"), }); // 실패 안내는 role=alert 로 표시되고 파일 규격을 다시 알려준다. const avatarAlert = account.locator(".vg-set__state--warn"); await expect(avatarAlert).toBeVisible(); await expect(avatarAlert).toHaveAttribute("role", "alert"); await expect(avatarAlert).toContainText("아바타 업로드에 실패했습니다"); // 기존 아바타는 그대로이고 거짓 저장됨 표시는 없다. expect(await preview.getAttribute("src")).toBe(originalSrc); await expect(account.locator(".vg-set__saved")).toHaveCount(0); }); // usecase: 학습자가 표시 이름을 빈값으로 저장 시도했다가 서버 거절을 받는다 test("표시 이름을 빈값으로 저장하면 거짓 저장됨 표시 없이 입력 상태가 유지된다", async ({ page, }) => { await mountSettingsFixtures(page, { profilePatchStatus: 422 }); await openLoadedSettings(page); const account = page.locator("#set-account"); const nameInput = account.getByLabel("표시 이름"); await expect(nameInput).toHaveValue("설정 유스케이스 학습자"); await nameInput.fill(""); await expect(nameInput).toHaveValue(""); await waitTwoFrames(page); const patchPromise = page.waitForResponse( (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname.endsWith("/users/me"), { timeout: 10_000 }, ); await account.locator(".vg-set__foot .vg-btn").click(); const patchResponse = await patchPromise; expect(patchResponse.status()).toBe(422); await waitTwoFrames(page); // 거절된 저장에 저장됨 플래시가 떠서는 안 되고, 입력값은 사용자가 둔 그대로다. await expect(account.locator(".vg-set__saved")).toHaveCount(0); await expect(nameInput).toHaveValue(""); }); // usecase: 학습자가 과도하게 긴 표시 이름을 저장 시도했다가 서버 거절을 받는다 test("과도하게 긴 표시 이름 저장이 거절되면 저장됨 표시가 뜨지 않는다", async ({ page }) => { await mountSettingsFixtures(page, { profilePatchStatus: 422 }); await openLoadedSettings(page); const account = page.locator("#set-account"); const nameInput = account.getByLabel("표시 이름"); const overlongName = "가나다라마바사아자차".repeat(20); // 200자 await nameInput.fill(overlongName); await expect(nameInput).toHaveValue(overlongName); await waitTwoFrames(page); const patchPromise = page.waitForResponse( (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname.endsWith("/users/me"), { timeout: 10_000 }, ); await account.locator(".vg-set__foot .vg-btn").click(); const patchResponse = await patchPromise; expect(patchResponse.status()).toBe(422); // 전송 본문에는 사용자가 입력한 값이 그대로 담긴다(클라이언트 임의 절단 없음). const body = patchResponse.request().postDataJSON() as { display_name?: string }; expect(body.display_name).toBe(overlongName); await waitTwoFrames(page); await expect(account.locator(".vg-set__saved")).toHaveCount(0); }); // usecase: 학습자가 지원 요청 섹션에서 접수한 티켓들의 상태·분류·해결 메모를 확인한다 test("지원 요청 목록 — 접수 티켓의 상태·분류·우선순위·해결 메모가 표시된다", async ({ page, }) => { await mountSettingsFixtures(page, { tickets: [ buildTicket({ ticket_id: "uc-ticket-resolved", status: "resolved", category: "voice_browser", priority: "high", subject: "음성 재생이 끊깁니다", resolution_note: "브라우저 권한 재허용으로 해결했습니다.", assigned_group: "서비스 운영자", resolved_at: TERMS_AT + 4000, }), buildTicket({ ticket_id: "uc-ticket-open", status: "open", category: "session_review", priority: "normal", subject: "리뷰 결과가 열리지 않아요", }), ], }); await openLoadedSettings(page); const support = page.locator("#set-support"); await expect(support.getByText("2건")).toBeVisible(); const tickets = support.locator(".vg-set__ticket"); await expect(tickets).toHaveCount(2); const resolvedTicket = tickets.filter({ hasText: "음성 재생이 끊깁니다" }); await expect(resolvedTicket.getByText("해결", { exact: true })).toBeVisible(); await expect(resolvedTicket).toContainText("음성·브라우저"); await expect(resolvedTicket).toContainText("높음"); await expect(resolvedTicket).toContainText("해결 메모"); await expect(resolvedTicket).toContainText("브라우저 권한 재허용으로 해결했습니다."); const openTicket = tickets.filter({ hasText: "리뷰 결과가 열리지 않아요" }); await expect(openTicket.getByText("접수", { exact: true })).toBeVisible(); await expect(openTicket).toContainText("처리 메모가 아직 없습니다."); }); // usecase: 학습자가 지원 요청 섹션에서 새 지원 티켓을 작성해 전송한다 test("지원 요청 섹션에서 새 지원 티켓을 작성해 전송할 수 있다", async ({ page }) => { await mountSettingsFixtures(page); await openLoadedSettings(page); // 서버 계약에는 POST /users/support-tickets(UserSupportTicketRequest)가 있으나 // 설정 화면에 작성 진입점이 있는지 검증한다. 사용자는 접수 목록을 보는 곳에서 // 새 요청도 접수할 수 있기를 기대한다. const support = page.locator("#set-support"); const composeEntry = support.getByRole("button", { name: /작성|접수|보내기|새 (요청|문의|티켓)/, }); await expect(composeEntry).toBeVisible(); await composeEntry.click(); const subjectInput = page.getByRole("textbox", { name: /제목|요약/ }); await expect(subjectInput).toBeVisible(); await subjectInput.fill("설정 화면에서 접수하는 문의"); const bodyInput = page.getByRole("textbox", { name: /내용|본문|상세/ }); await bodyInput.fill("음성 프리셋 저장이 간헐적으로 실패합니다."); const postPromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname.endsWith("/users/support-tickets"), { timeout: 10_000 }, ); await page.getByRole("button", { name: /전송|접수|제출/ }).click(); await postPromise; }); // usecase: 학습자가 음성 프리셋을 바꾸고 저장한다 test("음성 프리셋 변경 — 다른 프리셋 선택 후 저장하면 새 프리셋이 저장 요청에 담긴다", async ({ page, }) => { await mountSettingsFixtures(page); await openLoadedSettings(page); const voice = page.locator("#set-voice"); const radios = voice.getByRole("radio"); await expect(radios).toHaveCount(2); // 초기 선택은 서버 환경설정의 프리셋이다. await expect(radios.nth(0)).toHaveAttribute("aria-checked", "true"); await expect(radios.nth(1)).toHaveAttribute("aria-checked", "false"); await radios.nth(1).click(); await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true"); await expect(radios.nth(0)).toHaveAttribute("aria-checked", "false"); const patchPromise = page.waitForResponse( (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname.endsWith("/users/me/preferences"), { timeout: 10_000 }, ); await voice.getByRole("button", { name: "음성 저장" }).click(); const patchResponse = await patchPromise; const body = patchResponse.request().postDataJSON() as { voice_preset_id?: string }; expect(body.voice_preset_id).toBe(VOICE_PRESETS[1].id); await expect(voice.locator(".vg-set__saved")).toContainText("저장됨"); }); // usecase: 음성 저장이 실패해도 학습자의 선택 상태가 사라지지 않고 거짓 저장 표시가 없다 test("음성 저장 실패 시 저장됨 표시가 뜨지 않고 선택 상태가 유지된다", async ({ page }) => { await mountSettingsFixtures(page, { preferencesPatchStatus: 500 }); await openLoadedSettings(page); const voice = page.locator("#set-voice"); const radios = voice.getByRole("radio"); await radios.nth(1).click(); await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true"); const patchPromise = page.waitForResponse( (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname.endsWith("/users/me/preferences"), { timeout: 10_000 }, ); await voice.getByRole("button", { name: "음성 저장" }).click(); const patchResponse = await patchPromise; expect(patchResponse.status()).toBe(500); await waitTwoFrames(page); // 실패한 저장에 저장됨 플래시가 떠서는 안 되고, 사용자의 선택은 남아 있어야 한다. await expect(voice.locator(".vg-set__saved")).toHaveCount(0); await expect(radios.nth(1)).toHaveAttribute("aria-checked", "true"); }); });