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,109 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support";
import { createFormClient, fileWithSize, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
function createSuccessfulUpload(uploadedBodies: XMLHttpRequestBodyInit[]): UploadAudioContentRequest {
return async (options) => {
uploadedBodies.push(options.body);
return options.responseSchema.parse({ contentId: 9301 });
};
}
test("AudioContentFormPage serializes editable create settings and excludes unsupported create fields", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
const contentFile = fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000);
const coverImage = new File(["cover"], "cover.png", { type: "image/png" });
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(
<AudioContentFormPage
apiClient={createFormClient(requests)}
characterId="101"
createCropSource={(file) => Promise.resolve({ file, height: 1200, previewUrl: "blob:cover", width: 1200 })}
renderCrop={(request) => Promise.resolve(request.file)}
uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)}
/>,
);
// 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: "99999캔" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("구매 옵션"), { target: { value: "RENT_ONLY" } });
fireEvent.click(screen.getByLabelText("기간제"));
fireEvent.click(screen.getByLabelText("성인 콘텐츠"));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("대여 전용"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.click(screen.getByLabelText("댓글 허용"));
fireEvent.click(screen.getByLabelText("상세 정보 전체 공개"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00" } });
fireEvent.change(screen.getByLabelText("언어 코드"), { target: { value: "ko" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "적용" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("구매 옵션")).toBeInTheDocument();
const body = requireFormData(uploadedBodies.at(-1));
expect(await readJsonPart(body.get("request"))).toEqual({
title: "옵션 오디오",
detail: "옵션 설명",
tags: "옵션",
price: 99999,
purchaseOption: "RENT_ONLY",
limited: 1,
releaseDate: null,
themeId: 7,
isAdult: true,
isGeneratePreview: true,
isOnlyRental: true,
isPointAvailable: true,
isCommentAvailable: true,
isFullDetailVisible: false,
previewStartTime: "00:30",
previewEndTime: "01:00",
languageCode: "ko",
});
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("isActive");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("seriesIds");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("timezone");
});
test.each([
{ label: "음수 가격", value: "-1", expected: "-1" },
{ label: "소수 가격", value: "1.5", expected: "1.5" },
])("AudioContentFormPage keeps raw price input and blocks upload for %s", async ({ label, value, expected }) => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: label } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
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());
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(expected);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});