feat(ai-character): 오디오 콘텐츠 관리 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:36 +09:00
parent 286c536ba7
commit f9ed4d0094
26 changed files with 3600 additions and 0 deletions

View 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();
});