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,238 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support";
import { audioDetail, character, createFormClient, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.path === "/api/v2/admin/ai-characters/101") {
return options.responseSchema.parse({ ...character, isActive: false });
}
if (options.path === "/api/v2/admin/ai-characters/audio-content-themes") {
return options.responseSchema.parse([{ id: 7, theme: "힐링", image: "https://cdn.example.com/theme/healing.png" }]);
}
if (options.path === "/api/v2/admin/ai-characters/101/audio-contents/9001") {
return options.responseSchema.parse(audioDetail);
}
return options.responseSchema.parse(null);
},
};
}
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(
<AudioContentFormPage
apiClient={createFormClient(requests)}
characterId="101"
contentId="9001"
createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 })}
/>,
);
// 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: "0" } });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(screen.queryByLabelText("오디오 테마")).not.toBeInTheDocument();
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
expect(screen.getByText("시리즈: 현재 수정 화면에서는 변경할 수 없습니다.")).toBeInTheDocument();
const updateBody = requireFormData(requests.at(-1)?.body);
expect(updateBody.has("contentFile")).toBe(false);
expect(await readJsonPart(updateBody.get("request"))).toEqual({
title: "수정 오디오",
detail: "수정 설명",
tags: "수정",
price: 0,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
});
// When
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
const dialog = screen.getByRole("alertdialog", { name: "오디오 콘텐츠 비활성화 확인" });
expect(dialog).toHaveTextContent("수정 오디오");
expect(dialog).toHaveTextContent("목록 노출만 중지하며 콘텐츠는 보관됩니다.");
expect(dialog).not.toHaveTextContent("완전 삭제");
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents"));
expect(window.history.state).toMatchObject({ successNotification: "오디오 콘텐츠를 비활성화했습니다." });
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toEqual({ isActive: false });
});
test("AudioContentFormPage sends one deactivate request while pending and shows retry guidance on failure", async () => {
// Given
const requests: CapturedRequest[] = [];
let rejectDeactivate: () => void = () => { throw new Error("Deactivate request was not started"); };
const client: ApiClient = {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.path === "/api/v2/admin/ai-characters/101") {
return options.responseSchema.parse(character);
}
if (options.path === "/api/v2/admin/ai-characters/audio-content-themes") {
return options.responseSchema.parse([{ id: 7, theme: "힐링", image: "https://cdn.example.com/theme/healing.png" }]);
}
if (options.path === "/api/v2/admin/ai-characters/101/audio-contents/9001" && options.method === undefined) {
return options.responseSchema.parse(audioDetail);
}
await new Promise<void>((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); });
return options.responseSchema.parse(null);
},
};
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={client} characterId="101" contentId="9001" />);
fireEvent.click(await screen.findByRole("button", { name: "비활성화" }));
// When
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
fireEvent.click(screen.getByRole("button", { name: "처리 중" }));
rejectDeactivate();
// Then
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1);
expect(await screen.findByRole("alert")).toHaveTextContent("비활성화하지 못했습니다. 다시 시도하세요.");
});
test.each([
["create", undefined, "/ai-characters/101/audio-contents/new"],
["edit", "9001", "/ai-characters/101/audio-contents/9001/edit"],
])("AudioContentFormPage blocks the %s route for inactive characters", async (_mode, contentId, path) => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", path);
render(<AudioContentFormPage apiClient={createInactiveFormClient(requests)} characterId="101" contentId={contentId} />);
// When
await screen.findByText("비활성화된 AI 캐릭터에는 오디오 콘텐츠를 저장할 수 없습니다.");
// Then
expect(screen.queryByRole("form", { name: /오디오 콘텐츠/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /생성|저장/ })).not.toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST" || request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage blocks edit save while replacement cover crop source is preparing", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" createCropSource={() => new Promise(() => undefined)} />);
// When
fireEvent.change(await screen.findByLabelText("커버 이미지"), { 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(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage keeps existing edit cover when replacement crop is canceled", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(
<AudioContentFormPage
apiClient={createFormClient(requests)}
characterId="101"
contentId="9001"
createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 })}
/>,
);
// When
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
const updateRequest = requests.find((request) => request.method === "PUT");
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
});
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
// Given
const requests: CapturedRequest[] = [];
const createCropSource = vi.fn<(file: File) => Promise<CropSourceImage>>((file) => Promise.resolve({ file, height: 800, previewUrl: "blob:replacement", width: 800 }));
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" createCropSource={createCropSource} />);
// When
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/jpeg" })] } });
// Then
expect(await screen.findByText("JPEG 또는 PNG 파일만 업로드하세요.")).toBeInTheDocument();
expect(createCropSource).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
});
test("AudioContentFormPage ignores stale edit cover sources and saves the latest applied replacement", async () => {
// Given
const requests: CapturedRequest[] = [];
const cropResolvers = new Map<string, (source: CropSourceImage) => void>();
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(
<AudioContentFormPage
apiClient={createFormClient(requests)}
characterId="101"
contentId="9001"
createCropSource={(file) => new Promise((resolve) => cropResolvers.set(file.name, resolve))}
renderCrop={(request) => Promise.resolve(request.file)}
/>,
);
const staleFile = new File(["stale"], "stale.png", { type: "image/png" });
const freshFile = new File(["fresh"], "fresh.png", { type: "image/png" });
// When
fireEvent.change(await screen.findByLabelText("커버 이미지"), { 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/9001"));
const coverPart = requireFormData(requests.at(-1)?.body).get("coverImage");
if (!(coverPart instanceof File)) {
throw new TypeError("Expected cover image file");
}
expect(coverPart.name).toBe("fresh.png");
});
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" 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.getByRole("button", { name: "저장" })).toBeEnabled();
});