feat(ai-character): 오디오 콘텐츠 관리 기능 구현
This commit is contained in:
330
src/features/audio-contents/tests/audio-form-upload.test.tsx
Normal file
330
src/features/audio-contents/tests/audio-form-upload.test.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user