feat(ai-character): 시리즈 관리 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:32 +09:00
parent 3550a2dd03
commit 286c536ba7
27 changed files with 2822 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
import { z } from "zod";
import type { SeriesContentListItem, SeriesContentSearchItem, SeriesDetail, SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import { seriesContentListResponseSchema, seriesContentSearchResponseSchema, seriesDetailSchema, seriesGenreListResponseSchema, seriesListResponseSchema } from "@/features/series/model/types";
import { seriesCreateRequestSchema, seriesDeactivateRequestSchema, seriesUpdateRequestSchema } from "@/features/series/schemas/series-schema";
import type { SeriesCreateRequest, SeriesDeactivateRequest, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
import type { ApiClient } from "@/shared/api/client";
import type { PageData } from "@/shared/api/pagination";
export type GetSeriesParams = {
readonly characterId: string;
readonly page?: number;
readonly size?: number;
};
export type GetSeriesDetailParams = {
readonly characterId: string;
readonly seriesId: string;
};
export type GetSeriesContentsParams = GetSeriesDetailParams & {
readonly page?: number;
readonly size?: number;
};
export type SearchUnlinkedSeriesContentsParams = GetSeriesDetailParams & {
readonly searchWord: string;
};
export type AddSeriesContentsParams = GetSeriesDetailParams & {
readonly contentIdList: readonly number[];
};
export type RemoveSeriesContentParams = GetSeriesDetailParams & {
readonly contentId: number;
};
export type UpdateSeriesOrderParams = {
readonly characterId: string;
readonly ids: readonly number[];
};
export type CreateSeriesParams = {
readonly characterId: string;
readonly image: File;
readonly request: SeriesCreateRequest;
};
export type UpdateSeriesParams = GetSeriesDetailParams & {
readonly image?: File;
readonly request: SeriesUpdateRequest;
};
export type DeactivateSeriesParams = GetSeriesDetailParams & {
readonly request: SeriesDeactivateRequest;
};
export type GetAllSeriesForOrderParams = {
readonly characterId: string;
readonly size?: number;
};
export type SeriesOrderCollection = {
readonly isComplete: boolean;
readonly items: readonly SeriesListItem[];
};
const nullSuccessSchema = z.null();
function createSeriesFormData(image: File | undefined, request: object): FormData {
const body = new FormData();
if (image !== undefined) {
body.append("image", image);
}
body.append("request", new Blob([JSON.stringify(request)], { type: "application/json" }));
return body;
}
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 getSeries(apiClient: ApiClient, params: GetSeriesParams): Promise<PageData<SeriesListItem>> {
const page = normalizePage(params.page);
const size = normalizeSize(params.size);
const query = new URLSearchParams({ page: String(page), size: String(size) });
const response = await apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series?${query.toString()}`,
responseSchema: seriesListResponseSchema,
authentication: "required",
});
return {
totalCount: response.totalCount,
page,
size,
hasNext: (page + 1) * size < response.totalCount,
items: response.items,
};
}
export function getSeriesDetail(apiClient: ApiClient, params: GetSeriesDetailParams): Promise<SeriesDetail> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}`,
responseSchema: seriesDetailSchema,
authentication: "required",
});
}
export function getSeriesGenres(apiClient: ApiClient): Promise<readonly SeriesGenreItem[]> {
return apiClient.request({
path: "/api/v2/admin/ai-characters/series-genres",
responseSchema: seriesGenreListResponseSchema,
authentication: "required",
});
}
export function createSeries(apiClient: ApiClient, params: CreateSeriesParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series`,
method: "POST",
body: createSeriesFormData(params.image, seriesCreateRequestSchema.parse(params.request)),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function updateSeries(apiClient: ApiClient, params: UpdateSeriesParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}`,
method: "PUT",
body: createSeriesFormData(params.image, seriesUpdateRequestSchema.parse(params.request)),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function deactivateSeries(apiClient: ApiClient, params: GetSeriesDetailParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}`,
method: "PUT",
body: createSeriesFormData(undefined, seriesDeactivateRequestSchema.parse({ isActive: false })),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export async function getSeriesContents(apiClient: ApiClient, params: GetSeriesContentsParams): Promise<PageData<SeriesContentListItem>> {
const page = normalizePage(params.page);
const size = normalizeSize(params.size);
const query = new URLSearchParams({ page: String(page), size: String(size) });
const response = await apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents?${query.toString()}`,
responseSchema: seriesContentListResponseSchema,
authentication: "required",
});
return { totalCount: response.totalCount, page, size, hasNext: (page + 1) * size < response.totalCount, items: response.items };
}
export function searchUnlinkedSeriesContents(apiClient: ApiClient, params: SearchUnlinkedSeriesContentsParams): Promise<readonly SeriesContentSearchItem[]> {
const query = new URLSearchParams({ search_word: params.searchWord.trim() });
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents/search?${query.toString()}`,
responseSchema: seriesContentSearchResponseSchema,
authentication: "required",
});
}
export function addSeriesContents(apiClient: ApiClient, params: AddSeriesContentsParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentIdList: params.contentIdList }),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function removeSeriesContent(apiClient: ApiClient, params: RemoveSeriesContentParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents/${params.contentId}`,
method: "DELETE",
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export async function getAllSeriesForOrder(apiClient: ApiClient, params: GetAllSeriesForOrderParams): Promise<SeriesOrderCollection> {
const size = normalizeSize(params.size);
const firstPage = await getSeries(apiClient, { characterId: params.characterId, page: 0, size });
const pageCount = Math.ceil(firstPage.totalCount / size);
const restPages = await Promise.all(Array.from({ length: Math.max(0, pageCount - 1) }, (_, index) => getSeries(apiClient, { characterId: params.characterId, page: index + 1, size })));
const items = [firstPage, ...restPages].flatMap((page) => page.items);
return { isComplete: items.length === firstPage.totalCount, items };
}
export function updateSeriesOrder(apiClient: ApiClient, params: UpdateSeriesOrderParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/orders`,
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: params.ids }),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}

View File

@@ -0,0 +1,14 @@
import type { SeriesGenreItem } from "@/features/series/model/types";
export function GenreCombobox({ error, errorId, genres, onChange, value }: { readonly error?: string; readonly errorId?: string; readonly genres: readonly SeriesGenreItem[]; readonly onChange: (value: number | null) => void; 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>
{genres.map((genre) => <option key={genre.id} value={genre.id}>{genre.genre}{genre.isAdult ? " (성인)" : ""}</option>)}
</select>
{error === undefined ? null : <span className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</span>}
</label>
);
}

View File

@@ -0,0 +1,37 @@
import type { SeriesPublishedDay } from "@/features/series/schemas/series-schema";
const publishedDayOptions = [
["SUN", "일요일"],
["MON", "월요일"],
["TUE", "화요일"],
["WED", "수요일"],
["THU", "목요일"],
["FRI", "금요일"],
["SAT", "토요일"],
["RANDOM", "랜덤"],
] as const satisfies readonly (readonly [SeriesPublishedDay, string])[];
export function PublishedDaysField({ error, errorId, onChange, value }: { readonly error?: string; readonly errorId?: string; readonly onChange: (value: readonly SeriesPublishedDay[]) => void; readonly value: readonly SeriesPublishedDay[] }) {
function toggle(day: SeriesPublishedDay): void {
if (value.includes(day)) {
onChange(value.filter((item) => item !== day));
return;
}
onChange(day === "RANDOM" ? ["RANDOM"] : [...value.filter((item) => item !== "RANDOM"), day]);
}
return (
<fieldset className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<legend className="text-sm font-semibold"> </legend>
<div className="flex flex-wrap gap-3">
{publishedDayOptions.map(([day, label]) => (
<label className="flex items-center gap-2 text-sm font-semibold" key={day}>
<input aria-describedby={error === undefined ? undefined : errorId} aria-invalid={error === undefined ? undefined : true} checked={value.includes(day)} onChange={() => toggle(day)} type="checkbox" />
{label}
</label>
))}
</div>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</fieldset>
);
}

View File

@@ -0,0 +1,199 @@
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { addSeriesContents, getSeriesContents, removeSeriesContent, searchUnlinkedSeriesContents } from "@/features/series/api/series-api";
import type { SeriesContentListItem, SeriesContentSearchItem } from "@/features/series/model/types";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { PageData } from "@/shared/api/pagination";
import { PageState } from "@/shared/ui/page-state";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
import { SearchToolbar } from "@/shared/ui/search-toolbar";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
type ContentsState =
| { readonly status: "loading" }
| { readonly data: PageData<SeriesContentListItem>; readonly status: "content" }
| { readonly message: string; readonly status: "error" };
const mutationErrorMessage = "변경사항을 저장하지 못했습니다. 목록을 다시 불러온 뒤 재시도하세요.";
export function SeriesContents({ apiClient, canManageSeries, characterId, seriesId }: { readonly apiClient: ApiClient; readonly canManageSeries: boolean; readonly characterId: string; readonly seriesId: string }) {
const [page, setPage] = useState(0);
const [size, setSize] = useState(20);
const [retryKey, setRetryKey] = useState(0);
const [state, setState] = useState<ContentsState>({ status: "loading" });
const [search, setSearch] = useState("");
const [candidates, setCandidates] = useState<readonly SeriesContentSearchItem[]>([]);
const [selectedIds, setSelectedIds] = useState<readonly number[]>([]);
const [target, setTarget] = useState<SeriesContentListItem | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [isLinking, setIsLinking] = useState(false);
const [isUnlinking, setIsUnlinking] = useState(false);
const isLinkingRef = useRef(false);
const isUnlinkingRef = useRef(false);
const loadContents = useCallback(() => {
void getSeriesContents(apiClient, { characterId, seriesId, page, size })
.then((data) => setState({ data, status: "content" }))
.catch((error: unknown) => setState({ message: error instanceof ApiError ? error.message : "연결된 오디오를 불러오지 못했습니다.", status: "error" }));
}, [apiClient, characterId, page, seriesId, size]);
useEffect(() => loadContents(), [loadContents, retryKey]);
useEffect(() => {
if (!canManageSeries) {
return undefined;
}
const query = search.trim();
if (query.length === 0) {
return;
}
let isCurrent = true;
void searchUnlinkedSeriesContents(apiClient, { characterId, seriesId, searchWord: query })
.then((items) => {
if (isCurrent) {
setCandidates(items);
}
})
.catch(() => {
if (isCurrent) {
setCandidates([]);
}
});
return () => {
isCurrent = false;
};
}, [apiClient, canManageSeries, characterId, search, seriesId]);
const linkedIds = useMemo(() => new Set(state.status === "content" ? state.data.items.map((item) => item.contentId) : []), [state]);
const visibleCandidates = candidates.filter((item) => !linkedIds.has(item.contentId) && !selectedIds.includes(item.contentId));
function toggleSelected(contentId: number): void {
setSelectedIds((ids) => (ids.includes(contentId) ? ids.filter((id) => id !== contentId) : [...ids, contentId]));
}
async function linkSelected(): Promise<void> {
if (isLinkingRef.current) {
return;
}
isLinkingRef.current = true;
setIsLinking(true);
setMutationError(null);
try {
await addSeriesContents(apiClient, { characterId, seriesId, contentIdList: selectedIds });
setSelectedIds([]);
setSearch("");
setCandidates([]);
loadContents();
} catch {
setMutationError(mutationErrorMessage);
} finally {
isLinkingRef.current = false;
setIsLinking(false);
}
}
async function unlinkTarget(): Promise<void> {
if (target === null || isUnlinkingRef.current) {
return;
}
isUnlinkingRef.current = true;
setIsUnlinking(true);
setMutationError(null);
try {
await removeSeriesContent(apiClient, { characterId, seriesId, contentId: target.contentId });
setTarget(null);
loadContents();
} catch {
setTarget(null);
setMutationError(mutationErrorMessage);
} finally {
isUnlinkingRef.current = false;
setIsUnlinking(false);
}
}
function handleSearchChange(nextSearch: string): void {
setSearch(nextSearch);
setSelectedIds([]);
if (nextSearch.trim().length === 0) {
setCandidates([]);
}
}
return (
<section className="flex flex-col gap-4" aria-labelledby="series-contents-title">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold" id="series-contents-title"> </h2>
<p className="text-sm text-muted-foreground">{canManageSeries ? "현재 시리즈에 연결된 오디오를 조회하고 후보를 검색해 연결합니다." : "현재 시리즈에 연결된 오디오를 조회합니다."}</p>
</div>
{mutationError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{mutationError}</p>}
{isLinking ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
{state.status === "loading" ? <PageState state="loading" title="연결된 오디오를 불러오는 중" /> : null}
{state.status === "error" ? <PageState description={state.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="연결된 오디오 조회 실패" /> : null}
{state.status === "content" ? (
<>
{state.data.items.length === 0 ? <PageState description={canManageSeries ? "후보 검색으로 오디오를 연결하세요." : "현재 연결된 오디오가 없습니다."} state="empty" title="연결된 오디오가 없습니다." /> : null}
{state.data.items.length > 0 ? <ContentList canManageSeries={canManageSeries} items={state.data.items} onUnlink={setTarget} /> : null}
<ResourcePagination data={state.data} onPageChange={setPage} onSizeChange={(nextSize) => { setPage(0); setSize(nextSize); }} />
</>
) : null}
{canManageSeries ? <SearchToolbar key={search} onQueryChange={handleSearchChange} search={search} /> : null}
{visibleCandidates.length === 0 ? null : (
<section aria-label="연결 후보" className="rounded-lg border border-border bg-card p-4">
<div className="grid gap-3">
{visibleCandidates.map((item) => (
<label className="flex items-start gap-3 rounded-md border border-border p-3 hover:bg-accent" key={item.contentId}>
<input checked={selectedIds.includes(item.contentId)} className="mt-1" disabled={isLinking} onChange={() => toggleSelected(item.contentId)} type="checkbox" />
<img alt={`${item.title} 커버`} className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={item.coverImage} width="48" />
<span className="min-w-0"><span className="block font-semibold">{item.title}</span></span>
</label>
))}
</div>
</section>
)}
{canManageSeries ? <button className="self-start rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] disabled:opacity-60" disabled={selectedIds.length === 0 || isLinking} onClick={() => void linkSelected()} type="button"> </button> : null}
<UnlinkDialog isPending={isUnlinking} onCancel={() => setTarget(null)} onConfirm={() => void unlinkTarget()} target={target} />
</section>
);
}
function ContentList({ canManageSeries, items, onUnlink }: { readonly canManageSeries: boolean; readonly items: readonly SeriesContentListItem[]; readonly onUnlink: (item: SeriesContentListItem) => void }) {
return (
<ul className="grid gap-3">
{items.map((item) => (
<li className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center" key={item.contentId}>
<img alt={`${item.title} 커버`} className="size-14 shrink-0 rounded-md object-cover" height="56" loading="lazy" src={item.coverImage} width="56" />
<span className="min-w-0 flex-1"><span className="block break-words font-semibold">{item.title}</span>{item.isAdult ? <span className="mt-1 block text-sm font-semibold text-warning"></span> : null}</span>
{canManageSeries ? <button className="rounded-md border border-destructive bg-card px-3 py-2 font-semibold text-destructive hover:bg-accent" onClick={() => onUnlink(item)} type="button">{item.title} </button> : null}
</li>
))}
</ul>
);
}
function UnlinkDialog({ isPending, onCancel, onConfirm, target }: { readonly isPending: boolean; readonly onCancel: () => void; readonly onConfirm: () => void; readonly target: SeriesContentListItem | null }) {
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(target !== null);
function handleKeyDown(event: KeyboardEvent<HTMLElement>): void {
trapFocus(event);
if (event.key === "Escape") {
onCancel();
}
}
if (target === null) {
return null;
}
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-busy={isPending ? true : undefined} aria-label="오디오 연결을 해제하시겠습니까?" aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={handleKeyDown} ref={dialogRef} role="alertdialog">
<div className="flex flex-col gap-2"><h2 className="text-xl font-semibold"> ?</h2><p className="text-sm text-muted-foreground">{target.title} . .</p></div>
{isPending ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
<div className="flex justify-end gap-2"><button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isPending} onClick={onCancel} type="button"></button><button className="rounded-md border border-destructive bg-destructive px-4 py-2 font-semibold text-white hover:bg-destructive/90 disabled:opacity-60" disabled={isPending} onClick={onConfirm} type="button"> </button></div>
</section>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { createSeries, deactivateSeries, updateSeries } from "@/features/series/api/series-api";
import { GenreCombobox } from "@/features/series/components/GenreCombobox";
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import type { SeriesPublishedDay, SeriesState } from "@/features/series/schemas/series-schema";
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
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 { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
type FieldErrors = {
readonly days?: string;
readonly form?: string;
readonly genre?: string;
readonly image?: string;
readonly introduction?: string;
readonly keyword?: string;
readonly title?: string;
};
const stateOptions = [
["PROCEEDING", "연재중"],
["SUSPEND", "휴재중"],
["COMPLETE", "완결"],
] as const satisfies readonly (readonly [SeriesState, string])[];
const errorIds = {
days: "series-days-error",
genre: "series-genre-error",
introduction: "series-introduction-error",
keyword: "series-keyword-error",
title: "series-title-error",
} as const;
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
}
function imageError(image: File | null, mode: "create" | "edit"): string | undefined {
if (image === null) {
return mode === "create" ? "시리즈 이미지를 선택하세요." : undefined;
}
const result = validateSeriesImageFile(image);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "시리즈 이미지는 10MB 이하만 업로드할 수 있습니다.";
}
return "JPEG 또는 PNG 파일만 업로드하세요.";
}
function dayError(days: readonly SeriesPublishedDay[]): string | undefined {
if (days.length === 0) {
return "연재 요일을 선택하세요.";
}
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
}
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
}
function hasErrors(errors: FieldErrors): boolean {
return Object.values(errors).some((error) => error !== undefined);
}
export function SeriesForm({ apiClient, characterId, createCropSource, genres, mode, renderCrop, series }: { readonly apiClient: ApiClient; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly genres: readonly SeriesGenreItem[]; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly series?: SeriesListItem }) {
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
const [errors, setErrors] = useState<FieldErrors>({});
const [genreId, setGenreId] = useState<number | null>(series?.genreId ?? null);
const [image, setImage] = useState<File | null>(null);
const [introduction, setIntroduction] = useState(series?.introduction ?? "");
const [isAdult, setIsAdult] = useState(series?.isAdult ?? false);
const [isDeactivateDialogOpen, setIsDeactivateDialogOpen] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
const [isImagePreparing, setIsImagePreparing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deactivateError, setDeactivateError] = useState<string | undefined>();
const [keyword, setKeyword] = useState("");
const [publishedDays, setPublishedDays] = useState<readonly SeriesPublishedDay[]>(series?.publishedDaysOfWeek ?? []);
const [state, setState] = useState<SeriesState>(series?.state ?? "PROCEEDING");
const [studio, setStudio] = useState(series?.studio ?? "");
const [title, setTitle] = useState(series?.title ?? "");
const [writer, setWriter] = useState(series?.writer ?? "");
const formRef = useRef<HTMLFormElement>(null);
const imageSelectionIdRef = useRef(0);
const isDeactivatingRef = useRef(false);
const isSavingRef = useRef(false);
const seriesId = series === undefined ? null : String(series.seriesId);
const dirty = image !== null || title !== (series?.title ?? "") || introduction !== (series?.introduction ?? "") || keyword !== "" || genreId !== (series?.genreId ?? null) || isAdult !== (series?.isAdult ?? false) || publishedDays.join(",") !== (series?.publishedDaysOfWeek ?? []).join(",") || state !== (series?.state ?? "PROCEEDING") || studio !== (series?.studio ?? "") || writer !== (series?.writer ?? "");
const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
async function selectImage(file: File | null): Promise<void> {
const selectionId = imageSelectionIdRef.current + 1;
imageSelectionIdRef.current = selectionId;
if (file === null) {
setImage(null);
setCropSource(null);
setIsImagePreparing(false);
return;
}
const validation = validateSeriesImageFile(file);
if (!validation.ok) {
setImage(null);
setCropSource(null);
setIsImagePreparing(false);
setErrors((current) => ({ ...current, image: imageError(file, mode) }));
return;
}
setImage(null);
setCropSource(null);
setIsImagePreparing(true);
setErrors((current) => ({ ...current, image: undefined }));
try {
const source = await createCropSource(file);
if (imageSelectionIdRef.current === selectionId) {
setCropSource(source);
} else {
source.release?.();
}
} catch {
if (imageSelectionIdRef.current === selectionId) {
setErrors((current) => ({ ...current, image: "이미지 미리보기 준비에 실패했습니다." }));
}
} finally {
if (imageSelectionIdRef.current === selectionId) {
setIsImagePreparing(false);
}
}
}
function validate(): FieldErrors {
return {
days: dayError(publishedDays),
genre: genreId === null ? "장르를 선택하세요." : undefined,
image: isImageSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : imageError(image, mode),
introduction: introduction.trim().length === 0 ? "소개를 입력하세요." : undefined,
keyword: mode === "create" && keyword.trim().length === 0 ? "키워드를 입력하세요." : undefined,
title: title.trim().length === 0 ? "제목을 입력하세요." : undefined,
};
}
async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
event.preventDefault();
if (isSavingRef.current) {
return;
}
const nextErrors = validate();
setErrors(nextErrors);
if (hasErrors(nextErrors) || genreId === null) {
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
isSavingRef.current = true;
setIsSaving(true);
try {
if (mode === "create" && image !== null) {
await createSeries(apiClient, { characterId, image, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], keyword: keyword.trim(), genreId, isAdult, writer: textOrNull(writer), studio: textOrNull(studio) } });
navigateTo(routePaths.aiCharacterSeries(characterId), { successNotification: "시리즈를 생성했습니다." });
return;
}
if (mode === "edit" && seriesId !== null) {
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], genreId, isAdult, state: editedState(state, series), writer: textOrNull(writer), studio: textOrNull(studio) } });
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
}
} catch (error: unknown) {
setErrors({ form: error instanceof ApiError ? error.message : "시리즈를 저장하지 못했습니다." });
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
async function confirmDeactivate(): Promise<void> {
if (seriesId === null || isDeactivatingRef.current) {
return;
}
isDeactivatingRef.current = true;
setIsDeactivating(true);
setDeactivateError(undefined);
try {
await deactivateSeries(apiClient, { characterId, seriesId });
navigateTo(routePaths.aiCharacterSeries(characterId), { successNotification: "시리즈를 비활성화했습니다." });
} catch (error: unknown) {
if (!(error instanceof Error)) {
throw error;
}
setDeactivateError("비활성화하지 못했습니다. 다시 시도하세요.");
} finally {
isDeactivatingRef.current = false;
setIsDeactivating(false);
}
}
return (
<UnsavedChangesGuard dirty={dirty} impactDescription="저장하지 않은 시리즈 변경사항이 사라집니다." title="시리즈 편집을 취소하시겠습니까?">
{(requestRouteLeave) => (
<section className="flex flex-col gap-4" aria-labelledby="series-form-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">SERIES</p>
<h2 className="text-2xl font-bold leading-tight" id="series-form-title">{mode === "create" ? "시리즈 생성" : "시리즈 수정"}</h2>
<p className="text-sm text-muted-foreground">, , , .</p>
</div>
<form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" 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.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === 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) => setIntroduction(event.currentTarget.value)} value={introduction} /></label>
{errors.introduction === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.introduction} role="alert">{errors.introduction}</p>}
{mode === "create" ? <label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.keyword === undefined ? undefined : errorIds.keyword} aria-invalid={errors.keyword === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setKeyword(event.currentTarget.value)} value={keyword} /></label> : null}
{errors.keyword === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.keyword} role="alert">{errors.keyword}</p>}
<GenreCombobox error={errors.genre} errorId={errorIds.genre} genres={genres} onChange={setGenreId} value={genreId} />
<PublishedDaysField error={errors.days} errorId={errorIds.days} onChange={setPublishedDays} value={publishedDays} />
{mode === "edit" ? <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) => setState(event.currentTarget.value as SeriesState)} value={state}>{stateOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label> : null}
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.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) => setWriter(event.currentTarget.value)} value={writer} /></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) => setStudio(event.currentTarget.value)} value={studio} /></label>
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
<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.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} 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:opacity-60" disabled={isSaving || isImageSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div>
</form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />}
<ConfirmDeactivateDialog confirmLabel="비활성화 확인" errorMessage={deactivateError} impactDescription={`${title || "선택한 시리즈"}의 목록 노출만 중지하며 연결된 오디오 콘텐츠는 보관됩니다.`} isPending={isDeactivating} onCancel={() => setIsDeactivateDialogOpen(false)} onConfirm={() => void confirmDeactivate()} open={isDeactivateDialogOpen} targetName="시리즈" />
</section>
)}
</UnsavedChangesGuard>
);
}

View File

@@ -0,0 +1,59 @@
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { SeriesListItem } from "@/features/series/components/SeriesListItem";
import { formatSeriesFlags, formatSeriesGenre, formatSeriesPublishedDays, formatSeriesState } from "@/features/series/lib/series-display-labels";
import type { SeriesGenreItem } from "@/features/series/model/types";
import type { SeriesListItem as SeriesListItemData } from "@/features/series/model/types";
import type { PageData } from "@/shared/api/pagination";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
export function SeriesList({ characterId, data, genres }: { readonly characterId: string; readonly data: PageData<SeriesListItemData>; readonly genres: readonly SeriesGenreItem[] }) {
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>
<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((series) => {
const detailPath = routePaths.aiCharacterSeriesDetail(characterId, String(series.seriesId));
return (
<tr key={series.seriesId}>
<td className="border-b border-border px-4 py-3">
<p className="font-semibold">{series.title}</p>
<p className="mt-1 max-w-xl text-muted-foreground">{series.introduction}</p>
</td>
<td className="border-b border-border px-4 py-3">{formatSeriesPublishedDays(series.publishedDaysOfWeek)}</td>
<td className="border-b border-border px-4 py-3">{formatSeriesGenre(series.genreId, genres)}</td>
<td className="border-b border-border px-4 py-3">{formatSeriesState(series.state)}</td>
<td className="border-b border-border px-4 py-3">{formatSeriesFlags(series)}</td>
<td className="border-b border-border px-4 py-3">
<a
aria-label={`${series.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((series) => <SeriesListItem characterId={characterId} genres={genres} key={series.seriesId} series={series} />)}</div>;
return <ResponsiveResourceList ariaLabel="시리즈 목록" desktop={desktop} mobile={mobile} />;
}

View File

@@ -0,0 +1,32 @@
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { formatSeriesFlags, formatSeriesGenre, formatSeriesPublishedDays, formatSeriesState } from "@/features/series/lib/series-display-labels";
import type { SeriesListItem as SeriesListItemData } from "@/features/series/model/types";
import type { SeriesGenreItem } from "@/features/series/model/types";
export function SeriesListItem({ characterId, genres, series }: { readonly characterId: string; readonly genres: readonly SeriesGenreItem[]; readonly series: SeriesListItemData }) {
const detailPath = routePaths.aiCharacterSeriesDetail(characterId, String(series.seriesId));
return (
<a
aria-label={`${series.title} 상세 보기`}
className="block rounded-lg border border-border bg-card p-4 text-foreground hover:bg-accent focus-visible:bg-accent"
href={detailPath}
onClick={(event) => {
event.preventDefault();
navigateTo(detailPath);
}}
>
<span className="flex items-start gap-3">
<img alt="" className="size-16 shrink-0 rounded-md object-cover" height="64" loading="lazy" src={series.coverImageUrl} width="64" />
<span className="min-w-0 flex-1">
<span className="block break-words font-semibold">{series.title}</span>
<span className="mt-1 line-clamp-2 block break-keep break-words text-sm text-muted-foreground">{series.introduction}</span>
<span className="mt-2 block text-xs font-semibold text-info">{formatSeriesPublishedDays(series.publishedDaysOfWeek)} · {formatSeriesGenre(series.genreId, genres)}</span>
<span className="mt-1 block text-xs text-muted-foreground">{formatSeriesState(series.state)}</span>
<span className="mt-1 block text-xs text-muted-foreground">{formatSeriesFlags(series)}</span>
</span>
</span>
</a>
);
}

View File

@@ -0,0 +1,16 @@
import type { SeriesListItem } from "@/features/series/model/types";
export function SeriesOrderList({ isDisabled = false, items, onMoveDown, onMoveUp }: { readonly isDisabled?: boolean; readonly items: readonly SeriesListItem[]; readonly onMoveDown: (seriesId: number) => void; readonly onMoveUp: (seriesId: number) => void }) {
return (
<ol className="grid gap-3" aria-label="시리즈 순서 목록">
{items.map((series, index) => (
<li className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center" key={series.seriesId}>
<span className="grid size-10 shrink-0 place-items-center rounded-md bg-muted text-sm font-bold text-muted-foreground">{index + 1}</span>
<img alt="" className="size-14 shrink-0 rounded-md object-cover" height="56" loading="lazy" src={series.coverImageUrl} width="56" />
<span className="min-w-0 flex-1"><span className="block break-words font-semibold">{series.title}</span><span className="mt-1 block break-words text-sm text-muted-foreground">{series.introduction}</span></span>
<span className="flex gap-2"><button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isDisabled || index === 0} onClick={() => onMoveUp(series.seriesId)} type="button">{series.title} </button><button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isDisabled || index === items.length - 1} onClick={() => onMoveDown(series.seriesId)} type="button">{series.title} </button></span>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,24 @@
import type { SeriesDetail } from "@/features/series/model/types";
import { formatSeriesAudience, formatSeriesExposure, formatSeriesGenre, formatSeriesPublishedDays, formatSeriesState } from "@/features/series/lib/series-display-labels";
import type { SeriesGenreItem } from "@/features/series/model/types";
export function SeriesSummary({ genres, series }: { readonly genres: readonly SeriesGenreItem[]; readonly series: SeriesDetail }) {
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={series.coverImageUrl} width="128" />
<div className="min-w-0">
<p className="text-xs font-semibold text-info">{formatSeriesGenre(series.genreId, genres)}</p>
<h2 className="mt-1 break-words text-2xl font-bold leading-tight" id="series-title">{series.title}</h2>
<p className="mt-2 break-words text-sm text-muted-foreground">{series.introduction}</p>
<dl className="mt-4 grid gap-2 text-sm sm:grid-cols-2">
<div><dt className="font-semibold"> </dt><dd className="break-words text-muted-foreground">{formatSeriesPublishedDays(series.publishedDaysOfWeek)}</dd></div>
<div><dt className="font-semibold"></dt><dd className="text-muted-foreground">{formatSeriesState(series.state)}</dd></div>
<div><dt className="font-semibold"> </dt><dd className="text-muted-foreground">{formatSeriesAudience(series.isAdult)}</dd></div>
<div><dt className="font-semibold"> </dt><dd className="text-muted-foreground">{formatSeriesExposure(series.isActive)}</dd></div>
<div><dt className="font-semibold"></dt><dd className="break-words text-muted-foreground">{series.writer ?? "-"}</dd></div>
<div><dt className="font-semibold"></dt><dd className="break-words text-muted-foreground">{series.studio ?? "-"}</dd></div>
</dl>
</div>
</section>
);
}

View File

@@ -0,0 +1,46 @@
import type { SeriesDetail, SeriesGenreItem } from "@/features/series/model/types";
type SeriesPublishedDay = SeriesDetail["publishedDaysOfWeek"][number];
type SeriesState = SeriesDetail["state"];
type SeriesFlags = Pick<SeriesDetail, "isActive" | "isAdult">;
const dayLabels = {
SUN: "일",
MON: "월",
TUE: "화",
WED: "수",
THU: "목",
FRI: "금",
SAT: "토",
RANDOM: "랜덤",
} as const satisfies Record<SeriesPublishedDay, string>;
const stateLabels = {
PROCEEDING: "연재중",
SUSPEND: "휴재중",
COMPLETE: "완결",
} as const satisfies Record<SeriesState, string>;
export function formatSeriesPublishedDays(days: readonly SeriesPublishedDay[]): string {
return days.map((day) => dayLabels[day]).join(", ");
}
export function formatSeriesState(state: SeriesState): string {
return stateLabels[state];
}
export function formatSeriesGenre(genreId: number, genres: readonly SeriesGenreItem[]): string {
return genres.find((genre) => genre.id === genreId)?.genre ?? "알 수 없는 장르";
}
export function formatSeriesAudience(isAdult: boolean): string {
return isAdult ? "성인" : "전체 이용";
}
export function formatSeriesExposure(isActive: boolean): string {
return isActive ? "활성" : "비활성";
}
export function formatSeriesFlags(series: SeriesFlags): string {
return `${formatSeriesAudience(series.isAdult)} · ${formatSeriesExposure(series.isActive)}`;
}

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
const seriesManagementQuery = "(min-width: 768px)";
function getCanManageSeries(): boolean {
return typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia(seriesManagementQuery).matches;
}
export function useSeriesManagementCapability(): boolean {
const [canManageSeries, setCanManageSeries] = useState(getCanManageSeries);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return undefined;
}
const mediaQuery = window.matchMedia(seriesManagementQuery);
const updateCanManageSeries = () => setCanManageSeries(mediaQuery.matches);
updateCanManageSeries();
mediaQuery.addEventListener("change", updateCanManageSeries);
return () => mediaQuery.removeEventListener("change", updateCanManageSeries);
}, []);
return canManageSeries;
}

