713 lines
30 KiB
TypeScript
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");
|
|
});
|
|
});
|