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