View File

@@ -0,0 +1,69 @@
import { z } from "zod";
const nullableString = z.string().nullable();
const publishedDaysOfWeekSchema = z.union([
z.literal("SUN"),
z.literal("MON"),
z.literal("TUE"),
z.literal("WED"),
z.literal("THU"),
z.literal("FRI"),
z.literal("SAT"),
z.literal("RANDOM"),
]);
const seriesStateSchema = z.union([z.literal("PROCEEDING"), z.literal("SUSPEND"), z.literal("COMPLETE")]);
export const seriesGenreItemSchema = z.object({
id: z.number().int(),
genre: z.string(),
isAdult: z.boolean(),
});
export const seriesGenreListResponseSchema = z.array(seriesGenreItemSchema);
export const seriesListItemSchema = z.object({
seriesId: z.number().int(),
title: z.string(),
introduction: z.string(),
coverImageUrl: z.string(),
publishedDaysOfWeek: z.array(publishedDaysOfWeekSchema),
genreId: z.number().int(),
isAdult: z.boolean(),
state: seriesStateSchema,
isActive: z.boolean(),
writer: nullableString,
studio: nullableString,
});
export const seriesListResponseSchema = z.object({
totalCount: z.number().int(),
items: z.array(seriesListItemSchema),
});
export const seriesContentListItemSchema = z.object({
contentId: z.number().int(),
title: z.string(),
coverImage: z.string(),
isAdult: z.boolean(),
});
export const seriesContentSearchItemSchema = z.object({
contentId: z.number().int(),
title: z.string(),
coverImage: z.string(),
});
export const seriesContentListResponseSchema = z.object({
totalCount: z.number().int(),
items: z.array(seriesContentListItemSchema),
});
export const seriesContentSearchResponseSchema = z.array(seriesContentSearchItemSchema);
export const seriesDetailSchema = seriesListItemSchema;
export type SeriesDetail = z.infer<typeof seriesDetailSchema>;
export type SeriesContentListItem = z.infer<typeof seriesContentListItemSchema>;
export type SeriesContentSearchItem = z.infer<typeof seriesContentSearchItemSchema>;
export type SeriesGenreItem = z.infer<typeof seriesGenreItemSchema>;
export type SeriesListItem = z.infer<typeof seriesListItemSchema>;

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { getSeriesDetail, getSeriesGenres } from "@/features/series/api/series-api";
import { SeriesContents } from "@/features/series/components/SeriesContents";
import { SeriesSummary } from "@/features/series/components/SeriesSummary";
import { useSeriesManagementCapability } from "@/features/series/lib/use-series-management-capability";
import type { SeriesDetail, SeriesGenreItem } from "@/features/series/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import { PageState } from "@/shared/ui/page-state";
type DetailState =
| { readonly requestKey: string; readonly status: "loading" }
| { readonly character: CharacterDetail; readonly genres: readonly SeriesGenreItem[]; readonly requestKey: string; readonly series: SeriesDetail; readonly status: "content" }
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
export function SeriesDetailPage({ apiClient, characterId, seriesId }: { readonly apiClient: ApiClient; readonly characterId: string; readonly seriesId: string }) {
const canManageSeries = useSeriesManagementCapability();
const [state, setState] = useState<DetailState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${characterId}:${seriesId}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void Promise.all([
getCharacter(apiClient, characterId),
getSeriesDetail(apiClient, { characterId, seriesId }),
getSeriesGenres(apiClient),
])
.then(([character, series, genres]) => {
if (isCurrent) {
setState({ character, genres, requestKey, series, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "시리즈 상세를 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, seriesId, 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="시리즈 상세 조회 실패" />;
}
const canMutate = visibleState.character.isActive && canManageSeries;
return (
<CharacterWorkspaceLayout activeTab="series" character={visibleState.character}>
<section className="flex flex-col gap-4" aria-labelledby="series-title">
<SeriesSummary genres={visibleState.genres} series={visibleState.series} />
{canMutate ? <div className="flex justify-end">
<a className="rounded-md border border-input bg-card px-4 py-2 font-semibold text-link hover:bg-accent" href={routePaths.aiCharacterSeriesEdit(characterId, seriesId)} onClick={(event) => { event.preventDefault(); navigateTo(routePaths.aiCharacterSeriesEdit(characterId, seriesId)); }}></a>
<a className="rounded-md border border-input bg-card px-4 py-2 font-semibold text-link hover:bg-accent" href={routePaths.aiCharacterSeriesOrder(characterId)} onClick={(event) => { event.preventDefault(); navigateTo(routePaths.aiCharacterSeriesOrder(characterId)); }}> </a>
</div> : null}
<SeriesContents apiClient={apiClient} canManageSeries={canMutate} characterId={characterId} seriesId={seriesId} />
</section>
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { getSeriesDetail, getSeriesGenres } from "@/features/series/api/series-api";
import { SeriesForm } from "@/features/series/components/SeriesForm";
import { useSeriesManagementCapability } from "@/features/series/lib/use-series-management-capability";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/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 character: CharacterDetail; readonly genres: readonly SeriesGenreItem[]; readonly requestKey: string; readonly series?: SeriesListItem; readonly status: "content" }
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
function SeriesMobileMutationGuidance() {
return (
<section className="rounded-lg border border-border bg-card p-4" aria-labelledby="series-mobile-mutation-guidance-title">
<h2 className="text-xl font-semibold" id="series-mobile-mutation-guidance-title">릿 .</h2>
<p className="mt-2 text-sm text-muted-foreground"> 릿 .</p>
</section>
);
}
export function SeriesFormPage({ apiClient, characterId, createCropSource = createImageCropSource, renderCrop, seriesId }: { readonly apiClient: ApiClient; readonly characterId: string; readonly createCropSource?: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly seriesId?: string }) {
const canManageSeries = useSeriesManagementCapability();
const [state, setState] = useState<FormState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const mode = seriesId === undefined ? "create" : "edit";
const requestKey = `${characterId}:${seriesId ?? "new"}:${retryKey}`;
useEffect(() => {
if (!canManageSeries) {
return undefined;
}
let isCurrent = true;
const request = seriesId === undefined
? Promise.all([getCharacter(apiClient, characterId), getSeriesGenres(apiClient)]).then(([character, genres]) => ({ character, genres }))
: Promise.all([getCharacter(apiClient, characterId), getSeriesGenres(apiClient), getSeriesDetail(apiClient, { characterId, seriesId })]).then(([character, genres, series]) => ({ character, genres, series }));
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, canManageSeries, characterId, requestKey, seriesId]);
if (!canManageSeries) {
return <SeriesMobileMutationGuidance />;
}
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="series" character={visibleState.character}>
<PageState description="목록과 상세 조회만 가능합니다." state="empty" title="비활성화된 AI 캐릭터에는 시리즈를 저장할 수 없습니다." />
</CharacterWorkspaceLayout>
);
}
return (
<CharacterWorkspaceLayout activeTab="series" character={visibleState.character}>
<SeriesForm apiClient={apiClient} characterId={characterId} createCropSource={createCropSource} genres={visibleState.genres} mode={mode} renderCrop={renderCrop} series={visibleState.series} />
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import { navigateTo, useBrowserLocation } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { getSeries, getSeriesGenres } from "@/features/series/api/series-api";
import { SeriesList } from "@/features/series/components/SeriesList";
import { useSeriesManagementCapability } from "@/features/series/lib/use-series-management-capability";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/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 { PageState } from "@/shared/ui/page-state";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
type ListQuery = { readonly page: number; readonly size: number };
type ListState =
| { readonly requestKey: string; readonly status: "loading" }
| { readonly character: CharacterDetail; readonly data: PageData<SeriesListItem>; readonly genres: readonly SeriesGenreItem[]; 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,
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) });
navigateTo(`${routePaths.aiCharacterSeries(characterId)}?${nextQuery.toString()}`);
}
export function SeriesListPage({ apiClient, characterId }: { readonly apiClient: ApiClient; readonly characterId: string }) {
const canManageSeries = useSeriesManagementCapability();
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.size}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void Promise.all([
getCharacter(apiClient, characterId),
getSeries(apiClient, { characterId, page: query.page, size: query.size }),
getSeriesGenres(apiClient),
])
.then(([character, data, genres]) => {
if (isCurrent) {
setState({ character, data, genres, 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.size, requestKey]);
const visibleState: ListState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
const onPageChange = useCallback((page: number) => navigateList(characterId, { page, size: query.size }), [characterId, query.size]);
const onSizeChange = useCallback((size: number) => navigateList(characterId, { page: 0, size }), [characterId]);
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="series" character={visibleState.character}>
<section className="flex flex-col gap-4" aria-labelledby="series-list-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">SERIES</p>
<h2 className="text-2xl font-bold leading-tight" id="series-list-title"></h2>
<p className="text-sm text-muted-foreground"> 릿 , , .</p>
</div>
{visibleState.character.isActive && canManageSeries ? <a className="w-fit 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)]" href={routePaths.aiCharacterSeriesCreate(characterId)} onClick={(event) => { event.preventDefault(); navigateTo(routePaths.aiCharacterSeriesCreate(characterId)); }}> </a> : null}
{visibleState.character.isActive && !canManageSeries ? <p className="break-keep rounded-lg border border-border bg-card p-4 text-sm font-semibold text-muted-foreground"> 릿 .</p> : null}
{visibleState.data.items.length === 0 ? <PageState description="현재 캐릭터에 연결된 시리즈가 없습니다." state="empty" title="등록된 시리즈가 없습니다." /> : null}
{visibleState.data.items.length > 0 ? <SeriesList characterId={characterId} data={visibleState.data} genres={visibleState.genres} /> : null}
<ResourcePagination data={visibleState.data} onPageChange={onPageChange} onSizeChange={onSizeChange} />
</section>
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { getAllSeriesForOrder, updateSeriesOrder } from "@/features/series/api/series-api";
import { SeriesOrderList } from "@/features/series/components/SeriesOrderList";
import { useSeriesManagementCapability } from "@/features/series/lib/use-series-management-capability";
import type { SeriesListItem } from "@/features/series/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import { PageState } from "@/shared/ui/page-state";
type OrderState =
| { readonly status: "loading" }
| { readonly character: CharacterDetail; readonly isComplete: boolean; readonly items: readonly SeriesListItem[]; readonly status: "content" }
| { readonly message: string; readonly status: "error" };
const saveErrorMessage = "순서를 저장하지 못했습니다. 목록을 다시 불러온 뒤 재시도하세요.";
function SeriesOrderMobileGuidance() {
return (
<section className="rounded-lg border border-border bg-card p-4" aria-labelledby="series-order-mobile-guidance-title">
<h2 className="text-xl font-semibold" id="series-order-mobile-guidance-title">릿 .</h2>
<p className="mt-2 text-sm text-muted-foreground"> 릿 .</p>
</section>
);
}
function move(items: readonly SeriesListItem[], seriesId: number, offset: -1 | 1): readonly SeriesListItem[] {
const index = items.findIndex((item) => item.seriesId === seriesId);
const nextIndex = index + offset;
if (index < 0 || nextIndex < 0 || nextIndex >= items.length) {
return items;
}
const nextItems = [...items];
const item = nextItems[index];
const other = nextItems[nextIndex];
if (item === undefined || other === undefined) {
return items;
}
nextItems[index] = other;
nextItems[nextIndex] = item;
return nextItems;
}
export function SeriesOrderPage({ apiClient, characterId, pageSize = 50 }: { readonly apiClient: ApiClient; readonly characterId: string; readonly pageSize?: number }) {
const canManageSeries = useSeriesManagementCapability();
const [state, setState] = useState<OrderState>({ status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [savedMessage, setSavedMessage] = useState<string | null>(null);
const isSavingRef = useRef(false);
useEffect(() => {
if (!canManageSeries) {
return undefined;
}
let isCurrent = true;
void Promise.all([getCharacter(apiClient, characterId), getAllSeriesForOrder(apiClient, { characterId, size: pageSize })])
.then(([character, order]) => {
if (isCurrent) {
setSaveError(order.isComplete ? null : saveErrorMessage);
setState({ character, isComplete: order.isComplete, items: order.items, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "시리즈 순서를 불러오지 못했습니다.", status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, canManageSeries, characterId, pageSize, retryKey]);
async function saveOrder(items: readonly SeriesListItem[], isComplete: boolean): Promise<void> {
if (isSavingRef.current) {
return;
}
setSaveError(null);
setSavedMessage(null);
if (!isComplete) {
setSaveError(saveErrorMessage);
return;
}
isSavingRef.current = true;
setIsSaving(true);
try {
await updateSeriesOrder(apiClient, { characterId, ids: items.map((item) => item.seriesId) });
setSavedMessage("시리즈 순서를 저장했습니다.");
} catch {
setSaveError(saveErrorMessage);
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
if (!canManageSeries) {
return <SeriesOrderMobileGuidance />;
}
if (state.status === "loading") {
return <PageState state="loading" title="시리즈 순서를 불러오는 중" />;
}
if (state.status === "error") {
return <PageState description={state.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="시리즈 순서 조회 실패" />;
}
if (!state.character.isActive) {
return (
<CharacterWorkspaceLayout activeTab="series" character={state.character}>
<PageState description="목록과 상세 조회만 가능합니다." state="empty" title="비활성화된 AI 캐릭터에는 시리즈 순서를 저장할 수 없습니다." />
</CharacterWorkspaceLayout>
);
}
return (
<CharacterWorkspaceLayout activeTab="series" character={state.character}>
<section className="flex flex-col gap-4" aria-labelledby="series-order-title">
<div className="flex flex-col gap-2"><p className="text-xs font-semibold text-info">SERIES ORDER</p><h2 className="text-2xl font-bold leading-tight" id="series-order-title"> </h2><p className="text-sm text-muted-foreground"> / .</p></div>
{saveError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{saveError}</p>}
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
{savedMessage === null ? null : <p className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{savedMessage}</p>}
<SeriesOrderList isDisabled={isSaving} items={state.items} onMoveDown={(seriesId) => setState({ ...state, items: move(state.items, seriesId, 1) })} onMoveUp={(seriesId) => setState({ ...state, items: move(state.items, seriesId, -1) })} />
<button className="self-start 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:opacity-60" disabled={isSaving} onClick={() => void saveOrder(state.items, state.isComplete)} type="button"> </button>
</section>
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
const seriesPublishedDaySchema = z.union([
z.literal("SUN"),
z.literal("MON"),
z.literal("TUE"),
z.literal("WED"),
z.literal("THU"),
z.literal("FRI"),
z.literal("SAT"),
z.literal("RANDOM"),
]);
const seriesStateSchema = z.union([z.literal("PROCEEDING"), z.literal("SUSPEND"), z.literal("COMPLETE")]);
const publishedDaysSchema = z.array(seriesPublishedDaySchema)
.min(1)
.refine((days) => new Set(days).size === days.length)
.refine((days) => !days.includes("RANDOM") || days.length === 1);
export const seriesCreateRequestSchema = z.strictObject({
title: z.string().min(1),
introduction: z.string().min(1),
publishedDaysOfWeek: publishedDaysSchema,
keyword: z.string().min(1),
genreId: z.number().int().positive(),
isAdult: z.boolean().optional(),
writer: z.string().nullable().optional(),
studio: z.string().nullable().optional(),
});
export const seriesUpdateRequestSchema = z.strictObject({
title: z.string().nullable().optional(),
introduction: z.string().nullable().optional(),
publishedDaysOfWeek: publishedDaysSchema.nullable().optional(),
genreId: z.number().int().positive().nullable().optional(),
isAdult: z.boolean().nullable().optional(),
state: seriesStateSchema.optional(),
writer: z.string().nullable().optional(),
studio: z.string().nullable().optional(),
});
export const seriesDeactivateRequestSchema = z.strictObject({ isActive: z.literal(false) });
export const seriesCreateFormSchema = seriesCreateRequestSchema.extend({ image: z.instanceof(File) });
export const seriesUpdateFormSchema = seriesUpdateRequestSchema.extend({ image: z.instanceof(File).optional() });
export type SeriesCreateRequest = z.infer<typeof seriesCreateRequestSchema>;
export type SeriesDeactivateRequest = z.infer<typeof seriesDeactivateRequestSchema>;
export type SeriesPublishedDay = z.infer<typeof seriesPublishedDaySchema>;
export type SeriesState = z.infer<typeof seriesStateSchema>;
export type SeriesUpdateRequest = z.infer<typeof seriesUpdateRequestSchema>;

View File

@@ -0,0 +1,193 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { SeriesContents } from "@/features/series/components/SeriesContents";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
const linkedContents = {
totalCount: 2,
items: [
{ contentId: 9001, title: "달빛 상담 오디오", coverImage: "https://cdn.example.com/audio/luna-cover.png", isAdult: false },
{ contentId: 9002, title: "아침 안내 오디오", coverImage: "https://cdn.example.com/audio/morning-cover.png", isAdult: true },
],
} as const;
const candidates = [
{ contentId: 9002, title: "아침 안내 오디오", coverImage: "https://cdn.example.com/audio/morning-cover.png" },
{ contentId: 9003, title: "별빛 회고 오디오", coverImage: "https://cdn.example.com/audio/star-cover.png" },
] as const;
function createClient(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.includes("/contents/search")) {
return options.responseSchema.parse(candidates);
}
if (options.method === "POST" || options.method === "DELETE") {
return options.responseSchema.parse(null);
}
return options.responseSchema.parse(linkedContents);
},
};
}
test("SeriesContents lists linked audio without search query and links only unlinked candidates", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<SeriesContents apiClient={createClient(requests)} canManageSeries characterId="101" seriesId="5001" />);
// Then
expect(await screen.findByRole("heading", { name: "연결된 오디오" })).toBeInTheDocument();
await screen.findByText("달빛 상담 오디오");
const linkedUrl = new URL(requests[0]?.path ?? "", "https://api.example.com");
expect([...linkedUrl.searchParams.keys()]).toEqual(["page", "size"]);
expect(linkedUrl.searchParams.has("search_word")).toBe(false);
expect(screen.getByText("총 2개 · 1페이지")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "달빛 상담 오디오 커버" })).toHaveAttribute("src", "https://cdn.example.com/audio/luna-cover.png");
expect(screen.getByText("성인")).toBeInTheDocument();
// When
vi.useFakeTimers();
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "상담" } });
act(() => vi.advanceTimersByTime(300));
vi.useRealTimers();
// Then
await screen.findByText("별빛 회고 오디오");
expect(screen.queryByRole("checkbox", { name: /아침 안내 오디오/ })).not.toBeInTheDocument();
const candidate = screen.getByRole("checkbox", { name: /별빛 회고 오디오/ });
fireEvent.click(candidate);
expect(candidate).toBeChecked();
fireEvent.click(candidate);
fireEvent.click(candidate);
fireEvent.click(screen.getByRole("button", { name: "선택한 오디오 연결" }));
await waitFor(() => expect(requests.some((request) => request.method === "POST")).toBe(true));
expect(requests.find((request) => request.method === "POST")?.body).toBe(JSON.stringify({ contentIdList: [9003] }));
});
test("SeriesContents confirms unlink with target title, traps focus, closes on Escape, and sends body-less DELETE", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<SeriesContents apiClient={createClient(requests)} canManageSeries characterId="101" seriesId="5001" />);
await screen.findByText("달빛 상담 오디오");
const unlinkTrigger = screen.getByRole("button", { name: "달빛 상담 오디오 연결 해제" });
unlinkTrigger.focus();
// When
fireEvent.click(unlinkTrigger);
// Then
const dialog = screen.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" });
expect(dialog).toHaveTextContent("달빛 상담 오디오");
expect(dialog).toHaveTextContent("시리즈 재생 순서에서 제외됩니다");
await waitFor(() => expect(screen.getByRole("button", { name: "취소" })).toHaveFocus());
screen.getByRole("button", { name: "연결 해제" }).focus();
fireEvent.keyDown(dialog, { key: "Tab" });
expect(screen.getByRole("button", { name: "취소" })).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
expect(screen.getByRole("button", { name: "연결 해제" })).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" })).not.toBeInTheDocument());
await waitFor(() => expect(unlinkTrigger).toHaveFocus());
// When
fireEvent.click(unlinkTrigger);
fireEvent.click(screen.getByRole("button", { name: "연결 해제" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "DELETE")).toBe(true));
expect(requests.find((request) => request.method === "DELETE")).toMatchObject({
path: "/api/v2/admin/ai-characters/101/series/5001/contents/9001",
body: undefined,
});
});
test("SeriesContents preserves linked list and shows retry guidance when mutations fail", async () => {
// Given
const client: ApiClient = {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
if (options.method === "DELETE") {
throw new Error("conflict");
}
return options.responseSchema.parse(linkedContents);
},
};
render(<SeriesContents apiClient={client} canManageSeries characterId="101" seriesId="5001" />);
await screen.findByText("달빛 상담 오디오");
// When
fireEvent.click(screen.getByRole("button", { name: "달빛 상담 오디오 연결 해제" }));
fireEvent.click(screen.getByRole("button", { name: "연결 해제" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("변경사항을 저장하지 못했습니다. 목록을 다시 불러온 뒤 재시도하세요.");
expect(screen.getByText("달빛 상담 오디오")).toBeInTheDocument();
});
test("SeriesContents sends one link and one unlink request while each mutation is pending", async () => {
// Given
const requests: CapturedRequest[] = [];
let resolvePost: () => void = () => { throw new Error("Link request was not started"); };
let resolveDelete: () => void = () => { throw new Error("Unlink 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.includes("/contents/search")) {
return options.responseSchema.parse(candidates);
}
if (options.method === "POST") {
await new Promise<void>((resolve) => { resolvePost = resolve; });
return options.responseSchema.parse(null);
}
if (options.method === "DELETE") {
await new Promise<void>((resolve) => { resolveDelete = resolve; });
return options.responseSchema.parse(null);
}
return options.responseSchema.parse(linkedContents);
},
};
render(<SeriesContents apiClient={client} canManageSeries characterId="101" seriesId="5001" />);
await screen.findByText("달빛 상담 오디오");
// When
vi.useFakeTimers();
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "상담" } });
act(() => vi.advanceTimersByTime(300));
vi.useRealTimers();
fireEvent.click(await screen.findByRole("checkbox", { name: /별빛 회고 오디오/ }));
const linkButton = screen.getByRole("button", { name: "선택한 오디오 연결" });
fireEvent.click(linkButton);
expect(linkButton).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("오디오를 연결하는 중");
fireEvent.click(linkButton);
resolvePost();
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "달빛 상담 오디오 연결 해제" }));
const dialog = screen.getByRole("alertdialog", { name: "오디오 연결을 해제하시겠습니까?" });
const cancelButton = screen.getByRole("button", { name: "취소" });
const confirmButton = screen.getByRole("button", { name: "연결 해제" });
fireEvent.click(confirmButton);
expect(dialog).toHaveAttribute("aria-busy", "true");
expect(cancelButton).toBeDisabled();
expect(confirmButton).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("오디오 연결을 해제하는 중");
fireEvent.click(confirmButton);
resolveDelete();
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "DELETE")).toHaveLength(1));
});

