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,127 @@
import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types";
import { audioContentDetailSchema, audioContentListResponseSchema } from "@/features/audio-contents/model/types";
import { audioContentCreateRequestSchema, audioContentCreateResponseSchema, audioContentDeactivateRequestSchema, audioContentUpdateRequestSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentCreateRequest, AudioContentCreateResponse, AudioContentDeactivateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import type { ApiClient } from "@/shared/api/client";
import type { PageData } from "@/shared/api/pagination";
import { z } from "zod";
export type GetAudioContentsParams = {
readonly characterId: string;
readonly page?: number;
readonly search_word?: string;
readonly size?: number;
};
export type GetAudioContentParams = {
readonly characterId: string;
readonly contentId: string;
};
export type CreateAudioContentParams = {
readonly characterId: string;
readonly contentFile: File;
readonly coverImage: File;
readonly request: AudioContentCreateRequest;
};
export type UpdateAudioContentParams = {
readonly coverImage?: File;
readonly request: AudioContentUpdateRequest;
};
const nullSuccessSchema = z.null();
function createAudioContentFormData(contentFile: File | undefined, coverImage: File | undefined, request: object): FormData {
const body = new FormData();
if (contentFile !== undefined) {
body.append("contentFile", contentFile);
}
if (coverImage !== undefined) {
body.append("coverImage", coverImage);
}
body.append("request", new Blob([JSON.stringify(request)], { type: "application/json" }));
return body;
}
export function createAudioContentCreateBody(params: CreateAudioContentParams): FormData {
return createAudioContentFormData(params.contentFile, params.coverImage, audioContentCreateRequestSchema.parse(params.request));
}
export function createAudioContentUpdateBody(params: UpdateAudioContentParams): FormData {
return createAudioContentFormData(undefined, params.coverImage, audioContentUpdateRequestSchema.parse(params.request));
}
export function createAudioContentDeactivateBody(request: AudioContentDeactivateRequest): FormData {
return createAudioContentFormData(undefined, undefined, audioContentDeactivateRequestSchema.parse(request));
}
function normalizePage(value: number | undefined): number {
return Math.max(0, Math.trunc(value ?? 0));
}
function normalizeSize(value: number | undefined): number {
return Math.max(1, Math.trunc(value ?? 20));
}
export async function getAudioContents(apiClient: ApiClient, params: GetAudioContentsParams): Promise<PageData<AudioContentListItem>> {
const page = normalizePage(params.page);
const size = normalizeSize(params.size);
const query = new URLSearchParams({ page: String(page), size: String(size) });
const searchWord = params.search_word?.trim();
if (searchWord !== undefined && searchWord.length >= 2) {
query.set("search_word", searchWord);
}
const response = await apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/audio-contents?${query.toString()}`,
responseSchema: audioContentListResponseSchema,
authentication: "required",
});
return {
totalCount: response.totalCount,
page,
size,
hasNext: (page + 1) * size < response.totalCount,
items: response.items,
};
}
export function getAudioContent(apiClient: ApiClient, params: GetAudioContentParams): Promise<AudioContentDetail> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/audio-contents/${encodeURIComponent(params.contentId)}`,
responseSchema: audioContentDetailSchema,
authentication: "required",
});
}
export function createAudioContent(apiClient: ApiClient, params: CreateAudioContentParams): Promise<AudioContentCreateResponse> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/audio-contents`,
method: "POST",
body: createAudioContentCreateBody(params),
responseSchema: audioContentCreateResponseSchema,
authentication: "required",
});
}
export function updateAudioContent(apiClient: ApiClient, characterId: string, contentId: string, params: UpdateAudioContentParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/audio-contents/${encodeURIComponent(contentId)}`,
method: "PUT",
body: createAudioContentUpdateBody(params),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function deactivateAudioContent(apiClient: ApiClient, characterId: string, contentId: string): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/audio-contents/${encodeURIComponent(contentId)}`,
method: "PUT",
body: createAudioContentDeactivateBody({ isActive: false }),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
import { audioContentThemeSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
import type { ApiClient } from "@/shared/api/client";
const audioContentThemeListSchema = z.array(audioContentThemeSchema);
export function getAudioContentThemes(apiClient: ApiClient): Promise<readonly AudioContentTheme[]> {
return apiClient.request({
path: "/api/v2/admin/ai-characters/audio-content-themes",
responseSchema: audioContentThemeListSchema,
authentication: "required",
});
}

View File

@@ -0,0 +1,154 @@
import type { z } from "zod";
import { ApiError, UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
import { createApiResponseSchema } from "@/shared/api/types";
import { getRuntimeEnv } from "@/shared/config/env";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
type UploadAuthentication = "none" | "required";
export type UploadAuthDependencies = {
readonly clearSession: () => void;
readonly getToken: () => string | null;
readonly onAuthExpired: () => void;
};
export type UploadAudioContentOptions<Data> = {
readonly auth?: UploadAuthDependencies;
readonly authentication: UploadAuthentication;
readonly body: XMLHttpRequestBodyInit;
readonly method: "POST" | "PUT";
readonly onProgress?: (progress: number) => void;
readonly path: string;
readonly responseSchema: z.ZodType<Data>;
readonly signal?: AbortSignal;
};
let activeProtectedUploadCount = 0;
let hasHandledAuthenticationExpiry = false;
function finishProtectedUpload(isProtectedUpload: boolean): void {
if (!isProtectedUpload) {
return;
}
activeProtectedUploadCount -= 1;
if (activeProtectedUploadCount === 0) {
hasHandledAuthenticationExpiry = false;
}
}
function createAbortError(): DOMException {
return new DOMException("Upload aborted", "AbortError");
}
function readToken(auth: UploadAuthDependencies | undefined): string | null {
if (auth !== undefined) {
return auth.getToken();
}
return authSessionStorage.read()?.token ?? null;
}
function parseErrorResponse(status: number, responseText: string): ApiError {
try {
const parsed: unknown = JSON.parse(responseText);
if (parsed !== null && typeof parsed === "object" && "message" in parsed && "errorProperty" in parsed) {
const message = typeof parsed.message === "string" && parsed.message.trim().length > 0 ? parsed.message : UNKNOWN_API_ERROR_MESSAGE;
const errorProperty = typeof parsed.errorProperty === "string" ? parsed.errorProperty : null;
return new ApiError({ status, message, errorProperty });
}
} catch {
return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
}
return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
}
export function uploadAudioContent<Data>(options: UploadAudioContentOptions<Data>): Promise<Data> {
return new Promise((resolve, reject) => {
const isProtectedUpload = options.authentication === "required";
if (isProtectedUpload) {
activeProtectedUploadCount += 1;
}
const xhr = new XMLHttpRequest();
let isSettled = false;
const finishUpload = (): boolean => {
if (isSettled) {
return false;
}
isSettled = true;
options.signal?.removeEventListener("abort", abort);
finishProtectedUpload(isProtectedUpload);
return true;
};
const resolveUpload = (data: Data) => {
if (!finishUpload()) {
return;
}
resolve(data);
};
const rejectUpload = (error: unknown) => {
if (!finishUpload()) {
return;
}
reject(error);
};
const abort = () => {
xhr.abort();
rejectUpload(createAbortError());
};
xhr.open(options.method, new URL(options.path, getRuntimeEnv().apiBaseUrl).toString());
xhr.setRequestHeader("Accept-Language", "ko");
if (options.authentication === "required") {
const token = readToken(options.auth);
if (token !== null) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
}
}
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
options.onProgress?.((event.loaded / event.total) * 100);
}
});
xhr.onerror = () => rejectUpload(new ApiError({ status: xhr.status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }));
xhr.onload = () => {
if (xhr.status === 401 && isProtectedUpload && !hasHandledAuthenticationExpiry && readToken(options.auth) !== null) {
hasHandledAuthenticationExpiry = true;
options.auth?.clearSession();
options.auth?.onAuthExpired();
}
if (xhr.status >= 200 && xhr.status < 300) {
let parsed: ReturnType<ReturnType<typeof createApiResponseSchema<Data>>["safeParse"]>;
try {
parsed = createApiResponseSchema(options.responseSchema).safeParse(JSON.parse(xhr.responseText));
} catch (parseError) {
if (parseError instanceof SyntaxError) {
rejectUpload(new ApiError({ status: xhr.status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }));
return;
}
throw parseError;
}
if (parsed.success && parsed.data.success) {
resolveUpload(parsed.data.data);
return;
}
rejectUpload(new ApiError({ status: xhr.status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }));
return;
}
rejectUpload(parseErrorResponse(xhr.status, xhr.responseText));
};
if (options.signal?.aborted === true) {
abort();
return;
}
options.signal?.addEventListener("abort", abort, { once: true });
xhr.send(options.body);
});
}

View File

@@ -0,0 +1,269 @@
import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy";
import type { ApiClient } from "@/shared/api/client";
import { ApiError } from "@/shared/api/api-error";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { CAN_PRICE_MAX } from "@/shared/validation/can-price";
import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
import { UploadProgress } from "@/shared/ui/upload-progress";
import type { UploadProgressStatus } from "@/shared/ui/upload-progress";
export type UploadAudioContentRequest = <Data>(options: UploadAudioContentOptions<Data>) => Promise<Data>;
type FieldName = "audio" | "cover" | "detail" | "form" | "price" | "releaseDate" | "tags" | "theme" | "title";
type FieldErrors = Partial<Record<FieldName, string>>;
type UploadState = {
readonly progress: number;
readonly status: UploadProgressStatus;
};
const errorIds = {
detail: "audio-content-detail-error",
price: "audio-content-price-error",
releaseDate: "audio-content-release-date-error",
tags: "audio-content-tags-error",
theme: "audio-content-theme-error",
title: "audio-content-title-error",
} as const;
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
const [coverImage, setCoverImage] = useState<File | null>(null);
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
const [detail, setDetail] = useState(audio?.detail ?? "");
const [errors, setErrors] = useState<FieldErrors>({});
const [isDeactivateDialogOpen, setIsDeactivateDialogOpen] = useState(false);
const [isCoverPreparing, setIsCoverPreparing] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
const [deactivateError, setDeactivateError] = useState<string | undefined>();
const [price, setPrice] = useState(createInitialPrice(audio?.price));
const [releaseSchedule, setReleaseSchedule] = useState<ReleaseScheduleValue>({ publishMode: "immediate", releaseDateTime: "" });
const [tags, setTags] = useState(audio?.tag ?? "");
const [themeId, setThemeId] = useState<number | null>(null);
const [title, setTitle] = useState(audio?.title ?? "");
const [uploadState, setUploadState] = useState<UploadState>({ progress: 0, status: "idle" });
const [abortController, setAbortController] = useState<AbortController | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const coverSelectionIdRef = useRef(0);
const isDeactivatingRef = useRef(false);
const isSubmittingRef = useRef(false);
const contentId = audio === undefined ? null : String(audio.contentId);
const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings;
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function updatePurchaseOption(value: string) {
if (value === "BOTH" || value === "BUY_ONLY" || value === "RENT_ONLY") {
setCreateSettings((current) => ({ ...current, purchaseOption: value }));
}
}
async function selectCoverImage(file: File | null) {
const selectionId = coverSelectionIdRef.current + 1;
coverSelectionIdRef.current = selectionId;
if (file === null) {
setCoverImage(null);
setCropSource(null);
setIsCoverPreparing(false);
return;
}
setCoverImage(null);
setCropSource(null);
const coverError = coverErrorMessage(file, mode);
if (coverError !== undefined) { setErrors((current) => ({ ...current, cover: coverError })); return; }
setIsCoverPreparing(true);
setErrors((current) => ({ ...current, cover: undefined }));
try {
const source = await createCropSource(file);
if (coverSelectionIdRef.current === selectionId) {
setCropSource(source);
} else {
source.release?.();
}
} catch {
if (coverSelectionIdRef.current === selectionId) {
setErrors((current) => ({ ...current, cover: "이미지 미리보기 준비에 실패했습니다." }));
}
} finally {
if (coverSelectionIdRef.current === selectionId) {
setIsCoverPreparing(false);
}
}
}
function validateForm(): FieldErrors {
const parsedPrice = parsePrice(price);
return {
audio: audioErrorMessage(audioFile, mode),
cover: isCoverSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : coverErrorMessage(coverImage, mode),
detail: detail.trim().length === 0 ? "상세 설명을 입력하세요." : undefined,
price: parsedPrice === null || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX || !Number.isInteger(parsedPrice) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
releaseDate: releaseSchedule.publishMode === "scheduled" && !isFutureLocalDateTime(releaseSchedule.releaseDateTime) ? "미래 Asia/Seoul 시각을 입력하세요." : undefined,
tags: tags.trim().length === 0 ? "태그를 입력하세요." : undefined,
theme: mode === "create" && themeId === null ? "테마를 선택하세요." : undefined,
title: title.trim().length === 0 ? "제목을 입력하세요." : undefined,
};
}
function hasErrors(nextErrors: FieldErrors): boolean {
return Object.values(nextErrors).some((error) => error !== undefined);
}
async function submitWithCurrentState() {
if (isSubmittingRef.current) {
return;
}
const nextErrors = validateForm();
setErrors(nextErrors);
if (hasErrors(nextErrors)) {
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
const parsedPrice = parsePrice(price);
if (parsedPrice === null) {
return;
}
isSubmittingRef.current = true;
setUploadState({ progress: 0, status: "uploading" });
try {
if (mode === "create" && audioFile !== null && coverImage !== null && themeId !== null) {
const request = toCreateRequest({ detail: detail.trim(), price: parsedPrice, releaseSchedule, settings: createSettings, tags: tags.trim(), themeId, title: title.trim() });
const controller = new AbortController();
setAbortController(controller);
const result = await uploadAudioContentRequest({ auth: uploadAuth, authentication: "required", body: createAudioContentCreateBody({ characterId, contentFile: audioFile, coverImage, request }), method: "POST", onProgress: (progress) => setUploadState({ progress, status: "uploading" }), path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/audio-contents`, responseSchema: audioContentCreateResponseSchema, signal: controller.signal });
setAbortController(null);
setUploadState({ progress: 100, status: "success" });
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, String(result.contentId)), { successNotification: "오디오 콘텐츠를 생성했습니다." });
return;
}
if (mode === "edit" && audio !== undefined && contentId !== null) {
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: toUpdateRequest({ audio, detail: detail.trim(), price: parsedPrice, tags: tags.trim(), title: title.trim() }) });
setUploadState({ progress: 100, status: "success" });
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
}
} catch (error) {
setAbortController(null);
if (error instanceof DOMException && error.name === "AbortError") {
setErrors({});
setUploadState((current) => ({ progress: current.progress, status: "canceled" }));
return;
}
if (error instanceof ApiError && error.status === 415 && error.errorProperty === "contentFile") {
setErrors({ audio: error.message });
} else if (error instanceof ApiError && error.status === 415 && error.errorProperty === "coverImage") {
setErrors({ cover: error.message });
} else {
setErrors({ form: error instanceof ApiError ? error.message : "오디오 콘텐츠 저장에 실패했습니다." });
}
setUploadState((current) => ({ progress: current.progress, status: "error" }));
} finally {
isSubmittingRef.current = false;
}
}
async function submit(event: { readonly preventDefault: () => void }) {
event.preventDefault();
await submitWithCurrentState();
}
async function confirmDeactivate() {
if (contentId === null || isDeactivatingRef.current) {
return;
}
isDeactivatingRef.current = true;
setIsDeactivating(true);
setDeactivateError(undefined);
try {
await deactivateAudioContent(apiClient, characterId, contentId);
navigateTo(routePaths.aiCharacterAudioContents(characterId), { successNotification: "오디오 콘텐츠를 비활성화했습니다." });
} catch (error: unknown) {
if (!(error instanceof Error)) {
throw error;
}
setDeactivateError("비활성화하지 못했습니다. 다시 시도하세요.");
} finally {
isDeactivatingRef.current = false;
setIsDeactivating(false);
}
}
return (
<UnsavedChangesGuard dirty={isDirty} impactDescription="저장하지 않은 오디오 콘텐츠 변경사항이 사라집니다." title="오디오 편집을 취소하시겠습니까?">
{(requestRouteLeave) => (
<section className="flex flex-col gap-4" aria-labelledby="audio-form-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AUDIO CONTENTS</p>
<h2 className="text-2xl font-bold leading-tight" id="audio-form-title">{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}</h2>
<p className="text-sm text-muted-foreground">, , . .</p>
</div>
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label={mode === "create" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} onSubmit={(event) => void submit(event)} ref={formRef}>
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"> <textarea aria-describedby={errors.detail === undefined ? undefined : errorIds.detail} aria-invalid={errors.detail === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setDetail(event.currentTarget.value)} value={detail} /></label>
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.tags === undefined ? undefined : errorIds.tags} aria-invalid={errors.tags === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTags(event.currentTarget.value)} value={tags} /></label>
{errors.tags === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.tags} role="alert">{errors.tags}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatPrice(event.currentTarget.value))} value={price} /></label>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
{mode === "create" ? (
<fieldset className="grid gap-3 rounded-lg border border-border bg-card p-4 sm:grid-cols-2">
<legend className="text-sm font-semibold"> </legend>
<label className="flex flex-col gap-2 text-sm font-semibold"> <select aria-label="구매 옵션" className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} value={createSettings.purchaseOption}><option value="BOTH">/</option><option value="BUY_ONLY"> </option><option value="RENT_ONLY"> </option></select></label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.limited !== null} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, limited: checked ? 1 : null })); }} type="checkbox" /></label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isAdult} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isAdult: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isGeneratePreview} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isGeneratePreview: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isOnlyRental} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isOnlyRental: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isPointAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isPointAvailable: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isCommentAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isCommentAvailable: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isFullDetailVisible} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isFullDetailVisible: checked })); }} type="checkbox" /> </label>
<label className="flex flex-col gap-2 text-sm font-semibold"> <input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewStartTime: value.length === 0 ? null : value })); }} value={createSettings.previewStartTime ?? ""} /></label>
<label className="flex flex-col gap-2 text-sm font-semibold"> <input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewEndTime: value.length === 0 ? null : value })); }} value={createSettings.previewEndTime ?? ""} /></label>
<label className="flex flex-col gap-2 text-sm font-semibold"> <input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, languageCode: value.length === 0 ? null : value })); }} value={createSettings.languageCode ?? ""} /></label>
</fieldset>
) : null}
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} ( )</p>}
{mode === "create" ? <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground"> .</p>}
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.cover} label="커버 이미지" onChange={(file) => void selectCoverImage(file)} value={coverImage} />
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{uploadState.status === "idle" ? null : <UploadProgress fileName={audioFile?.name ?? coverImage?.name} onCancel={abortController === null ? undefined : () => abortController.abort()} onRetry={uploadState.status === "error" ? () => void submitWithCurrentState() : undefined} progress={uploadState.progress} status={uploadState.status} />}
<div className="flex justify-end gap-2">
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null}
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div>
</form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}
<ConfirmDeactivateDialog confirmLabel="비활성화 확인" errorMessage={deactivateError} impactDescription={`${title || "선택한 오디오 콘텐츠"}의 목록 노출만 중지하며 콘텐츠는 보관됩니다.`} isPending={isDeactivating} onCancel={() => setIsDeactivateDialogOpen(false)} onConfirm={() => void confirmDeactivate()} open={isDeactivateDialogOpen} targetName="오디오 콘텐츠" />
</section>
)}
</UnsavedChangesGuard>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { AudioContentListItem } from "@/features/audio-contents/components/AudioContentListItem";
import type { AudioContentListItem as AudioContentListItemData } from "@/features/audio-contents/model/types";
import type { PageData } from "@/shared/api/pagination";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
const desktopListQuery = "(min-width: 768px)";
function formatCan(price: number): string {
return `${price.toLocaleString("ko-KR")}`;
}
function getIsDesktopList(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(desktopListQuery).matches;
}
function useIsDesktopList(): boolean {
const [isDesktop, setIsDesktop] = useState(getIsDesktopList);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return undefined;
}
const mediaQuery = window.matchMedia(desktopListQuery);
const updateIsDesktop = () => setIsDesktop(mediaQuery.matches);
updateIsDesktop();
mediaQuery.addEventListener("change", updateIsDesktop);
return () => mediaQuery.removeEventListener("change", updateIsDesktop);
}, []);
return isDesktop;
}
export function AudioContentList({ characterId, data }: { readonly characterId: string; readonly data: PageData<AudioContentListItemData> }) {
const isDesktop = useIsDesktopList();
const desktop = (
<table className="min-w-full border-separate border-spacing-0 text-left text-sm">
<thead className="text-muted-foreground">
<tr>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
</tr>
</thead>
<tbody>
{data.items.map((audio) => {
const detailPath = routePaths.aiCharacterAudioContentDetail(characterId, String(audio.audioContentId));
return (
<tr key={audio.audioContentId}>
<td className="border-b border-border px-4 py-3">
<p className="font-semibold">{audio.title}</p>
<p className="mt-1 max-w-xl text-muted-foreground">{audio.detail}</p>
<p className="mt-2 text-xs font-semibold text-info">{audio.theme} · {audio.tags}</p>
</td>
<td className="border-b border-border px-4 py-3">{formatCan(audio.price)}</td>
<td className="min-w-80 border-b border-border px-4 py-3"><AdminAudioPlayer playerId={`audio-list-${audio.audioContentId}`} src={audio.contentUrl} title={audio.title} /></td>
<td className="border-b border-border px-4 py-3">
<a
aria-label={`${audio.title} 상세 보기`}
className="font-semibold text-link hover:text-link-hover"
href={detailPath}
onClick={(event) => {
event.preventDefault();
navigateTo(detailPath);
}}
>
</a>
</td>
</tr>
);
})}
</tbody>
</table>
);
const mobile = <div className="grid gap-3 p-3">{data.items.map((audio) => <AudioContentListItem audio={audio} characterId={characterId} key={audio.audioContentId} />)}</div>;
return (
<section aria-label="오디오 콘텐츠 목록" className="rounded-lg border border-border bg-card" role="region">
{isDesktop ? <div className="overflow-x-auto">{desktop}</div> : mobile}
</section>
);
}

