feat(ai-character): 시리즈 관리 기능 구현
This commit is contained in:
215
src/features/series/api/series-api.ts
Normal file
215
src/features/series/api/series-api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
14
src/features/series/components/GenreCombobox.tsx
Normal file
14
src/features/series/components/GenreCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/features/series/components/PublishedDaysField.tsx
Normal file
37
src/features/series/components/PublishedDaysField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
src/features/series/components/SeriesContents.tsx
Normal file
199
src/features/series/components/SeriesContents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/features/series/components/SeriesForm.tsx
Normal file
246
src/features/series/components/SeriesForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/features/series/components/SeriesList.tsx
Normal file
59
src/features/series/components/SeriesList.tsx
Normal 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} />;
|
||||
}
|
||||
32
src/features/series/components/SeriesListItem.tsx
Normal file
32
src/features/series/components/SeriesListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/features/series/components/SeriesOrderList.tsx
Normal file
16
src/features/series/components/SeriesOrderList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/features/series/components/SeriesSummary.tsx
Normal file
24
src/features/series/components/SeriesSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/features/series/lib/series-display-labels.ts
Normal file
46
src/features/series/lib/series-display-labels.ts
Normal 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)}`;
|
||||
}
|
||||
26
src/features/series/lib/use-series-management-capability.ts
Normal file
26
src/features/series/lib/use-series-management-capability.ts
Normal 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;
|
||||
}
|
||||
69
src/features/series/model/types.ts
Normal file
69
src/features/series/model/types.ts
Normal 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>;
|
||||
73
src/features/series/pages/SeriesDetailPage.tsx
Normal file
73
src/features/series/pages/SeriesDetailPage.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
87
src/features/series/pages/SeriesFormPage.tsx
Normal file
87
src/features/series/pages/SeriesFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/features/series/pages/SeriesListPage.tsx
Normal file
98
src/features/series/pages/SeriesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/features/series/pages/SeriesOrderPage.tsx
Normal file
133
src/features/series/pages/SeriesOrderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/features/series/schemas/series-schema.ts
Normal file
51
src/features/series/schemas/series-schema.ts
Normal 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>;
|
||||
193
src/features/series/tests/series-contents.test.tsx
Normal file
193
src/features/series/tests/series-contents.test.tsx
Normal 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));
|
||||
});
|
||||
279
src/features/series/tests/series-contract.test.ts
Normal file
279
src/features/series/tests/series-contract.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
105
src/features/series/tests/series-form-crop.test.tsx
Normal file
105
src/features/series/tests/series-form-crop.test.tsx
Normal 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();
|
||||
});
|
||||
50
src/features/series/tests/series-form-pending.test.tsx
Normal file
50
src/features/series/tests/series-form-pending.test.tsx
Normal 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();
|
||||
});
|
||||
286
src/features/series/tests/series-form.test.tsx
Normal file
286
src/features/series/tests/series-form.test.tsx
Normal 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);
|
||||
});
|
||||
9
src/features/series/tests/series-image-policy.test.ts
Normal file
9
src/features/series/tests/series-image-policy.test.ts
Normal 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 });
|
||||
});
|
||||
184
src/features/series/tests/series-order.test.tsx
Normal file
184
src/features/series/tests/series-order.test.tsx
Normal 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("달빛 상담 시리즈");
|
||||
});
|
||||
250
src/features/series/tests/series-routes.test.tsx
Normal file
250
src/features/series/tests/series-routes.test.tsx
Normal 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();
|
||||
});
|
||||
15
src/features/series/tests/series-update-invariants.test.ts
Normal file
15
src/features/series/tests/series-update-invariants.test.ts
Normal 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" });
|
||||
});
|
||||
26
src/features/series/validation/series-image-policy.ts
Normal file
26
src/features/series/validation/series-image-policy.ts
Normal 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user