feat(ai-character): 커뮤니티 게시글 관리 기능 구현
This commit is contained in:
89
src/features/community-posts/api/community-post-api.ts
Normal file
89
src/features/community-posts/api/community-post-api.ts
Normal 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 } });
|
||||||
|
}
|
||||||
177
src/features/community-posts/components/CommunityPostForm.tsx
Normal file
177
src/features/community-posts/components/CommunityPostForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/features/community-posts/components/CommunityPostSheet.tsx
Normal file
202
src/features/community-posts/components/CommunityPostSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
|
||||||
|
|
||||||
|
export const createCommunityPostCropSource = createImageCropSource;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
66
src/features/community-posts/model/types.ts
Normal file
66
src/features/community-posts/model/types.ts
Normal 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>;
|
||||||
62
src/features/community-posts/pages/CommunityPostFormPage.tsx
Normal file
62
src/features/community-posts/pages/CommunityPostFormPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/features/community-posts/pages/CommunityPostListPage.tsx
Normal file
118
src/features/community-posts/pages/CommunityPostListPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
src/features/community-posts/tests/community-contract.test.ts
Normal file
259
src/features/community-posts/tests/community-contract.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
323
src/features/community-posts/tests/community-form.test.tsx
Normal file
323
src/features/community-posts/tests/community-form.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
121
src/features/community-posts/tests/community-list.test.tsx
Normal file
121
src/features/community-posts/tests/community-list.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
273
src/features/community-posts/tests/community-sheet.test.tsx
Normal file
273
src/features/community-posts/tests/community-sheet.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
212
src/features/community-posts/tests/community-test-support.ts
Normal file
212
src/features/community-posts/tests/community-test-support.ts
Normal 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 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user