View File

@@ -0,0 +1,35 @@
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import type { AudioContentListItem as AudioContentListItemData } from "@/features/audio-contents/model/types";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
function formatCan(price: number): string {
return `${price.toLocaleString("ko-KR")}`;
}
export function AudioContentListItem({ audio, characterId }: { readonly audio: AudioContentListItemData; readonly characterId: string }) {
const detailPath = routePaths.aiCharacterAudioContentDetail(characterId, String(audio.audioContentId));
return (
<article className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<a
aria-label={`${audio.title} 상세 보기`}
className="flex items-start gap-3 text-foreground hover:text-link focus-visible:text-link"
href={detailPath}
onClick={(event) => {
event.preventDefault();
navigateTo(detailPath);
}}
>
<img alt="" className="size-16 shrink-0 rounded-md object-cover" height="64" loading="lazy" src={audio.coverImageUrl} width="64" />
<span className="min-w-0 flex-1">
<span className="block break-words font-semibold">{audio.title}</span>
<span className="mt-1 line-clamp-2 block break-words text-sm text-muted-foreground">{audio.detail}</span>
<span className="mt-2 block text-xs font-semibold text-info">{audio.theme} · {formatCan(audio.price)}</span>
<span className="mt-1 block break-words text-xs text-muted-foreground">{audio.tags}</span>
</span>
</a>
<AdminAudioPlayer playerId={`audio-list-${audio.audioContentId}`} src={audio.contentUrl} title={audio.title} />
</article>
);
}

