270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { expect, test, vi } from "vitest";
|
|
|
|
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
|
|
import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support";
|
|
import { audioDetail, character, createFormClient, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
|
|
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
|
|
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
|
|
|
function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
|
|
return {
|
|
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
|
requests.push({ body: options.body, method: options.method, path: options.path });
|
|
if (options.path === "/api/v2/admin/ai-characters/101") {
|
|
return options.responseSchema.parse({ ...character, isActive: false });
|
|
}
|
|
if (options.path === "/api/v2/admin/ai-characters/audio-content-themes") {
|
|
return options.responseSchema.parse([{ id: 7, theme: "힐링", image: "https://cdn.example.com/theme/healing.png" }]);
|
|
}
|
|
if (options.path === "/api/v2/admin/ai-characters/101/audio-contents/9001") {
|
|
return options.responseSchema.parse(audioDetail);
|
|
}
|
|
|
|
return options.responseSchema.parse(null);
|
|
},
|
|
};
|
|
}
|
|
|
|
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s through the submit button", async (value) => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
|
|
|
// When
|
|
const priceInput = await screen.findByLabelText("가격");
|
|
fireEvent.change(priceInput, { target: { value } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
expect(priceInput).toHaveValue(Number(value));
|
|
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
|
});
|
|
|
|
test("AudioContentFormPage allows the maximum edit price", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("가격"), { target: { value: "99999" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
|
|
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
|
|
});
|
|
|
|
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(
|
|
<AudioContentFormPage
|
|
apiClient={createFormClient(requests)}
|
|
characterId="101"
|
|
contentId="9001"
|
|
createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 })}
|
|
/>,
|
|
);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "수정 오디오" } });
|
|
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "수정 설명" } });
|
|
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "0" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
|
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText("오디오 테마")).not.toBeInTheDocument();
|
|
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
|
|
expect(screen.getByText("시리즈: 현재 수정 화면에서는 변경할 수 없습니다.")).toBeInTheDocument();
|
|
const updateBody = requireFormData(requests.at(-1)?.body);
|
|
expect(updateBody.has("contentFile")).toBe(false);
|
|
expect(await readJsonPart(updateBody.get("request"))).toEqual({
|
|
title: "수정 오디오",
|
|
detail: "수정 설명",
|
|
tags: "상담,힐링",
|
|
price: 0,
|
|
isAdult: false,
|
|
isPointAvailable: true,
|
|
isCommentAvailable: true,
|
|
});
|
|
|
|
// When
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
|
const dialog = screen.getByRole("alertdialog", { name: "오디오 콘텐츠 비활성화 확인" });
|
|
expect(dialog).toHaveTextContent("수정 오디오");
|
|
expect(dialog).toHaveTextContent("목록 노출만 중지하며 콘텐츠는 보관됩니다.");
|
|
expect(dialog).not.toHaveTextContent("완전 삭제");
|
|
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents"));
|
|
expect(window.history.state).toMatchObject({ successNotification: "오디오 콘텐츠를 비활성화했습니다." });
|
|
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toEqual({ isActive: false });
|
|
});
|
|
|
|
test("AudioContentFormPage sends one deactivate request while pending and shows retry guidance on failure", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
let rejectDeactivate: () => void = () => { throw new Error("Deactivate request was not started"); };
|
|
const client: ApiClient = {
|
|
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
|
requests.push({ body: options.body, method: options.method, path: options.path });
|
|
if (options.path === "/api/v2/admin/ai-characters/101") {
|
|
return options.responseSchema.parse(character);
|
|
}
|
|
if (options.path === "/api/v2/admin/ai-characters/audio-content-themes") {
|
|
return options.responseSchema.parse([{ id: 7, theme: "힐링", image: "https://cdn.example.com/theme/healing.png" }]);
|
|
}
|
|
if (options.path === "/api/v2/admin/ai-characters/101/audio-contents/9001" && options.method === undefined) {
|
|
return options.responseSchema.parse(audioDetail);
|
|
}
|
|
await new Promise<void>((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); });
|
|
return options.responseSchema.parse(null);
|
|
},
|
|
};
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(<AudioContentFormPage apiClient={client} characterId="101" contentId="9001" />);
|
|
fireEvent.click(await screen.findByRole("button", { name: "비활성화" }));
|
|
|
|
// When
|
|
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "처리 중" }));
|
|
rejectDeactivate();
|
|
|
|
// Then
|
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1);
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("비활성화하지 못했습니다. 다시 시도하세요.");
|
|
});
|
|
|
|
test.each([
|
|
["create", undefined, "/ai-characters/101/audio-contents/new"],
|
|
["edit", "9001", "/ai-characters/101/audio-contents/9001/edit"],
|
|
])("AudioContentFormPage blocks the %s route for inactive characters", async (_mode, contentId, path) => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", path);
|
|
render(<AudioContentFormPage apiClient={createInactiveFormClient(requests)} characterId="101" contentId={contentId} />);
|
|
|
|
// When
|
|
await screen.findByText("비활성화된 AI 캐릭터에는 오디오 콘텐츠를 저장할 수 없습니다.");
|
|
|
|
// Then
|
|
expect(screen.queryByRole("form", { name: /오디오 콘텐츠/ })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /생성|저장/ })).not.toBeInTheDocument();
|
|
expect(requests.filter((request) => request.method === "POST" || request.method === "PUT")).toHaveLength(0);
|
|
});
|
|
|
|
test("AudioContentFormPage blocks edit save while replacement cover crop source is preparing", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" createCropSource={() => new Promise(() => undefined)} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "pending.png", { type: "image/png" })] } });
|
|
|
|
// Then
|
|
await waitFor(() => expect(screen.getByRole("button", { name: "저장" })).toBeDisabled());
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
|
});
|
|
|
|
test("AudioContentFormPage keeps existing edit cover when replacement crop is canceled", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(
|
|
<AudioContentFormPage
|
|
apiClient={createFormClient(requests)}
|
|
characterId="101"
|
|
contentId="9001"
|
|
createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 })}
|
|
/>,
|
|
);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
|
|
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
|
|
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
|
const updateRequest = requests.find((request) => request.method === "PUT");
|
|
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
|
|
});
|
|
|
|
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
const createCropSource = vi.fn<(file: File) => Promise<CropSourceImage>>((file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 }));
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" createCropSource={createCropSource} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/jpeg" })] } });
|
|
|
|
// Then
|
|
expect(await screen.findByText("JPEG 또는 PNG 파일만 업로드하세요.")).toBeInTheDocument();
|
|
expect(createCropSource).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("AudioContentFormPage ignores stale edit cover sources and saves the latest applied replacement", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
const cropResolvers = new Map<string, (source: CropSourceImage) => void>();
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(
|
|
<AudioContentFormPage
|
|
apiClient={createFormClient(requests)}
|
|
characterId="101"
|
|
contentId="9001"
|
|
createCropSource={(file) => new Promise((resolve) => cropResolvers.set(file.name, resolve))}
|
|
renderCrop={(request) => Promise.resolve(request.file)}
|
|
/>,
|
|
);
|
|
const staleFile = new File(["stale"], "stale.png", { type: "image/png" });
|
|
const freshFile = new File(["fresh"], "fresh.png", { type: "image/png" });
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [staleFile] } });
|
|
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [freshFile] } });
|
|
cropResolvers.get("fresh.png")?.({ file: freshFile, height: 800, previewUrl: "blob:fresh", width: 800 });
|
|
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
|
await waitFor(() => expect(screen.getByText("fresh.png")).toBeInTheDocument());
|
|
cropResolvers.get("stale.png")?.({ file: staleFile, height: 800, previewUrl: "blob:stale", width: 800 });
|
|
|
|
// Then
|
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
|
const coverPart = requireFormData(requests.at(-1)?.body).get("coverImage");
|
|
if (!(coverPart instanceof File)) {
|
|
throw new TypeError("Expected cover image file");
|
|
}
|
|
expect(coverPart.name).toBe("fresh.png");
|
|
});
|
|
|
|
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" createCropSource={() => Promise.reject(new Error("preview failed"))} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "broken.png", { type: "image/png" })] } });
|
|
|
|
// Then
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
|
|
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled();
|
|
});
|