View File

@@ -0,0 +1,279 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { apiBaseUrl } from "@/app/app-test-support";
import { addSeriesContents, createSeries, deactivateSeries, getAllSeriesForOrder, getSeries, getSeriesContents, getSeriesDetail, getSeriesGenres, removeSeriesContent, searchUnlinkedSeriesContents, updateSeries, updateSeriesOrder } from "@/features/series/api/series-api";
import { seriesContentListResponseSchema, seriesContentSearchResponseSchema, seriesDetailSchema, seriesGenreListResponseSchema, seriesListResponseSchema } from "@/features/series/model/types";
import { seriesCreateFormSchema, seriesUpdateFormSchema } from "@/features/series/schemas/series-schema";
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-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 adminToken = "mock-admin-jwt";
const listItem = {
seriesId: 5001,
title: "달빛 상담 시리즈",
introduction: "밤마다 이어지는 상담 에피소드",
coverImageUrl: "https://cdn.example.com/series/moon.png",
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
state: "PROCEEDING",
isActive: true,
writer: "스튜디오 루나",
studio: "소다랩",
} as const;
const contentItem = {
contentId: 9001,
title: "달빛 상담 오디오",
coverImage: "https://cdn.example.com/audio/luna-cover.png",
isAdult: false,
} as const;
const candidateItem = {
contentId: 9002,
title: "아침 안내 오디오",
coverImage: "https://cdn.example.com/audio/morning-cover.png",
} as const;
const genreItems = [
{ id: 77, genre: "로맨스", isAdult: false },
{ id: 88, genre: "일상", isAdult: false },
] as const;
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.includes("/contents/search")) {
return options.responseSchema.parse([candidateItem]);
}
if (options.path.includes("/contents?page=")) {
return options.responseSchema.parse({ totalCount: 1, items: [contentItem] });
}
if (options.method === "POST" || options.method === "DELETE" || options.method === "PUT") {
return options.responseSchema.parse(null);
}
if (options.path.endsWith("/series?page=2&size=50")) {
return options.responseSchema.parse({ totalCount: 1, items: [listItem] });
}
return options.responseSchema.parse(listItem);
},
};
}
function authorizedFetch(path: string) {
return fetch(`${apiBaseUrl}${path}`, { headers: { Authorization: `Bearer ${adminToken}` } });
}
function requireData<Data>(data: Data | null): Data {
if (data === null) {
throw new Error("response data missing");
}
return data;
}
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
window.history.replaceState({}, "", "/");
});
describe("Series read-only contract", () => {
test("P10-T3 reads active genres with no query or body and keeps detail as SeriesListItem enums", async () => {
// Given
const requests: CapturedRequest[] = [];
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/series-genres") {
return options.responseSchema.parse(genreItems);
}
return options.responseSchema.parse(listItem);
},
};
// When
const genres = await getSeriesGenres(client);
const series = await getSeriesDetail(client, { characterId: "101", seriesId: "5001" });
// Then
expect(genres).toEqual(genreItems);
expect(series).toEqual(listItem);
expect(seriesGenreListResponseSchema.parse(genreItems)).toEqual(genreItems);
expect(requests).toEqual([
{ path: "/api/v2/admin/ai-characters/series-genres", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/series/5001", method: undefined, body: undefined },
]);
expect(new URL(requests[0]?.path ?? "", apiBaseUrl).search).toBe("");
});
test("P10-T3 serializes create update and deactivate multipart payloads without forbidden fields", async () => {
// Given
const requests: CapturedRequest[] = [];
const image = new File(["image"], "series.png", { type: "image/png" });
const client = createCapturingClient(requests);
// When
await createSeries(client, {
characterId: "101",
image,
request: { title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON", "WED"], keyword: "달빛", genreId: 77, isAdult: false, writer: "작가", studio: null },
});
await updateSeries(client, {
characterId: "101",
seriesId: "5001",
request: { title: "수정 시리즈", introduction: "수정 소개", publishedDaysOfWeek: ["RANDOM"], genreId: 88, isAdult: false, state: "COMPLETE", writer: null, studio: "스튜디오" },
});
await updateSeries(client, {
characterId: "101",
seriesId: "5001",
image,
request: { title: "이미지 교체" },
});
await deactivateSeries(client, { characterId: "101", seriesId: "5001" });
// Then
expect(requests.map((request) => ({ method: request.method, path: request.path }))).toEqual([
{ method: "POST", path: "/api/v2/admin/ai-characters/101/series" },
{ method: "PUT", path: "/api/v2/admin/ai-characters/101/series/5001" },
{ method: "PUT", path: "/api/v2/admin/ai-characters/101/series/5001" },
{ method: "PUT", path: "/api/v2/admin/ai-characters/101/series/5001" },
]);
expect(requests[0]?.body).toBeInstanceOf(FormData);
const createBody = requests[0]?.body as FormData;
const updateBody = requests[1]?.body as FormData;
const imageUpdateBody = requests[2]?.body as FormData;
const deactivateBody = requests[3]?.body as FormData;
expect(createBody.get("image")).toBe(image);
expect(updateBody.get("image")).toBeNull();
expect(imageUpdateBody.get("image")).toBe(image);
await expect((createBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON", "WED"], keyword: "달빛", genreId: 77, isAdult: false, writer: "작가", studio: null }));
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "수정 시리즈", introduction: "수정 소개", publishedDaysOfWeek: ["RANDOM"], genreId: 88, isAdult: false, state: "COMPLETE", writer: null, studio: "스튜디오" }));
await expect((deactivateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ isActive: false }));
});
test("P10-T3 validates create update day rules and image policy", () => {
// Given
const validImage = new File(["x"], "series.jpg", { type: "image/jpeg" });
const oversizedImage = new File([new Uint8Array(SERIES_IMAGE_POLICY.maxBytes + 1)], "series.jpg", { type: "image/jpeg" });
// When / Then
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 77 }).success).toBe(true);
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["RANDOM"], keyword: "달빛", genreId: 77 }).success).toBe(true);
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON"], keyword: "달빛" }).success).toBe(false);
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON", "MON"], keyword: "달빛", genreId: 77 }).success).toBe(false);
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["RANDOM", "MON"], keyword: "달빛", genreId: 77 }).success).toBe(false);
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 0 }).success).toBe(false);
expect(seriesCreateFormSchema.safeParse({ image: validImage, title: "새 시리즈", introduction: "소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 77, state: "PROCEEDING" }).success).toBe(false);
expect(seriesUpdateFormSchema.safeParse({ title: "수정", publishedDaysOfWeek: ["RANDOM"], state: "SUSPEND" }).success).toBe(true);
expect(seriesUpdateFormSchema.safeParse({ title: "수정", publishedDaysOfWeek: ["MON", "MON"] }).success).toBe(false);
expect(seriesUpdateFormSchema.safeParse({ title: "수정", publishedDaysOfWeek: ["RANDOM", "WED"] }).success).toBe(false);
expect(seriesUpdateFormSchema.safeParse({ title: "수정", keyword: "수정 금지" }).success).toBe(false);
expect(SERIES_IMAGE_POLICY).toEqual({ aspect: 210 / 297, cropRequired: true, maxBytes: 10_485_760, maxWidth: 1000, noUpscale: true });
expect(Math.abs(Math.round(1000 * (297 / 210)) - 1414)).toBeLessThanOrEqual(1);
expect(validateSeriesImageFile(validImage)).toEqual({ ok: true });
expect(validateSeriesImageFile(new File(["x"], "series.gif", { type: "image/gif" }))).toEqual({ ok: false, reason: "extension" });
expect(validateSeriesImageFile(new File(["x"], "series.jpg", { type: "text/plain" }))).toEqual({ ok: false, reason: "mime" });
expect(validateSeriesImageFile(oversizedImage)).toEqual({ ok: false, reason: "size" });
});
test("list sends page and size only and reads data totalCount items", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const list = await getSeries(client, { characterId: "101", page: 2, size: 50 });
const series = await getSeriesDetail(client, { characterId: "101", seriesId: "5001" });
// Then
expect(list).toEqual({ totalCount: 1, page: 2, size: 50, hasNext: false, items: [listItem] });
expect(series).toEqual(listItem);
expect(requests).toEqual([
{ path: "/api/v2/admin/ai-characters/101/series?page=2&size=50", method: undefined },
{ path: "/api/v2/admin/ai-characters/101/series/5001", method: undefined },
]);
const url = new URL(requests[0]?.path ?? "", apiBaseUrl);
expect([...url.searchParams.keys()]).toEqual(["page", "size"]);
});
test("content link unlink and order APIs use the P5-T4 payload contract", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const contents = await getSeriesContents(client, { characterId: "101", seriesId: "5001", page: 1, size: 20 });
const candidates = await searchUnlinkedSeriesContents(client, { characterId: "101", seriesId: "5001", searchWord: "상담" });
await addSeriesContents(client, { characterId: "101", seriesId: "5001", contentIdList: [9002, 9003] });
await removeSeriesContent(client, { characterId: "101", seriesId: "5001", contentId: 9002 });
await updateSeriesOrder(client, { characterId: "101", ids: [5002, 5001] });
// Then
expect(contents).toEqual({ page: 1, size: 20, totalCount: 1, hasNext: false, items: [contentItem] });
expect(candidates).toEqual([candidateItem]);
expect(seriesContentListResponseSchema.parse({ totalCount: 1, items: [contentItem] }).items[0]).toEqual(contentItem);
expect(seriesContentSearchResponseSchema.parse([candidateItem])[0]).toEqual(candidateItem);
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/series/5001/contents?page=1&size=20", method: undefined, body: undefined }, { path: "/api/v2/admin/ai-characters/101/series/5001/contents/search?search_word=%EC%83%81%EB%8B%B4", method: undefined, body: undefined }, { path: "/api/v2/admin/ai-characters/101/series/5001/contents", method: "POST", body: JSON.stringify({ contentIdList: [9002, 9003] }) }, { path: "/api/v2/admin/ai-characters/101/series/5001/contents/9002", method: "DELETE", body: undefined }, { path: "/api/v2/admin/ai-characters/101/series/orders", method: "PUT", body: JSON.stringify({ ids: [5002, 5001] }) }]);
});
test("all-page order fetch uses totalCount page size before saving every id", async () => {
// Given
const requests: CapturedRequest[] = [];
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.endsWith("page=0&size=2")) {
return options.responseSchema.parse({ totalCount: 3, items: [listItem, { ...listItem, seriesId: 5002, title: "아침 루틴 시리즈" }] });
}
if (options.path.endsWith("page=1&size=2")) {
return options.responseSchema.parse({ totalCount: 3, items: [{ ...listItem, seriesId: 5003, title: "별빛 마감 시리즈" }] });
}
return options.responseSchema.parse(null);
},
};
// When
const order = await getAllSeriesForOrder(client, { characterId: "101", size: 2 });
await updateSeriesOrder(client, { characterId: "101", ids: [...order.items.map((item) => item.seriesId)].reverse() });
// Then
expect(order.items.map((item) => item.seriesId)).toEqual([5001, 5002, 5003]);
expect(requests.map((request) => request.path)).toEqual(["/api/v2/admin/ai-characters/101/series?page=0&size=2", "/api/v2/admin/ai-characters/101/series?page=1&size=2", "/api/v2/admin/ai-characters/101/series/orders"]);
expect(requests[2]?.body).toBe(JSON.stringify({ ids: [5003, 5002, 5001] }));
});
test("mock handler exposes only provided list and detail contracts", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/series?page=0&size=20");
const detailResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/series/5001");
// Then
const list = requireData(createApiResponseSchema(seriesListResponseSchema).parse(await listResponse.json()).data);
const series = requireData(createApiResponseSchema(seriesDetailSchema).parse(await detailResponse.json()).data);
expect(list.totalCount).toBe(2);
expect(list.items[0]).toMatchObject({ seriesId: 5001, state: "PROCEEDING" });
expect(series).toMatchObject({ seriesId: 5001, genreId: 77, state: "PROCEEDING" });
});
});

