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(options: ApiRequestOptions): Promise { 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(); // 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(); // 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( 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(options: ApiRequestOptions): Promise { 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((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); }); return options.responseSchema.parse(null); }, }; window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit"); render(); 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(); // 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( 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( 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>((file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 })); window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit"); render(); // 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 void>(); window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit"); render( 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( 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(); });