import { fireEvent, screen, waitFor } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; import { ApiError } from "@/shared/api/api-error"; import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy"; import { fileWithSize, readJsonPart, renderAudioForm, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support"; import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support"; import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm"; import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; class FakeXMLHttpRequest { static instances: FakeXMLHttpRequest[] = []; readonly headers = new Map(); readonly upload = new EventTarget(); body: XMLHttpRequestBodyInit | null = null; method = ""; onerror: ((event: Event) => void) | null = null; onload: ((event: Event) => void) | null = null; responseText = ""; status = 0; url = ""; constructor() { FakeXMLHttpRequest.instances.push(this); } abort(): void {} open(method: string, url: string): void { this.method = method; this.url = url; } setRequestHeader(name: string, value: string): void { this.headers.set(name, value); } send(body: XMLHttpRequestBodyInit | null): void { this.body = body; } progress(loaded: number, total: number): void { this.upload.dispatchEvent(new ProgressEvent("progress", { lengthComputable: true, loaded, total })); } succeed(contentId: number): void { this.status = 200; this.responseText = JSON.stringify({ success: true, message: null, data: { contentId }, errorProperty: null }); this.onload?.(new Event("load")); } } afterEach(() => { FakeXMLHttpRequest.instances = []; vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); async function fillValidCreateForm(): Promise { fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "재시도 오디오" } }); fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "재시도 설명" } }); fireEvent.change(screen.getByLabelText("태그"), { target: { value: "재시도" } }); fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } }); fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } }); fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } }); fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } }); fireEvent.click(await screen.findByRole("button", { name: "적용" })); await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument()); } function requireFile(part: FormDataEntryValue | null): File { if (part instanceof File) { return part; } throw new TypeError("Expected File part"); } test("AudioContentFormPage create uses the upload adapter even when the default request is used", async () => { // Given const requests: CapturedRequest[] = []; vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com"); vi.stubEnv("VITE_API_MODE", "server"); vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests }); // When await fillValidCreateForm(); fireEvent.click(screen.getByRole("button", { name: "생성" })); // Then await waitFor(() => expect(FakeXMLHttpRequest.instances).toHaveLength(1)); const request = FakeXMLHttpRequest.instances[0]; if (request === undefined) { throw new Error("expected upload request"); } request.progress(512, 1024); await waitFor(() => expect(screen.getByLabelText("업로드 진행률")).toHaveAttribute("aria-valuenow", "50")); request.succeed(9301); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301")); expect(request.method).toBe("POST"); expect(request.url).toBe("https://api.example.com/api/v2/admin/ai-characters/101/audio-contents"); expect(requests.some((item) => item.path === "/api/v2/admin/ai-characters/101/audio-contents" && item.method === "POST")).toBe(false); }); test("AudioContentFormPage uses the shared audio MIME policy for file picker accept", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests }); // Then expect(await screen.findByLabelText("오디오 파일")).toHaveAttribute("accept", AUDIO_FILE_POLICY.allowedMimeTypes.join(",")); }); test("AudioContentFormPage marks canceled upload without a form error", async () => { // Given const requests: CapturedRequest[] = []; const cancelingUpload: UploadAudioContentRequest = (options) => new Promise((_resolve, reject) => { options.signal?.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")), { once: true }); }); window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, uploadAudioContentRequest: cancelingUpload }); // When await fillValidCreateForm(); fireEvent.click(screen.getByRole("button", { name: "생성" })); fireEvent.click(await screen.findByRole("button", { name: "업로드 취소" })); // Then expect(await screen.findByText("취소됨")).toBeInTheDocument(); expect(screen.queryByText("오디오 콘텐츠 저장에 실패했습니다.")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "다시 시도" })).not.toBeInTheDocument(); }); test("AudioContentFormPage keeps a pending upload to one request and allows resubmit after cancel", async () => { // Given const requests: CapturedRequest[] = []; let uploadAttempts = 0; const pendingUpload: UploadAudioContentRequest = (options) => { uploadAttempts += 1; return new Promise((_resolve, reject) => { options.signal?.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")), { once: true }); }); }; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, uploadAudioContentRequest: pendingUpload }); // When await fillValidCreateForm(); const submitButton = screen.getByRole("button", { name: "생성" }); fireEvent.click(submitButton); fireEvent.click(submitButton); // Then await waitFor(() => expect(uploadAttempts).toBe(1)); // When fireEvent.click(await screen.findByRole("button", { name: "업로드 취소" })); expect(await screen.findByText("취소됨")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "생성" })); // Then await waitFor(() => expect(uploadAttempts).toBe(2)); }); test("AudioContentFormPage maps 415 file errors to the matching file fields", async () => { // Given const requests: CapturedRequest[] = []; const contentFileFailure: UploadAudioContentRequest = async () => { throw new ApiError({ status: 415, message: "지원하지 않는 오디오 형식입니다.", errorProperty: "contentFile" }); }; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, uploadAudioContentRequest: contentFileFailure }); // When await fillValidCreateForm(); fireEvent.click(screen.getByRole("button", { name: "생성" })); // Then expect(await screen.findByText("지원하지 않는 오디오 형식입니다.")).toBeInTheDocument(); expect(screen.getByLabelText("오디오 파일")).toHaveAttribute("aria-invalid", "true"); }); test("AudioContentFormPage retry uses the latest edited fields after an upload failure", async () => { // Given const requests: CapturedRequest[] = []; const uploadedBodies: XMLHttpRequestBodyInit[] = []; let uploadAttempts = 0; const failingUpload: UploadAudioContentRequest = async (options) => { uploadAttempts += 1; uploadedBodies.push(options.body); if (uploadAttempts === 1) { options.onProgress?.(30); throw new ApiError({ status: 415, message: "지원하지 않는 오디오 형식입니다.", errorProperty: "contentFile" }); } options.onProgress?.(100); return options.responseSchema.parse({ contentId: 9301 }); }; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, uploadAudioContentRequest: failingUpload }); // When await fillValidCreateForm(); fireEvent.click(screen.getByRole("button", { name: "생성" })); expect(await screen.findByText("지원하지 않는 오디오 형식입니다.")).toBeInTheDocument(); fireEvent.change(screen.getByLabelText("제목"), { target: { value: "수정 후 재시도" } }); fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); // Then await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301")); expect(uploadAttempts).toBe(2); expect(await readJsonPart(requireFormData(uploadedBodies[1]).get("request"))).toMatchObject({ title: "수정 후 재시도" }); }); test("AudioContentFormPage crop cancel does not commit the uncropped cover image", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests }); // When fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } }); fireEvent.click(await screen.findByRole("button", { name: "취소" })); // Then expect(screen.queryByText("cover.png")).not.toBeInTheDocument(); expect(screen.getAllByText("선택된 파일 없음")).toHaveLength(2); expect(screen.getAllByText("파일 선택")).toHaveLength(2); }); test("AudioContentFormPage rejects an oversized cover before crop preparation", async () => { // Given const requests: CapturedRequest[] = []; const createCropSource = vi.fn<(file: File) => Promise>((file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })); window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, createCropSource }); // When fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [fileWithSize("too-large.png", "image/png", 10_485_761)] } }); // Then expect(await screen.findByText("커버 이미지는 10MB 이하만 업로드할 수 있습니다.")).toBeInTheDocument(); expect(createCropSource).not.toHaveBeenCalled(); expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument(); }); test("AudioContentFormPage blocks create submit while cover crop source is preparing", async () => { // Given const requests: CapturedRequest[] = []; let uploadAttempts = 0; const pendingUpload: UploadAudioContentRequest = async (options) => { uploadAttempts += 1; return options.responseSchema.parse({ contentId: 9301 }); }; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, createCropSource: () => new Promise(() => undefined), uploadAudioContentRequest: pendingUpload }); // When fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "준비 중 오디오" } }); fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "준비 중 설명" } }); fireEvent.change(screen.getByLabelText("태그"), { target: { value: "준비" } }); fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } }); fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } }); fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } }); fireEvent.change(screen.getByLabelText("커버 이미지"), { 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(uploadAttempts).toBe(0); }); test("AudioContentFormPage ignores stale cover crop sources and uploads the latest applied cover", async () => { // Given const requests: CapturedRequest[] = []; const cropResolvers = new Map void>(); const uploadedBodies: BodyInit[] = []; const uploadRequest: UploadAudioContentRequest = async (options) => { uploadedBodies.push(options.body); return options.responseSchema.parse({ contentId: 9301 }); }; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, createCropSource: (file) => new Promise((resolve) => cropResolvers.set(file.name, resolve)), renderCrop: (request) => Promise.resolve(request.file), uploadAudioContentRequest: uploadRequest, }); fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "최신 커버 오디오" } }); fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "최신 커버 설명" } }); fireEvent.change(screen.getByLabelText("태그"), { target: { value: "최신" } }); fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } }); fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } }); fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } }); const staleFile = new File(["stale"], "stale.png", { type: "image/png" }); const freshFile = new File(["fresh"], "fresh.png", { type: "image/png" }); // When fireEvent.change(screen.getByLabelText("커버 이미지"), { 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/9301")); expect(requireFile(requireFormData(uploadedBodies[0]).get("coverImage")).name).toBe("fresh.png"); }); test("AudioContentFormPage shows a cover preparation error when preview creation rejects", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); renderAudioForm({ requests, 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.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument(); });