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