View File

@@ -0,0 +1,25 @@
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
function formatCan(price: number): string {
return `${price.toLocaleString("ko-KR")}`;
}
export function AudioContentSummary({ audio }: { readonly audio: AudioContentDetail }) {
return (
<section aria-label="오디오 콘텐츠 요약" className="grid gap-4 rounded-lg border border-border bg-card p-4 md:grid-cols-[8rem_1fr]">
<img alt="" className="size-32 rounded-lg object-cover" height="128" src={audio.coverImageUrl} width="128" />
<div className="min-w-0">
<p className="text-xs font-semibold text-info">{audio.themeStr}</p>
<h2 className="mt-1 break-words text-2xl font-bold leading-tight" id="audio-content-title">{audio.title}</h2>
<p className="mt-2 break-words text-sm text-muted-foreground">{audio.detail}</p>
<dl className="mt-4 grid gap-2 text-sm sm:grid-cols-2">
<div><dt className="font-semibold"></dt><dd className="text-muted-foreground">{formatCan(audio.price)}</dd></div>
<div><dt className="font-semibold"></dt><dd className="text-muted-foreground">{audio.duration}</dd></div>
<div><dt className="font-semibold"></dt><dd className="break-words text-muted-foreground">{audio.tag}</dd></div>
<div><dt className="font-semibold"></dt><dd className="break-words text-muted-foreground">{audio.releaseDate === null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)}</dd></div>
</dl>
</div>
</section>
);
}

View File

@@ -0,0 +1,14 @@
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
export function AudioContentThemeSelect({ error, errorId, onChange, themes, value }: { readonly error?: string; readonly errorId?: string; readonly onChange: (themeId: number | null) => void; readonly themes: readonly AudioContentTheme[]; readonly value: number | null }) {
return (
<label className="flex flex-col gap-2 text-sm font-semibold">
<select aria-describedby={error === undefined ? undefined : errorId} aria-label="오디오 테마" aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange(event.currentTarget.value === "" ? null : Number(event.currentTarget.value))} value={value ?? ""}>
<option value=""> </option>
{themes.map((theme) => <option key={theme.id} value={theme.id}>{theme.theme}</option>)}
</select>
{error === undefined ? null : <span className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</span>}
</label>
);
}

View File

@@ -0,0 +1,30 @@
export type ReleaseScheduleValue = {
readonly publishMode: "immediate" | "scheduled";
readonly releaseDateTime: string;
};
export function ReleaseScheduleField({ error, errorId, onChange, value }: { readonly error?: string; readonly errorId?: string; readonly onChange: (value: ReleaseScheduleValue) => void; readonly value: ReleaseScheduleValue }) {
function setMode(publishMode: ReleaseScheduleValue["publishMode"]) {
onChange({ publishMode, releaseDateTime: publishMode === "immediate" ? "" : value.releaseDateTime });
}
return (
<fieldset className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<legend className="text-sm font-semibold"> </legend>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={value.publishMode === "immediate"} name="publishMode" onChange={() => setMode("immediate")} type="radio" />
</label>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={value.publishMode === "scheduled"} name="publishMode" onChange={() => setMode("scheduled")} type="radio" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-describedby={error === undefined ? undefined : errorId} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground disabled:bg-muted disabled:text-muted-foreground" disabled={value.publishMode === "immediate"} onChange={(event) => onChange({ publishMode: value.publishMode, releaseDateTime: event.currentTarget.value })} type="datetime-local" value={value.releaseDateTime} />
</label>
<p className="text-sm text-muted-foreground">Asia/Seoul .</p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</fieldset>
);
}

View File