View File

@@ -0,0 +1,105 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { expect, test } from "vitest";
import { SeriesForm } from "@/features/series/components/SeriesForm";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = { readonly body?: BodyInit | null; readonly method?: string; readonly path: string };
const genres = [
{ id: 77, genre: "로맨스", isAdult: false },
{ id: 88, genre: "일상", isAdult: false },
] as const satisfies readonly SeriesGenreItem[];
const existingSeries = {
seriesId: 5001,
title: "달빛 상담 시리즈",
introduction: "밤마다 이어지는 상담 에피소드",
coverImageUrl: "https://cdn.example.com/series/moon.png",
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
state: "PROCEEDING",
isActive: true,
writer: "스튜디오 루나",
studio: "소다랩",
} as const satisfies SeriesListItem;
function createClient(requests: CapturedRequest[]): ApiClient {
return { async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> { requests.push({ body: options.body, method: options.method, path: options.path }); return options.responseSchema.parse(null); } };
}
function createImage(name = "series.png"): File {
return new File(["image"], name, { type: "image/png" });
}
function renderEditForm(requests: CapturedRequest[], createCropSource: (file: File) => Promise<CropSourceImage>) {
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={(request) => Promise.resolve(request.file)} series={existingSeries} />);
}
function requireFile(part: FormDataEntryValue | null): File {
if (part instanceof File) {
return part;
}
throw new TypeError("Expected File part");
}
test("SeriesForm blocks edit save while replacement image crop source is preparing", async () => {
const requests: CapturedRequest[] = [];
renderEditForm(requests, () => new Promise(() => undefined));
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("pending.png")] } });
await waitFor(() => expect(screen.getByRole("button", { name: "저장" })).toBeDisabled());
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("SeriesForm ignores stale image crop sources and saves the latest applied replacement", async () => {
const requests: CapturedRequest[] = [];
const cropResolvers = new Map<string, (source: CropSourceImage) => void>();
renderEditForm(requests, (file) => new Promise((resolve) => cropResolvers.set(file.name, resolve)));
const staleFile = createImage("stale.png");
const freshFile = createImage("fresh.png");
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [staleFile] } });
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [freshFile] } });
cropResolvers.get("fresh.png")?.({ file: freshFile, height: 1414, previewUrl: "blob:fresh", width: 1000 });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await screen.findByText("fresh.png");
cropResolvers.get("stale.png")?.({ file: staleFile, height: 1414, previewUrl: "blob:stale", width: 1000 });
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001"));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(requireFile(updateBody.get("image")).name).toBe("fresh.png");
});
test("SeriesForm keeps existing edit image when replacement crop is canceled", async () => {
const requests: CapturedRequest[] = [];
renderEditForm(requests, (file) => Promise.resolve({ file, height: 1414, previewUrl: "blob:series-preview", width: 1000 }));
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("replacement.png")] } });
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001"));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(updateBody.get("image")).toBeNull();
});
test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => {
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", mode === "create" ? "/ai-characters/101/series/new" : "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={() => Promise.reject(new Error("preview failed"))} genres={genres} mode={mode} renderCrop={(request) => Promise.resolve(request.file)} series={mode === "edit" ? existingSeries : undefined} />);
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("broken.png")] } });
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,50 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { SeriesForm } from "@/features/series/components/SeriesForm";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
const genres = [{ id: 77, genre: "로맨스", isAdult: false }] as const satisfies readonly SeriesGenreItem[];
const series = {
seriesId: 5001,
title: "달빛 상담 시리즈",
introduction: "밤마다 이어지는 상담 에피소드",
coverImageUrl: "https://cdn.example.com/series/moon.png",
publishedDaysOfWeek: ["SUN"],
genreId: 77,
isAdult: false,
state: "PROCEEDING",
isActive: true,
writer: null,
studio: null,
} as const satisfies SeriesListItem;
function createPendingClient(requests: ApiRequestOptions<unknown>[]): ApiClient {
return {
request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push(options as ApiRequestOptions<unknown>);
return new Promise<Data>(() => undefined);
},
};
}
function createCropSource(file: File): Promise<CropSourceImage> {
return Promise.resolve({ file, height: 1414, previewUrl: "blob:series", width: 1000 });
}
test("Series edit shows saving status and sends one update while pending", async () => {
const requests: ApiRequestOptions<unknown>[] = [];
render(<SeriesForm apiClient={createPendingClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" series={series} />);
fireEvent.change(screen.getByLabelText("제목"), { target: { value: "달빛 상담 시리즈 수정" } });
const submitButton = screen.getByRole("button", { name: "저장" });
fireEvent.click(submitButton);
fireEvent.click(submitButton);
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
expect(screen.getByRole("status")).toHaveTextContent("시리즈를 저장하는 중");
expect(submitButton).toBeDisabled();
});

View File

@@ -0,0 +1,286 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { SeriesForm } from "@/features/series/components/SeriesForm";
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
const genres = [
{ id: 77, genre: "로맨스", isAdult: false },
{ id: 88, genre: "일상", isAdult: false },
] as const satisfies readonly SeriesGenreItem[];
const existingSeries = {
seriesId: 5001,
title: "달빛 상담 시리즈",
introduction: "밤마다 이어지는 상담 에피소드",
coverImageUrl: "https://cdn.example.com/series/moon.png",
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
state: "PROCEEDING",
isActive: true,
writer: "스튜디오 루나",
studio: "소다랩",
} as const satisfies SeriesListItem;
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: false,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
} as const;
function createClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
return options.responseSchema.parse(null);
},
};
}
function createInactivePageClient(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/series-genres") {
return options.responseSchema.parse(genres);
}
if (options.path === "/api/v2/admin/ai-characters/101/series/5001") {
return options.responseSchema.parse(existingSeries);
}
return options.responseSchema.parse(null);
},
};
}
function createImage(name = "series.png"): File {
return new File(["image"], name, { type: "image/png" });
}
async function readRequestPart(body: BodyInit | null | undefined): Promise<unknown> {
if (body instanceof FormData) {
const request = body.get("request");
if (request instanceof Blob) {
return JSON.parse(await request.text());
}
}
return null;
}
async function createCropSource(file: File): Promise<CropSourceImage> {
return { file, height: 1414, previewUrl: "blob:series-preview", width: 1000 };
}
async function renderCrop(request: CropRenderRequest): Promise<File> {
return new File([String(request.outputWidth)], "cropped-series.png", { type: "image/png" });
}
test("SeriesForm creates with required image request and navigates to list notification", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/new");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
// When
fireEvent.change(screen.getByLabelText("제목"), { target: { value: "새 시리즈" } });
fireEvent.change(screen.getByLabelText("소개"), { target: { value: "새 시리즈 소개" } });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "달빛" } });
fireEvent.click(screen.getByRole("checkbox", { name: "월요일" }));
fireEvent.change(screen.getByLabelText("장르"), { target: { value: "77" } });
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "작가" } });
fireEvent.change(screen.getByLabelText("스튜디오"), { target: { value: "스튜디오" } });
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage()] } });
await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("cropped-series.png");
fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "POST")).toBe(true));
const requestBody = requests.find((request) => request.method === "POST")?.body as FormData;
expect(requestBody.get("image")).toBeInstanceOf(File);
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
expect(window.location.pathname).toBe("/ai-characters/101/series");
expect(window.history.state.successNotification).toBe("시리즈를 생성했습니다.");
});
test.each([
["create", undefined, "/ai-characters/101/series/new"],
["edit", "5001", "/ai-characters/101/series/5001/edit"],
])("SeriesFormPage blocks the %s route for inactive characters", async (_mode, seriesId, path) => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", path);
render(<SeriesFormPage apiClient={createInactivePageClient(requests)} characterId="101" seriesId={seriesId} />);
// 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("SeriesForm edits enum fields without keyword or image requirement and deactivates with isActive false only", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
expect(screen.getByLabelText("제목")).toHaveValue("달빛 상담 시리즈");
expect(screen.queryByLabelText("키워드")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("상태"), { target: { value: "COMPLETE" } });
fireEvent.click(screen.getByRole("checkbox", { name: "랜덤" }));
expect(screen.getByRole("checkbox", { name: "랜덤" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "일요일" })).not.toBeChecked();
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(updateBody.get("image")).toBeNull();
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", publishedDaysOfWeek: ["RANDOM"], genreId: 77, isAdult: false, state: "COMPLETE", writer: "스튜디오 루나", studio: "소다랩" }));
expect(window.location.pathname).toBe("/ai-characters/101/series/5001");
// When
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(2));
const deactivateBody = requests.at(-1)?.body as FormData;
await expect((deactivateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ isActive: false }));
expect(window.location.pathname).toBe("/ai-characters/101/series");
});
test("SeriesForm 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.method === "PUT") {
await new Promise<void>((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); });
}
return options.responseSchema.parse(null);
},
};
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={client} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
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("SeriesForm omits unchanged state from edit payload", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
fireEvent.change(screen.getByLabelText("제목"), { target: { value: "달빛 상담 시리즈 수정" } });
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
title: "달빛 상담 시리즈 수정",
introduction: "밤마다 이어지는 상담 에피소드",
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
writer: "스튜디오 루나",
studio: "소다랩",
});
});
test("SeriesForm rejects mismatched image extension and MIME before crop", async () => {
const requests: CapturedRequest[] = [];
let cropSourceCalls = 0;
window.history.pushState({}, "", "/ai-characters/101/series/new");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={async (file) => { cropSourceCalls += 1; return createCropSource(file); }} genres={genres} mode="create" renderCrop={renderCrop} />);
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [new File(["image"], "series.png", { type: "image/jpeg" })] } });
expect(await screen.findByRole("alert")).toHaveTextContent("JPEG 또는 PNG 파일만 업로드하세요.");
expect(cropSourceCalls).toBe(0);
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
});
test("SeriesForm links validation errors and focuses the first invalid control", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/new");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
// When
const titleInput = screen.getByLabelText("제목");
fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" }));
// Then
const titleError = await screen.findByText("제목을 입력하세요.");
const introductionError = screen.getByText("소개를 입력하세요.");
const keywordError = screen.getByText("키워드를 입력하세요.");
const genreError = screen.getByText("장르를 선택하세요.");
const daysError = screen.getByText("연재 요일을 선택하세요.");
expect(titleError).toHaveAttribute("id", "series-title-error");
expect(introductionError).toHaveAttribute("id", "series-introduction-error");
expect(keywordError).toHaveAttribute("id", "series-keyword-error");
expect(genreError).toHaveAttribute("id", "series-genre-error");
expect(daysError).toHaveAttribute("id", "series-days-error");
expect(titleInput).toHaveAttribute("aria-describedby", "series-title-error");
expect(screen.getByLabelText("소개")).toHaveAttribute("aria-describedby", "series-introduction-error");
expect(screen.getByLabelText("키워드")).toHaveAttribute("aria-describedby", "series-keyword-error");
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(titleInput).toHaveFocus());
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});

