feat(ai-character): 커뮤니티 게시글 관리 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:39 +09:00
parent f9ed4d0094
commit 20d38e38c0
21 changed files with 2292 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import { z } from "zod";
import { communityPostCreateRequestSchema, communityPostListResponseSchema, communityPostUpdateRequestSchema } from "@/features/community-posts/model/types";
import type { CommunityPostCreateRequest, CommunityPostListData, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
import type { ApiClient } from "@/shared/api/client";
export type GetCommunityPostsParams = {
readonly characterId: string;
readonly page?: number;
readonly size?: number;
};
export type UpdateCommunityPostParams = {
readonly postImage?: File;
readonly request: CommunityPostUpdateRequest;
};
export type CreateCommunityPostParams = {
readonly audioFile?: File;
readonly postImage?: File;
readonly request: CommunityPostCreateRequest;
};
const nullSuccessSchema = z.null();
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));
}
function createCommunityPostBody(audioFile: File | undefined, postImage: File | undefined, request: object): FormData {
const body = new FormData();
if (audioFile !== undefined) {
body.append("audioFile", audioFile);
}
if (postImage !== undefined) {
body.append("postImage", postImage);
}
body.append("request", new Blob([JSON.stringify(request)], { type: "application/json" }));
return body;
}
function createCommunityPostCreateBody(params: CreateCommunityPostParams): FormData {
return createCommunityPostBody(params.audioFile, params.postImage, communityPostCreateRequestSchema.parse(params.request));
}
function createCommunityPostUpdateBody(params: UpdateCommunityPostParams): FormData {
return createCommunityPostBody(undefined, params.postImage, communityPostUpdateRequestSchema.parse(params.request));
}
export async function getCommunityPosts(apiClient: ApiClient, params: GetCommunityPostsParams): Promise<CommunityPostListData> {
const page = normalizePage(params.page);
const size = normalizeSize(params.size);
const query = new URLSearchParams({ page: String(page), size: String(size) });
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/community-posts?${query.toString()}`,
responseSchema: communityPostListResponseSchema,
authentication: "required",
});
}
export function updateCommunityPost(apiClient: ApiClient, characterId: string, postId: string, params: UpdateCommunityPostParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/community-posts/${encodeURIComponent(postId)}`,
method: "PUT",
body: createCommunityPostUpdateBody(params),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function createCommunityPost(apiClient: ApiClient, characterId: string, params: CreateCommunityPostParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/community-posts`,
method: "POST",
body: createCommunityPostCreateBody(params),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function softDeleteCommunityPost(apiClient: ApiClient, characterId: string, postId: string): Promise<null> {
return updateCommunityPost(apiClient, characterId, postId, { request: { isActive: false, isFixed: false } });
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from "react";
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-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 { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy";
import { CAN_PRICE_MAX } from "@/shared/validation/can-price";
type CommunityPostFormProps = {
readonly apiClient: ApiClient;
readonly characterId: string;
readonly createCropSource: (file: File) => Promise<CropSourceImage>;
readonly onCreated: () => void;
readonly renderCrop?: (request: CropRenderRequest) => Promise<File>;
};
const errorIds = {
content: "community-post-content-error",
price: "community-post-price-error",
} as const;
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
const base = { content, isAdult, isCommentAvailable };
return price === null ? base : { ...base, price };
}
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [content, setContent] = useState("");
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
const [errors, setErrors] = useState<CommunityPostFormErrors>({});
const [isAdult, setIsAdult] = useState(false);
const [isCommentAvailable, setIsCommentAvailable] = useState(true);
const [isImagePreparing, setIsImagePreparing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [postImage, setPostImage] = useState<File | null>(null);
const [price, setPrice] = useState("");
const formRef = useRef<HTMLFormElement>(null);
const imageSelectionId = useRef(0);
const isSavingRef = useRef(false);
useEffect(() => () => cropSource?.release?.(), [cropSource]);
async function selectPostImage(file: File | null) {
imageSelectionId.current += 1;
const currentSelectionId = imageSelectionId.current;
if (file === null) {
setPostImage(null);
setCropSource(null);
setIsImagePreparing(false);
setErrors((current) => ({ ...current, image: undefined }));
return;
}
setIsImagePreparing(true);
try {
const prepared = await prepareCommunityPostImage(file, createCropSource);
if (currentSelectionId !== imageSelectionId.current) {
if (prepared.kind === "crop") {
prepared.source.release?.();
}
return;
}
switch (prepared.kind) {
case "cleared":
setPostImage(null);
setCropSource(null);
setErrors((current) => ({ ...current, image: undefined }));
break;
case "crop":
setPostImage(null);
setCropSource(prepared.source);
setErrors((current) => ({ ...current, image: undefined }));
break;
case "error":
setPostImage(null);
setCropSource(null);
setErrors((current) => ({ ...current, image: prepared.message }));
break;
case "ready":
setPostImage(prepared.file);
setCropSource(null);
setErrors((current) => ({ ...current, image: undefined }));
break;
}
} catch (error: unknown) {
if (currentSelectionId !== imageSelectionId.current) {
return;
}
if (error instanceof Error) {
setErrors((current) => ({ ...current, image: "이미지 미리보기를 불러오지 못했습니다." }));
return;
}
throw error;
} finally {
if (currentSelectionId === imageSelectionId.current) {
setIsImagePreparing(false);
}
}
}
function validateForm(): CommunityPostFormErrors {
const parsedPrice = parseCommunityPostPrice(price);
const hasPriceInput = price.trim().length > 0;
return {
audio: communityPostAudioErrorMessage(audioFile),
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
};
}
async function submit(event: { readonly preventDefault: () => void }) {
event.preventDefault();
if (isSavingRef.current) {
return;
}
const nextErrors = validateForm();
setErrors(nextErrors);
if (hasCommunityPostFormErrors(nextErrors)) {
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
isSavingRef.current = true;
setIsSaving(true);
try {
await createCommunityPost(apiClient, characterId, {
audioFile: audioFile ?? undefined,
postImage: postImage ?? undefined,
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
});
onCreated();
} catch (error: unknown) {
setErrors({ form: error instanceof ApiError ? error.message : "커뮤니티 게시글을 생성하지 못했습니다." });
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
return (
<section className="flex flex-col gap-4" aria-labelledby="community-post-form-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">COMMUNITY</p>
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title"> </h2>
<p className="text-sm text-muted-foreground"> . GIF는 crop .</p>
</div>
<form aria-label="커뮤니티 게시글 생성 입력 화면" 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"><textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
{errors.content === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.content} role="alert">{errors.content}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatCommunityPostPrice(event.currentTarget.value))} value={price} /></label>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(event.currentTarget.checked)} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" /> </label>
<FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{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">
<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 || isImagePreparing || cropSource !== null} type="submit"></button>
</div>
</form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setPostImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => setCropSource(null)} open policy={COMMUNITY_POST_IMAGE_POLICY} renderCrop={renderCrop} />}
</section>
);
}

View File

@@ -0,0 +1,45 @@
import { CommunityPostListItem } from "@/features/community-posts/components/CommunityPostListItem";
import { formatCommunityPostStatus } from "@/features/community-posts/lib/community-post-display-labels";
import type { CommunityPostListData } from "@/features/community-posts/model/types";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
function formatCan(price: number): string {
return `${price.toLocaleString("ko-KR")}`;
}
export function CommunityPostList({ data, onOpen }: { readonly data: CommunityPostListData; readonly onOpen: (postId: number) => void }) {
const desktop = (
<table className="min-w-full border-separate border-spacing-0 text-left text-sm">
<thead className="text-muted-foreground">
<tr>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
</tr>
</thead>
<tbody>
{data.items.map((post) => (
<tr key={post.postId}>
<td className="border-b border-border px-4 py-3">
<p className="max-w-xl break-words font-semibold">{post.content}</p>
<p className="mt-1 text-xs text-muted-foreground">{post.date} · {formatCan(post.price)}</p>
{post.audioUrl === null ? null : <div className="mt-3"><AdminAudioPlayer playerId={`community-row-${post.postId}`} src={post.audioUrl} title={`커뮤니티 게시글 ${post.postId}`} /></div>}
</td>
<td className="border-b border-border px-4 py-3 whitespace-nowrap">{formatCommunityPostStatus(post)}</td>
<td className="border-b border-border px-4 py-3"> {post.likeCount.toLocaleString("ko-KR")} · {post.commentCount.toLocaleString("ko-KR")}</td>
<td className="border-b border-border px-4 py-3">
<button aria-label={`${post.content} 게시글 열기`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold whitespace-nowrap hover:bg-accent" onClick={() => onOpen(post.postId)} type="button">
</button>
</td>
</tr>
))}
</tbody>
</table>
);
const mobile = <div className="grid gap-3 p-3">{data.items.map((post) => <CommunityPostListItem key={post.postId} onOpen={onOpen} post={post} />)}</div>;
return <ResponsiveResourceList ariaLabel="커뮤니티 게시글 목록" desktop={desktop} mobile={mobile} />;
}

View File

@@ -0,0 +1,27 @@
import type { CommunityPostListItem as CommunityPostListItemData } from "@/features/community-posts/model/types";
import { formatCommunityPostStatus } from "@/features/community-posts/lib/community-post-display-labels";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
function formatCan(price: number): string {
return `${price.toLocaleString("ko-KR")}`;
}
export function CommunityPostListItem({ onOpen, post }: { readonly onOpen: (postId: number) => void; readonly post: CommunityPostListItemData }) {
return (
<article className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex items-start gap-3">
{post.imageUrl === null ? null : <img alt="" className="size-16 shrink-0 rounded-md object-cover" height="64" loading="lazy" src={post.imageUrl} width="64" />}
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-info">{post.isFixed ? "고정 게시글" : "커뮤니티 게시글"}</p>
<p className="mt-1 line-clamp-3 break-words font-semibold">{post.content}</p>
<p className="mt-2 text-xs font-semibold text-info">{formatCommunityPostStatus(post)}</p>
<p className="mt-1 text-xs text-muted-foreground">{post.date} · {post.likeCount.toLocaleString("ko-KR")} · {post.commentCount.toLocaleString("ko-KR")} · {formatCan(post.price)}</p>
</div>
</div>
{post.audioUrl === null ? null : <AdminAudioPlayer playerId={`community-list-${post.postId}`} src={post.audioUrl} title={`커뮤니티 게시글 ${post.postId}`} />}
<button aria-label={`${post.content} 게시글 열기`} className="min-h-11 rounded-md border border-input bg-card px-4 py-2 font-semibold whitespace-nowrap hover:bg-accent" onClick={() => onOpen(post.postId)} type="button">
</button>
</article>
);
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from "react";
import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
import { CommentThread } from "@/features/comments/components/CommentThread";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
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 { useModalFocus } from "@/shared/ui/use-modal-focus";
export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = canMutate, characterId, createCropSource, onClose, onDeleted, onMutated, post, renderCrop }: { readonly apiClient: ApiClient; readonly canMutate: boolean; readonly canMutateComments?: boolean; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly onClose: () => void; readonly onDeleted: () => void; readonly onMutated: () => void; readonly post: CommunityPostListItem; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
const [content, setContent] = useState(post.content);
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
const [isAdult, setIsAdult] = useState(post.isAdult);
const [isCommentAvailable, setIsCommentAvailable] = useState(post.isCommentAvailable);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [imageErrorMessage, setImageErrorMessage] = useState<string | undefined>(undefined);
const [isImagePreparing, setIsImagePreparing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [postImage, setPostImage] = useState<File | null>(null);
const isDeletingRef = useRef(false);
const isSavingRef = useRef(false);
const imageSelectionId = useRef(0);
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function getMutationErrorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : "커뮤니티 게시글을 저장하지 못했습니다.";
}
async function savePost() {
if (isSavingRef.current || imageErrorMessage !== undefined || isImagePreparing || cropSource !== null) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
try {
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: { content, isAdult, isCommentAvailable, isFixed: post.isFixed } });
onMutated();
} catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error));
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
async function selectPostImage(file: File | null) {
imageSelectionId.current += 1;
const currentSelectionId = imageSelectionId.current;
setIsImagePreparing(true);
try {
const prepared = await prepareCommunityPostImage(file, createCropSource);
if (currentSelectionId !== imageSelectionId.current) {
if (prepared.kind === "crop") {
prepared.source.release?.();
}
return;
}
switch (prepared.kind) {
case "cleared":
setPostImage(null);
setCropSource(null);
setImageErrorMessage(undefined);
break;
case "crop":
setPostImage(null);
setCropSource(prepared.source);
setImageErrorMessage(undefined);
break;
case "error":
setPostImage(null);
setCropSource(null);
setImageErrorMessage(prepared.message);
break;
case "ready":
setPostImage(prepared.file);
setCropSource(null);
setImageErrorMessage(undefined);
break;
}
} catch (error: unknown) {
if (currentSelectionId !== imageSelectionId.current) {
return;
}
if (error instanceof Error) {
setImageErrorMessage("이미지 미리보기를 불러오지 못했습니다.");
return;
}
throw error;
} finally {
if (currentSelectionId === imageSelectionId.current) {
setIsImagePreparing(false);
}
}
}
async function toggleFixed() {
if (isSavingRef.current) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
try {
await updateCommunityPost(apiClient, characterId, String(post.postId), { request: { isFixed: !post.isFixed } });
onMutated();
} catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error));
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
async function deletePost() {
if (isDeletingRef.current) {
return;
}
isDeletingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
try {
await softDeleteCommunityPost(apiClient, characterId, String(post.postId));
onClose();
onDeleted();
} catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error));
} finally {
isDeletingRef.current = false;
setIsSaving(false);
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
onClose();
return;
}
trapFocus(event);
}
return (
<div className="fixed inset-0 z-modal flex justify-end bg-background/80 p-4">
<div aria-label="커뮤니티 게시글" aria-modal="true" className="flex min-h-0 w-full max-w-xl flex-col gap-4 overflow-auto rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} ref={dialogRef} role="dialog">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-info">COMMUNITY</p>
<h2 className="text-xl font-semibold"> </h2>
</div>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={onClose} type="button"></button>
</div>
{post.imageUrl === null ? null : <img alt="게시글 이미지" className="max-h-72 rounded-lg object-cover" src={post.imageUrl} />}
{canMutate ? <FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={imageErrorMessage} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} /> : null}
{canMutate ? (
<>
<label className="flex flex-col gap-2 text-sm font-semibold">
<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} />
</label>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(event.currentTarget.checked)} type="checkbox" />
</label>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" />
</label>
</>
) : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold">{post.content}</p>}
{post.audioUrl === null ? null : <AdminAudioPlayer playerId={`community-sheet-${post.postId}`} src={post.audioUrl} title={`커뮤니티 게시글 ${post.postId}`} />}
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setPostImage(file); setCropSource(null); }} onCancel={() => setCropSource(null)} open policy={COMMUNITY_POST_IMAGE_POLICY} renderCrop={renderCrop} />}
{errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
<dl className="grid gap-2 rounded-lg border border-border bg-muted p-3 text-sm sm:grid-cols-2">
<div><dt className="font-semibold text-muted-foreground"></dt><dd>{post.date}</dd></div>
<div><dt className="font-semibold text-muted-foreground"></dt><dd> {post.likeCount.toLocaleString("ko-KR")} · {post.commentCount.toLocaleString("ko-KR")}</dd></div>
</dl>
{post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm"> : {post.firstComment.comment}</p>}
{post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null}
{canMutate ? <div className="flex flex-wrap gap-2">
<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={isMutationDisabled} onClick={() => void savePost()} type="button"> </button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button>
<button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button"></button>
</div> : null}
</div>
<ConfirmDeactivateDialog confirmLabel="비활성화 확인" errorMessage={errorMessage ?? undefined} impactDescription="게시글을 목록에서 제외합니다. 연결된 댓글은 삭제하지 않습니다." isPending={isSaving} onCancel={() => setIsDeleteDialogOpen(false)} onConfirm={() => void deletePost()} open={isDeleteDialogOpen} targetName="커뮤니티 게시글" />
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
export type CommunityPostFormErrors = {
readonly audio?: string;
readonly content?: string;
readonly form?: string;
readonly image?: string;
readonly price?: string;
};
export function parseCommunityPostPrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatCommunityPostPrice(value: string): string {
return formatCanPriceInput(value);
}
export function communityPostAudioErrorMessage(file: File | null): string | undefined {
if (file === null) {
return undefined;
}
const result = validateAudioFile(file);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "오디오 파일은 1,024,000,000 bytes 이하만 업로드할 수 있습니다.";
}
if (result.reason === "mimeExtensionCombination") {
return "확장자와 MIME 조합이 올바른 MP3, AAC, M4A 파일만 업로드하세요.";
}
return "MP3, AAC, M4A 파일만 업로드하세요.";
}
export function hasCommunityPostFormErrors(errors: CommunityPostFormErrors): boolean {
return Object.values(errors).some((error) => error !== undefined);
}

View File

@@ -0,0 +1,11 @@
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
type CommunityPostStatus = Pick<CommunityPostListItem, "isAdult" | "isCommentAvailable" | "isFixed">;
export function formatCommunityPostStatus(post: CommunityPostStatus): string {
const fixedLabel = post.isFixed ? "고정" : "일반";
const commentLabel = post.isCommentAvailable ? "댓글 허용" : "댓글 차단";
const adultLabel = post.isAdult ? "성인 콘텐츠" : "일반 콘텐츠";
return `${fixedLabel} · ${commentLabel} · ${adultLabel}`;
}

View File

@@ -0,0 +1,3 @@
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
export const createCommunityPostCropSource = createImageCropSource;

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
const communityManagementQuery = "(min-width: 768px)";
function getCanManageCommunity(): boolean {
return typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia(communityManagementQuery).matches;
}
export function useCommunityManagementCapability(): boolean {
const [canManageCommunity, setCanManageCommunity] = useState(getCanManageCommunity);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return undefined;
}
const mediaQuery = window.matchMedia(communityManagementQuery);
const updateCanManageCommunity = () => setCanManageCommunity(mediaQuery.matches);
updateCanManageCommunity();
mediaQuery.addEventListener("change", updateCanManageCommunity);
return () => mediaQuery.removeEventListener("change", updateCanManageCommunity);
}, []);
return canManageCommunity;
}

View File

@@ -0,0 +1,66 @@
import { z } from "zod";
import { canPriceSchema } from "@/shared/validation/can-price";
const nullableString = z.string().nullable();
export const communityPostCommentSchema = z.object({
id: z.number().int(),
writerId: z.number().int(),
nickname: z.string(),
profileUrl: z.string(),
comment: z.string(),
isSecret: z.boolean(),
date: z.string(),
replyCount: z.number().int(),
});
export const communityPostListItemSchema = z.object({
postId: z.number().int(),
creatorId: z.number().int(),
creatorNickname: z.string(),
creatorProfileUrl: z.string(),
imageUrl: nullableString,
audioUrl: nullableString,
content: z.string(),
price: z.number().int().nonnegative(),
date: z.string(),
dateUtc: z.string(),
isCommentAvailable: z.boolean(),
isAdult: z.boolean(),
isFixed: z.boolean(),
isLike: z.boolean(),
existOrdered: z.boolean(),
likeCount: z.number().int(),
commentCount: z.number().int(),
firstComment: communityPostCommentSchema.nullable(),
});
export const communityPostListResponseSchema = z.object({
totalCount: z.number().int(),
page: z.number().int(),
size: z.number().int(),
hasNext: z.boolean(),
items: z.array(communityPostListItemSchema),
});
export const communityPostCreateRequestSchema = z.strictObject({
content: z.string().min(1),
isCommentAvailable: z.boolean(),
isAdult: z.boolean(),
price: canPriceSchema.optional(),
});
export const communityPostUpdateRequestSchema = z.strictObject({
content: nullableString.optional(),
isCommentAvailable: z.boolean().nullable().optional(),
isAdult: z.boolean().nullable().optional(),
isActive: z.literal(false).optional(),
isFixed: z.boolean().optional(),
});
export type CommunityPostComment = z.infer<typeof communityPostCommentSchema>;
export type CommunityPostCreateRequest = z.infer<typeof communityPostCreateRequestSchema>;
export type CommunityPostListItem = z.infer<typeof communityPostListItemSchema>;
export type CommunityPostUpdateRequest = z.infer<typeof communityPostUpdateRequestSchema>;
export type CommunityPostListData = z.infer<typeof communityPostListResponseSchema>;

View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
import { createCommunityPostCropSource } from "@/features/community-posts/lib/create-community-post-crop-source";
import { useCommunityManagementCapability } from "@/features/community-posts/lib/use-community-management-capability";
import { getCharacter } from "@/features/characters/api/character-api";
import type { CharacterDetail } from "@/features/characters/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import 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 requestKey: string; readonly status: "content" }
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
export function CommunityPostFormPage({ apiClient, characterId, createCropSource = createCommunityPostCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly characterId: string; readonly createCropSource?: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
const canManageCommunity = useCommunityManagementCapability();
const [state, setState] = useState<FormState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${characterId}:community-new:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void getCharacter(apiClient, characterId)
.then((character) => {
if (isCurrent) {
setState({ character, requestKey, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "커뮤니티 게시글 입력 화면 정보를 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, requestKey]);
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="커뮤니티 게시글 입력 화면 조회 실패" />;
}
return (
<CharacterWorkspaceLayout activeTab="community" character={visibleState.character}>
{!canManageCommunity ? <PageState description="커뮤니티 게시글 생성은 태블릿 이상 화면에서 진행해 주세요." state="empty" title="모바일에서는 커뮤니티 게시글 생성을 제한합니다" /> : null}
{canManageCommunity && visibleState.character.isActive ? <CommunityPostForm apiClient={apiClient} characterId={characterId} createCropSource={createCropSource} onCreated={() => navigateTo(routePaths.aiCharacterCommunityPosts(characterId), { successNotification: "커뮤니티 게시글을 생성했습니다." })} renderCrop={renderCrop} /> : null}
{canManageCommunity && !visibleState.character.isActive ? <PageState description="비활성 캐릭터에서는 커뮤니티 게시글을 생성할 수 없습니다." state="empty" title="커뮤니티 게시글 생성 차단" /> : null}
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,118 @@
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 { getCommunityPosts } from "@/features/community-posts/api/community-post-api";
import { CommunityPostList } from "@/features/community-posts/components/CommunityPostList";
import { CommunityPostSheet } from "@/features/community-posts/components/CommunityPostSheet";
import { createCommunityPostCropSource } from "@/features/community-posts/lib/create-community-post-crop-source";
import { useCommunityManagementCapability } from "@/features/community-posts/lib/use-community-management-capability";
import type { CommunityPostListData } from "@/features/community-posts/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
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: CommunityPostListData; 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.aiCharacterCommunityPosts(characterId)}?${nextQuery.toString()}`);
}
export function CommunityPostListPage({ apiClient, characterId }: { readonly apiClient: ApiClient; readonly characterId: string }) {
const canManageCommunity = useCommunityManagementCapability();
const location = useBrowserLocation();
const query = getListQuery();
const [state, setState] = useState<ListState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const [selectedPostId, setSelectedPostId] = useState<number | null>(null);
const requestKey = `${location.visitKey}:${characterId}:${query.page}:${query.size}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void Promise.all([
getCharacter(apiClient, characterId),
getCommunityPosts(apiClient, { characterId, page: query.page, size: query.size }),
])
.then(([character, data]) => {
if (isCurrent) {
setState({ character, data, requestKey, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "커뮤니티 목록을 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, query.page, query.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="커뮤니티 목록 조회 실패" />;
}
const selectedPost = visibleState.data.items.find((post) => post.postId === selectedPostId) ?? null;
return (
<CharacterWorkspaceLayout activeTab="community" character={visibleState.character}>
<AudioPlaybackProvider>
<section className="flex flex-col gap-4" aria-labelledby="community-post-list-title">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">COMMUNITY</p>
<h2 className="text-2xl font-bold leading-tight" id="community-post-list-title"> </h2>
<p className="text-sm text-muted-foreground"> , , .</p>
</div>
{visibleState.character.isActive && canManageCommunity ? <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.aiCharacterCommunityPostCreate(characterId)} onClick={(event) => { event.preventDefault(); navigateTo(routePaths.aiCharacterCommunityPostCreate(characterId)); }}> </a> : null}
</div>
{visibleState.data.items.length === 0 ? <PageState description="현재 캐릭터에 연결된 커뮤니티 게시글이 없습니다." state="empty" title="등록된 커뮤니티 게시글이 없습니다." /> : null}
{visibleState.data.items.length > 0 ? <CommunityPostList data={visibleState.data} onOpen={setSelectedPostId} /> : null}
<ResourcePagination data={visibleState.data} onPageChange={onPageChange} onSizeChange={onSizeChange} />
</section>
{selectedPost === null ? null : (
<CommunityPostSheet
apiClient={apiClient}
characterId={characterId}
createCropSource={createCommunityPostCropSource}
onClose={() => setSelectedPostId(null)}
onDeleted={() => navigateTo(`${routePaths.aiCharacterCommunityPosts(characterId)}?page=${query.page}&size=${query.size}`, { successNotification: "커뮤니티 게시글을 비활성화했습니다." })}
onMutated={() => setRetryKey((key) => key + 1)}
post={selectedPost}
canMutate={visibleState.character.isActive && canManageCommunity}
canMutateComments={visibleState.character.isActive}
/>
)}
</AudioPlaybackProvider>
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,259 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { createCommunityPost, getCommunityPosts, softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
import { communityPostCreateRequestSchema, communityPostListResponseSchema, communityPostUpdateRequestSchema } from "@/features/community-posts/model/types";
import { createApiResponseSchema } from "@/shared/api/types";
import { previewCommunityPostAudioUrl } from "@/shared/mocks/community-post-fixtures";
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
import { server } from "@/shared/test/server";
import { authorizedFetch, contractCommunityPost as communityPost, createCapturingClient, filePart, multipartInit, multipartRawInit, readJsonPart, requestPart, requireData, requireFormData, textPart } from "./community-test-support";
import type { CapturedApiRequest } from "./community-test-support";
const apiBaseUrl = "https://api.example.com";
describe("Community post contract", () => {
test("list sends page size only and consumes pagination object data", async () => {
// Given
const requests: CapturedApiRequest[] = [];
const client = createCapturingClient(requests);
// When
const list = await getCommunityPosts(client, { characterId: "101", page: 2, size: 50 });
// Then
expect(list).toEqual({ totalCount: 51, page: 2, size: 50, hasNext: true, items: [communityPost] });
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts?page=2&size=50", method: undefined, body: undefined }]);
const url = new URL(requests[0]?.path ?? "", apiBaseUrl);
expect([...url.searchParams.keys()]).toEqual(["page", "size"]);
expect(url.searchParams.has("timezone")).toBe(false);
expect(communityPostListResponseSchema.parse({ totalCount: 1, page: 2, size: 50, hasNext: true, items: [communityPost] }).items[0]).not.toHaveProperty("fixedAtUtc");
});
test("update and soft delete use multipart request and null success", async () => {
// Given
const requests: CapturedApiRequest[] = [];
const client = createCapturingClient(requests);
// When
const postImage = new File(["image"], "post.png", { type: "image/png" });
await updateCommunityPost(client, "101", "7001", { postImage, request: { content: "수정", isAdult: true, isCommentAvailable: false, isFixed: true } });
await softDeleteCommunityPost(client, "101", "7001");
// Then
expect(requests.map((request) => ({ path: request.path, method: request.method }))).toEqual([
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001", method: "PUT" },
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001", method: "PUT" },
]);
const updateBody = requireFormData(requests[0]?.body);
expect(updateBody.get("postImage")).toBe(postImage);
expect(updateBody.has("audioFile")).toBe(false);
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정", isAdult: true, isCommentAvailable: false, isFixed: true });
expect(await readJsonPart(updateBody.get("request"))).not.toHaveProperty("price");
const deleteRequest = await readJsonPart(requireFormData(requests[1]?.body).get("request"));
expect(deleteRequest).toEqual({ isActive: false, isFixed: false });
expect(deleteRequest).not.toEqual(expect.objectContaining({ isActive: true }));
});
test("create sends optional media and request without active or fixed flags", async () => {
// Given
const requests: CapturedApiRequest[] = [];
const client = createCapturingClient(requests);
const audioFile = new File(["audio"], "voice.m4a", { type: "audio/x-m4a" });
const postImage = new File(["image"], "post.gif", { type: "image/gif" });
// When
await createCommunityPost(client, "101", { audioFile, postImage, request: { content: "새 커뮤니티 게시글", isAdult: false, isCommentAvailable: true, price: 1200 } });
// Then
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts", method: "POST", body: requests[0]?.body }]);
const body = requireFormData(requests[0]?.body);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(postImage);
const request = await readJsonPart(body.get("request"));
expect(request).toEqual({ content: "새 커뮤니티 게시글", isAdult: false, isCommentAvailable: true, price: 1200 });
expect(request).not.toHaveProperty("isActive");
expect(request).not.toHaveProperty("isFixed");
expect(() => communityPostCreateRequestSchema.parse({ content: "새 커뮤니티 게시글", isAdult: false, isCommentAvailable: true })).not.toThrow();
});
test("schema rejects negative price and isActive true", () => {
expect(() => communityPostListResponseSchema.parse({ totalCount: 1, page: 0, size: 20, hasNext: false, items: [{ ...communityPost, price: -1 }] })).toThrow();
expect(() => communityPostUpdateRequestSchema.parse({ isActive: true })).toThrow();
});
test.each([undefined, 0, 99_999])("create schema accepts optional CAN price boundary %s", (price) => {
const request = price === undefined ? { content: "가격", isAdult: false, isCommentAvailable: true } : { content: "가격", isAdult: false, isCommentAvailable: true, price };
expect(() => communityPostCreateRequestSchema.parse(request)).not.toThrow();
});
test.each([-1, 100_000, 1.5])("create schema rejects invalid CAN price %i", (price) => {
expect(() => communityPostCreateRequestSchema.parse({ content: "가격", isAdult: false, isCommentAvailable: true, price })).toThrow();
});
test("mock handlers update community posts through the list store without detail", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const firstListResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
const firstList = requireData(createApiResponseSchema(communityPostListResponseSchema).parse(await firstListResponse.json()).data);
const firstPost = firstList.items[0];
if (firstPost === undefined) {
throw new Error("expected community post fixture");
}
const pinResponse = await authorizedFetch(`/api/v2/admin/ai-characters/101/community-posts/${firstPost.postId}`, multipartInit("PUT", { isFixed: true }));
const pinnedListResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
const deleteResponse = await authorizedFetch(`/api/v2/admin/ai-characters/101/community-posts/${firstPost.postId}`, multipartInit("PUT", { isActive: false, isFixed: false }));
const finalListResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
// Then
expect(createApiResponseSchema(z.null()).parse(await pinResponse.json()).data).toBeNull();
expect(createApiResponseSchema(z.null()).parse(await deleteResponse.json()).data).toBeNull();
expect(await pinnedListResponse.json()).toMatchObject({ success: true, data: { items: expect.arrayContaining([expect.objectContaining({ postId: firstPost.postId, isFixed: true })]) } });
expect(await finalListResponse.json()).toMatchObject({ success: true, data: { items: expect.not.arrayContaining([expect.objectContaining({ postId: firstPost.postId })]) } });
});
test("mock handlers create community posts through multipart only", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const createdResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartInit("POST", { content: "mock 생성 게시글", isAdult: false, isCommentAvailable: true, price: 99 }, ["audioFile", "postImage"]));
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
const invalidResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartInit("POST", { content: "bad", isAdult: false, isCommentAvailable: true, isFixed: false }));
// Then
expect(createApiResponseSchema(z.null()).parse(await createdResponse.json()).data).toBeNull();
expect(invalidResponse.status).toBe(400);
expect(await listResponse.json()).toMatchObject({
success: true,
data: { items: expect.arrayContaining([
expect.objectContaining({ audioUrl: expect.stringMatching(/^data:audio\//), content: "mock 생성 게시글", imageUrl: expect.stringMatching(/^data:image\//), price: 99 }),
]) },
});
});
test("mock handlers scope community posts to the requested character", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const firstCharacterListResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
const secondCharacterListResponse = await authorizedFetch("/api/v2/admin/ai-characters/102/community-posts?page=0&size=20");
const createSecondCharacterResponse = await authorizedFetch("/api/v2/admin/ai-characters/102/community-posts", multipartInit("POST", { content: "테오 전용 게시글", isAdult: false, isCommentAvailable: true }));
const secondCharacterAfterCreateResponse = await authorizedFetch("/api/v2/admin/ai-characters/102/community-posts?page=0&size=20");
// Then
const firstCharacterList = requireData(createApiResponseSchema(communityPostListResponseSchema).parse(await firstCharacterListResponse.json()).data);
const secondCharacterList = requireData(createApiResponseSchema(communityPostListResponseSchema).parse(await secondCharacterListResponse.json()).data);
const secondCharacterAfterCreate = requireData(createApiResponseSchema(communityPostListResponseSchema).parse(await secondCharacterAfterCreateResponse.json()).data);
expect(firstCharacterList.items).toEqual(expect.arrayContaining([expect.objectContaining({ creatorId: 101 })]));
expect(secondCharacterList.items).toEqual([]);
expect(createApiResponseSchema(z.null()).parse(await createSecondCharacterResponse.json()).data).toBeNull();
expect(secondCharacterAfterCreate.items).toEqual([expect.objectContaining({ content: "테오 전용 게시글", creatorId: 102 })]);
});
test("mock handlers reject unsupported or duplicate community multipart parts", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
const createRequest = { content: "strict", isAdult: false, isCommentAvailable: true };
// When
const duplicateRequestResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartRawInit("POST", [requestPart(createRequest), requestPart(createRequest)]));
const duplicateImageResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartRawInit("POST", [filePart("postImage"), filePart("postImage"), requestPart(createRequest)]));
const unknownCreatePartResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartRawInit("POST", [filePart("thumbnail"), requestPart(createRequest)]));
const updateAudioResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001", multipartRawInit("PUT", [filePart("audioFile"), requestPart({ isFixed: true })]));
// Then
expect(duplicateRequestResponse.status).toBe(400);
expect(duplicateImageResponse.status).toBe(400);
expect(unknownCreatePartResponse.status).toBe(400);
expect(updateAudioResponse.status).toBe(400);
});
test("mock handlers reject non-multipart bodies and text upload fields", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
const createRequest = { content: "strict", isAdult: false, isCommentAvailable: true };
// When
const rawTextResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", {
body: multipartInit("POST", createRequest).body,
headers: { "Content-Type": "text/plain" },
method: "POST",
});
const textImageCreateResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartRawInit("POST", [textPart("postImage"), requestPart(createRequest)]));
const textAudioCreateResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", multipartRawInit("POST", [textPart("audioFile"), requestPart(createRequest)]));
const textImageUpdateResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001", multipartRawInit("PUT", [textPart("postImage"), requestPart({ isFixed: true })]));
// Then
expect(rawTextResponse.status).toBe(400);
expect(textImageCreateResponse.status).toBe(400);
expect(textAudioCreateResponse.status).toBe(400);
expect(textImageUpdateResponse.status).toBe(400);
});
test("mock handlers reject raw multipart when declared boundary differs from the body boundary", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
const createRequest = { content: "boundary mismatch", isAdult: false, isCommentAvailable: true };
// When
const response = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", {
body: `--body-boundary\r\n${requestPart(createRequest)}\r\n--body-boundary--\r\n`,
headers: { "Content-Type": "multipart/form-data; boundary=declared-boundary" },
method: "POST",
});
// Then
expect(response.status).toBe(400);
});
test("mock handlers reject raw multipart without closing boundary or with trailing data", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
const createRequest = { content: "strict boundary", isAdult: false, isCommentAvailable: true };
// When
const missingCloseResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", {
body: `--test-boundary\r\n${requestPart(createRequest)}\r\n`,
headers: { "Content-Type": "multipart/form-data; boundary=test-boundary" },
method: "POST",
});
const trailingDataResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts", {
body: `--test-boundary\r\n${requestPart(createRequest)}\r\n--test-boundary--\r\nextra`,
headers: { "Content-Type": "multipart/form-data; boundary=test-boundary" },
method: "POST",
});
// Then
expect(missingCloseResponse.status).toBe(400);
expect(trailingDataResponse.status).toBe(400);
});
test("mock handlers update postImage preview and use a valid bundled audio preview", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
const beforeListResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
const beforeList = requireData(createApiResponseSchema(communityPostListResponseSchema).parse(await beforeListResponse.json()).data);
const beforePost = beforeList.items.find((post) => post.postId === 7001);
if (beforePost === undefined) {
throw new Error("expected community post fixture");
}
// When
const updateResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001", multipartInit("PUT", { content: "이미지 갱신", isAdult: false, isCommentAvailable: true, isFixed: true }, ["postImage"]));
const listResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts?page=0&size=20");
const afterList = requireData(createApiResponseSchema(communityPostListResponseSchema).parse(await listResponse.json()).data);
const afterPost = afterList.items.find((post) => post.postId === 7001);
// Then
expect(createApiResponseSchema(z.null()).parse(await updateResponse.json()).data).toBeNull();
expect(previewCommunityPostAudioUrl).toMatch(/^data:audio\/wav;base64,/);
expect(beforePost.imageUrl).toBeNull();
expect(afterPost?.imageUrl).toMatch(/^data:image\//);
expect(afterPost?.imageUrl).not.toBe(beforePost.imageUrl);
});
});

View File

@@ -0,0 +1,323 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
import { validateCommunityPostImageFile } from "@/features/community-posts/validation/community-post-media-policy";
import { fileWithSize, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { server } from "@/shared/test/server";
import { basePost, inactiveCharacter } from "./community-test-support";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
function createCommunityFormClient(requests: CapturedRequest[], isActive = true): 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/202") {
return options.responseSchema.parse(inactiveCharacter);
}
if (options.path === "/api/v2/admin/ai-characters/101") {
return options.responseSchema.parse({
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,
tags: ["상담", "힐링"],
hobbies: [],
values: [],
goals: [],
relationships: [],
personalities: [],
backgrounds: [],
memories: [],
originalWork: null,
});
}
return options.responseSchema.parse(null);
},
};
}
function createCropSource(file: File): Promise<CropSourceImage> {
if (file.name === "wide.png") {
return Promise.resolve({ file, height: 500, previewUrl: "blob:wide", width: 1000 });
}
if (file.name === "big.gif") {
return Promise.resolve({ file, height: 600, previewUrl: "blob:big-gif", width: 801 });
}
return Promise.resolve({ file, height: 600, previewUrl: "blob:gif", width: 800 });
}
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("Community create route opens from the existing list workspace", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
server.use(http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 1, page: 0, size: 20, hasNext: false, items: [basePost] }, errorProperty: null })));
window.history.pushState({}, "", "/ai-characters/101/community-posts?page=0&size=20");
// When
render(<App />);
fireEvent.click(await screen.findByRole("link", { name: "커뮤니티 게시글 생성" }));
// Then
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글 생성" })).toBeInTheDocument();
expect(window.location.pathname).toBe("/ai-characters/101/community-posts/new");
});
test("Community create entrypoints are blocked for inactive characters", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/202`, () => HttpResponse.json({ success: true, message: null, data: inactiveCharacter, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/202/community-posts`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, page: 0, size: 20, hasNext: false, items: [] }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/202/community-posts?page=0&size=20");
// When
render(<App />);
// Then
expect(await screen.findByText("비활성 캐릭터는 읽기 전용입니다.")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "커뮤니티 게시글 생성" })).not.toBeInTheDocument();
// When
window.history.pushState({}, "", "/ai-characters/202/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([], false)} characterId="202" createCropSource={createCropSource} />);
// Then
expect(await screen.findByText("비활성 캐릭터는 읽기 전용입니다.")).toBeInTheDocument();
expect(screen.queryByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "생성" })).not.toBeInTheDocument();
});
test("Community create form validates content audio policy and GIF width before submit", async () => {
// Given
const requests: CapturedRequest[] = [];
const audioFile = fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000);
const animatedGif = new File(["gif"], "post.gif", { type: "image/gif" });
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
// When
fireEvent.click(await screen.findByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("내용을 입력하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "GIF와 오디오가 있는 게시글" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [audioFile] } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "big.gif", { type: "image/gif" })] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("GIF 이미지는 원본 width 800px 이하만 업로드할 수 있습니다.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/community-posts"));
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
const body = requireFormData(requests.at(-1)?.body);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(animatedGif);
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true });
});
test("Community create form crops JPEG PNG with free ratio and no upscale", async () => {
// Given
const requests: CapturedRequest[] = [];
const croppedImage = new File(["cropped"], "post-cropped.png", { type: "image/png" });
const renderCrop: (request: CropRenderRequest) => Promise<File> = vi.fn(() => Promise.resolve(croppedImage));
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} renderCrop={renderCrop} />);
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "PNG crop 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000캔" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(renderCrop).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/community-posts"));
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ aspect: "free", outputHeight: 400, outputWidth: 800 }));
const body = requireFormData(requests.at(-1)?.body);
expect(body.get("postImage")).toBe(croppedImage);
expect(await readJsonPart(body.get("request"))).toEqual({ content: "PNG crop 게시글", isAdult: false, isCommentAvailable: true, price: 1000 });
});
test("Community create form blocks prices outside the CAN range before submit", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000캔" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("Community create form links validation errors and focuses the first invalid control", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
// When
const contentInput = await screen.findByLabelText("내용");
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000캔" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
const contentError = await screen.findByText("내용을 입력하세요.");
const priceError = screen.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
expect(contentError).toHaveAttribute("id", "community-post-content-error");
expect(priceError).toHaveAttribute("id", "community-post-price-error");
expect(contentInput).toHaveAttribute("aria-describedby", "community-post-content-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "community-post-price-error");
expect(contentInput).toHaveAttribute("aria-invalid", "true");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(contentInput).toHaveFocus());
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("Community create form keeps submit disabled while image preparation is pending", async () => {
// Given
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
const requests: CapturedRequest[] = [];
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
resolveCropSource = resolve;
});
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={() => cropSourceReady} />);
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "느린 이미지 준비" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
// Then
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
// When
resolveCropSource({ file: new File(["image"], "wide.png", { type: "image/png" }), height: 500, previewUrl: "blob:wide", width: 1000 });
// Then
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
});
test("Community image policy rejects mismatched MIME and extension combinations", () => {
expect(validateCommunityPostImageFile(new File(["image"], "post.gif", { type: "image/png" }))).toEqual({ ok: false, reason: "mime" });
expect(validateCommunityPostImageFile(new File(["image"], "post.png", { type: "image/gif" }))).toEqual({ ok: false, reason: "mime" });
expect(validateCommunityPostImageFile(new File(["image"], "post.jpg", { type: "image/png" }))).toEqual({ ok: false, reason: "mime" });
});
test("Community create form ignores stale image preparation results", async () => {
let resolveStaleCropSource: (source: CropSourceImage) => void = () => undefined;
const staleCropSource = new Promise<CropSourceImage>((resolve) => {
resolveStaleCropSource = resolve;
});
const requests: CapturedRequest[] = [];
const createRaceCropSource = (file: File) => file.name === "stale.png" ? staleCropSource : createCropSource(file);
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createRaceCropSource} />);
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "최신 이미지만 저장" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "stale.png", { type: "image/png" })] } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "fresh.gif", { type: "image/gif" })] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
await act(async () => {
resolveStaleCropSource({ file: new File(["image"], "stale.png", { type: "image/png" }), height: 500, previewUrl: "blob:stale", width: 1000 });
await staleCropSource;
});
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/community-posts"));
const submittedImage = requireFormData(requests.at(-1)?.body).get("postImage");
if (!(submittedImage instanceof File)) {
throw new Error("expected submitted image file");
}
expect(submittedImage.name).toBe("fresh.gif");
});
test("Community create form clears pending image preparation when selection is canceled", async () => {
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
resolveCropSource = resolve;
});
const requests: CapturedRequest[] = [];
const createRaceCropSource = (file: File) => file.name === "slow.png" ? cropSourceReady : createCropSource(file);
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createRaceCropSource} />);
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "이미지 취소 후 저장" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "slow.png", { type: "image/png" })] } });
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
await act(async () => {
resolveCropSource({ file: new File(["image"], "slow.png", { type: "image/png" }), height: 500, previewUrl: "blob:slow", width: 1000 });
await cropSourceReady;
});
expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/community-posts"));
expect(requireFormData(requests.at(-1)?.body).has("postImage")).toBe(false);
});

View File

@@ -0,0 +1,121 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
import { server } from "@/shared/test/server";
const communityPost = {
postId: 7001,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: "https://cdn.example.com/characters/luna.png",
imageUrl: null,
audioUrl: null,
content: "오늘의 상담 기록입니다.",
price: 0,
date: "2026-07-28 10:00:00",
dateUtc: "2026-07-28T01:00:00Z",
isCommentAvailable: true,
isAdult: false,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 3,
commentCount: 0,
firstComment: null,
} as const;
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("Community list uses required query only and renders loading empty error retry", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const communityRequests: Request[] = [];
let listRequestCount = 0;
let resolveFirstListReady: (finish: () => void) => void = () => undefined;
const firstListReady = new Promise<() => void>((resolve) => {
resolveFirstListReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts`, ({ request }) => {
communityRequests.push(request);
listRequestCount += 1;
if (listRequestCount === 1) {
return new Promise((resolve) => {
resolveFirstListReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, page: 0, size: 20, hasNext: false, 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: 21, page: 1, size: 20, hasNext: false, items: [communityPost] }, errorProperty: null });
}),
);
window.history.pushState({}, "", "/ai-characters/101/community-posts?page=0&size=20&search=상담&active=true");
// When
render(<App />);
// Then
expect(await screen.findByText("커뮤니티 게시글 목록을 불러오는 중")).toBeInTheDocument();
const finishFirstList = await firstListReady;
finishFirstList();
expect(await screen.findByText("등록된 커뮤니티 게시글이 없습니다.")).toBeInTheDocument();
const firstUrl = new URL(communityRequests[0]?.url ?? "");
expect([...firstUrl.searchParams.keys()]).toEqual(["page", "size"]);
expect(firstUrl.searchParams.has("timezone")).toBe(false);
expect(screen.queryByRole("searchbox", { name: "검색어" })).not.toBeInTheDocument();
// When
act(() => {
window.history.pushState({}, "", "/ai-characters/101/community-posts?page=1&size=20");
window.dispatchEvent(new PopStateEvent("popstate"));
});
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("커뮤니티 목록 실패");
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "커뮤니티" })).toHaveAttribute("aria-current", "page");
const openButtons = screen.getAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" });
expect(openButtons.length).toBeGreaterThan(0);
expect(openButtons[0]).toHaveTextContent("열기");
expect(openButtons[0]).not.toHaveTextContent(communityPost.content);
const statusLabels = screen.getAllByText("일반 · 댓글 허용 · 일반 콘텐츠");
expect(statusLabels).toHaveLength(2);
expect(statusLabels[0]).toHaveClass("whitespace-nowrap");
expect(screen.queryByText("일반 · 댓글 허용 · 성인 false")).not.toBeInTheDocument();
expect(screen.getByText("총 21개 · 2페이지")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
});
test("Community detail and edit paths are not app routes", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
const detailRequests: Request[] = [];
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/:postId`, ({ request }) => {
detailRequests.push(request);
return HttpResponse.json({ success: false, message: "detail must not be called", data: null, errorProperty: null }, { status: 500 });
}),
);
window.history.pushState({}, "", "/ai-characters/101/community-posts/7001/edit");
// When
render(<App />);
// Then
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(detailRequests).toHaveLength(0);
});

View File

@@ -0,0 +1,44 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
import { CommunityPostSheet } from "@/features/community-posts/components/CommunityPostSheet";
import { basePost, createCropSource } from "@/features/community-posts/tests/community-test-support";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
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);
},
};
}
test("Community create shows saving status and sends one request while pending", async () => {
const requests: ApiRequestOptions<unknown>[] = [];
render(<CommunityPostForm apiClient={createPendingClient(requests)} characterId="101" createCropSource={createCropSource} onCreated={() => undefined} />);
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 === "POST")).toHaveLength(1));
expect(screen.getByText("커뮤니티 게시글을 저장하는 중")).toHaveAttribute("role", "status");
expect(submitButton).toBeDisabled();
});
test("Community sheet shows saving status and sends one update while pending", async () => {
const requests: ApiRequestOptions<unknown>[] = [];
render(<CommunityPostSheet apiClient={createPendingClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "커뮤니티 수정 중" } });
const saveButton = screen.getByRole("button", { name: "수정 저장" });
fireEvent.click(saveButton);
fireEvent.click(saveButton);
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
expect(screen.getByText("커뮤니티 게시글을 저장하는 중")).toHaveAttribute("role", "status");
expect(saveButton).toBeDisabled();
});

View File

@@ -0,0 +1,59 @@
import { expect, test, vi } from "vitest";
import { prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
type TestCropSourceOptions = {
readonly events?: string[];
readonly file: File;
readonly release?: () => void;
readonly width: number;
};
function testCropSource({ events, file, release = vi.fn(), width }: TestCropSourceOptions): CropSourceImage {
return {
file,
height: 600,
previewUrl: `blob:${file.name}`,
release: () => {
events?.push("release");
release();
},
get width() {
events?.push("width");
return width;
},
};
}
test("prepareCommunityPostImage releases GIF crop sources after width validation", async () => {
const validGif = new File(["gif"], "valid.gif", { type: "image/gif" });
const oversizedGif = new File(["gif"], "oversized.gif", { type: "image/gif" });
const jpeg = new File(["jpeg"], "post.jpg", { type: "image/jpeg" });
const releaseValid = vi.fn();
const releaseOversized = vi.fn();
const releaseJpeg = vi.fn();
const jpegSource = testCropSource({ file: jpeg, release: releaseJpeg, width: 800 });
const validEvents: string[] = [];
const oversizedEvents: string[] = [];
const createCropSource = vi.fn((file: File) => {
if (file === validGif) {
return Promise.resolve(testCropSource({ events: validEvents, file, release: releaseValid, width: 800 }));
}
if (file === oversizedGif) {
return Promise.resolve(testCropSource({ events: oversizedEvents, file, release: releaseOversized, width: 801 }));
}
return Promise.resolve(jpegSource);
});
await expect(prepareCommunityPostImage(validGif, createCropSource)).resolves.toEqual({ file: validGif, kind: "ready" });
await expect(prepareCommunityPostImage(oversizedGif, createCropSource)).resolves.toEqual({ kind: "error", message: "GIF 이미지는 원본 width 800px 이하만 업로드할 수 있습니다." });
await expect(prepareCommunityPostImage(jpeg, createCropSource)).resolves.toEqual({ kind: "crop", source: jpegSource });
expect(releaseValid).toHaveBeenCalledTimes(1);
expect(releaseOversized).toHaveBeenCalledTimes(1);
expect(releaseJpeg).not.toHaveBeenCalled();
expect(validEvents).toEqual(["width", "release"]);
expect(oversizedEvents).toEqual(["width", "release"]);
});

View File

@@ -0,0 +1,65 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
import { formatCommunityPostPrice, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
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 renderForm(requests: CapturedRequest[]) {
const onCreated = vi.fn();
render(<CommunityPostForm apiClient={createClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 600, previewUrl: "blob:post", width: 800 })} onCreated={onCreated} />);
return onCreated;
}
async function submitWithPrice(value: string, requests: CapturedRequest[]) {
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
}
test.each(["-1", "1.5"])("Community price keeps invalid raw input %s and blocks submit", async (value) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
await submitWithPrice(value, requests);
expect(screen.getByLabelText("가격")).toHaveValue(value);
expect(onCreated).not.toHaveBeenCalled();
});
test.each(["0", "99,999캔"])("Community price allows boundary input %s", async (value) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1));
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
});
test("Community price parser rejects negative and decimal raw input", () => {
expect(parseCommunityPostPrice("-1")).toBeNull();
expect(parseCommunityPostPrice("1.5")).toBeNull();
expect(formatCommunityPostPrice("-1")).toBe("-1");
expect(formatCommunityPostPrice("1.5")).toBe("1.5");
});

View File

@@ -0,0 +1,273 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
import { CommunityPostSheet } from "@/features/community-posts/components/CommunityPostSheet";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { server } from "@/shared/test/server";
import { basePost, createCropSource, createSheetClient, inactiveCharacter, installCommunityHandlers, readJsonPart, requireFormData } from "./community-test-support";
import type { CapturedApiRequest, CommunitySheetState } from "./community-test-support";
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("Community Sheet edits pins unpins and soft deletes from the list item without detail GET", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const state: CommunitySheetState = { mutationCount: 0, post: basePost, requests: [] };
installCommunityHandlers(state);
window.history.pushState({}, "", "/ai-characters/101/community-posts?page=0&size=20");
// When
render(<App />);
const openButtons = await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" });
const openButton = openButtons[0];
if (openButton === undefined) {
throw new Error("expected community open button");
}
fireEvent.click(openButton);
// Then
const dialog = screen.getByRole("dialog", { name: "커뮤니티 게시글" });
expect(dialog).toHaveTextContent("오늘의 상담 기록입니다.");
expect(screen.getAllByRole("group", { name: "커뮤니티 게시글 7001 오디오 플레이어" }).some((player) => !dialog.contains(player))).toBe(true);
expect(within(dialog).getByRole("group", { name: "커뮤니티 게시글 7001 오디오 플레이어" })).toBeInTheDocument();
expect(screen.getByText("첫 댓글: 좋아요")).toBeInTheDocument();
expect(state.requests.filter((request) => new URL(request.url).pathname.includes("/community-posts/7001") && request.method === "GET")).toHaveLength(0);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "수정된 커뮤니티 게시글" } });
fireEvent.click(screen.getByRole("checkbox", { name: "성인 콘텐츠" }));
fireEvent.click(screen.getByRole("checkbox", { name: "댓글 허용" }));
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
// Then
await waitFor(() => expect(state.requests.some((request) => request.method === "PUT")).toBe(true));
await waitFor(() => expect(screen.getAllByText("수정된 커뮤니티 게시글").length).toBeGreaterThan(0));
// When
fireEvent.click(screen.getByRole("button", { name: "고정하기" }));
// Then
await waitFor(() => expect(screen.getByRole("button", { name: "고정 해제" })).toBeInTheDocument());
// When
fireEvent.click(screen.getByRole("button", { name: "고정 해제" }));
// Then
await waitFor(() => expect(screen.getByRole("button", { name: "고정하기" })).toBeInTheDocument());
// When
const deactivateButton = screen.getByRole("button", { name: "비활성화" });
deactivateButton.focus();
fireEvent.click(deactivateButton);
// Then
const confirmDialog = screen.getByRole("alertdialog", { name: "커뮤니티 게시글 비활성화 확인" });
expect(confirmDialog).toHaveTextContent("게시글을 목록에서 제외합니다.");
expect(state.requests.filter((request) => request.method === "PUT")).toHaveLength(3);
// When
fireEvent.click(within(confirmDialog).getByRole("button", { name: "취소" }));
// Then
expect(screen.queryByRole("alertdialog", { name: "커뮤니티 게시글 비활성화 확인" })).not.toBeInTheDocument();
await waitFor(() => expect(deactivateButton).toHaveFocus());
expect(state.requests.filter((request) => request.method === "PUT")).toHaveLength(3);
// When
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
const confirmButton = screen.getByRole("button", { name: "비활성화 확인" });
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
// Then
await waitFor(() => expect(screen.queryByRole("dialog", { name: "커뮤니티 게시글" })).not.toBeInTheDocument());
expect(state.requests.filter((request) => request.method === "PUT")).toHaveLength(4);
expect(await screen.findByLabelText("작업 성공")).toHaveTextContent("커뮤니티 게시글을 비활성화했습니다.");
expect(await screen.findByText("등록된 커뮤니티 게시글이 없습니다.")).toBeInTheDocument();
}, 10_000);
test("Community Sheet sends actual multipart update requests without audio or price", async () => {
// Given
const requests: CapturedApiRequest[] = [];
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "수정된 커뮤니티 게시글" } });
fireEvent.click(screen.getByRole("checkbox", { name: "성인 콘텐츠" }));
fireEvent.click(screen.getByRole("checkbox", { name: "댓글 허용" }));
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
// Then
await waitFor(() => expect(requests).toHaveLength(1));
const updateBody = requireFormData(requests[0]?.body);
expect(updateBody.has("audioFile")).toBe(false);
expect(updateBody.has("price")).toBe(false);
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false, isFixed: false });
// When
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
// Then
expect(requests).toHaveLength(1);
// When
const confirmButton = screen.getByRole("button", { name: "비활성화 확인" });
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
// Then
await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false });
});
test("Community Sheet keeps save impossible while image preparation is pending", async () => {
// Given
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
const requests: CapturedApiRequest[] = [];
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
resolveCropSource = resolve;
});
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={() => cropSourceReady} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "post.png", { type: "image/png" })] } });
// Then
expect(screen.getByRole("button", { name: "수정 저장" })).toBeDisabled();
// When
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
resolveCropSource({ file: new File(["image"], "post.png", { type: "image/png" }), height: 600, previewUrl: "blob:pending", width: 800 });
// Then
expect(requests).toHaveLength(0);
});
test("Community Sheet ignores stale image preparation results", async () => {
let resolveStaleCropSource: (source: CropSourceImage) => void = () => undefined;
const staleCropSource = new Promise<CropSourceImage>((resolve) => {
resolveStaleCropSource = resolve;
});
const requests: CapturedApiRequest[] = [];
const createRaceCropSource = (file: File) => file.name === "stale.png" ? staleCropSource : createCropSource(file);
const freshGif = new File(["gif"], "fresh.gif", { type: "image/gif" });
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createRaceCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "stale.png", { type: "image/png" })] } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [freshGif] } });
await waitFor(() => expect(screen.getByRole("button", { name: "수정 저장" })).not.toBeDisabled());
await act(async () => {
resolveStaleCropSource({ file: new File(["image"], "stale.png", { type: "image/png" }), height: 500, previewUrl: "blob:stale", width: 1000 });
await staleCropSource;
});
expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif);
});
test("Community Sheet resets saving state and shows an alert when mutation fails", async () => {
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 1, page: 0, size: 20, hasNext: false, items: [basePost] }, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/:postId/comments`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
http.put(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/7001`, () => HttpResponse.json({ success: false, message: "저장 실패", data: null, errorProperty: null }, { status: 500 })),
);
window.history.pushState({}, "", "/ai-characters/101/community-posts?page=0&size=20");
render(<App />);
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");
expect(screen.getByRole("button", { name: "수정 저장" })).not.toBeDisabled();
});
test("Community media error does not refetch URLs or autoplay", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const play = vi.spyOn(window.HTMLMediaElement.prototype, "play").mockImplementation(() => Promise.resolve());
const load = vi.spyOn(window.HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts`, ({ request }) => {
requests.push(request);
return HttpResponse.json({ success: true, message: null, data: { totalCount: 1, page: 0, size: 20, hasNext: false, items: [basePost] }, errorProperty: null });
}),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/:postId`, ({ request }) => {
requests.push(request);
return HttpResponse.json({ success: false, message: "detail must not be called", data: null, errorProperty: null }, { status: 500 });
}),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/:postId/comments`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
);
window.history.pushState({}, "", "/ai-characters/101/community-posts?page=0&size=20");
// When
render(<App />);
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
const audio = document.querySelector("audio");
if (audio === null) {
throw new Error("expected community audio player");
}
fireEvent.error(audio);
fireEvent.click(await screen.findByRole("button", { name: "오디오 다시 시도" }));
// Then
expect(requests.filter((request) => new URL(request.url).pathname === "/api/v2/admin/ai-characters/101/community-posts")).toHaveLength(1);
expect(requests.filter((request) => new URL(request.url).pathname.includes("/community-posts/7001"))).toHaveLength(0);
expect(audio).toHaveAttribute("src", basePost.audioUrl);
expect(load).toHaveBeenCalledTimes(1);
expect(play).not.toHaveBeenCalled();
});
test("Community Sheet hides mutation buttons for inactive characters", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
const requests: Request[] = [];
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/202`, () => HttpResponse.json({ success: true, message: null, data: inactiveCharacter, errorProperty: null })),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/202/community-posts`, ({ request }) => {
requests.push(request);
return HttpResponse.json({ success: true, message: null, data: { totalCount: 1, page: 0, size: 20, hasNext: false, items: [{ ...basePost, creatorId: 202, creatorNickname: "미카" }] }, errorProperty: null });
}),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/202/community-posts/:postId/comments`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
http.put(`${apiBaseUrl}/api/v2/admin/ai-characters/202/community-posts/7001`, ({ request }) => {
requests.push(request);
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
}),
);
window.history.pushState({}, "", "/ai-characters/202/community-posts?page=0&size=20");
// When
render(<App />);
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
// Then
expect(await screen.findByText("비활성 캐릭터는 읽기 전용입니다.")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "커뮤니티 게시글" })).toHaveTextContent("오늘의 상담 기록입니다.");
expect(screen.queryByRole("button", { name: "수정 저장" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "고정하기" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "비활성화" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("새 댓글")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "댓글 등록" })).not.toBeInTheDocument();
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});

View File

@@ -0,0 +1,212 @@
import { http, HttpResponse } from "msw";
import { apiBaseUrl } from "@/app/app-test-support";
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import { server } from "@/shared/test/server";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
export type CapturedApiRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
export type CommunitySheetState = {
mutationCount: number;
post: CommunityPostListItem | null;
readonly requests: Request[];
};
export const contractCommunityPost = {
postId: 7001,
creatorId: 101,
creatorNickname: "루나",
creatorProfileUrl: "https://cdn.example.com/characters/luna.png",
imageUrl: "https://cdn.example.com/community/post.png",
audioUrl: "https://cdn.example.com/community/post.m4a",
content: "오늘의 상담 기록입니다.",
price: 0,
date: "2026-07-28 10:00:00",
dateUtc: "2026-07-28T01:00:00Z",
isCommentAvailable: true,
isAdult: false,
isFixed: false,
isLike: false,
existOrdered: false,
likeCount: 3,
commentCount: 1,
firstComment: {
id: 8001,
writerId: 301,
nickname: "팬",
profileUrl: "https://cdn.example.com/fan.png",
comment: "좋아요",
isSecret: false,
date: "2026-07-28 11:00:00",
replyCount: 0,
},
} as const;
export const basePost = {
...contractCommunityPost,
} satisfies CommunityPostListItem;
export const inactiveCharacter = {
id: 202,
characterUUID: "character-202",
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,
} as const;
export function createCapturingClient(requests: CapturedApiRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === "POST" || options.method === "PUT") {
return options.responseSchema.parse(null);
}
return options.responseSchema.parse({ totalCount: 51, page: 2, size: 50, hasNext: true, items: [contractCommunityPost] });
},
};
}
export function createSheetClient(requests: CapturedApiRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
if (options.path.includes("/comments")) {
return options.responseSchema.parse({ totalCount: 0, items: [] });
}
requests.push({ body: options.body, method: options.method, path: options.path });
return options.responseSchema.parse(null);
},
};
}
export function createCropSource(file: File): Promise<CropSourceImage> {
return Promise.resolve({ file, height: 600, previewUrl: "blob:sheet", width: 800 });
}
export function requireFormData(body: BodyInit | null | undefined): FormData {
if (body instanceof FormData) {
return body;
}
throw new TypeError("Expected FormData body");
}
export async function readJsonPart(part: FormDataEntryValue | null): Promise<unknown> {
if (part instanceof Blob) {
return JSON.parse(await part.text());
}
if (typeof part === "string") {
return JSON.parse(part);
}
return null;
}
export function requireData<Data>(data: Data | null): Data {
if (data === null) {
throw new Error("response data missing");
}
return data;
}
export function authorizedFetch(path: string, init: RequestInit = {}) {
return fetch(`${apiBaseUrl}${path}`, {
...init,
headers: { Authorization: "Bearer mock-admin-jwt", ...init.headers },
});
}
export function multipartInit(method: "POST" | "PUT", request: object, filePartNames: readonly string[] = []): RequestInit {
const boundary = "test-boundary";
const fileParts = filePartNames.map((name) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.bin"\r\nContent-Type: application/octet-stream\r\n\r\nfile\r\n`).join("");
return {
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
method,
body: `${fileParts}--${boundary}\r\nContent-Disposition: form-data; name="request"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(request)}\r\n--${boundary}--\r\n`,
};
}
export function multipartRawInit(method: "POST" | "PUT", parts: readonly string[]): RequestInit {
const boundary = "test-boundary";
return {
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
method,
body: `${parts.map((part) => `--${boundary}\r\n${part}\r\n`).join("")}--${boundary}--\r\n`,
};
}
export function requestPart(request: object): string {
return `Content-Disposition: form-data; name="request"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(request)}`;
}
export function filePart(name: string): string {
return `Content-Disposition: form-data; name="${name}"; filename="${name}.bin"\r\nContent-Type: application/octet-stream\r\n\r\nfile`;
}
export function textPart(name: string): string {
return `Content-Disposition: form-data; name="${name}"\r\n\r\ntext`;
}
export function installCommunityHandlers(state: CommunitySheetState) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts`, ({ request }) => {
state.requests.push(request);
const items = state.post === null ? [] : [state.post];
return HttpResponse.json({ success: true, message: null, data: { totalCount: items.length, page: 0, size: 20, hasNext: false, items }, errorProperty: null });
}),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/:postId`, ({ request }) => {
state.requests.push(request);
return HttpResponse.json({ success: false, message: "detail must not be called", data: null, errorProperty: null }, { status: 500 });
}),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/:postId/comments`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, items: [] }, errorProperty: null })),
http.put(`${apiBaseUrl}/api/v2/admin/ai-characters/101/community-posts/7001`, async ({ request }) => {
state.requests.push(request);
const post = state.post;
if (post !== null) {
state.mutationCount += 1;
if (state.mutationCount === 4) {
state.post = null;
} else {
state.post = {
...post,
content: state.mutationCount === 1 ? "수정된 커뮤니티 게시글" : post.content,
isAdult: state.mutationCount === 1 ? true : post.isAdult,
isCommentAvailable: state.mutationCount === 1 ? false : post.isCommentAvailable,
isFixed: state.mutationCount === 2 ? true : state.mutationCount === 3 ? false : post.isFixed,
};
}
}
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
}),
);
}

View File

@@ -0,0 +1,70 @@
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
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 COMMUNITY_POST_IMAGE_POLICY = createImagePolicy({ aspect: "free", cropRequired: true, maxWidth: 800, noUpscale: true });
const communityPostImageFilePolicy = {
allowedExtensions: [".gif", ".jpeg", ".jpg", ".png"],
allowedMimeTypes: ["image/gif", "image/jpeg", "image/png"],
maxBytes: COMMUNITY_POST_IMAGE_POLICY.maxBytes,
} as const;
export type CommunityPostImagePrepareResult =
| { readonly kind: "cleared" }
| { readonly file: File; readonly kind: "ready" }
| { readonly kind: "crop"; readonly source: CropSourceImage }
| { readonly kind: "error"; readonly message: string };
export function validateCommunityPostImageFile(file: File): FileValidationResult {
const result = validateFile(file, communityPostImageFilePolicy);
if (!result.ok) {
return result;
}
const extension = getFileExtension(file.name);
if ((extension === ".gif" && file.type !== "image/gif") || ((extension === ".jpg" || extension === ".jpeg") && file.type !== "image/jpeg") || (extension === ".png" && file.type !== "image/png")) {
return { ok: false, reason: "mime" };
}
return { ok: true };
}
export function communityPostImageErrorMessage(file: File): string | undefined {
const result = validateCommunityPostImageFile(file);
if (result.ok) {
return undefined;
}
if (result.reason === "size") {
return "게시글 이미지는 10MB 이하만 업로드할 수 있습니다.";
}
return "JPEG, PNG, GIF 파일만 업로드하세요.";
}
export function isCommunityPostGif(file: File): boolean {
return getFileExtension(file.name) === ".gif" && file.type === "image/gif";
}
export async function prepareCommunityPostImage(file: File | null, createCropSource: (file: File) => Promise<CropSourceImage>): Promise<CommunityPostImagePrepareResult> {
if (file === null) {
return { kind: "cleared" };
}
const message = communityPostImageErrorMessage(file);
if (message !== undefined) {
return { kind: "error", message };
}
const source = await createCropSource(file);
if (isCommunityPostGif(file)) {
const isOversized = source.width > COMMUNITY_POST_IMAGE_POLICY.maxWidth;
source.release?.();
return isOversized
? { kind: "error", message: "GIF 이미지는 원본 width 800px 이하만 업로드할 수 있습니다." }
: { file, kind: "ready" };
}
return { kind: "crop", source };
}