Files
voiceon-character-admin/tests/e2e/fan-talk.spec.ts
2026-08-01 01:30:52 +09:00

247 lines
13 KiB
TypeScript

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
const hasHorizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(hasHorizontalOverflow).toBe(false);
}
async function zoomTo200Percent(page: Page): Promise<void> {
await page.evaluate(() => {
document.documentElement.style.zoom = "2";
});
}
async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const blockingViolations = results.violations.filter(
(violation) => violation.impact === "critical" || violation.impact === "serious",
);
expect(blockingViolations).toEqual([]);
}
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
return;
}
await page.keyboard.press("Tab");
}
await expect(target).toBeFocused();
}
async function loginThroughMockMode(page: Page): Promise<void> {
await page.goto("/login");
await page.getByLabel("이메일").fill("admin@test.com");
await page.getByLabel("비밀번호").fill("password");
await page.getByRole("button", { name: "로그인" }).click();
await expect(page).toHaveURL(/\/ai-characters$/);
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
}
async function openFirstUnansweredFanTalk(page: Page): Promise<Locator> {
await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click();
return page.getByRole("dialog", { name: "FanTalk 답변" });
}
async function expectFanTalkListReady(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden();
}
test("FanTalk mock journey creates edits and deletes through list-backed endpoints", async ({ page }) => {
// Given
const fanTalkRequests: { readonly body: string | null; readonly method: string; readonly path: string; readonly search: string }[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith("/api/v2/admin/") && url.pathname.includes("/fan-talks")) {
fanTalkRequests.push({ body: request.postData(), method: request.method(), path: url.pathname, search: url.search });
}
});
await loginThroughMockMode(page);
// When
await page.goto("/ai-characters/101/fan-talks?page=0&size=20&status=pending&sort=createdAt");
// Then
await expect(page.getByRole("heading", { name: "FanTalk", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "검색어" })).toBeHidden();
await expect(page.locator("body")).toContainText("2026. 07. 28. 10:00");
await expect(page.locator("body")).toContainText("2026. 07. 28. 11:00");
await expect(page.locator("body")).not.toContainText("2026-07-28T01:00:00Z");
await expect(page.locator("body")).not.toContainText("2026-07-28T02:00:00Z");
expect(fanTalkRequests).toContainEqual({ body: null, method: "GET", path: "/api/v2/admin/ai-characters/101/fan-talks", search: "?page=0&size=20" });
// When
await page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" }).click();
const dialog = page.getByRole("dialog", { name: "FanTalk 답변" });
await dialog.getByLabel("답변 내용").fill("응원 고마워요.");
await dialog.getByRole("button", { name: "답변 등록" }).click();
// Then
const createStatus = dialog.getByRole("status", { name: "답변 저장 성공" });
await expect(createStatus).toContainText("답변이 등록되었습니다.");
await expect(createStatus).not.toContainText("fanTalk");
await expect(createStatus).not.toContainText("reply");
await expect(createStatus).not.toContainText("creator");
await expect(createStatus).not.toContainText("2026-07-28T04:00:00Z");
await expect(dialog).toContainText("응원 고마워요.");
await expect(dialog).toContainText("2026. 07. 28. 13:00");
await expect(dialog).not.toContainText("2026-07-28T04:00:00Z");
await expect(dialog.getByLabel("답변 내용")).toBeHidden();
await dialog.getByRole("button", { name: "닫기" }).click();
expect(fanTalkRequests.filter((request) => request.method === "POST" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001/replies" && request.body === JSON.stringify({ content: "응원 고마워요." }))).toHaveLength(1);
// When
await page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" }).click();
const createdReplyDialog = page.getByRole("dialog", { name: "FanTalk 답변" });
await createdReplyDialog.getByLabel("답변 내용").fill("방금 등록한 답변을 수정합니다.");
await createdReplyDialog.getByRole("button", { name: "답변 수정" }).click();
// Then
await expect(createdReplyDialog.getByRole("status", { name: "답변 저장 성공" })).toContainText("답변이 수정되었습니다.");
await createdReplyDialog.getByRole("button", { name: "닫기" }).click();
await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" })).toBeVisible();
expect(fanTalkRequests.filter((request) => request.method === "PUT" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001/replies/9001" && request.body === JSON.stringify({ content: "방금 등록한 답변을 수정합니다." }))).toHaveLength(1);
// When
await page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" }).click();
const editDialog = page.getByRole("dialog", { name: "FanTalk 답변" });
await expect(editDialog.getByLabel("답변 내용")).toHaveValue("이미 답변했습니다.");
await editDialog.getByLabel("답변 내용").fill("수정한 답변입니다.");
await editDialog.getByRole("button", { name: "답변 수정" }).click();
// Then
const updateStatus = editDialog.getByRole("status", { name: "답변 저장 성공" });
await expect(updateStatus).toContainText("답변이 수정되었습니다.");
await expect(updateStatus).not.toContainText("reply");
await expect(updateStatus).not.toContainText("2026-07-28T05:00:00Z");
expect(fanTalkRequests.filter((request) => request.method === "PUT" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7002/replies/7102" && request.body === JSON.stringify({ content: "수정한 답변입니다." }))).toHaveLength(1);
expect(fanTalkRequests.every((request) => request.body === null || !request.body.includes("isActive"))).toBe(true);
// When
await editDialog.getByRole("button", { name: "FanTalk 원글 삭제" }).click();
await page.getByRole("button", { name: "삭제 확인" }).click();
// Then
await expect(editDialog).toBeHidden();
await expect(page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })).toBeHidden();
expect(fanTalkRequests.filter((request) => request.method === "DELETE" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7002" && request.body === null)).toHaveLength(1);
expect(fanTalkRequests.some((request) => request.method === "GET" && request.path === "/api/v2/admin/ai-characters/101/fan-talks/7001")).toBe(false);
expect(fanTalkRequests.every((request) => !request.search.includes("status=") && !request.search.includes("sort="))).toBe(true);
});
for (const width of [320, 768, 1280] as const) {
test(`FanTalk create edit and delete stay available at ${width}px`, async ({ page }) => {
// Given
await page.setViewportSize({ width, height: 640 });
await loginThroughMockMode(page);
// When / Then
await page.goto("/ai-characters/101/fan-talks");
await expectFanTalkListReady(page);
await expectNoHorizontalOverflow(page);
const dialog = await openFirstUnansweredFanTalk(page);
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel("답변 내용")).toBeVisible();
await expect(dialog.getByRole("button", { name: "답변 등록" })).toBeVisible();
await dialog.getByLabel("답변 내용").fill(`${width}px 응답입니다.`);
await dialog.getByRole("button", { name: "답변 등록" }).click();
await expect(dialog).toContainText(`${width}px 응답입니다.`);
await expect(dialog.getByLabel("답변 내용")).toBeHidden();
await dialog.getByRole("button", { name: "닫기" }).click();
await page.getByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" }).click();
const editDialog = page.getByRole("dialog", { name: "FanTalk 답변" });
await expect(editDialog.getByRole("button", { name: "답변 수정" })).toBeVisible();
await expect(editDialog.getByRole("button", { name: "FanTalk 원글 삭제" })).toBeVisible();
await editDialog.getByLabel("답변 내용").fill(`${width}px 수정입니다.`);
await editDialog.getByRole("button", { name: "답변 수정" }).click();
await expect(editDialog.getByRole("status", { name: "답변 저장 성공" })).toContainText("답변이 수정되었습니다.");
await editDialog.getByRole("button", { name: "FanTalk 원글 삭제" }).click();
await page.getByRole("button", { name: "삭제 확인" }).click();
await expect(editDialog).toBeHidden();
});
}
test("320px FanTalk reply sheet keeps input and submit usable in a keyboard viewport", async ({ page }) => {
// Given
await page.setViewportSize({ width: 320, height: 480 });
await loginThroughMockMode(page);
await page.goto("/ai-characters/101/fan-talks");
// When / Then
const dialog = await openFirstUnansweredFanTalk(page);
const replyInput = dialog.getByLabel("답변 내용");
const submitButton = dialog.getByRole("button", { name: "답변 등록" });
await expect(replyInput).toBeInViewport();
await replyInput.focus();
await expect(submitButton).toBeInViewport();
await replyInput.fill("좁은 화면에서도 등록합니다.");
await submitButton.click();
await expect(dialog).toContainText("좁은 화면에서도 등록합니다.");
await expectNoHorizontalOverflow(page);
});
test("keyboard-only FanTalk flow opens sheet creates reply and restores focus", async ({ browserName, page }) => {
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus buttons in this keyboard path.");
// Given
await page.setViewportSize({ width: 1280, height: 900 });
await loginThroughMockMode(page);
await page.goto("/ai-characters/101/fan-talks");
const opener = page.getByRole("button", { name: "첫 번째 응원입니다. 답변하기" });
// When / Then
await pressTabUntilFocused(page, opener);
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog", { name: "FanTalk 답변" });
const closeButton = dialog.getByRole("button", { name: "닫기" });
await expect(closeButton).toBeFocused();
const replyInput = dialog.getByLabel("답변 내용");
await pressTabUntilFocused(page, replyInput);
await page.keyboard.type("키보드로 작성한 답변입니다.");
await pressTabUntilFocused(page, dialog.getByRole("button", { name: "답변 등록" }));
await page.keyboard.press("Enter");
await expect(dialog.getByRole("status", { name: "답변 저장 성공" })).toBeVisible();
await expect(dialog).toContainText("키보드로 작성한 답변입니다.");
await expect(replyInput).toBeHidden();
await pressTabUntilFocused(page, closeButton);
await page.keyboard.press("Enter");
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "첫 번째 응원입니다. 답변 보기" })).toBeFocused();
});
test("FanTalk route keeps 200 percent zoom and axe coverage across viewport widths", async ({ page }) => {
// Given
await loginThroughMockMode(page);
for (const width of [320, 768, 1280] as const) {
await page.setViewportSize({ width, height: 900 });
await page.goto("/ai-characters/101/fan-talks");
// When
await zoomTo200Percent(page);
// Then
await expectNoHorizontalOverflow(page);
await expectNoCriticalOrSeriousAxeViolations(page);
}
});