331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
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<string, string>();
|
|
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<void> {
|
|
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<CropSourceImage>>((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<string, (source: CropSourceImage) => 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();
|
|
});
|