@@ -0,0 +1,126 @@
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import type { AudioContentCreateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { validateAudioCoverFile } from "@/features/audio-contents/validation/audio-cover-policy";
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
export function createInitialPrice(price: number | undefined): string {
return price === undefined ? "" : `${price.toLocaleString("ko-KR")}`;
}
export function parsePrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatPrice(value: string): string {
return formatCanPriceInput(value);
}
const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
function toUtcMilliseconds(value: string, offsetHours: number): number {
if (!localDateTimePattern.test(value)) {
return Number.NaN;
}
return Date.UTC(Number(value.slice(0, 4)), Number(value.slice(5, 7)) - 1, Number(value.slice(8, 10)), Number(value.slice(11, 13)) - offsetHours, Number(value.slice(14, 16)));
}
export function toUtcReleaseDate(value: string, timezone: "Asia/Seoul"): string {
const offsetHours = timezone === "Asia/Seoul" ? 9 : 0;
return new Date(toUtcMilliseconds(value, offsetHours)).toISOString().replace(".000Z", "Z");
}
export function isFutureLocalDateTime(value: string): boolean {
const selected = toUtcMilliseconds(value, 9);
return Number.isFinite(selected) && selected > Date.now();
}
export type AudioContentCreateSettings = Pick<AudioContentCreateRequest, "purchaseOption" | "limited" | "isAdult" | "isGeneratePreview" | "isOnlyRental" | "isPointAvailable" | "isCommentAvailable" | "isFullDetailVisible" | "previewStartTime" | "previewEndTime" | "languageCode">;
export const defaultAudioContentCreateSettings = {
purchaseOption: "BOTH",
limited: null,
isAdult: false,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
isCommentAvailable: false,
isFullDetailVisible: true,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
} satisfies AudioContentCreateSettings;
export function audioErrorMessage(file: File | null, mode: "create" | "edit"): string | undefined {
if (mode === "edit") {
return undefined;
}
if (file === null) {
return "오디오 파일을 선택하세요.";
}
const result = validateAudioFile(file);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "오디오 파일은 1,024,000,000 bytes 이하만 업로드할 수 있습니다.";
}
if (result.reason === "mimeExtensionCombination") {
return "확장자와 MIME 조합이 올바른 MP3, AAC, M4A 파일만 업로드하세요.";
}
return "MP3, AAC, M4A 파일만 업로드하세요.";
}
export function coverErrorMessage(file: File | null, mode: "create" | "edit"): string | undefined {
if (file === null) {
return mode === "create" ? "커버 이미지를 선택하세요." : undefined;
}
const result = validateAudioCoverFile(file);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "커버 이미지는 10MB 이하만 업로드할 수 있습니다.";
}
return "JPEG 또는 PNG 파일만 업로드하세요.";
}
export function toCreateRequest(params: { readonly detail: string; readonly price: number; readonly releaseSchedule: ReleaseScheduleValue; readonly settings: AudioContentCreateSettings; readonly tags: string; readonly themeId: number; readonly title: string }): AudioContentCreateRequest {
return {
title: params.title,
detail: params.detail,
tags: params.tags,
price: params.price,
purchaseOption: params.settings.purchaseOption,
limited: params.settings.limited,
releaseDate: params.releaseSchedule.publishMode === "immediate" ? null : toUtcReleaseDate(params.releaseSchedule.releaseDateTime, "Asia/Seoul"),
themeId: params.themeId,
isAdult: params.settings.isAdult,
isGeneratePreview: params.settings.isGeneratePreview,
isOnlyRental: params.settings.isOnlyRental,
isPointAvailable: params.settings.isPointAvailable,
isCommentAvailable: params.settings.isCommentAvailable,
isFullDetailVisible: params.settings.isFullDetailVisible,
previewStartTime: params.settings.previewStartTime,
previewEndTime: params.settings.previewEndTime,
languageCode: params.settings.languageCode,
};
}
export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest {
return {
title: params.title,
detail: params.detail,
tags: params.tags,
price: params.price,
isAdult: params.audio.isAdult,
isPointAvailable: params.audio.isAvailableUsePoint,
isCommentAvailable: params.audio.isCommentAvailable,
};
}

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
const nullableString = z.string().nullable();
const nullableNumber = z.number().int().nullable();
export const audioContentListItemSchema = z.object({
audioContentId: z.number().int(),
title: z.string(),
detail: z.string(),
coverImageUrl: z.string(),
creatorNickname: z.string(),
theme: z.string(),
price: z.number().int(),
totalContentCount: nullableNumber,
remainingContentCount: nullableNumber,
isAdult: z.boolean(),
isPointAvailable: z.boolean(),
isCommentAvailable: z.boolean(),
remainingTime: z.string(),
contentUrl: z.string(),
date: z.string(),
releaseDate: nullableString,
tags: z.string(),
});
export const audioContentListResponseSchema = z.object({
totalCount: z.number().int(),
items: z.array(audioContentListItemSchema),
});
const otherContentSchema = z.object({ contentId: z.number().int(), title: z.string(), coverUrl: z.string() });
const audioContentCreatorSchema = z.object({
creatorId: z.number().int(),
nickname: z.string(),
profileImageUrl: z.string(),
isFollowing: z.boolean(),
isFollow: z.boolean(),
isNotify: z.boolean(),
});
const contentBuyerSchema = z.object({ nickname: z.string(), profileImageUrl: z.string() });
const audioContentCommentSchema = z.object({
id: z.number().int(),
writerId: z.number().int(),
nickname: z.string(),
profileUrl: z.string(),
comment: z.string(),
languageCode: nullableString,
isSecret: z.boolean(),
donationCan: z.number().int(),
date: z.string(),
replyCount: z.number().int(),
});
const translatedContentSchema = z.object({ title: nullableString, detail: nullableString, tags: nullableString });
const purchaseOptionSchema = z.union([z.literal("BOTH"), z.literal("BUY_ONLY"), z.literal("RENT_ONLY")]);
const orderTypeSchema = z.union([z.literal("RENTAL"), z.literal("KEEP")]).nullable();
export const audioContentDetailSchema = z.object({
contentId: z.number().int(),
title: z.string(),
detail: z.string(),
languageCode: nullableString,
coverImageUrl: z.string(),
contentUrl: z.string(),
themeStr: z.string(),
tag: z.string(),
price: z.number().int(),
duration: z.string(),
releaseDate: nullableString,
totalContentCount: nullableNumber,
remainingContentCount: nullableNumber,
orderSequence: nullableNumber,
isActivePreview: z.boolean(),
isAdult: z.boolean(),
isMosaic: z.boolean(),
isOnlyRental: z.boolean(),
existOrdered: z.boolean(),
purchaseOption: purchaseOptionSchema,
orderType: orderTypeSchema,
remainingTime: nullableString,
creatorOtherContentList: z.array(otherContentSchema),
sameThemeOtherContentList: z.array(otherContentSchema),
isCommentAvailable: z.boolean(),
isLike: z.boolean(),
likeCount: z.number().int(),
commentList: z.array(audioContentCommentSchema),
commentCount: z.number().int(),
isPin: z.boolean(),
isAvailablePin: z.boolean(),
creator: audioContentCreatorSchema,
previousContent: otherContentSchema.nullable(),
nextContent: otherContentSchema.nullable(),
buyerList: z.array(contentBuyerSchema),
isAvailableUsePoint: z.boolean(),
translated: translatedContentSchema.nullable(),
});
export type AudioContentDetail = z.infer<typeof audioContentDetailSchema>;
export type AudioContentListItem = z.infer<typeof audioContentListItemSchema>;

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { getAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { AudioContentSummary } from "@/features/audio-contents/components/AudioContentSummary";
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { CommentThread } from "@/features/comments/components/CommentThread";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
import { PageState } from "@/shared/ui/page-state";
type DetailState =
| { readonly requestKey: string; readonly status: "loading" }
| { readonly audio: AudioContentDetail; readonly character: CharacterDetail; readonly requestKey: string; readonly status: "content" }
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
export function AudioContentDetailPage({ apiClient, characterId, contentId }: { readonly apiClient: ApiClient; readonly characterId: string; readonly contentId: string }) {
const [state, setState] = useState<DetailState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${characterId}:${contentId}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void Promise.all([
getCharacter(apiClient, characterId),
getAudioContent(apiClient, { characterId, contentId }),
])
.then(([character, audio]) => {
if (isCurrent) {
setState({ audio, character, requestKey, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "오디오 콘텐츠 상세를 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, contentId, requestKey]);
const visibleState: DetailState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
if (visibleState.status === "loading") {
return <PageState state="loading" title="오디오 콘텐츠 상세를 불러오는 중" />;
}
if (visibleState.status === "error") {
return <PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="오디오 콘텐츠 상세 조회 실패" />;
}
return (
<CharacterWorkspaceLayout activeTab="audio" character={visibleState.character}>
<section className="flex flex-col gap-4" aria-labelledby="audio-content-title">
{visibleState.character.isActive ? <div className="hidden justify-end md:flex">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => navigateTo(routePaths.aiCharacterAudioContentEdit(characterId, contentId))} type="button"></button>
</div> : null}
<AudioContentSummary audio={visibleState.audio} />
<AudioPlaybackProvider>
<AdminAudioPlayer playerId={`audio-detail-${visibleState.audio.contentId}`} src={visibleState.audio.contentUrl} title={visibleState.audio.title} />
</AudioPlaybackProvider>
{visibleState.audio.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={visibleState.character.isActive} target={{ kind: "audio", characterId, contentId: String(visibleState.audio.contentId), creatorId: visibleState.audio.creator.creatorId }} /> : null}
</section>
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { getAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { getAudioContentThemes } from "@/features/audio-contents/api/audio-content-theme-api";
import type { UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
import { AudioContentForm } from "@/features/audio-contents/components/AudioContentForm";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { PageState } from "@/shared/ui/page-state";
type FormState =
| { readonly requestKey: string; readonly status: "loading" }
| { readonly audio?: AudioContentDetail; readonly character: CharacterDetail; readonly requestKey: string; readonly status: "content"; readonly themes: readonly AudioContentTheme[] }
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
const mutationRouteQuery = "(min-width: 768px)";
function getCanUseMutationRoutes(): boolean {
return typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia(mutationRouteQuery).matches;
}
function useCanUseMutationRoutes(): boolean {
const [canUseMutationRoutes, setCanUseMutationRoutes] = useState(getCanUseMutationRoutes);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return undefined;
}
const mediaQuery = window.matchMedia(mutationRouteQuery);
const updateCanUseMutationRoutes = () => setCanUseMutationRoutes(mediaQuery.matches);
updateCanUseMutationRoutes();
mediaQuery.addEventListener("change", updateCanUseMutationRoutes);
return () => mediaQuery.removeEventListener("change", updateCanUseMutationRoutes);
}, []);
return canUseMutationRoutes;
}
function AudioContentMobileMutationGuidance({ mode }: { readonly mode: "create" | "edit" }) {
const title = mode === "create" ? "데스크톱에서 오디오 콘텐츠를 생성해 주세요." : "데스크톱에서 오디오 콘텐츠를 수정해 주세요.";
const description = mode === "create" ? "오디오 콘텐츠 생성과 업로드 관리는 태블릿 이상 화면에서 이용할 수 있습니다." : "오디오 콘텐츠 수정, 업로드, 비활성화 관리는 태블릿 이상 화면에서 이용할 수 있습니다.";
return (
<section className="rounded-lg border border-border bg-card p-4" aria-labelledby="audio-content-mobile-mutation-guidance-title">
<h2 className="text-xl font-semibold" id="audio-content-mobile-mutation-guidance-title">{title}</h2>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
</section>
);
}
export function AudioContentFormPage({ apiClient, characterId, contentId, createCropSource = createImageCropSource, renderCrop, uploadAuth, uploadAudioContentRequest }: { readonly apiClient: ApiClient; readonly characterId: string; readonly contentId?: string; readonly createCropSource?: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
const [state, setState] = useState<FormState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const mode = contentId === undefined ? "create" : "edit";
const canUseMutationRoutes = useCanUseMutationRoutes();
const requestKey = `${characterId}:${contentId ?? "new"}:${retryKey}`;
useEffect(() => {
if (!canUseMutationRoutes) {
return undefined;
}
let isCurrent = true;
const request = contentId === undefined
? Promise.all([getCharacter(apiClient, characterId), getAudioContentThemes(apiClient)]).then(([character, themes]) => ({ character, themes }))
: Promise.all([getCharacter(apiClient, characterId), getAudioContent(apiClient, { characterId, contentId })]).then(([character, audio]) => ({ audio, character, themes: [] }));
void request
.then((data) => {
if (isCurrent) {
setState({ ...data, requestKey, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "오디오 콘텐츠 입력 화면 정보를 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, canUseMutationRoutes, characterId, contentId, mode, requestKey]);
if (!canUseMutationRoutes) {
return <AudioContentMobileMutationGuidance mode={mode} />;
}
const visibleState: FormState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
if (visibleState.status === "loading") {
return <PageState state="loading" title="오디오 콘텐츠 입력 화면을 불러오는 중" />;
}
if (visibleState.status === "error") {
return <PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="오디오 콘텐츠 입력 화면 조회 실패" />;
}
if (!visibleState.character.isActive) {
return (
<CharacterWorkspaceLayout activeTab="audio" character={visibleState.character}>
<PageState description="목록과 상세 조회만 가능합니다." state="empty" title="비활성화된 AI 캐릭터에는 오디오 콘텐츠를 저장할 수 없습니다." />
</CharacterWorkspaceLayout>
);
}
return (
<CharacterWorkspaceLayout activeTab="audio" character={visibleState.character}>
<AudioContentForm apiClient={apiClient} audio={visibleState.audio} characterId={characterId} createCropSource={createCropSource} mode={mode} renderCrop={renderCrop} themes={visibleState.themes} uploadAuth={uploadAuth} uploadAudioContentRequest={uploadAudioContentRequest} />
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from "react";
import { navigateTo, useBrowserLocation } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { getAudioContents } from "@/features/audio-contents/api/audio-content-api";
import { AudioContentList } from "@/features/audio-contents/components/AudioContentList";
import type { AudioContentListItem } from "@/features/audio-contents/model/types";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { PageData } from "@/shared/api/pagination";
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
import { PageState } from "@/shared/ui/page-state";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
import { SearchToolbar } from "@/shared/ui/search-toolbar";
type ListQuery = { readonly page: number; readonly search: string; readonly size: number };
type ListState =
| { readonly requestKey: string; readonly status: "loading" }
| { readonly character: CharacterDetail; readonly data: PageData<AudioContentListItem>; readonly requestKey: string; readonly status: "content" }
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
function getListQuery(): ListQuery {
const query = new URLSearchParams(window.location.search);
const page = Number(query.get("page") ?? "0");
const size = Number(query.get("size") ?? "20");
return {
page: Number.isFinite(page) ? Math.max(0, Math.trunc(page)) : 0,
search: query.get("search_word") ?? "",
size: Number.isFinite(size) ? Math.max(1, Math.trunc(size)) : 20,
};
}
function navigateList(characterId: string, query: ListQuery): void {
const nextQuery = new URLSearchParams({ page: String(query.page), size: String(query.size) });
const search = query.search.trim();
if (search.length > 0) {
nextQuery.set("search_word", search);
}
navigateTo(`${routePaths.aiCharacterAudioContents(characterId)}?${nextQuery.toString()}`);
}
export function AudioContentListPage({ apiClient, characterId }: { readonly apiClient: ApiClient; readonly characterId: string }) {
const location = useBrowserLocation();
const query = getListQuery();
const [state, setState] = useState<ListState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${location.visitKey}:${characterId}:${query.page}:${query.search}:${query.size}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void Promise.all([
getCharacter(apiClient, characterId),
getAudioContents(apiClient, { characterId, search_word: query.search, page: query.page, size: query.size }),
])
.then(([character, data]) => {
if (isCurrent) {
setState({ character, data, requestKey, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "오디오 콘텐츠 목록을 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, query.page, query.search, query.size, requestKey]);
const visibleState: ListState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
const onQueryChange = useCallback((nextSearch: string) => navigateList(characterId, { page: 0, search: nextSearch, size: query.size }), [characterId, query.size]);
const onPageChange = useCallback((page: number) => navigateList(characterId, { page, search: query.search, size: query.size }), [characterId, query.search, query.size]);
const onSizeChange = useCallback((size: number) => navigateList(characterId, { page: 0, search: query.search, size }), [characterId, query.search]);
if (visibleState.status === "loading") {
return <PageState state="loading" title="오디오 콘텐츠 목록을 불러오는 중" />;
}
if (visibleState.status === "error") {
return <PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="오디오 콘텐츠 목록 조회 실패" />;
}
return (
<CharacterWorkspaceLayout activeTab="audio" character={visibleState.character}>
<section className="flex flex-col gap-4" aria-labelledby="audio-contents-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AUDIO CONTENTS</p>
<h2 className="text-2xl font-bold leading-tight" id="audio-contents-title"> </h2>
<p className="text-sm text-muted-foreground"> .</p>
</div>
{visibleState.character.isActive ? <div className="hidden justify-end md:flex">
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => navigateTo(routePaths.aiCharacterAudioContentCreate(characterId))} type="button"> </button>
</div> : null}
<SearchToolbar key={query.search} onQueryChange={onQueryChange} search={query.search} />
{visibleState.data.items.length === 0 ? <PageState description="검색어를 바꾸거나 나중에 다시 시도하세요." state="empty" title="검색 조건에 맞는 오디오 콘텐츠가 없습니다." /> : null}
{visibleState.data.items.length > 0 ? (
<AudioPlaybackProvider>
<AudioContentList characterId={characterId} data={visibleState.data} />
</AudioPlaybackProvider>
) : null}
<ResourcePagination data={visibleState.data} onPageChange={onPageChange} onSizeChange={onSizeChange} />
</section>
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,53 @@
import { z } from "zod";
import { canPriceSchema } from "@/shared/validation/can-price";
export const purchaseOptionSchema = z.union([z.literal("BOTH"), z.literal("BUY_ONLY"), z.literal("RENT_ONLY")]);
export const audioContentThemeSchema = z.object({
id: z.number().int(),
theme: z.string(),
image: z.string(),
});
export const audioContentCreateResponseSchema = z.object({ contentId: z.number().int() });
export const audioContentCreateRequestSchema = z.object({
title: z.string().min(1),
detail: z.string().min(1),
tags: z.string().min(1),
price: canPriceSchema,
purchaseOption: purchaseOptionSchema.default("BOTH"),
limited: z.number().int().nullable().default(null),
releaseDate: z.string().nullable(),
themeId: z.number().int().positive(),
isAdult: z.boolean().default(false),
isGeneratePreview: z.boolean().default(false),
isOnlyRental: z.boolean().default(false),
isPointAvailable: z.boolean().default(false),
isCommentAvailable: z.boolean().default(false),
isFullDetailVisible: z.boolean().default(true),
previewStartTime: z.string().nullable().default(null),
previewEndTime: z.string().nullable().default(null),
languageCode: z.string().nullable().default(null),
});
export const audioContentUpdateRequestSchema = z.strictObject({
title: z.string().nullable().optional(),
detail: z.string().nullable().optional(),
tags: z.string().nullable().optional(),
price: canPriceSchema.nullable().optional(),
isAdult: z.boolean().nullable().optional(),
isPointAvailable: z.boolean().nullable().optional(),
isCommentAvailable: z.boolean().nullable().optional(),
});
export const audioContentDeactivateRequestSchema = z.strictObject({
isActive: z.literal(false),
});
export type AudioContentCreateRequest = z.infer<typeof audioContentCreateRequestSchema>;
export type AudioContentCreateResponse = z.infer<typeof audioContentCreateResponseSchema>;
export type AudioContentDeactivateRequest = z.infer<typeof audioContentDeactivateRequestSchema>;
export type AudioContentTheme = z.infer<typeof audioContentThemeSchema>;
export type AudioContentUpdateRequest = z.infer<typeof audioContentUpdateRequestSchema>;

View File

@@ -0,0 +1,251 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { createAudioContent, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { getAudioContentThemes } from "@/features/audio-contents/api/audio-content-theme-api";
import { audioContentCreateRequestSchema, audioContentDeactivateRequestSchema, audioContentUpdateRequestSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import { AUDIO_COVER_POLICY, validateAudioCoverFile } from "@/features/audio-contents/validation/audio-cover-policy";
import { createApiResponseSchema } from "@/shared/api/types";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
import { server } from "@/shared/test/server";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
const apiBaseUrl = "https://api.example.com";
const adminToken = "mock-admin-jwt";
function createCapturingClient(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/audio-content-themes") {
return options.responseSchema.parse([{ id: 7, theme: "힐링", image: "https://cdn.example.com/theme/healing.png" }]);
}
if (options.path.endsWith("/audio-contents") && options.method === "POST") {
return options.responseSchema.parse({ contentId: 9301 });
}
return options.responseSchema.parse(null);
},
};
}
function requireFormData(body: BodyInit | null | undefined): FormData {
if (body instanceof FormData) {
return body;
}
throw new TypeError("Expected FormData body");
}
async function readJsonPart(part: FormDataEntryValue | null): Promise<unknown> {
if (part instanceof Blob) {
return JSON.parse(await part.text());
}
if (typeof part === "string") {
return JSON.parse(part);
}
return null;
}
function fileWithSize(name: string, type: string, size: number): File {
const file = new File(["x"], name, { type });
Object.defineProperty(file, "size", { value: size });
return file;
}
function authorizedFetch(path: string, init: RequestInit = {}) {
return fetch(`${apiBaseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${adminToken}`, ...init.headers },
});
}
function multipartInit(request: object, fileParts: readonly string[] = []): RequestInit {
const boundary = "test-boundary";
const files = fileParts.map((name) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.bin"\r\nContent-Type: application/octet-stream\r\n\r\nfile-bytes\r\n`).join("");
return {
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
body: `${files}--${boundary}\r\nContent-Disposition: form-data; name="request"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(request)}\r\n--${boundary}--\r\n`,
};
}
function requireData<Data>(data: Data | null): Data {
if (data === null) {
throw new Error("response data missing");
}
return data;
}
describe("Audio content mutation contract", () => {
test("theme API reads id/theme/image and create sends required multipart without unsupported fields", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
const contentFile = new File(["audio"], "voice.m4a", { type: "audio/x-m4a" });
const coverImage = new File(["cover"], "cover.png", { type: "image/png" });
// When
await expect(getAudioContentThemes(client)).resolves.toEqual([{ id: 7, theme: "힐링", image: "https://cdn.example.com/theme/healing.png" }]);
const createResult = await createAudioContent(client, {
characterId: "101",
contentFile,
coverImage,
request: audioContentCreateRequestSchema.parse({
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
tags: "상담,힐링",
price: 1000,
purchaseOption: "BOTH",
limited: null,
releaseDate: null,
themeId: 7,
isAdult: false,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: true,
isCommentAvailable: true,
isFullDetailVisible: true,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
}),
});
// Then
expect(createResult.contentId).toBe(9301);
expect(requests).toMatchObject([
{ path: "/api/v2/admin/ai-characters/audio-content-themes", method: undefined },
{ path: "/api/v2/admin/ai-characters/101/audio-contents", method: "POST" },
]);
const body = requireFormData(requests[1]?.body);
expect(body.get("contentFile")).toBe(contentFile);
expect(body.get("coverImage")).toBe(coverImage);
expect(body.has("audioFile")).toBe(false);
const request = await readJsonPart(body.get("request"));
expect(request).toEqual({
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
tags: "상담,힐링",
price: 1000,
purchaseOption: "BOTH",
limited: null,
releaseDate: null,
themeId: 7,
isAdult: false,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: true,
isCommentAvailable: true,
isFullDetailVisible: true,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
});
expect(request).not.toHaveProperty("status");
expect(request).not.toHaveProperty("isActive");
expect(request).not.toHaveProperty("seriesIds");
});
test("normal update rejects isActive and deactivate sends only isActive false", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
const coverImage = new File(["new-cover"], "cover.jpg", { type: "image/jpeg" });
// When
expect(() => audioContentUpdateRequestSchema.parse({ title: "수정 제목", isActive: false })).toThrow();
expect(() => audioContentUpdateRequestSchema.parse({ title: "수정 제목", isActive: true })).toThrow();
expect(() => audioContentUpdateRequestSchema.parse({ title: "수정 제목", isActive: null })).toThrow();
await updateAudioContent(client, "101", "9001", {
coverImage,
request: {
title: "수정 제목",
detail: "수정 설명",
tags: "수정,태그",
price: 0,
isAdult: true,
isPointAvailable: false,
isCommentAvailable: false,
},
});
await deactivateAudioContent(client, "101", "9001");
// Then
expect(requests).toMatchObject([
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001", method: "PUT" },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001", method: "PUT" },
]);
const updateBody = requireFormData(requests[0]?.body);
expect(updateBody.get("coverImage")).toBe(coverImage);
expect(updateBody.has("contentFile")).toBe(false);
expect(await readJsonPart(updateBody.get("request"))).toEqual({
title: "수정 제목",
detail: "수정 설명",
tags: "수정,태그",
price: 0,
isAdult: true,
isPointAvailable: false,
isCommentAvailable: false,
});
const deactivateBody = requireFormData(requests[1]?.body);
expect(deactivateBody.has("coverImage")).toBe(false);
expect(audioContentDeactivateRequestSchema.parse(await readJsonPart(deactivateBody.get("request")))).toEqual({ isActive: false });
expect(await readJsonPart(deactivateBody.get("request"))).toEqual({ isActive: false });
});
test.each([0, 99_999])("create and update schemas accept CAN price boundary %i", (price) => {
expect(audioContentCreateRequestSchema.parse({ title: "가격", detail: "설명", tags: "태그", price, themeId: 7, releaseDate: null }).price).toBe(price);
expect(audioContentUpdateRequestSchema.parse({ price }).price).toBe(price);
});
test.each([-1, 100_000, 1.5])("create and update schemas reject invalid CAN price %i", (price) => {
expect(() => audioContentCreateRequestSchema.parse({ title: "가격", detail: "설명", tags: "태그", price, themeId: 7, releaseDate: null })).toThrow();
expect(() => audioContentUpdateRequestSchema.parse({ price })).toThrow();
});
test("audio cover policy reuses square no-upscale 800px JPEG/PNG 10MB profile", () => {
expect(AUDIO_COVER_POLICY).toEqual({ aspect: 1, cropRequired: true, maxBytes: 10_485_760, maxWidth: 800, noUpscale: true });
expect(validateAudioCoverFile(fileWithSize("cover.jpg", "image/jpeg", 10_485_760))).toEqual({ ok: true });
expect(validateAudioCoverFile(fileWithSize("cover.png", "image/png", 10_485_760))).toEqual({ ok: true });
expect(validateAudioCoverFile(fileWithSize("cover.gif", "image/gif", 10))).toEqual({ ok: false, reason: "extension" });
expect(validateAudioCoverFile(fileWithSize("cover.png", "image/png", 10_485_761))).toEqual({ ok: false, reason: "size" });
});
test("audio cover policy rejects mismatched extension and MIME pairs", () => {
expect(validateAudioCoverFile(fileWithSize("cover.jpg", "image/png", 10))).toEqual({ ok: false, reason: "mime" });
expect(validateAudioCoverFile(fileWithSize("cover.jpeg", "image/png", 10))).toEqual({ ok: false, reason: "mime" });
expect(validateAudioCoverFile(fileWithSize("cover.png", "image/jpeg", 10))).toEqual({ ok: false, reason: "mime" });
});
test("mock handlers validate multipart and update the same list/detail store", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const themeResponse = await authorizedFetch("/api/v2/admin/ai-characters/audio-content-themes");
const createResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents", { method: "POST", ...multipartInit({ title: "새 오디오", detail: "새 설명", tags: "신규", price: 100, themeId: 7, releaseDate: null }, ["contentFile", "coverImage"]) });
const createJson = createApiResponseSchema(z.object({ contentId: z.number().int() })).parse(await createResponse.json());
const contentId = requireData(createJson.data).contentId;
const updateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/101/audio-contents/${contentId}`, { method: "PUT", ...multipartInit({ title: "수정 오디오", detail: "수정 설명", tags: "수정", price: 0, isCommentAvailable: false }) });
const detailResponse = await authorizedFetch(`/api/v2/admin/ai-characters/101/audio-contents/${contentId}`);
const deactivateResponse = await authorizedFetch(`/api/v2/admin/ai-characters/101/audio-contents/${contentId}`, { method: "PUT", ...multipartInit({ isActive: false }) });
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents?page=0&size=20");
// Then
expect(createApiResponseSchema(z.array(z.object({ id: z.number(), theme: z.string(), image: z.string() }))).parse(await themeResponse.json()).data).toHaveLength(2);
expect(createJson.data).toEqual({ contentId });
expect(createApiResponseSchema(z.null()).parse(await updateResponse.json()).data).toBeNull();
expect(createApiResponseSchema(z.null()).parse(await deactivateResponse.json()).data).toBeNull();
expect(await detailResponse.json()).toMatchObject({ success: true, data: { contentId, title: "수정 오디오", price: 0, isCommentAvailable: false } });
expect(await listResponse.json()).toMatchObject({ success: true, data: { items: expect.not.arrayContaining([expect.objectContaining({ audioContentId: contentId })]) } });
});
});

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

View File

@@ -0,0 +1,153 @@
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
export type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
export const character = {
id: 101,
characterUUID: "character-uuid-101",
name: "루나",
imageUrl: null,
description: "차분한 상담형 AI 캐릭터",
systemPrompt: "친절하고 안전하게 답한다.",
characterType: "Character",
age: 24,
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "다정함",
appearance: null,
region: "KR",
isActive: true,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
};
export const audioDetail = {
contentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
languageCode: "ko",
coverImageUrl: "https://cdn.example.com/audio/luna-cover.png",
contentUrl: "https://cdn.example.com/audio/luna.m4a",
themeStr: "힐링",
tag: "상담,힐링",
price: 1000,
duration: "03:10",
releaseDate: "2026-07-28T01:00:00Z",
totalContentCount: 5,
remainingContentCount: 4,
orderSequence: 1,
isActivePreview: true,
isAdult: false,
isMosaic: false,
isOnlyRental: false,
existOrdered: false,
purchaseOption: "RENT_ONLY",
orderType: null,
remainingTime: "7일",
creatorOtherContentList: [],
sameThemeOtherContentList: [],
isCommentAvailable: true,
isLike: false,
likeCount: 0,
commentList: [],
commentCount: 0,
isPin: false,
isAvailablePin: false,
creator: { creatorId: 101, nickname: "루나", profileImageUrl: "https://cdn.example.com/luna.png", isFollowing: false, isFollow: false, isNotify: false },
previousContent: null,
nextContent: null,
buyerList: [],
isAvailableUsePoint: true,
translated: null,
};
export function createFormClient(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);
}
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.method === "PUT") {
return options.responseSchema.parse(null);
}
if (options.path === "/api/v2/admin/ai-characters/101/audio-contents/9001") {
return options.responseSchema.parse(audioDetail);
}
return options.responseSchema.parse({ contentId: 9301 });
},
};
}
export function requireFormData(body: BodyInit | null | undefined): FormData {
if (body instanceof FormData) {
return body;
}
throw new TypeError("Expected FormData body");
}
export async function readJsonPart(part: FormDataEntryValue | null): Promise<unknown> {
if (part instanceof Blob) {
return JSON.parse(await part.text());
}
if (typeof part === "string") {
return JSON.parse(part);
}
return null;
}
export function fileWithSize(name: string, type: string, size: number): File {
const file = new File(["x"], name, { type });
Object.defineProperty(file, "size", { value: size });
return file;
}
type RenderFormOptions = {
readonly contentId?: string;
readonly createCropSource?: (file: File) => Promise<{ readonly file: File; readonly height: number; readonly previewUrl: string; readonly width: number }>;
readonly renderCrop?: (request: CropRenderRequest) => Promise<File>;
readonly requests: CapturedRequest[];
readonly uploadAudioContentRequest?: UploadAudioContentRequest;
};
export function renderAudioFormPage(options: RenderFormOptions): ReactElement {
return (
<AudioContentFormPage
apiClient={createFormClient(options.requests)}
characterId="101"
contentId={options.contentId}
createCropSource={options.createCropSource ?? ((file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 }))}
renderCrop={options.renderCrop ?? ((request) => Promise.resolve(request.file))}
uploadAudioContentRequest={options.uploadAudioContentRequest}
/>
);
}
export function renderAudioForm(options: RenderFormOptions) {
return render(renderAudioFormPage(options));
}

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

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

View File

@@ -0,0 +1,308 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { createFormClient, fileWithSize, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
import type { CapturedRequest } 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 creates immediate audio with required theme, crop policy, 캔 price, and no unsupported upload limits", 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" });
const croppedCover = new File(["cropped"], "cover-cropped.png", { type: "image/png" });
const renderCrop: (request: CropRenderRequest) => Promise<File> = vi.fn(() => Promise.resolve(croppedCover));
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={renderCrop}
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: "1,000캔" } });
expect(screen.getByLabelText("가격")).toHaveValue("1,000캔");
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(await screen.findByText("테마를 선택하세요.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
expect(screen.getByLabelText("예약 공개일")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ aspect: 1, outputWidth: 800, outputHeight: 800 }));
const body = requireFormData(uploadedBodies.at(-1));
expect(body.get("contentFile")).toBe(contentFile);
expect(body.get("coverImage")).toBe(croppedCover);
expect(await readJsonPart(body.get("request"))).toEqual({
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
tags: "상담,힐링",
price: 1000,
purchaseOption: "BOTH",
limited: null,
releaseDate: null,
themeId: 7,
isAdult: false,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
isCommentAvailable: false,
isFullDetailVisible: true,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
});
expect(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeInTheDocument();
expect(screen.getByText("제목, 상세 설명, 태그는 저장 전 운영 기준에 맞게 검토하세요. 업로드 제한은 안내된 파일 정책을 따릅니다.")).toBeInTheDocument();
});
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);
});
test("AudioContentFormPage validates audio boundary and serializes scheduled Asia Seoul releaseDate as UTC Z", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-07-29T08:00:00Z").getTime());
const futureDate = "2026-07-29T18:00";
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: "예약 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "예약 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "예약" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "0" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_001)] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("오디오 파일은 1,024,000,000 bytes 이하만 업로드할 수 있습니다.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.jpg", { type: "image/jpeg" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.jpg")).toBeInTheDocument());
fireEvent.click(screen.getByLabelText("예약 공개"));
fireEvent.change(screen.getByLabelText("예약 공개일"), { target: { value: "2000-01-01T00:00" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("미래 Asia/Seoul 시각을 입력하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("예약 공개일"), { target: { value: futureDate } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
const request = await readJsonPart(requireFormData(uploadedBodies.at(-1)).get("request"));
expect(request).toMatchObject({ releaseDate: "2026-07-29T09:00:00Z" });
expect(request).not.toHaveProperty("timezone");
});
test("AudioContentFormPage blocks prices outside the CAN range before upload", async () => {
// 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: "가격 경계" } });
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: "100,000캔" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
test("AudioContentFormPage links validation errors and focuses the first invalid control", async () => {
// 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
const titleInput = await screen.findByLabelText("제목");
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
const titleError = await screen.findByText("제목을 입력하세요.");
const detailError = screen.getByText("상세 설명을 입력하세요.");
const tagsError = screen.getByText("태그를 입력하세요.");
const priceError = screen.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
const themeError = screen.getByText("테마를 선택하세요.");
expect(titleError).toHaveAttribute("id", "audio-content-title-error");
expect(detailError).toHaveAttribute("id", "audio-content-detail-error");
expect(tagsError).toHaveAttribute("id", "audio-content-tags-error");
expect(priceError).toHaveAttribute("id", "audio-content-price-error");
expect(themeError).toHaveAttribute("id", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-describedby", "audio-content-title-error");
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
expect(screen.getByLabelText("태그")).toHaveAttribute("aria-describedby", "audio-content-tags-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "audio-content-price-error");
expect(screen.getByLabelText("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(titleInput).toHaveFocus());
expect(uploadedBodies).toHaveLength(0);
});
test("AudioContentFormPage uses Korean loading and error copy for operators", async () => {
// Given
const pendingClient: ApiClient = {
request: () => new Promise<never>(() => undefined),
};
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={pendingClient} characterId="101" />);
// Then
expect(screen.getByText("오디오 콘텐츠 입력 화면을 불러오는 중")).toBeInTheDocument();
// Given
const failingClient: ApiClient = {
request: async () => {
throw new Error("boom");
},
};
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={failingClient} characterId="101" />);
// Then
expect(await screen.findByText("오디오 콘텐츠 입력 화면 정보를 불러오지 못했습니다.")).toBeInTheDocument();
expect(screen.getByText("오디오 콘텐츠 입력 화면 조회 실패")).toBeInTheDocument();
});

View File

@@ -0,0 +1,170 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
const character = {
id: 101,
characterUUID: "character-uuid-101",
name: "루나",
imageUrl: null,
description: "차분한 상담형 AI 캐릭터",
systemPrompt: "친절하고 안전하게 답한다.",
characterType: "Character",
age: 24,
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "다정함",
appearance: null,
region: "KR",
isActive: true,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
} as const;
const audioItems = [
{
audioContentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
coverImageUrl: "https://cdn.example.com/audio/luna-cover.png",
creatorNickname: "루나",
theme: "힐링",
price: 1000,
totalContentCount: 5,
remainingContentCount: 4,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
remainingTime: "7일",
contentUrl: "https://cdn.example.com/signed/luna.m4a?token=secret-one",
date: "2026-07-28T01:00:00Z",
releaseDate: "2026-07-28T01:00:00Z",
tags: "상담,힐링",
},
{
audioContentId: 9002,
title: "아침 안내 오디오",
detail: "하루를 시작하는 안내",
coverImageUrl: "https://cdn.example.com/audio/morning-cover.png",
creatorNickname: "루나",
theme: "안내",
price: 0,
totalContentCount: null,
remainingContentCount: null,
isAdult: false,
isPointAvailable: false,
isCommentAvailable: false,
remainingTime: "",
contentUrl: "https://cdn.example.com/signed/morning.m4a?token=secret-two",
date: "2026-07-27 09:00:00",
releaseDate: null,
tags: "안내",
},
] as const;
function saveAdminSession() {
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
afterEach(() => {
vi.useRealTimers();
window.history.replaceState({}, "", "/");
});
test("Audio list restores and serializes search_word only and exposes list players without status filters", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const audioRequests: Request[] = [];
const playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
const pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({ success: true, message: null, data: character, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/audio-contents`, ({ request }) => {
audioRequests.push(request);
return HttpResponse.json({ success: true, message: null, data: { totalCount: 2, items: audioItems }, errorProperty: null });
}),
);
window.history.pushState({}, "", "/ai-characters/101/audio-contents?search_word=루나&page=1&size=20");
// When
render(<App />);
// Then
expect(await screen.findByRole("heading", { name: "오디오 콘텐츠" }, { timeout: 5_000 })).toBeInTheDocument();
await waitFor(() => expect(audioRequests.length).toBeGreaterThan(0));
const firstUrl = new URL(audioRequests.at(-1)?.url ?? "");
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("루나");
expect(firstUrl.searchParams.get("search_word")).toBe("루나");
expect(firstUrl.searchParams.get("page")).toBe("1");
expect(firstUrl.searchParams.get("size")).toBe("20");
expect(firstUrl.searchParams.has("status")).toBe(false);
expect(firstUrl.searchParams.has("isActive")).toBe(false);
expect(firstUrl.searchParams.has("active")).toBe(false);
// When
vi.useFakeTimers();
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "달빛" } });
act(() => vi.advanceTimersByTime(300));
vi.useRealTimers();
// Then
await waitFor(() => expect(audioRequests.length).toBeGreaterThan(1));
const secondUrl = new URL(audioRequests.at(-1)?.url ?? "");
expect(secondUrl.searchParams.get("search_word")).toBe("달빛");
expect(window.location.search).toContain("search_word=%EB%8B%AC%EB%B9%9B");
expect(window.location.search).not.toContain("search=");
expect(document.querySelectorAll("audio")).toHaveLength(audioItems.length);
const detailLinks = screen.getAllByRole("link", { name: "달빛 상담 오디오 상세 보기" });
const firstDetailLink = detailLinks[0];
if (firstDetailLink === undefined) {
throw new Error("expected detail link");
}
expect(firstDetailLink).toHaveAttribute("href", "/ai-characters/101/audio-contents/9001");
const playButtons = screen.getAllByRole("button", { name: "재생" });
const firstPlayButton = playButtons[0];
const secondPlayButton = playButtons[1];
if (firstPlayButton === undefined || secondPlayButton === undefined) {
throw new Error("expected audio players in list");
}
fireEvent.click(firstPlayButton);
fireEvent.click(secondPlayButton);
expect(playSpy).toHaveBeenCalledTimes(2);
expect(pauseSpy).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /다운로드/ })).not.toBeInTheDocument();
});
test("Audio list hides creation entrypoint for inactive characters", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({ success: true, message: null, data: { ...character, isActive: false }, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/audio-contents`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 2, items: audioItems }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/101/audio-contents");
// When
render(<App />);
await screen.findByRole("heading", { name: "오디오 콘텐츠" });
// Then
expect(screen.queryByRole("button", { name: "오디오 생성" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "달빛 상담 오디오 상세 보기" })).toHaveAttribute("href", "/ai-characters/101/audio-contents/9001");
});

View File

@@ -0,0 +1,202 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly path: string;
};
const character = {
id: 101,
characterUUID: "character-uuid-101",
name: "루나",
imageUrl: null,
description: "차분한 상담형 AI 캐릭터",
systemPrompt: "친절하고 안전하게 답한다.",
characterType: "Character",
age: 24,
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "다정함",
appearance: null,
region: "KR",
isActive: true,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
} as const;
const signedUrl = "https://cdn.example.com/signed/luna.m4a?token=secret-detail";
const audioDetail = {
contentId: 9001,
title: "달빛 상담 오디오",
detail: "잠들기 전 듣는 상담 오디오",
languageCode: "ko",
coverImageUrl: "https://cdn.example.com/audio/luna-cover.png",
contentUrl: signedUrl,
themeStr: "힐링",
tag: "상담,힐링",
price: 1000,
duration: "03:10",
releaseDate: "2026-07-28T01:00:00Z",
totalContentCount: 5,
remainingContentCount: 4,
orderSequence: 1,
isActivePreview: true,
isAdult: false,
isMosaic: false,
isOnlyRental: false,
existOrdered: false,
purchaseOption: "RENT_ONLY",
orderType: null,
remainingTime: "7일",
creatorOtherContentList: [],
sameThemeOtherContentList: [],
isCommentAvailable: true,
isLike: false,
likeCount: 0,
commentList: [],
commentCount: 0,
isPin: false,
isAvailablePin: false,
creator: {
creatorId: 101,
nickname: "루나",
profileImageUrl: "https://cdn.example.com/luna.png",
isFollowing: false,
isFollow: false,
isNotify: false,
},
previousContent: null,
nextContent: null,
buyerList: [],
isAvailableUsePoint: true,
translated: null,
} as const;
let playSpy: ReturnType<typeof vi.spyOn>;
let loadSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
});
afterEach(() => {
window.history.replaceState({}, "", "/");
});
function createDetailClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
if (options.path.includes("/comments")) {
return options.responseSchema.parse({ totalCount: 0, items: [] });
}
requests.push({ 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/101/audio-contents/9001") {
return options.responseSchema.parse(audioDetail);
}
return options.responseSchema.parse(null);
},
};
}
function createInactiveDetailClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
if (options.path.includes("/comments")) {
return options.responseSchema.parse({ totalCount: 0, items: [] });
}
requests.push({ 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/101/audio-contents/9001") {
return options.responseSchema.parse(audioDetail);
}
return options.responseSchema.parse(null);
},
};
}
test("Audio detail omits timezone query and media errors do not refetch or replay signed URLs", async () => {
// Given
const requests: CapturedRequest[] = [];
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const localStorageSpy = vi.spyOn(Storage.prototype, "setItem");
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001");
// When
render(<AudioContentDetailPage apiClient={createDetailClient(requests)} characterId="101" contentId="9001" />);
// Then
expect(await screen.findByRole("heading", { name: "달빛 상담 오디오" })).toBeInTheDocument();
expect(screen.getByText("2026. 07. 28. 10:00")).toBeInTheDocument();
expect(requests).toEqual([
{ path: "/api/v2/admin/ai-characters/101" },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001" },
]);
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
expect(audio).toHaveAttribute("src", signedUrl);
expect(audio).toHaveAttribute("controlsList", "nodownload");
expect(screen.queryByRole("button", { name: /다운로드/ })).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "재생" }));
act(() => {
audio.dispatchEvent(new Event("error"));
});
const mediaError = await screen.findByRole("alert");
fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" }));
// Then
expect(mediaError).toHaveTextContent("페이지 새로고침 후 다시 시도하세요");
expect(requests).toHaveLength(2);
expect(playSpy).toHaveBeenCalledTimes(1);
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl));
});
test("Audio detail keeps inactive content readable while blocking edit and comment mutations", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001");
// When
render(<AudioContentDetailPage apiClient={createInactiveDetailClient(requests)} characterId="101" contentId="9001" />);
// Then
expect(await screen.findByRole("heading", { name: "달빛 상담 오디오" })).toBeInTheDocument();
expect(document.querySelector("audio")).toHaveAttribute("src", signedUrl);
expect(await screen.findByRole("heading", { name: "댓글 관리" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "수정" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("새 댓글")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "댓글 등록" })).not.toBeInTheDocument();
expect(requests).toEqual([
{ path: "/api/v2/admin/ai-characters/101" },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001" },
]);
});

View File

@@ -0,0 +1,168 @@
import { http, HttpResponse } from "msw";
import { z } from "zod";
import { afterEach, expect, test, vi } from "vitest";
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { createApiClient } from "@/shared/api/client";
import { apiBaseUrl, valueSchema } from "@/shared/api/__tests__/client-test-helpers";
import { server } from "@/shared/test/server";
class LifecycleXMLHttpRequest {
static instances: LifecycleXMLHttpRequest[] = [];
readonly headers = new Map<string, string>();
readonly upload = new EventTarget();
onerror: ((event: Event) => void) | null = null;
onload: ((event: Event) => void) | null = null;
responseText = "";
status = 0;
constructor() {
LifecycleXMLHttpRequest.instances.push(this);
}
abort(): void {}
open(): void {}
setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
}
send(): void {}
failNetwork(): void {
this.onerror?.(new Event("error"));
}
failWithBody(status: number, body: string): void {
this.status = status;
this.responseText = body;
this.onload?.(new Event("load"));
}
}
const createResponseSchema = z.object({ contentId: z.number().int() });
afterEach(() => {
LifecycleXMLHttpRequest.instances = [];
vi.unstubAllEnvs();
});
function installLifecycleUpload(): void {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
vi.stubEnv("VITE_API_MODE", "server");
vi.stubGlobal("XMLHttpRequest", LifecycleXMLHttpRequest);
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
function sessionExpiredBody(): string {
return JSON.stringify({ success: false, message: "세션이 만료되었습니다.", data: null, errorProperty: null });
}
test("fetch and XHR 401 failures clear the same session once and reset for the next session", async () => {
// Given
installLifecycleUpload();
let currentToken: string | null = "first-token";
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
const auth = { clearSession, getToken: () => currentToken, onAuthExpired };
const client = createApiClient(auth);
server.use(http.get(`${apiBaseUrl}/protected`, () => HttpResponse.json({ success: false, message: "세션이 만료되었습니다.", data: null, errorProperty: null }, { status: 401 })));
// When
const fetchRequest = client.request({ path: "/protected", responseSchema: valueSchema, authentication: "required" });
const xhrUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema });
const firstUploadRequest = LifecycleXMLHttpRequest.instances[0];
if (firstUploadRequest === undefined) {
throw new Error("expected upload request");
}
firstUploadRequest.failWithBody(401, sessionExpiredBody());
await Promise.allSettled([fetchRequest, xhrUpload]);
currentToken = "second-token";
const nextUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema });
const secondUploadRequest = LifecycleXMLHttpRequest.instances[1];
if (secondUploadRequest === undefined) {
throw new Error("expected second upload request");
}
secondUploadRequest.failWithBody(401, sessionExpiredBody());
// Then
await expect(nextUpload).rejects.toMatchObject({ status: 401 });
expect(clearSession).toHaveBeenCalledTimes(2);
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});
test("XHR 401 skips expiry when fetch already cleared the injected session", async () => {
// Given
installLifecycleUpload();
let currentToken: string | null = "first-token";
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
const auth = { clearSession, getToken: () => currentToken, onAuthExpired };
const client = createApiClient(auth);
server.use(http.get(`${apiBaseUrl}/protected`, () => HttpResponse.json({ success: false, message: "세션이 만료되었습니다.", data: null, errorProperty: null }, { status: 401 })));
// When
await expect(client.request({ path: "/protected", responseSchema: valueSchema, authentication: "required" })).rejects.toMatchObject({ status: 401 });
const xhrUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema });
const uploadRequest = LifecycleXMLHttpRequest.instances[0];
if (uploadRequest === undefined) {
throw new Error("expected upload request");
}
uploadRequest.failWithBody(401, sessionExpiredBody());
// Then
await expect(xhrUpload).rejects.toMatchObject({ status: 401 });
expect(clearSession).toHaveBeenCalledTimes(1);
expect(onAuthExpired).toHaveBeenCalledTimes(1);
});
test("late abort after network failure does not swallow the next session upload 401", async () => {
// Given
installLifecycleUpload();
let currentToken: string | null = "first-token";
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
const auth = { clearSession, getToken: () => currentToken, onAuthExpired };
const controller = new AbortController();
const failedUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema, signal: controller.signal });
const networkRequest = LifecycleXMLHttpRequest.instances[0];
if (networkRequest === undefined) {
throw new Error("expected network upload request");
}
networkRequest.failNetwork();
await expect(failedUpload).rejects.toMatchObject({ status: 0 });
// When
controller.abort();
const firstSessionUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema });
const firstSessionRequest = LifecycleXMLHttpRequest.instances[1];
if (firstSessionRequest === undefined) {
throw new Error("expected first session upload request");
}
firstSessionRequest.failWithBody(401, sessionExpiredBody());
await expect(firstSessionUpload).rejects.toMatchObject({ status: 401 });
currentToken = "second-token";
const secondSessionUpload = uploadAudioContent({ auth, authentication: "required", body: new FormData(), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents", responseSchema: createResponseSchema });
const secondSessionRequest = LifecycleXMLHttpRequest.instances[2];
if (secondSessionRequest === undefined) {
throw new Error("expected second session upload request");
}
secondSessionRequest.failWithBody(401, sessionExpiredBody());
// Then
await expect(secondSessionUpload).rejects.toMatchObject({ status: 401 });
expect(clearSession).toHaveBeenCalledTimes(2);
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});

View File

@@ -0,0 +1,304 @@
import { z } from "zod";
import { afterEach, expect, test, vi } from "vitest";
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = [];
readonly headers = new Map<string, string>();
readonly upload = new EventTarget();
aborted = false;
body: XMLHttpRequestBodyInit | null = null;
method = "";
onerror: ((event: Event) => void) | null = null;
onload: ((event: Event) => void) | null = null;
readyState = 0;
responseText = "";
status = 0;
url = "";
constructor() {
FakeXMLHttpRequest.instances.push(this);
}
abort(): void {
this.aborted = true;
}
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 }));
}
fail(status: number, message: string): void {
this.status = status;
this.responseText = JSON.stringify({ success: false, message, data: null, errorProperty: "contentFile" });
this.onload?.(new Event("load"));
}
failWithBody(status: number, body: string): void {
this.status = status;
this.responseText = body;
this.onload?.(new Event("load"));
}
failNetwork(): void {
this.onerror?.(new Event("error"));
}
succeed(data: unknown): void {
this.status = 200;
this.responseText = JSON.stringify({ success: true, message: null, data, errorProperty: null });
this.onload?.(new Event("load"));
}
succeedWithInvalidJson(): void {
this.status = 200;
this.responseText = "not-json";
this.onload?.(new Event("load"));
}
}
const createResponseSchema = z.object({ contentId: z.number().int() });
afterEach(() => {
FakeXMLHttpRequest.instances = [];
vi.unstubAllEnvs();
});
function installFakeUpload() {
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
vi.stubEnv("VITE_API_MODE", "server");
vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest);
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
test("uploadAudioContent reports progress and parses success through the API response schema", async () => {
// Given
installFakeUpload();
const progress: number[] = [];
const body = new FormData();
// When
const upload = uploadAudioContent({
authentication: "required",
body,
method: "POST",
onProgress: (value) => progress.push(value),
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const request = FakeXMLHttpRequest.instances[0];
if (request === undefined) {
throw new Error("expected upload request");
}
request.progress(256, 1024);
request.succeed({ contentId: 9301 });
// Then
await expect(upload).resolves.toEqual({ contentId: 9301 });
expect(request.method).toBe("POST");
expect(request.url).toBe("https://api.example.com/api/v2/admin/ai-characters/101/audio-contents");
expect(request.headers.get("Accept-Language")).toBe("ko");
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
expect(request.body).toBe(body);
expect(progress).toEqual([25]);
});
test("uploadAudioContent aborts through AbortController and full retry creates a fresh request", async () => {
// Given
installFakeUpload();
const body = new FormData();
const controller = new AbortController();
// When
const canceled = uploadAudioContent({
authentication: "required",
body,
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
signal: controller.signal,
});
const firstRequest = FakeXMLHttpRequest.instances[0];
if (firstRequest === undefined) {
throw new Error("expected first upload request");
}
controller.abort();
// Then
await expect(canceled).rejects.toMatchObject({ name: "AbortError" });
expect(firstRequest.aborted).toBe(true);
// When
const retry = uploadAudioContent({
authentication: "required",
body,
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const secondRequest = FakeXMLHttpRequest.instances[1];
if (secondRequest === undefined) {
throw new Error("expected retry upload request");
}
secondRequest.succeed({ contentId: 9302 });
// Then
await expect(retry).resolves.toEqual({ contentId: 9302 });
expect(secondRequest).not.toBe(firstRequest);
expect(secondRequest.body).toBe(body);
});
test("uploadAudioContent preserves 415 API error details without resumable upload state", async () => {
// Given
installFakeUpload();
// When
const upload = uploadAudioContent({
authentication: "required",
body: new FormData(),
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const request = FakeXMLHttpRequest.instances[0];
if (request === undefined) {
throw new Error("expected upload request");
}
request.fail(415, "지원하지 않는 오디오 형식입니다.");
// Then
await expect(upload).rejects.toMatchObject({ status: 415, message: "지원하지 않는 오디오 형식입니다.", errorProperty: "contentFile" });
expect(FakeXMLHttpRequest.instances).toHaveLength(1);
});
test("uploadAudioContent clears session once for a burst of protected 401 upload failures", async () => {
// Given
installFakeUpload();
const clearSession = vi.fn();
const onAuthExpired = vi.fn();
const auth = { clearSession, getToken: () => "admin-token", onAuthExpired };
// When
const firstUpload = uploadAudioContent({
auth,
authentication: "required",
body: new FormData(),
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const secondUpload = uploadAudioContent({
auth,
authentication: "required",
body: new FormData(),
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const firstRequest = FakeXMLHttpRequest.instances[0];
const secondRequest = FakeXMLHttpRequest.instances[1];
if (firstRequest === undefined || secondRequest === undefined) {
throw new Error("expected upload requests");
}
firstRequest.failWithBody(401, JSON.stringify({ success: false, message: "세션이 만료되었습니다.", data: null, errorProperty: null }));
secondRequest.failWithBody(401, "not-json");
// Then
await expect(firstUpload).rejects.toMatchObject({ status: 401 });
await expect(secondUpload).rejects.toMatchObject({ status: 401 });
expect(clearSession).toHaveBeenCalledTimes(1);
expect(onAuthExpired).toHaveBeenCalledTimes(1);
});
test("uploadAudioContent keeps session for non-401 upload failures", async () => {
// Given
installFakeUpload();
const clearSession = vi.fn();
const onAuthExpired = vi.fn();
// When
const upload = uploadAudioContent({
auth: { clearSession, getToken: () => "admin-token", onAuthExpired },
authentication: "required",
body: new FormData(),
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const request = FakeXMLHttpRequest.instances[0];
if (request === undefined) {
throw new Error("expected upload request");
}
request.failWithBody(403, JSON.stringify({ success: false, message: "권한이 없습니다.", data: null, errorProperty: null }));
// Then
await expect(upload).rejects.toMatchObject({ status: 403 });
expect(clearSession).not.toHaveBeenCalled();
expect(onAuthExpired).not.toHaveBeenCalled();
});
test("uploadAudioContent normalizes malformed success response to the shared unknown API error", async () => {
// Given
installFakeUpload();
// When
const upload = uploadAudioContent({
authentication: "required",
body: new FormData(),
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const request = FakeXMLHttpRequest.instances[0];
if (request === undefined) {
throw new Error("expected upload request");
}
request.succeedWithInvalidJson();
// Then
await expect(upload).rejects.toMatchObject({ status: 200, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
});
test.each([
["network failure", (request: FakeXMLHttpRequest) => request.failNetwork(), 0],
["malformed error JSON", (request: FakeXMLHttpRequest) => request.failWithBody(500, "not-json"), 500],
["malformed error envelope", (request: FakeXMLHttpRequest) => request.failWithBody(500, JSON.stringify({ success: false, data: null, errorProperty: null })), 500],
["empty server message", (request: FakeXMLHttpRequest) => request.failWithBody(500, JSON.stringify({ success: false, message: "", data: null, errorProperty: null })), 500],
])("uploadAudioContent normalizes %s to the shared unknown API error", async (_label, fail, status) => {
// Given
installFakeUpload();
// When
const upload = uploadAudioContent({
authentication: "required",
body: new FormData(),
method: "POST",
path: "/api/v2/admin/ai-characters/101/audio-contents",
responseSchema: createResponseSchema,
});
const request = FakeXMLHttpRequest.instances[0];
if (request === undefined) {
throw new Error("expected upload request");
}
fail(request);
// Then
await expect(upload).rejects.toMatchObject({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null });
});

View File

@@ -0,0 +1,28 @@
import { createImagePolicy } from "@/shared/validation/image-policy";
import { getFileExtension, validateFile } from "@/shared/validation/file-validation";
import type { FileValidationResult } from "@/shared/validation/file-validation";
export const AUDIO_COVER_POLICY = createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true });
const audioCoverFilePolicy = {
allowedExtensions: [".jpg", ".jpeg", ".png"],
allowedMimeTypes: ["image/jpeg", "image/png"],
maxBytes: AUDIO_COVER_POLICY.maxBytes,
};
export function validateAudioCoverFile(file: File): FileValidationResult {
const baseResult = validateFile(file, audioCoverFilePolicy);
if (!baseResult.ok) {
return baseResult;
}
const extension = getFileExtension(file.name);
if ((extension === ".jpg" || extension === ".jpeg") && file.type !== "image/jpeg") {
return { ok: false, reason: "mime" };
}
if (extension === ".png" && file.type !== "image/png") {
return { ok: false, reason: "mime" };
}
return { ok: true };
}