View File

@@ -0,0 +1,9 @@
import { expect, test } from "vitest";
import { validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
test("series image policy rejects extension and MIME mismatches", () => {
expect(validateSeriesImageFile(new File(["image"], "series.png", { type: "image/jpeg" }))).toEqual({ ok: false, reason: "mime" });
expect(validateSeriesImageFile(new File(["image"], "series.jpg", { type: "image/png" }))).toEqual({ ok: false, reason: "mime" });
expect(validateSeriesImageFile(new File(["image"], "series.jpeg", { type: "image/jpeg" }))).toEqual({ ok: true });
});

View File

@@ -0,0 +1,184 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { expect, test } from "vitest";
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
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 firstPage = {
totalCount: 3,
items: [
{ seriesId: 5001, title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", coverImageUrl: "https://cdn.example.com/series/moon.png", publishedDaysOfWeek: ["SUN"], genreId: 77, isAdult: false, state: "PROCEEDING", isActive: true, writer: "스튜디오 루나", studio: "소다랩" },
{ seriesId: 5002, title: "아침 루틴 시리즈", introduction: "하루 시작을 돕는 안내", coverImageUrl: "https://cdn.example.com/series/morning.png", publishedDaysOfWeek: ["RANDOM"], genreId: 88, isAdult: false, state: "SUSPEND", isActive: true, writer: null, studio: "소다랩" },
],
} as const;
const secondPage = {
totalCount: 3,
items: [
{ ...firstPage.items[0], seriesId: 5003, title: "별빛 마감 시리즈" },
],
} as const;
function createClient(requests: CapturedRequest[], mode: "failSave" | "inactive" | "mismatch" | "success" = "success"): 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(mode === "inactive" ? { ...character, isActive: false } : character);
}
if (options.method === "PUT") {
if (mode === "failSave") {
throw new Error("missing id");
}
return options.responseSchema.parse(null);
}
if (options.path.endsWith("page=0&size=2")) {
return options.responseSchema.parse(mode === "mismatch" ? { ...firstPage, totalCount: 4 } : firstPage);
}
return options.responseSchema.parse(secondPage);
},
};
}
test("SeriesOrderPage blocks order mutations for inactive characters", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<SeriesOrderPage apiClient={createClient(requests, "inactive")} characterId="101" pageSize={2} />);
// When
await screen.findByText("비활성화된 AI 캐릭터에는 시리즈 순서를 저장할 수 없습니다.");
// Then
expect(screen.queryByRole("button", { name: "순서 저장" })).not.toBeInTheDocument();
expect(screen.queryByRole("list", { name: "시리즈 순서 목록" })).not.toBeInTheDocument();
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("SeriesOrderPage fetches every page, reorders with keyboard buttons, and saves every id", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<SeriesOrderPage apiClient={createClient(requests)} characterId="101" pageSize={2} />);
// Then
expect(await screen.findByRole("heading", { name: "시리즈 순서 관리" })).toBeInTheDocument();
await screen.findByText("별빛 마감 시리즈");
expect(requests.map((request) => request.path)).toContain("/api/v2/admin/ai-characters/101/series?page=0&size=2");
expect(requests.map((request) => request.path)).toContain("/api/v2/admin/ai-characters/101/series?page=1&size=2");
// When
fireEvent.click(screen.getByRole("button", { name: "아침 루틴 시리즈 위로" }));
fireEvent.click(screen.getByRole("button", { name: "달빛 상담 시리즈 아래로" }));
fireEvent.click(screen.getByRole("button", { name: "순서 저장" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
expect(requests.find((request) => request.method === "PUT")?.body).toBe(JSON.stringify({ ids: [5002, 5003, 5001] }));
});
test("SeriesOrderPage preserves current order and shows refetch guidance when save fails", async () => {
// Given
render(<SeriesOrderPage apiClient={createClient([], "failSave")} characterId="101" pageSize={2} />);
await screen.findByText("별빛 마감 시리즈");
// When
fireEvent.click(screen.getByRole("button", { name: "아침 루틴 시리즈 위로" }));
fireEvent.click(screen.getByRole("button", { name: "순서 저장" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("순서를 저장하지 못했습니다. 목록을 다시 불러온 뒤 재시도하세요.");
const rows = within(screen.getByRole("list", { name: "시리즈 순서 목록" })).getAllByRole("listitem").map((row) => row.textContent ?? "");
expect(rows[0]).toContain("아침 루틴 시리즈");
expect(rows[1]).toContain("달빛 상담 시리즈");
});
test("SeriesOrderPage sends one save request while the current save is pending", async () => {
// Given
const requests: CapturedRequest[] = [];
let resolveSave: () => void = () => { throw new Error("Save 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.method === "PUT") {
await new Promise<void>((resolve) => { resolveSave = resolve; });
return options.responseSchema.parse(null);
}
if (options.path.endsWith("page=0&size=2")) {
return options.responseSchema.parse(firstPage);
}
return options.responseSchema.parse(secondPage);
},
};
render(<SeriesOrderPage apiClient={client} characterId="101" pageSize={2} />);
await screen.findByText("별빛 마감 시리즈");
// When
const saveButton = screen.getByRole("button", { name: "순서 저장" });
fireEvent.click(saveButton);
expect(saveButton).toBeDisabled();
expect(screen.getByRole("button", { name: "달빛 상담 시리즈 아래로" })).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("시리즈 순서를 저장하는 중");
fireEvent.click(saveButton);
resolveSave();
// Then
await screen.findByRole("status");
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1);
});
test("SeriesOrderPage blocks save and keeps collected order when totalCount does not match fetched items", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<SeriesOrderPage apiClient={createClient(requests, "mismatch")} characterId="101" pageSize={2} />);
// When
await screen.findByText("별빛 마감 시리즈");
fireEvent.click(screen.getByRole("button", { name: "아침 루틴 시리즈 위로" }));
fireEvent.click(screen.getByRole("button", { name: "순서 저장" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("목록을 다시 불러온 뒤 재시도하세요.");
expect(requests.some((request) => request.method === "PUT")).toBe(false);
const rows = within(screen.getByRole("list", { name: "시리즈 순서 목록" })).getAllByRole("listitem").map((row) => row.textContent ?? "");
expect(rows[0]).toContain("아침 루틴 시리즈");
expect(rows[1]).toContain("달빛 상담 시리즈");
});

View File

@@ -0,0 +1,250 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { createElement } from "react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
import type { SeriesListItem } from "@/features/series/model/types";
import { mockSeriesGenres, mockSeriesListItems } from "@/shared/mocks/series-fixtures";
import { server } from "@/shared/test/server";
const listItem = mockSeriesListItems[0];
const displayLeakItem = { ...listItem, isAdult: true } satisfies SeriesListItem;
function expectNoRawSeriesDisplayText(): void {
expect(document.body).not.toHaveTextContent(/\bSUN\b|\bPROCEEDING\b|isAdult true|isActive true|genreId/);
}
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
server.use(http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/series-genres`, () => HttpResponse.json({ success: true, message: null, data: mockSeriesGenres, errorProperty: null })));
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("Series list ignores active and search filters while rendering loading empty error retry", async () => {
// Given
saveAdminSession();
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const seriesRequests: Request[] = [];
let listRequestCount = 0;
let resolveFirstListReady: (finishList: () => void) => void = () => undefined;
const firstListReady = new Promise<() => void>((resolve) => {
resolveFirstListReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series`, ({ request }) => {
seriesRequests.push(request);
listRequestCount += 1;
if (listRequestCount === 1) {
return new Promise((resolve) => {
resolveFirstListReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })));
});
}
if (listRequestCount === 2) {
return HttpResponse.json({ success: false, message: "시리즈 목록 실패", data: null, errorProperty: null }, { status: 500 });
}
return HttpResponse.json({ success: true, message: null, data: { totalCount: 1, items: [displayLeakItem] }, errorProperty: null });
}),
);
window.history.pushState({}, "", "/ai-characters/101/series?page=0&size=20&active=true&search=달빛&status=OPEN");
// When
render(createElement(App));
const finishFirstList = await firstListReady;
// Then
expect(screen.getByRole("status")).toHaveTextContent("시리즈 목록을 불러오는 중");
finishFirstList();
expect(await screen.findByText("등록된 시리즈가 없습니다.")).toBeInTheDocument();
const firstUrl = new URL(seriesRequests[0]?.url ?? "");
expect([...firstUrl.searchParams.keys()]).toEqual(["page", "size"]);
expect(screen.queryByRole("searchbox", { name: "검색어" })).not.toBeInTheDocument();
// When
act(() => {
window.history.pushState({}, "", "/ai-characters/101/series?page=1&size=20");
window.dispatchEvent(new PopStateEvent("popstate"));
});
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("시리즈 목록 실패");
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
const detailLinks = await screen.findAllByRole("link", { name: "달빛 상담 시리즈 상세 보기" });
expect(detailLinks[0]).toHaveAttribute("href", "/ai-characters/101/series/5001");
expect(screen.getAllByText((text) => text.includes("로맨스"))).toHaveLength(2);
expect(screen.getAllByText((text) => text.includes("일, 수"))).toHaveLength(2);
expect(screen.getAllByText("연재중")).toHaveLength(2);
expect(screen.getAllByText("성인 · 활성")).toHaveLength(2);
expectNoRawSeriesDisplayText();
});
test("Series list hides creation entrypoint for inactive characters", async () => {
// Given
saveAdminSession();
useAiCharactersResponse();
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({
success: true,
message: null,
data: {
id: 101,
characterUUID: "character-101",
name: "루나",
imageUrl: null,
description: "차분한 상담형 캐릭터",
systemPrompt: "친절하게 답한다.",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: false,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
errorProperty: null,
})),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 1, items: [listItem] }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/101/series");
// When
render(createElement(App));
await screen.findByRole("heading", { name: "시리즈" });
// Then
expect(screen.queryByRole("link", { name: "시리즈 생성" })).not.toBeInTheDocument();
expect(screen.getAllByRole("link", { name: "달빛 상담 시리즈 상세 보기" })[0]).toHaveAttribute("href", "/ai-characters/101/series/5001");
});
test("Series detail direct route restores the same display-only resource after remount", async () => {
// Given
saveAdminSession();
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
let detailRequestCount = 0;
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series/5001`, () => {
detailRequestCount += 1;
return HttpResponse.json({ success: true, message: null, data: displayLeakItem, errorProperty: null });
}),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series/5001/contents`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/101/series/5001");
// When
const firstRender = render(createElement(App));
// Then
expect(await screen.findByRole("heading", { name: "달빛 상담 시리즈" })).toBeInTheDocument();
expect(screen.getByText("일, 수")).toBeInTheDocument();
expect(screen.getByText("로맨스")).toBeInTheDocument();
expect(screen.getByText("연재중")).toBeInTheDocument();
expect(screen.getByText("성인")).toBeInTheDocument();
expect(screen.getByText("활성")).toBeInTheDocument();
expectNoRawSeriesDisplayText();
expect(screen.getByRole("link", { name: "수정" })).toHaveAttribute("href", "/ai-characters/101/series/5001/edit");
for (const action of ["생성", "비활성화", "연결", "해제", "순서 변경"]) {
expect(screen.queryByRole("button", { name: action })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: action })).not.toBeInTheDocument();
}
// When
firstRender.unmount();
render(createElement(App));
// Then
expect(await screen.findByRole("heading", { name: "달빛 상담 시리즈" })).toBeInTheDocument();
await waitFor(() => expect(detailRequestCount).toBe(2));
});
test("Series detail does not expose genre id when lookup misses", async () => {
// Given
saveAdminSession();
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/series-genres`, () => HttpResponse.json({ success: true, message: null, data: [], errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series/5001`, () => HttpResponse.json({ success: true, message: null, data: displayLeakItem, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series/5001/contents`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/101/series/5001");
// When
render(createElement(App));
// Then
expect(await screen.findByText("알 수 없는 장르")).toBeInTheDocument();
expect(document.body).not.toHaveTextContent(/장르 #|genreId|77/);
});
test("Series detail keeps inactive character resources read-only", async () => {
// Given
saveAdminSession();
useAiCharactersResponse();
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({
success: true,
message: null,
data: {
id: 101,
characterUUID: "character-101",
name: "루나",
imageUrl: null,
description: "차분한 상담형 캐릭터",
systemPrompt: "친절하게 답한다.",
characterType: "Character",
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
region: "KR",
isActive: false,
tags: [],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
},
errorProperty: null,
})),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series/5001`, () => HttpResponse.json({ success: true, message: null, data: listItem, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/series/5001/contents`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/101/series/5001");
// When
render(createElement(App));
await screen.findByRole("heading", { name: "달빛 상담 시리즈" });
// Then
expect(screen.queryByRole("link", { name: "수정" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "전체 시리즈 순서 관리" })).not.toBeInTheDocument();
expect(screen.queryByRole("searchbox", { name: "검색어" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "선택한 오디오 연결" })).not.toBeInTheDocument();
expect(screen.getByText("현재 시리즈에 연결된 오디오를 조회합니다.")).toBeInTheDocument();
});

View File

@@ -0,0 +1,15 @@
import { expect, test } from "vitest";
import { seriesUpdateRequestSchema } from "@/features/series/schemas/series-schema";
test("series update schema rejects null state and isActive values", () => {
// Given / When / Then
expect(seriesUpdateRequestSchema.safeParse({ state: null }).success).toBe(false);
expect(seriesUpdateRequestSchema.safeParse({ isActive: true }).success).toBe(false);
expect(seriesUpdateRequestSchema.safeParse({ isActive: null }).success).toBe(false);
});
test("series update schema accepts changed state enum without isActive", () => {
// Given / When / Then
expect(seriesUpdateRequestSchema.parse({ state: "COMPLETE" })).toEqual({ state: "COMPLETE" });
});

View File

@@ -0,0 +1,26 @@
import { getFileExtension, validateFile } from "@/shared/validation/file-validation";
import type { FileValidationResult } from "@/shared/validation/file-validation";
import { createImagePolicy } from "@/shared/validation/image-policy";
export const SERIES_IMAGE_POLICY = createImagePolicy({ aspect: 210 / 297, cropRequired: true, maxWidth: 1000, noUpscale: true });
const seriesImageFilePolicy = {
allowedExtensions: [".jpg", ".jpeg", ".png"],
allowedMimeTypes: ["image/jpeg", "image/png"],
maxBytes: SERIES_IMAGE_POLICY.maxBytes,
};
const seriesImageMimeByExtension: Record<string, string> = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
};
export function validateSeriesImageFile(file: File): FileValidationResult {
const result = validateFile(file, seriesImageFilePolicy);
if (!result.ok) {
return result;
}
return seriesImageMimeByExtension[getFileExtension(file.name)] === file.type ? { ok: true } : { ok: false, reason: "mime" };
}