vignette/apps/web/e2e/uc-settings-consent.spec.ts

713 lines
30 KiB
TypeScript

/* =====================================================================
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<Record<string, unknown>>;
/** 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<string, unknown>) {
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<FixtureState> {
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/static/avatars/*.png", (route) =>
route.fulfill({
status: 200,
contentType: "image/png",
body: Buffer.from(PNG_1X1_BASE64, "base64"),
}),
);
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<string, unknown>;
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<string, unknown>;
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<void>((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: DB에 남은 아바타 경로의 실제 파일이 사라져 404여도 깨진 이미지 아이콘 대신 이니셜을 유지한다
test("아바타 파일 404 시 톱바와 설정 미리보기 모두 이니셜 폴백을 유지한다", async ({ page }) => {
const staleAvatar = "/uploads/profile-avatars/stale-avatar.jpg";
await mountSettingsFixtures(page, { avatarUrl: staleAvatar });
await page.route("**/api/uploads/profile-avatars/stale-avatar.jpg", (route) =>
route.fulfill(jsonRoute({ detail: "Not Found" }, 404)),
);
await openLoadedSettings(page);
const avatarFrames = page.locator(
".vg-topbar__avatar, .vg-set__rail-avatar, .vg-set__avatar",
);
await expect(avatarFrames).toHaveCount(3);
for (const frame of await avatarFrames.all()) {
await expect(frame.locator("[data-image-fallback='failed']")).toHaveText("설");
await expect(frame.locator("img")).toHaveCount(0);
}
});
// usecase: 저장값이 웹 이미지가 아닌 scheme이면 요청하지 않고 즉시 안전한 이니셜 상태로 닫힌다
test("빈 URL과 비웹 scheme은 네트워크 요청 없이 이니셜 폴백으로 정규화한다", async ({ page }) => {
let unsafeRequestCount = 0;
page.on("request", (request) => {
if (/javascript:|data:|file:/i.test(request.url())) unsafeRequestCount += 1;
});
await mountSettingsFixtures(page, { avatarUrl: " javascript:alert(1) " });
await openLoadedSettings(page);
const avatarFrames = page.locator(
".vg-topbar__avatar, .vg-set__rail-avatar, .vg-set__avatar",
);
for (const frame of await avatarFrames.all()) {
await expect(frame.locator("[data-image-fallback='empty']")).toHaveText("설");
await expect(frame.locator("img")).toHaveCount(0);
}
expect(unsafeRequestCount).toBe(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");
});
});