feat(ai-character): 댓글과 팬톡 운영 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:43 +09:00
parent 20d38e38c0
commit 3faca90135
20 changed files with 2154 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import { z } from "zod";
import { audioCommentCreateRequestSchema, commentPageSchema, commentUpdateRequestSchema, communityCommentCreateRequestSchema } from "@/features/comments/model/types";
import type { AudioCommentCreateRequest, CommentPage, CommentTarget, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types";
import type { ApiClient } from "@/shared/api/client";
export type CommentPageParams = {
readonly page?: number;
readonly size?: number;
};
export type GetRepliesParams = CommentPageParams & {
readonly commentId: number;
};
export type UpdateCommentParams = {
readonly commentId: number;
readonly request: CommentUpdateRequest;
};
export type DeleteCommentParams = {
readonly commentId: number;
};
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 commentCollectionPath(target: CommentTarget): string {
switch (target.kind) {
case "audio":
return `/api/v2/admin/ai-characters/${encodeURIComponent(target.characterId)}/audio-contents/${encodeURIComponent(target.contentId)}/comments`;
case "community":
return `/api/v2/admin/ai-characters/${encodeURIComponent(target.characterId)}/community-posts/${encodeURIComponent(target.postId)}/comments`;
}
}
function commentItemPath(target: CommentTarget, commentId: number): string {
return `${commentCollectionPath(target)}/${encodeURIComponent(String(commentId))}`;
}
function pageQuery(params: CommentPageParams): string {
return new URLSearchParams({ page: String(normalizePage(params.page)), size: String(normalizeSize(params.size)) }).toString();
}
export function getRootComments(apiClient: ApiClient, target: CommentTarget, params: CommentPageParams = {}): Promise<CommentPage> {
return apiClient.request({
path: `${commentCollectionPath(target)}?${pageQuery(params)}`,
responseSchema: commentPageSchema,
authentication: "required",
});
}
export function getReplies(apiClient: ApiClient, target: CommentTarget, params: GetRepliesParams): Promise<CommentPage> {
return apiClient.request({
path: `${commentItemPath(target, params.commentId)}/replies?${pageQuery(params)}`,
responseSchema: commentPageSchema,
authentication: "required",
});
}
export function createComment(apiClient: ApiClient, target: Extract<CommentTarget, { readonly kind: "audio" }>, request: AudioCommentCreateRequest): Promise<null>;
export function createComment(apiClient: ApiClient, target: Extract<CommentTarget, { readonly kind: "community" }>, request: CommunityCommentCreateRequest): Promise<null>;
export function createComment(apiClient: ApiClient, target: CommentTarget, request: AudioCommentCreateRequest | CommunityCommentCreateRequest): Promise<null> {
const body = target.kind === "audio"
? audioCommentCreateRequestSchema.parse(request)
: communityCommentCreateRequestSchema.parse(request);
return apiClient.request({
path: commentCollectionPath(target),
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function updateComment(apiClient: ApiClient, target: CommentTarget, params: UpdateCommentParams): Promise<null> {
return apiClient.request({
path: commentItemPath(target, params.commentId),
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(commentUpdateRequestSchema.parse(params.request)),
responseSchema: nullSuccessSchema,
authentication: "required",
});
}
export function deleteComment(apiClient: ApiClient, target: CommentTarget, params: DeleteCommentParams): Promise<null> {
return apiClient.request({
path: commentItemPath(target, params.commentId),
method: "DELETE",
responseSchema: nullSuccessSchema,
authentication: "required",
});
}

View File

@@ -0,0 +1,47 @@
import { useRef, useState } from "react";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
export function CommentForm({ errorId, isSaving, label, onSubmit, submitLabel }: { readonly errorId: string; readonly isSaving: boolean; readonly label: string; readonly onSubmit: (comment: string) => Promise<boolean>; readonly submitLabel: string }) {
const [comment, setComment] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const trimmedComment = comment.trim();
async function submit() {
if (isSaving) {
return;
}
if (trimmedComment.length === 0) {
setErrorMessage("댓글 내용을 입력해 주세요.");
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
const isSubmitted = await onSubmit(trimmedComment);
if (isSubmitted) {
setComment("");
setErrorMessage(null);
}
}
return (
<form className="flex flex-col gap-2" onSubmit={(event) => { event.preventDefault(); void submit(); }} ref={formRef}>
<label className="flex flex-col gap-2 text-sm font-semibold">
{label}
<textarea aria-describedby={errorMessage === null ? undefined : errorId} aria-invalid={errorMessage === null ? undefined : true} className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => {
const nextComment = event.currentTarget.value;
if (nextComment.trim().length > 0) {
setErrorMessage(null);
}
setComment(nextComment);
}} value={comment} />
</label>
{errorMessage === null ? null : <p aria-label={`${label} 오류`} className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{errorMessage}</p>}
<button 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)] disabled:opacity-60" disabled={isSaving || trimmedComment.length === 0} type="submit">
{submitLabel}
</button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { useState } from "react";
import type { CommentRecord } from "@/features/comments/model/types";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, showRepliesButton }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly showRepliesButton?: boolean }) {
const [draft, setDraft] = useState(comment.comment);
const [isEditing, setIsEditing] = useState(false);
const label = `${comment.comment}`;
function saveEdit() {
const nextComment = draft.trim();
if (nextComment.length === 0) {
return;
}
onEdit(nextComment);
setIsEditing(false);
}
return (
<article className="rounded-lg border border-border bg-card p-3" aria-label={`${comment.nickname} 댓글`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{comment.nickname}</p>
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
</div>
<div className="flex flex-wrap gap-2">
{showRepliesButton === true && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} </button> : null}
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} </button> : null}
{canDelete ? <button className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">{label} </button> : null}
</div>
</div>
{isEditing ? (
<div className="mt-3 flex flex-col gap-2">
<label className="flex flex-col gap-2 text-sm font-semibold">
<textarea className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal" onChange={(event) => setDraft(event.currentTarget.value)} value={draft} />
</label>
<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={isSaving || draft.trim().length === 0} onClick={saveEdit} type="button"> </button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => { setDraft(comment.comment); setIsEditing(false); }} type="button"></button>
</div>
</div>
) : <p className="mt-3 break-words text-sm">{comment.comment}</p>}
</article>
);
}

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createComment, deleteComment, getReplies, getRootComments, updateComment } from "@/features/comments/api/comment-api";
import { CommentForm } from "@/features/comments/components/CommentForm";
import { CommentItem } from "@/features/comments/components/CommentItem";
import type { CommentPage, CommentRecord, CommentTarget } from "@/features/comments/model/types";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import { PageState } from "@/shared/ui/page-state";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
type LoadState =
| { readonly status: "loading" }
| { readonly data: CommentPage; readonly status: "content" }
| { readonly message: string; readonly status: "error" };
type ReplyEntry = {
readonly page: number;
readonly state: LoadState;
};
type ReplyState = Record<number, ReplyEntry>;
const pageSize = 20;
function getErrorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : "댓글을 처리하지 못했습니다.";
}
function canEdit(comment: CommentRecord, target: CommentTarget): boolean {
return comment.writerId === target.creatorId;
}
export function CommentThread({ apiClient, canMutate = true, target }: { readonly apiClient: ApiClient; readonly canMutate?: boolean; readonly target: CommentTarget }) {
const audioContentId = target.kind === "audio" ? target.contentId : "";
const communityPostId = target.kind === "community" ? target.postId : "";
const requestTarget = useMemo<CommentTarget>(() => target.kind === "audio"
? { kind: "audio", characterId: target.characterId, contentId: audioContentId, creatorId: target.creatorId }
: { kind: "community", characterId: target.characterId, postId: communityPostId, creatorId: target.creatorId }, [audioContentId, communityPostId, target.characterId, target.creatorId, target.kind]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [expandedRootIds, setExpandedRootIds] = useState<readonly number[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [rootPage, setRootPage] = useState(0);
const [rootReloadKey, setRootReloadKey] = useState(0);
const [roots, setRoots] = useState<LoadState>({ status: "loading" });
const [replies, setReplies] = useState<ReplyState>({});
const isSavingRef = useRef(false);
const loadReplies = useCallback(async function loadRepliesForPage(rootId: number, page = 0): Promise<void> {
setReplies((current) => ({ ...current, [rootId]: { page, state: { status: "loading" } } }));
try {
const data = await getReplies(apiClient, requestTarget, { commentId: rootId, page, size: pageSize });
if (page > 0 && data.items.length === 0 && data.totalCount <= page * pageSize) {
await loadRepliesForPage(rootId, page - 1);
return;
}
setReplies((current) => ({ ...current, [rootId]: { page, state: { data, status: "content" } } }));
} catch (error: unknown) {
setReplies((current) => ({ ...current, [rootId]: { page, state: { message: getErrorMessage(error), status: "error" } } }));
}
}, [apiClient, requestTarget]);
useEffect(() => {
let isCurrent = true;
setRoots((current) => current.status === "content" ? current : { status: "loading" });
void getRootComments(apiClient, requestTarget, { page: rootPage, size: pageSize })
.then((data) => {
if (isCurrent) {
setRoots({ data, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setRoots({ message: getErrorMessage(error), status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, requestTarget, rootPage, rootReloadKey]);
async function runMutation(work: () => Promise<null>, rootId?: number): Promise<boolean> {
if (isSavingRef.current) {
return false;
}
isSavingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
try {
await work();
setRootReloadKey((key) => key + 1);
if (rootId !== undefined) {
await loadReplies(rootId, replies[rootId]?.page ?? 0);
}
return true;
} catch (error: unknown) {
setErrorMessage(getErrorMessage(error));
return false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
async function createRoot(comment: string): Promise<boolean> {
if (requestTarget.kind === "audio") {
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: null, isSecret: false, languageCode: null }));
}
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: null, isSecret: false }));
}
async function createReply(rootId: number, comment: string): Promise<boolean> {
if (requestTarget.kind === "audio") {
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: rootId, isSecret: false, languageCode: null }), rootId);
}
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: rootId, isSecret: false }), rootId);
}
function toggleReplies(rootId: number) {
setExpandedRootIds((current) => current.includes(rootId) ? current.filter((id) => id !== rootId) : [...current, rootId]);
if (replies[rootId] === undefined) {
void loadReplies(rootId, 0);
}
}
function renderReplies(root: CommentRecord) {
if (!expandedRootIds.includes(root.id)) {
return null;
}
const entry = replies[root.id] ?? { page: 0, state: { status: "loading" } };
const state = entry.state;
if (state.status === "loading") {
return <PageState state="loading" title="답글을 불러오는 중" />;
}
if (state.status === "error") {
return <PageState description={state.message} onRetry={() => void loadReplies(root.id)} state="error" title="답글 조회 실패" />;
}
return (
<section aria-label={`${root.comment} 답글`} className="ml-0 flex flex-col gap-3 border-l border-border pl-3 sm:ml-4">
{canMutate ? <CommentForm errorId={`comment-reply-${root.id}-error`} isSaving={isSaving} label={`${root.comment}에 답글`} onSubmit={(comment) => createReply(root.id, comment)} submitLabel="답글 등록" /> : null}
{state.data.items.map((reply) => (
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(reply, requestTarget)} comment={reply} isSaving={isSaving} key={reply.id} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: reply.id }), root.id)} onEdit={(comment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: reply.id, request: { comment } }), root.id)} />
))}
{state.data.totalCount > pageSize ? <ResourcePagination data={{ totalCount: state.data.totalCount, page: entry.page, size: pageSize, hasNext: (entry.page + 1) * pageSize < state.data.totalCount, items: state.data.items }} onPageChange={(page) => void loadReplies(root.id, page)} onSizeChange={() => void loadReplies(root.id, 0)} sizeOptions={[20]} /> : null}
</section>
);
}
return (
<section className="flex flex-col gap-4 rounded-lg border border-border bg-muted p-4" aria-labelledby="comment-thread-title">
<div className="flex flex-col gap-1">
<p className="text-xs font-semibold text-info">COMMENTS</p>
<h2 className="text-xl font-semibold" id="comment-thread-title"> </h2>
</div>
{canMutate ? <CommentForm errorId="comment-root-error" isSaving={isSaving} label="새 댓글" onSubmit={createRoot} submitLabel="댓글 등록" /> : null}
{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-card p-3 text-sm font-semibold" role="status"> </p> : null}
{roots.status === "loading" ? <PageState state="loading" title="댓글을 불러오는 중" /> : null}
{roots.status === "error" ? <PageState description={roots.message} onRetry={() => setRootReloadKey((key) => key + 1)} state="error" title="댓글 조회 실패" /> : null}
{roots.status === "content" ? (
<>
<p className="text-sm font-semibold text-muted-foreground"> {roots.data.totalCount.toLocaleString("ko-KR")}</p>
{roots.data.totalCount === 0 ? <PageState description="새 댓글을 등록해 첫 대화를 시작할 수 있습니다." state="empty" title="댓글이 없습니다" /> : (
<>
<div className="flex flex-col gap-3">
{roots.data.items.map((comment) => (
<div className="flex flex-col gap-3" key={comment.id}>
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} showRepliesButton={comment.replyCount > 0 || expandedRootIds.includes(comment.id)} />
{renderReplies(comment)}
</div>
))}
</div>
<ResourcePagination data={{ totalCount: roots.data.totalCount, page: rootPage, size: pageSize, hasNext: (rootPage + 1) * pageSize < roots.data.totalCount, items: roots.data.items }} onPageChange={setRootPage} onSizeChange={() => setRootPage(0)} sizeOptions={[20]} />
</>
)}
</>
) : null}
</section>
);
}

View File

@@ -0,0 +1,48 @@
import { z } from "zod";
const nullableString = z.string().nullable();
export type CommentTarget =
| { readonly kind: "audio"; readonly characterId: string; readonly contentId: string; readonly creatorId: number }
| { readonly kind: "community"; readonly characterId: string; readonly postId: string; readonly creatorId: number };
export const commentRecordSchema = 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(),
languageCode: nullableString.optional(),
donationCan: z.number().int().optional(),
});
export const commentPageSchema = z.object({
totalCount: z.number().int(),
items: z.array(commentRecordSchema),
});
export const audioCommentCreateRequestSchema = z.strictObject({
comment: z.string().min(1),
parentId: z.number().int().nullable().optional(),
isSecret: z.boolean(),
languageCode: nullableString.optional(),
});
export const communityCommentCreateRequestSchema = z.strictObject({
comment: z.string().min(1),
parentId: z.number().int().nullable().optional(),
isSecret: z.boolean(),
});
export const commentUpdateRequestSchema = z.strictObject({
comment: z.string().min(1),
});
export type AudioCommentCreateRequest = z.infer<typeof audioCommentCreateRequestSchema>;
export type CommentPage = z.infer<typeof commentPageSchema>;
export type CommentRecord = z.infer<typeof commentRecordSchema>;
export type CommentUpdateRequest = z.infer<typeof commentUpdateRequestSchema>;
export type CommunityCommentCreateRequest = z.infer<typeof communityCommentCreateRequestSchema>;

View File

@@ -0,0 +1,200 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { createComment, deleteComment, getReplies, getRootComments, updateComment } from "@/features/comments/api/comment-api";
import type { CommentTarget } from "@/features/comments/model/types";
import { createApiResponseSchema } from "@/shared/api/types";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
import { server } from "@/shared/test/server";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
const apiBaseUrl = "https://api.example.com";
const adminToken = "mock-admin-jwt";
const audioTarget = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget;
const communityTarget = { kind: "community", characterId: "101", postId: "7001", creatorId: 101 } satisfies CommentTarget;
const fanRoot = {
id: 1101,
writerId: 301,
nickname: "팬",
profileUrl: "https://cdn.example.com/fan.png",
comment: "팬 루트 댓글",
isSecret: false,
date: "2026-07-29T00:30:00Z",
replyCount: 2,
languageCode: "ko",
donationCan: 5,
} as const;
function createCapturingClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === "POST" || options.method === "PUT" || options.method === "DELETE") {
return options.responseSchema.parse(null);
}
return options.responseSchema.parse({ totalCount: 51, items: [fanRoot] });
},
};
}
function authorizedFetch(path: string, init: RequestInit = {}) {
return fetch(`${apiBaseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${adminToken}`, ...init.headers },
});
}
describe("comments contract", () => {
test("Audio comments use target-specific paths, page query, nullable parentId, languageCode, null mutations, and bodyless DELETE", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const roots = await getRootComments(client, audioTarget, { page: 2, size: 50 });
const replies = await getReplies(client, audioTarget, { commentId: 1101, page: 1, size: 20 });
await getRootComments(client, audioTarget, { page: 0, size: 1 });
await getReplies(client, audioTarget, { commentId: 1101, page: 0, size: 51 });
const created = await createComment(client, audioTarget, { comment: "AI 루트", parentId: null, isSecret: false, languageCode: "ko" });
const replyCreated = await createComment(client, audioTarget, { comment: "AI 답글", parentId: 1101, isSecret: true, languageCode: null });
const updated = await updateComment(client, audioTarget, { commentId: 1201, request: { comment: "수정" } });
const deleted = await deleteComment(client, audioTarget, { commentId: 1101 });
// Then
expect(roots).toEqual({ totalCount: 51, items: [fanRoot] });
expect(replies).toEqual({ totalCount: 51, items: [fanRoot] });
expect(created).toBeNull();
expect(replyCreated).toBeNull();
expect(updated).toBeNull();
expect(deleted).toBeNull();
expect(requests).toEqual([
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments?page=2&size=50", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1101/replies?page=1&size=20", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments?page=0&size=1", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1101/replies?page=0&size=51", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", method: "POST", body: JSON.stringify({ comment: "AI 루트", parentId: null, isSecret: false, languageCode: "ko" }) },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", method: "POST", body: JSON.stringify({ comment: "AI 답글", parentId: 1101, isSecret: true, languageCode: null }) },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1201", method: "PUT", body: JSON.stringify({ comment: "수정" }) },
{ path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1101", method: "DELETE", body: undefined },
]);
});
test("Community create omits languageCode while sharing root reply update and delete semantics", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
await getRootComments(client, communityTarget, { page: 0, size: 20 });
await getReplies(client, communityTarget, { commentId: 2101, page: 0, size: 50 });
await createComment(client, communityTarget, { comment: "커뮤니티 루트", isSecret: false });
await createComment(client, communityTarget, { comment: "커뮤니티 답글", parentId: 2101, isSecret: true });
await updateComment(client, communityTarget, { commentId: 2201, request: { comment: "커뮤니티 수정" } });
await deleteComment(client, communityTarget, { commentId: 2101 });
// Then
expect(requests).toEqual([
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments?page=0&size=20", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments/2101/replies?page=0&size=50", method: undefined, body: undefined },
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", method: "POST", body: JSON.stringify({ comment: "커뮤니티 루트", isSecret: false }) },
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", method: "POST", body: JSON.stringify({ comment: "커뮤니티 답글", parentId: 2101, isSecret: true }) },
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments/2201", method: "PUT", body: JSON.stringify({ comment: "커뮤니티 수정" }) },
{ path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments/2101", method: "DELETE", body: undefined },
]);
});
test("mock handlers page roots and direct replies, mutate both target types, and return null successes", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const audioRootsResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments?page=0&size=1");
const audioReplyCreateResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comment: "AI 답글", parentId: 1101, isSecret: false, languageCode: "ko" }) });
const audioRepliesResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1101/replies?page=0&size=20");
const communityUpdateResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments/2102", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comment: "수정한 AI 댓글" }) });
const communityDeleteResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments/2101", { method: "DELETE" });
const communityRootsResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments?page=0&size=20");
// Then
const listSchema = createApiResponseSchema(z.object({ totalCount: z.number().int(), items: z.array(z.object({ id: z.number().int(), comment: z.string(), replyCount: z.number().int() })) }));
expect(listSchema.parse(await audioRootsResponse.json()).data).toMatchObject({ totalCount: 2, items: [{ id: 1101 }] });
expect(createApiResponseSchema(z.null()).parse(await audioReplyCreateResponse.json()).data).toBeNull();
expect(listSchema.parse(await audioRepliesResponse.json()).data).toMatchObject({ totalCount: 3, items: expect.arrayContaining([expect.objectContaining({ comment: "AI 답글" })]) });
expect(createApiResponseSchema(z.null()).parse(await communityUpdateResponse.json()).data).toBeNull();
expect(createApiResponseSchema(z.null()).parse(await communityDeleteResponse.json()).data).toBeNull();
expect(listSchema.parse(await communityRootsResponse.json()).data?.items).toEqual(expect.not.arrayContaining([expect.objectContaining({ id: 2101 })]));
});
test("mock handlers reject replies to existing Audio and Community replies", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const audioResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment: "3단계 오디오 댓글", parentId: 1201, isSecret: false, languageCode: "ko" }),
});
const communityResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment: "3단계 커뮤니티 댓글", parentId: 2201, isSecret: false }),
});
// Then
expect(audioResponse.ok).toBe(false);
expect(communityResponse.ok).toBe(false);
});
test("mock handlers reject Audio and Community fan-authored comment updates", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const audioResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1201", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment: "팬 오디오 댓글 수정" }),
});
const communityResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments/2201", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment: "팬 커뮤니티 댓글 수정" }),
});
// Then
expect(audioResponse.ok).toBe(false);
expect(communityResponse.ok).toBe(false);
});
test("mock handlers preserve Audio and Community direct replies after root deletion", async () => {
// Given
server.use(...createMockHandlers(createMockStore(), apiBaseUrl));
// When
const audioDeleteResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1101", { method: "DELETE" });
const audioRootsResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments?page=0&size=20");
const audioRepliesResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1101/replies?page=0&size=20");
const communityDeleteResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments/2101", { method: "DELETE" });
const communityRootsResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments?page=0&size=20");
const communityRepliesResponse = await authorizedFetch("/api/v2/admin/ai-characters/101/community-posts/7001/comments/2101/replies?page=0&size=20");
// Then
const listSchema = createApiResponseSchema(z.object({ totalCount: z.number().int(), items: z.array(z.object({ id: z.number().int() })) }));
expect(createApiResponseSchema(z.null()).parse(await audioDeleteResponse.json()).data).toBeNull();
expect(listSchema.parse(await audioRootsResponse.json()).data?.items).toEqual(expect.not.arrayContaining([expect.objectContaining({ id: 1101 })]));
expect(listSchema.parse(await audioRepliesResponse.json()).data).toMatchObject({ totalCount: 2, items: [{ id: 1201 }, { id: 1202 }] });
expect(createApiResponseSchema(z.null()).parse(await communityDeleteResponse.json()).data).toBeNull();
expect(listSchema.parse(await communityRootsResponse.json()).data?.items).toEqual(expect.not.arrayContaining([expect.objectContaining({ id: 2101 })]));
expect(listSchema.parse(await communityRepliesResponse.json()).data).toMatchObject({ totalCount: 2, items: [{ id: 2201 }, { id: 2202 }] });
});
});

View File

@@ -0,0 +1,56 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { CommentThread } from "@/features/comments/components/CommentThread";
import type { CommentRecord, CommentTarget } from "@/features/comments/model/types";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
const target = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget;
const root = {
id: 1101,
writerId: 301,
nickname: "팬",
profileUrl: "https://cdn.example.com/fan.png",
comment: "팬 루트 댓글",
isSecret: false,
date: "2026-07-29T00:30:00Z",
replyCount: 0,
languageCode: "ko",
donationCan: 5,
} as const satisfies CommentRecord;
function getForm(control: HTMLElement): HTMLFormElement {
const form = control.closest("form");
if (form instanceof HTMLFormElement) {
return form;
}
throw new Error("expected form");
}
function createPendingClient(requests: ApiRequestOptions<unknown>[]): ApiClient {
return {
request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push(options as ApiRequestOptions<unknown>);
if (options.method === "POST") {
return new Promise<Data>(() => undefined);
}
return Promise.resolve(options.responseSchema.parse({ totalCount: 1, items: [root] }));
},
};
}
test("CommentThread shows saving status and sends one create request while pending", async () => {
const requests: ApiRequestOptions<unknown>[] = [];
render(<CommentThread apiClient={createPendingClient(requests)} target={target} />);
await screen.findByText("팬 루트 댓글");
const input = screen.getByLabelText("새 댓글");
fireEvent.change(input, { target: { value: "운영자 댓글" } });
fireEvent.submit(getForm(input));
fireEvent.submit(getForm(input));
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
expect(screen.getByRole("status")).toHaveTextContent("댓글을 저장하는 중");
expect(screen.getByRole("button", { name: "댓글 등록" })).toBeDisabled();
});

View File

@@ -0,0 +1,317 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { expect, test } from "vitest";
import { CommentThread } from "@/features/comments/components/CommentThread";
import type { CommentRecord, CommentTarget } from "@/features/comments/model/types";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
const target = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget;
const fanRoot = {
id: 1101,
writerId: 301,
nickname: "팬",
profileUrl: "https://cdn.example.com/fan.png",
comment: "팬 루트 댓글",
isSecret: false,
date: "2026-07-29T00:30:00Z",
replyCount: 1,
languageCode: "ko",
donationCan: 5,
} as const satisfies CommentRecord;
const aiRoot = {
id: 1102,
writerId: 101,
nickname: "루나",
profileUrl: "https://cdn.example.com/luna.png",
comment: "AI 루트 댓글",
isSecret: false,
date: "2026-07-29T01:00:00Z",
replyCount: 0,
languageCode: null,
donationCan: 0,
} as const satisfies CommentRecord;
const fanReply = { ...fanRoot, id: 1201, comment: "팬 답글", replyCount: 0 } satisfies CommentRecord;
const aiReply = { ...aiRoot, id: 1202, comment: "AI 답글", replyCount: 0 } satisfies CommentRecord;
const pagedReplies = Array.from({ length: 21 }, (_, index) => ({
...fanRoot,
id: 1300 + index,
comment: `팬 답글 ${index + 1}`,
replyCount: 0,
})) satisfies readonly CommentRecord[];
function createThreadClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === "POST" || options.method === "PUT" || options.method === "DELETE") {
return options.responseSchema.parse(null);
}
if (options.path.includes("/1101/replies")) {
return options.responseSchema.parse({ totalCount: 2, items: [fanReply, aiReply] });
}
return options.responseSchema.parse({ totalCount: 2, items: [fanRoot, aiRoot] });
},
};
}
function createPagedReplyClient(requests: CapturedRequest[]): ApiClient {
let deletedLastReply = false;
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === "DELETE") {
deletedLastReply = true;
return options.responseSchema.parse(null);
}
if (options.path.includes("/1101/replies?page=1&size=20")) {
if (deletedLastReply) {
return options.responseSchema.parse({ totalCount: 20, items: [] });
}
return options.responseSchema.parse({ totalCount: 21, items: [pagedReplies[20]] });
}
if (options.path.includes("/1101/replies")) {
return options.responseSchema.parse({ totalCount: 21, items: pagedReplies.slice(0, 20) });
}
return options.responseSchema.parse({ totalCount: 1, items: [fanRoot] });
},
};
}
function createEmptyRootClient(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({ totalCount: 0, items: [] });
},
};
}
function createCreateFailureThenSuccessClient(requests: CapturedRequest[]): ApiClient {
let postAttempts = 0;
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === "POST") {
postAttempts += 1;
if (postAttempts === 1 || postAttempts === 3) {
throw new Error("create failed");
}
return options.responseSchema.parse(null);
}
if (options.path.includes("/1101/replies")) {
return options.responseSchema.parse({ totalCount: 2, items: [fanReply, aiReply] });
}
return options.responseSchema.parse({ totalCount: 2, items: [fanRoot, aiRoot] });
},
};
}
function getFormForControl(control: HTMLElement): HTMLFormElement {
const form = control.closest("form");
if (form instanceof HTMLFormElement) {
return form;
}
throw new Error("expected parent form");
}
test("CommentThread links and focuses empty root and reply comment errors without sending mutations", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
expect(await screen.findByText("팬 루트 댓글")).toBeInTheDocument();
const rootInput = screen.getByLabelText("새 댓글");
// When
fireEvent.submit(getFormForControl(rootInput));
// Then
const rootError = await screen.findByRole("alert", { name: "새 댓글 오류" });
expect(rootError).toHaveAttribute("id", "comment-root-error");
expect(rootInput).toHaveAttribute("aria-describedby", "comment-root-error");
expect(rootInput).toHaveAttribute("aria-invalid", "true");
expect(rootInput).toHaveFocus();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
// When
fireEvent.click(screen.getByRole("button", { name: "팬 루트 댓글 답글 보기" }));
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
const replyInput = within(repliesRegion).getByLabelText("팬 루트 댓글에 답글");
fireEvent.submit(getFormForControl(replyInput));
// Then
const replyError = await within(repliesRegion).findByRole("alert", { name: "팬 루트 댓글에 답글 오류" });
expect(replyError).toHaveAttribute("id", "comment-reply-1101-error");
expect(replyInput).toHaveAttribute("aria-describedby", "comment-reply-1101-error");
expect(replyInput).toHaveAttribute("aria-invalid", "true");
expect(replyInput).toHaveFocus();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("CommentThread supports exactly two levels, AI-only edit, writer-independent delete, refetch, and UTC display", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
// Then
expect(await screen.findByText("팬 루트 댓글")).toBeInTheDocument();
expect(screen.getByText("2026. 07. 29. 09:30")).toBeInTheDocument();
expect(screen.getByText("총 2개")).toBeInTheDocument();
expect(screen.getByText("팬 루트 댓글").closest("article")).not.toHaveTextContent("수정");
expect(screen.getByText("AI 루트 댓글").closest("article")).toHaveTextContent("수정");
expect(screen.getAllByRole("button", { name: /삭제/ })).toHaveLength(2);
// When: create root
fireEvent.change(screen.getByLabelText("새 댓글"), { target: { value: "운영자 루트 댓글" } });
fireEvent.click(screen.getByRole("button", { name: "댓글 등록" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "운영자 루트 댓글", parentId: null, isSecret: false, languageCode: null }))).toBe(true));
// When: load replies and create direct reply
fireEvent.click(screen.getByRole("button", { name: "팬 루트 댓글 답글 보기" }));
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
expect(within(repliesRegion).getByText("AI 답글")).toBeInTheDocument();
expect(within(repliesRegion).queryByRole("button", { name: /AI 답글 답글/ })).not.toBeInTheDocument();
fireEvent.change(within(repliesRegion).getByLabelText("팬 루트 댓글에 답글"), { target: { value: "운영자 답글" } });
fireEvent.click(within(repliesRegion).getByRole("button", { name: "답글 등록" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "운영자 답글", parentId: 1101, isSecret: false, languageCode: null }))).toBe(true));
// When: edit AI-authored reply and delete fan-authored reply
fireEvent.click(within(repliesRegion).getByRole("button", { name: "AI 답글 수정" }));
fireEvent.change(within(repliesRegion).getByLabelText("댓글 수정 내용"), { target: { value: "AI 답글 수정" } });
fireEvent.click(within(repliesRegion).getByRole("button", { name: "수정 저장" }));
await waitFor(() => expect(requests.some((request) => request.method === "PUT" && request.path.endsWith("/comments/1202") && request.body === JSON.stringify({ comment: "AI 답글 수정" }))).toBe(true));
fireEvent.click(within(repliesRegion).getByRole("button", { name: "팬 답글 삭제" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "DELETE" && request.path.endsWith("/comments/1201") && request.body === undefined)).toBe(true));
expect(requests.filter((request) => request.path.endsWith("/comments/1201") && request.method === "PUT")).toHaveLength(0);
expect(requests.filter((request) => request.method === undefined && request.path.includes("comments?page=0&size=20")).length).toBeGreaterThan(1);
expect(requests.filter((request) => request.method === undefined && request.path.includes("/1101/replies?page=0&size=20")).length).toBeGreaterThan(1);
});
test("CommentThread pages direct replies and refetches the active reply page after deleting its last item", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createPagedReplyClient(requests)} target={target} />);
expect(await screen.findByText("팬 루트 댓글")).toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "팬 루트 댓글 답글 보기" }));
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
fireEvent.click(within(repliesRegion).getByRole("button", { name: "다음 페이지" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === undefined && request.path.includes("/1101/replies?page=1&size=20"))).toBe(true));
expect(await screen.findByText("팬 답글 21")).toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "팬 답글 21 삭제" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === undefined && request.path.includes("/1101/replies?page=1&size=20"))).toHaveLength(2));
await waitFor(() => expect(requests.filter((request) => request.method === undefined && request.path.includes("/1101/replies?page=0&size=20"))).toHaveLength(2));
expect(await screen.findByText("팬 답글 1")).toBeInTheDocument();
});
test("CommentThread keeps create drafts after failure and clears them only after retry success", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createCreateFailureThenSuccessClient(requests)} target={target} />);
expect(await screen.findByText("팬 루트 댓글")).toBeInTheDocument();
const rootInput = screen.getByLabelText("새 댓글");
fireEvent.change(rootInput, { target: { value: "실패 후 유지할 루트 댓글" } });
// When
fireEvent.submit(getFormForControl(rootInput));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("댓글을 처리하지 못했습니다.");
expect(rootInput).toHaveValue("실패 후 유지할 루트 댓글");
// When
fireEvent.submit(getFormForControl(rootInput));
// Then
await waitFor(() => expect(rootInput).toHaveValue(""));
expect(requests.filter((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "실패 후 유지할 루트 댓글", parentId: null, isSecret: false, languageCode: null }))).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "팬 루트 댓글 답글 보기" }));
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
const replyInput = within(repliesRegion).getByLabelText("팬 루트 댓글에 답글");
fireEvent.change(replyInput, { target: { value: "실패 후 유지할 답글" } });
fireEvent.submit(getFormForControl(replyInput));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("댓글을 처리하지 못했습니다.");
expect(replyInput).toHaveValue("실패 후 유지할 답글");
// When
fireEvent.submit(getFormForControl(replyInput));
// Then
await waitFor(() => expect(replyInput).toHaveValue(""));
expect(requests.filter((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "실패 후 유지할 답글", parentId: 1101, isSecret: false, languageCode: null }))).toHaveLength(2);
});
test("CommentThread keeps comments read-only when mutation is disabled", async () => {
// Given
const requests: CapturedRequest[] = [];
// When
render(<CommentThread apiClient={createThreadClient(requests)} canMutate={false} target={target} />);
// Then
expect(await screen.findByText("팬 루트 댓글")).toBeInTheDocument();
expect(screen.getByText("AI 루트 댓글")).toBeInTheDocument();
expect(screen.queryByLabelText("새 댓글")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "댓글 등록" })).not.toBeInTheDocument();
expect(screen.getByText("AI 루트 댓글").closest("article")).not.toHaveTextContent("수정");
expect(screen.queryByRole("button", { name: /삭제/ })).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "팬 루트 댓글 답글 보기" }));
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
// Then
expect(within(repliesRegion).getByText("팬 답글")).toBeInTheDocument();
expect(within(repliesRegion).getByText("AI 답글")).toBeInTheDocument();
expect(within(repliesRegion).queryByLabelText("팬 루트 댓글에 답글")).not.toBeInTheDocument();
expect(within(repliesRegion).queryByRole("button", { name: "답글 등록" })).not.toBeInTheDocument();
expect(within(repliesRegion).getByText("AI 답글").closest("article")).not.toHaveTextContent("수정");
expect(within(repliesRegion).queryByRole("button", { name: /삭제/ })).not.toBeInTheDocument();
});
test("CommentThread shows an explicit empty state while keeping the root create form", async () => {
// Given
const requests: CapturedRequest[] = [];
// When
render(<CommentThread apiClient={createEmptyRootClient(requests)} target={target} />);
// Then
expect(await screen.findByText("댓글이 없습니다")).toBeInTheDocument();
expect(screen.getByLabelText("새 댓글")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "댓글 등록" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "페이지" })).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,90 @@
import { z } from "zod";
import { fanTalkListResponseSchema } from "@/features/fan-talks/model/types";
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
import { fanTalkReplyCreateRequestSchema, fanTalkReplyResponseSchema, fanTalkReplyUpdateRequestSchema, fanTalkReplyUpdateResponseSchema } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
import type { FanTalkReplyCreateRequest, FanTalkReplyResponse, FanTalkReplyUpdateRequest, FanTalkReplyUpdateResponse } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
import type { ApiClient } from "@/shared/api/client";
import type { PageData } from "@/shared/api/pagination";
export type GetFanTalksParams = {
readonly characterId: string;
readonly page?: number;
readonly size?: number;
};
export type CreateFanTalkReplyParams = {
readonly characterId: string;
readonly fanTalkId: string;
readonly request: FanTalkReplyCreateRequest;
};
export type UpdateFanTalkReplyParams = {
readonly characterId: string;
readonly fanTalkId: string;
readonly replyId: string;
readonly request: FanTalkReplyUpdateRequest;
};
export type DeleteFanTalkParams = {
readonly characterId: string;
readonly fanTalkId: string;
};
function normalizePage(value: number | undefined): number {
return Math.max(0, Math.trunc(value ?? 0));
}
function normalizeSize(value: number | undefined): number {
return Math.min(Math.max(Math.trunc(value ?? 20), 20), 50);
}
export async function getFanTalks(apiClient: ApiClient, params: GetFanTalksParams): Promise<PageData<FanTalkListItem>> {
const page = normalizePage(params.page);
const size = normalizeSize(params.size);
const query = new URLSearchParams({ page: String(page), size: String(size) });
const response = await apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/fan-talks?${query.toString()}`,
responseSchema: fanTalkListResponseSchema,
authentication: "required",
});
return {
totalCount: response.fanTalkCount,
page: response.page,
size: response.size,
hasNext: response.hasNext,
items: response.fanTalks,
};
}
export function createFanTalkReply(apiClient: ApiClient, params: CreateFanTalkReplyParams): Promise<FanTalkReplyResponse> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/fan-talks/${encodeURIComponent(params.fanTalkId)}/replies`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fanTalkReplyCreateRequestSchema.parse(params.request)),
responseSchema: fanTalkReplyResponseSchema,
authentication: "required",
});
}
export function updateFanTalkReply(apiClient: ApiClient, params: UpdateFanTalkReplyParams): Promise<FanTalkReplyUpdateResponse> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/fan-talks/${encodeURIComponent(params.fanTalkId)}/replies/${encodeURIComponent(params.replyId)}`,
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fanTalkReplyUpdateRequestSchema.parse(params.request)),
responseSchema: fanTalkReplyUpdateResponseSchema,
authentication: "required",
});
}
export function deleteFanTalk(apiClient: ApiClient, params: DeleteFanTalkParams): Promise<null> {
return apiClient.request({
path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/fan-talks/${encodeURIComponent(params.fanTalkId)}`,
method: "DELETE",
responseSchema: z.null(),
authentication: "required",
});
}

View File

@@ -0,0 +1,44 @@
import { FanTalkListItem } from "@/features/fan-talks/components/FanTalkListItem";
import type { FanTalkListItem as FanTalkListItemData } from "@/features/fan-talks/model/types";
import type { PageData } from "@/shared/api/pagination";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
function getReplyActionLabel(fanTalk: FanTalkListItemData): string {
return fanTalk.creatorReplies.length === 0 ? "답변하기" : "답변 보기";
}
export function FanTalkList({ canReply = true, data, onOpen }: { readonly canReply?: boolean; readonly data: PageData<FanTalkListItemData>; readonly onOpen: (fanTalkId: 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">FanTalk</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((fanTalk) => {
const reply = fanTalk.creatorReplies[0] ?? null;
return (
<tr key={fanTalk.fanTalkId}>
<td className="border-b border-border px-4 py-3">
<p className="font-semibold">{fanTalk.content}</p>
<p className="mt-1 text-xs text-muted-foreground">{fanTalk.writerNickname} · {formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
</td>
<td className="border-b border-border px-4 py-3">{reply === null ? "미답변" : reply.content}</td>
<td className="border-b border-border px-4 py-3">
{canReply ? <button aria-label={`${fanTalk.content} ${getReplyActionLabel(fanTalk)}`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold whitespace-nowrap hover:bg-accent" onClick={() => onOpen(fanTalk.fanTalkId)} type="button">{getReplyActionLabel(fanTalk)}</button> : "읽기 전용"}
</td>
</tr>
);
})}
</tbody>
</table>
);
const mobile = <div className="grid gap-3 p-3">{data.items.map((fanTalk) => <FanTalkListItem canReply={canReply} fanTalk={fanTalk} key={fanTalk.fanTalkId} onOpen={onOpen} />)}</div>;
return <ResponsiveResourceList ariaLabel="FanTalk 목록" desktop={desktop} mobile={mobile} />;
}

View File

@@ -0,0 +1,22 @@
import type { FanTalkListItem as FanTalkListItemData } from "@/features/fan-talks/model/types";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
function getReplyActionLabel(fanTalk: FanTalkListItemData): string {
return fanTalk.creatorReplies.length === 0 ? "답변하기" : "답변 보기";
}
export function FanTalkListItem({ canReply = true, fanTalk, onOpen }: { readonly canReply?: boolean; readonly fanTalk: FanTalkListItemData; readonly onOpen: (fanTalkId: number) => void }) {
const reply = fanTalk.creatorReplies[0] ?? null;
return (
<article className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div className="min-w-0">
<p className="text-xs font-semibold text-info">{fanTalk.writerNickname}</p>
<p className="mt-1 line-clamp-3 break-words font-semibold">{fanTalk.content}</p>
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
</div>
{reply === null ? null : <p className="rounded-md border border-border bg-muted p-3 text-sm">: {reply.content}</p>}
{canReply ? <button aria-label={`${fanTalk.content} ${getReplyActionLabel(fanTalk)}`} className="min-h-11 rounded-md border border-input bg-card px-4 py-2 font-semibold whitespace-nowrap hover:bg-accent" onClick={() => onOpen(fanTalk.fanTalkId)} type="button">{getReplyActionLabel(fanTalk)}</button> : <p className="text-sm font-semibold text-muted-foreground"> </p>}
</article>
);
}

View File

@@ -0,0 +1,47 @@
import { useRef, useState } from "react";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
const errorId = "fan-talk-reply-error";
const formRef = useRef<HTMLFormElement>(null);
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
const visibleErrorMessage = contentErrorMessage ?? errorMessage;
function submit() {
if (content.trim().length === 0) {
setContentErrorMessage("답변 내용을 입력해 주세요.");
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
setContentErrorMessage(null);
onSubmit();
}
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
ref={formRef}
>
<label className="flex flex-col gap-2 text-sm font-semibold">
<textarea aria-describedby={visibleErrorMessage === null ? undefined : errorId} aria-invalid={visibleErrorMessage === null ? 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) => {
const nextContent = event.currentTarget.value;
if (nextContent.trim().length > 0) {
setContentErrorMessage(null);
}
onChange(nextContent);
}} value={content} />
</label>
{visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>}
<button 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)] disabled:opacity-60" disabled={isSaving} type="submit">
{submitLabel}
</button>
</form>
);
}

View File

@@ -0,0 +1,123 @@
import { useRef, useState } from "react";
import { createFanTalkReply, deleteFanTalk, updateFanTalkReply } from "@/features/fan-talks/api/fan-talk-api";
import { FanTalkReplyForm } from "@/features/fan-talks/components/FanTalkReplyForm";
import type { FanTalkCreatorReply, FanTalkListItem } from "@/features/fan-talks/model/types";
import type { FanTalkReplyResponse } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
function getMutationErrorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : "FanTalk 답변을 저장하지 못했습니다.";
}
function ReadonlyReply({ reply }: { readonly reply: FanTalkCreatorReply | FanTalkReplyResponse }) {
return (
<section className="rounded-lg border border-border bg-muted p-3" aria-label="등록된 답변">
<p className="break-words text-sm font-semibold">{reply.content}</p>
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(reply.createdAtUtc)}</p>
</section>
);
}
export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, onRefresh }: { readonly apiClient: ApiClient; readonly characterId: string; readonly fanTalk: FanTalkListItem; readonly onClose: () => void; readonly onRefresh: () => void }) {
const existingReply = fanTalk.creatorReplies[0] ?? null;
const [content, setContent] = useState(existingReply?.content ?? "");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [savedReply, setSavedReply] = useState<FanTalkReplyResponse | null>(null);
const [startedWithoutReply] = useState(existingReply === null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isSavingRef = useRef(false);
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const visibleReply = savedReply ?? existingReply;
const isEditing = !startedWithoutReply && existingReply !== null;
async function submitReply() {
if (isSavingRef.current) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
if (existingReply === null) {
const reply = await createFanTalkReply(apiClient, { characterId, fanTalkId: String(fanTalk.fanTalkId), request: { content } });
setSavedReply(reply);
setSuccessMessage("답변이 등록되었습니다.");
} else {
await updateFanTalkReply(apiClient, { characterId, fanTalkId: String(fanTalk.fanTalkId), replyId: String(existingReply.fanTalkId), request: { content } });
setSuccessMessage("답변이 수정되었습니다.");
}
onRefresh();
} catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error));
onRefresh();
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}
async function confirmDelete() {
if (isSavingRef.current) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
await deleteFanTalk(apiClient, { characterId, fanTalkId: String(fanTalk.fanTalkId) });
setIsDeleteDialogOpen(false);
onClose();
onRefresh();
} catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error));
onRefresh();
} finally {
isSavingRef.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="FanTalk 답변" 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">FANTALK</p>
<h2 className="text-xl font-semibold">FanTalk </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>
<section className="rounded-lg border border-border bg-card p-3" aria-label="FanTalk 원문">
<p className="text-xs font-semibold text-info">{fanTalk.writerNickname}</p>
<p className="mt-1 break-words text-sm">{fanTalk.content}</p>
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
</section>
{successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>}
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
{visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
<button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk </button>
</div>
<ConfirmDeactivateDialog confirmLabel="삭제 확인" errorMessage={errorMessage ?? undefined} impactDescription="팬 작성 FanTalk 원글을 목록에서 제외합니다. 연결된 답변 삭제는 가정하지 않습니다." isPending={isSaving} onCancel={() => setIsDeleteDialogOpen(false)} onConfirm={() => void confirmDelete()} open={isDeleteDialogOpen} targetName="FanTalk 원글" />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { z } from "zod";
export const fanTalkCreatorReplySchema = z.strictObject({
fanTalkId: z.number().int(),
writerId: z.number().int(),
writerNickname: z.string(),
writerProfileImageUrl: z.string(),
content: z.string(),
createdAtUtc: z.string(),
});
export const fanTalkListItemSchema = z.strictObject({
fanTalkId: z.number().int(),
writerId: z.number().int(),
writerNickname: z.string(),
writerProfileImageUrl: z.string(),
content: z.string(),
createdAtUtc: z.string(),
creatorReplies: z.array(fanTalkCreatorReplySchema),
});
export const fanTalkListResponseSchema = z.strictObject({
fanTalkCount: z.number().int(),
fanTalks: z.array(fanTalkListItemSchema),
page: z.number().int(),
size: z.number().int(),
hasNext: z.boolean(),
});
export type FanTalkCreatorReply = z.infer<typeof fanTalkCreatorReplySchema>;
export type FanTalkListItem = z.infer<typeof fanTalkListItemSchema>;
export type FanTalkListResponse = z.infer<typeof fanTalkListResponseSchema>;

View File

@@ -0,0 +1,101 @@
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 { getFanTalks } from "@/features/fan-talks/api/fan-talk-api";
import { FanTalkList } from "@/features/fan-talks/components/FanTalkList";
import { FanTalkReplySheet } from "@/features/fan-talks/components/FanTalkReplySheet";
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { PageData } from "@/shared/api/pagination";
import { PageState } from "@/shared/ui/page-state";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
type ListQuery = { readonly page: number; readonly size: number };
type ListState =
| { readonly requestKey: string; readonly status: "loading" }
| { readonly character: CharacterDetail; readonly data: PageData<FanTalkListItem>; 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.min(Math.max(Math.trunc(size), 20), 50) : 20,
};
}
function navigateList(characterId: string, query: ListQuery): void {
const nextQuery = new URLSearchParams({ page: String(query.page), size: String(query.size) });
navigateTo(`${routePaths.aiCharacterFanTalks(characterId)}?${nextQuery.toString()}`);
}
export function FanTalkListPage({ apiClient, characterId }: { readonly apiClient: ApiClient; readonly characterId: string }) {
const location = useBrowserLocation();
const query = getListQuery();
const [state, setState] = useState<ListState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
const [selectedFanTalkId, setSelectedFanTalkId] = useState<number | null>(null);
const requestKey = `${location.visitKey}:${characterId}:${query.page}:${query.size}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void Promise.all([
getCharacter(apiClient, characterId),
getFanTalks(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 : "FanTalk 목록을 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, query.page, query.size, requestKey, refreshKey]);
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="FanTalk 목록을 불러오는 중" />;
}
if (visibleState.status === "error") {
return <PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="FanTalk 목록 조회 실패" />;
}
const selectedFanTalk = visibleState.data.items.find((fanTalk) => fanTalk.fanTalkId === selectedFanTalkId) ?? null;
return (
<CharacterWorkspaceLayout activeTab="fanTalks" character={visibleState.character}>
<section className="flex flex-col gap-4" aria-labelledby="fan-talk-list-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">FANTALK</p>
<h2 className="text-2xl font-bold leading-tight" id="fan-talk-list-title">FanTalk</h2>
<p className="text-sm text-muted-foreground"> item에서 FanTalk에 .</p>
</div>
{visibleState.data.items.length === 0 ? <PageState description="현재 캐릭터에 연결된 FanTalk가 없습니다." state="empty" title="등록된 FanTalk가 없습니다." /> : null}
{visibleState.data.items.length > 0 ? <FanTalkList canReply={visibleState.character.isActive} data={visibleState.data} onOpen={setSelectedFanTalkId} /> : null}
<ResourcePagination data={visibleState.data} onPageChange={onPageChange} onSizeChange={onSizeChange} />
</section>
{selectedFanTalk === null ? null : (
<FanTalkReplySheet apiClient={apiClient} characterId={characterId} fanTalk={selectedFanTalk} onClose={() => setSelectedFanTalkId(null)} onRefresh={() => setRefreshKey((key) => key + 1)} />
)}
</CharacterWorkspaceLayout>
);
}

View File

@@ -0,0 +1,25 @@
import { z } from "zod";
import { fanTalkListItemSchema } from "@/features/fan-talks/model/types";
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
export const fanTalkReplyCreateRequestSchema = z.strictObject({
content: z.string(),
});
export const fanTalkReplyUpdateRequestSchema = fanTalkReplyCreateRequestSchema;
export const fanTalkReplyResponseSchema = z.strictObject({
fanTalkId: z.number().int(),
replyId: z.number().int(),
creatorMemberId: z.number().int(),
content: z.string(),
createdAtUtc: z.string(),
});
export const fanTalkReplyUpdateResponseSchema = fanTalkListItemSchema;
export type FanTalkReplyCreateRequest = z.infer<typeof fanTalkReplyCreateRequestSchema>;
export type FanTalkReplyUpdateRequest = z.infer<typeof fanTalkReplyUpdateRequestSchema>;
export type FanTalkReplyResponse = z.infer<typeof fanTalkReplyResponseSchema>;
export type FanTalkReplyUpdateResponse = FanTalkListItem;

View File

@@ -0,0 +1,147 @@
import { describe, expect, test } from "vitest";
import { createFanTalkReply, deleteFanTalk, getFanTalks, updateFanTalkReply } from "@/features/fan-talks/api/fan-talk-api";
import { fanTalkListResponseSchema } from "@/features/fan-talks/model/types";
import { fanTalkReplyCreateRequestSchema } from "@/features/fan-talks/schemas/fan-talk-reply-schema";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
readonly body?: BodyInit | null;
readonly method?: string;
readonly path: string;
};
const firstFanTalk = {
fanTalkId: 7002,
writerId: 4002,
writerNickname: "별팬",
writerProfileImageUrl: "https://cdn.example.com/fans/star.png",
content: "두 번째로 온 응원입니다.",
createdAtUtc: "2026-07-28T02:00:00Z",
creatorReplies: [],
} as const;
const secondFanTalk = {
fanTalkId: 7001,
writerId: 4001,
writerNickname: "달팬",
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
content: "첫 번째 응원입니다.",
createdAtUtc: "2026-07-28T01:00:00Z",
creatorReplies: [
{
fanTalkId: 7001,
writerId: 501,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "이미 답변한 내용입니다.",
createdAtUtc: "2026-07-28T03:00:00Z",
},
],
} as const;
const fanTalkReply = {
fanTalkId: 7002,
replyId: 9001,
creatorMemberId: 501,
content: "응원 고마워요.",
createdAtUtc: "2026-07-28T04:00:00Z",
} as const;
const fanTalkReplyUpdate = {
fanTalkId: 7102,
writerId: 501,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "수정한 답변입니다.",
createdAtUtc: "2026-07-28T05:00:00Z",
creatorReplies: [],
} as const;
function createCapturingClient(requests: CapturedRequest[]): ApiClient {
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === "POST") {
return options.responseSchema.parse(fanTalkReply);
}
if (options.method === "PUT") {
return options.responseSchema.parse(fanTalkReplyUpdate);
}
if (options.method === "DELETE") {
return options.responseSchema.parse(null);
}
return options.responseSchema.parse({
fanTalkCount: 2,
fanTalks: [firstFanTalk, secondFanTalk],
page: 2,
size: 50,
hasNext: true,
});
},
};
}
describe("FanTalk contract", () => {
test("GET sends page size only and maps list response to PageData preserving backend order", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const page = await getFanTalks(client, { characterId: "101", page: 2, size: 50 });
// Then
expect(page).toEqual({ totalCount: 2, page: 2, size: 50, hasNext: true, items: [firstFanTalk, secondFanTalk] });
expect(page.items.map((item) => item.fanTalkId)).toEqual([7002, 7001]);
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/fan-talks?page=2&size=50", method: undefined, body: undefined }]);
const url = new URL(requests[0]?.path ?? "", "https://api.example.com");
expect([...url.searchParams.keys()]).toEqual(["page", "size"]);
});
test("POST sends JSON content to replies endpoint and keeps response fields", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const reply = await createFanTalkReply(client, { characterId: "101", fanTalkId: "7002", request: { content: "응원 고마워요." } });
// Then
expect(reply).toEqual(fanTalkReply);
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/fan-talks/7002/replies", method: "POST", body: JSON.stringify({ content: "응원 고마워요." }) }]);
});
test("PUT sends JSON content only to the creator reply id from creatorReplies fanTalkId", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const reply = await updateFanTalkReply(client, { characterId: "101", fanTalkId: "7002", replyId: "7102", request: { content: "수정한 답변입니다." } });
// Then
expect(reply).toEqual(fanTalkReplyUpdate);
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/fan-talks/7002/replies/7102", method: "PUT", body: JSON.stringify({ content: "수정한 답변입니다." }) }]);
expect(requests[0]?.body).not.toBe(JSON.stringify({ content: "수정한 답변입니다.", isActive: true }));
});
test("DELETE sends no body to the root FanTalk endpoint", async () => {
// Given
const requests: CapturedRequest[] = [];
const client = createCapturingClient(requests);
// When
const result = await deleteFanTalk(client, { characterId: "101", fanTalkId: "7002" });
// Then
expect(result).toBeNull();
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/fan-talks/7002", method: "DELETE", body: undefined }]);
});
test("schemas reject extra response fields and uncontracted request fields", () => {
expect(() => fanTalkListResponseSchema.parse({ fanTalkCount: 1, fanTalks: [firstFanTalk], page: 0, size: 20, hasNext: false, sort: "createdAt" })).toThrow();
expect(() => fanTalkReplyCreateRequestSchema.parse({ content: "답변", fanTalkId: 7002 })).toThrow();
});
});

View File

@@ -0,0 +1,43 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { expect, test } from "vitest";
import { FanTalkReplySheet } from "@/features/fan-talks/components/FanTalkReplySheet";
import type { FanTalkListItem } from "@/features/fan-talks/model/types";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
const fanTalk = {
fanTalkId: 7001,
writerId: 4001,
writerNickname: "달팬",
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
content: "첫 번째 응원입니다.",
createdAtUtc: "2026-07-28T01:00:00Z",
creatorReplies: [],
} as const satisfies FanTalkListItem;
function createRejectingClient(requests: ApiRequestOptions<unknown>[]): ApiClient {
return {
request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push(options as ApiRequestOptions<unknown>);
return Promise.reject(new ApiError({ message: "삭제 실패", errorProperty: "serverError", status: 500 }));
},
};
}
test("FanTalk unanswered root delete failure keeps the confirm dialog open for retry", async () => {
const requests: ApiRequestOptions<unknown>[] = [];
render(<FanTalkReplySheet apiClient={createRejectingClient(requests)} characterId="101" fanTalk={fanTalk} onClose={() => undefined} onRefresh={() => undefined} />);
fireEvent.click(screen.getByRole("button", { name: "FanTalk 원글 삭제" }));
const confirmDialog = screen.getByRole("alertdialog", { name: "FanTalk 원글 비활성화 확인" });
const confirmButton = within(confirmDialog).getByRole("button", { name: "삭제 확인" });
fireEvent.click(confirmButton);
await waitFor(() => expect(requests.filter((request) => request.method === "DELETE")).toHaveLength(1));
expect(await within(confirmDialog).findByRole("alert")).toHaveTextContent("삭제 실패");
expect(confirmButton).not.toBeDisabled();
fireEvent.click(confirmButton);
await waitFor(() => expect(requests.filter((request) => request.method === "DELETE")).toHaveLength(2));
});

View File

@@ -0,0 +1,181 @@
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 answeredFanTalk = {
fanTalkId: 7002,
writerId: 4002,
writerNickname: "별팬",
writerProfileImageUrl: "https://cdn.example.com/fans/star.png",
content: "두 번째로 온 응원입니다.",
createdAtUtc: "2026-07-28T02:00:00Z",
creatorReplies: [
{
fanTalkId: 7002,
writerId: 501,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "이미 답변했습니다.",
createdAtUtc: "2026-07-28T03:00:00Z",
},
],
} as const;
const pendingFanTalk = {
fanTalkId: 7001,
writerId: 4001,
writerNickname: "달팬",
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
content: "첫 번째 응원입니다.",
createdAtUtc: "2026-07-28T01:00:00Z",
creatorReplies: [],
} as const;
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("FanTalk list ignores uncontracted query keys and renders loading empty error retry", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const fanTalkRequests: 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/fan-talks`, ({ request }) => {
fanTalkRequests.push(request);
listRequestCount += 1;
if (listRequestCount === 1) {
return new Promise((resolve) => {
resolveFirstListReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { fanTalkCount: 0, fanTalks: [], page: 0, size: 20, hasNext: false }, errorProperty: null })));
});
}
if (listRequestCount === 2) {
return HttpResponse.json({ success: false, message: "FanTalk 목록 실패", data: null, errorProperty: null }, { status: 500 });
}
return HttpResponse.json({ success: true, message: null, data: { fanTalkCount: 2, fanTalks: [answeredFanTalk, pendingFanTalk], page: 1, size: 20, hasNext: false }, errorProperty: null });
}),
);
window.history.pushState({}, "", "/ai-characters/101/fan-talks?page=0&size=20&search=x&status=y&sort=z");
// When
render(<App />);
// Then
expect(await screen.findByText("FanTalk 목록을 불러오는 중")).toBeInTheDocument();
const finishFirstList = await firstListReady;
finishFirstList();
expect(await screen.findByText("등록된 FanTalk가 없습니다.")).toBeInTheDocument();
const firstUrl = new URL(fanTalkRequests[0]?.url ?? "");
expect(firstUrl.pathname).toBe("/api/v2/admin/ai-characters/101/fan-talks");
expect(firstUrl.search).toBe("?page=0&size=20");
expect([...firstUrl.searchParams.keys()]).toEqual(["page", "size"]);
expect(screen.queryByRole("searchbox", { name: "검색어" })).not.toBeInTheDocument();
// When
act(() => {
window.history.pushState({}, "", "/ai-characters/101/fan-talks?page=1&size=20");
window.dispatchEvent(new PopStateEvent("popstate"));
});
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("FanTalk 목록 실패");
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByRole("heading", { name: "FanTalk" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "FanTalk" })).toHaveAttribute("aria-current", "page");
expect(screen.getAllByText("두 번째로 온 응원입니다.")[0]?.compareDocumentPosition(screen.getAllByText("첫 번째 응원입니다.")[0] ?? document.body)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(document.body).toHaveTextContent("2026. 07. 28. 11:00");
expect(document.body).toHaveTextContent("2026. 07. 28. 10:00");
expect(document.body).not.toHaveTextContent("2026-07-28T02:00:00Z");
expect(document.body).not.toHaveTextContent("2026-07-28T01:00:00Z");
expect(screen.queryByRole("button", { name: /수정/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /삭제/ })).not.toBeInTheDocument();
});
test("FanTalk detail path is not an app route and does not call detail GET", 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/fan-talks/:fanTalkId`, ({ 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/fan-talks/7001");
// When
render(<App />);
// Then
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(detailRequests).toHaveLength(0);
});
test("Inactive character FanTalk list hides reply actions", 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: {
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,
},
errorProperty: null,
}),
),
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/202/fan-talks`, () =>
HttpResponse.json({ success: true, message: null, data: { fanTalkCount: 1, fanTalks: [pendingFanTalk], page: 0, size: 20, hasNext: false }, errorProperty: null }),
),
);
window.history.pushState({}, "", "/ai-characters/202/fan-talks");
// When
render(<App />);
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("비활성 캐릭터는 읽기 전용입니다.");
expect(screen.queryByRole("button", { name: /답변하기/ })).not.toBeInTheDocument();
expect(screen.getAllByText("읽기 전용")).toHaveLength(2);
});

View File

@@ -0,0 +1,299 @@
import { 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 { server } from "@/shared/test/server";
type CreatorReply = {
readonly fanTalkId: number;
readonly writerId: number;
readonly writerNickname: string;
readonly writerProfileImageUrl: string;
readonly content: string;
readonly createdAtUtc: string;
};
type FanTalkFixture = {
readonly fanTalkId: number;
readonly writerId: number;
readonly writerNickname: string;
readonly writerProfileImageUrl: string;
readonly content: string;
readonly createdAtUtc: string;
readonly creatorReplies: readonly CreatorReply[];
};
const existingReply = {
fanTalkId: 7102,
writerId: 501,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "이미 답변했습니다.",
createdAtUtc: "2026-07-28T03:00:00Z",
} as const;
const pendingFanTalk = {
fanTalkId: 7001,
writerId: 4001,
writerNickname: "달팬",
writerProfileImageUrl: "https://cdn.example.com/fans/moon.png",
content: "첫 번째 응원입니다.",
createdAtUtc: "2026-07-28T01:00:00Z",
creatorReplies: [],
} as const;
const answeredFanTalk = {
fanTalkId: 7002,
writerId: 4002,
writerNickname: "별팬",
writerProfileImageUrl: "https://cdn.example.com/fans/star.png",
content: "두 번째로 온 응원입니다.",
createdAtUtc: "2026-07-28T02:00:00Z",
creatorReplies: [existingReply],
} as const;
const replyResponse = {
fanTalkId: 7001,
replyId: 9001,
creatorMemberId: 501,
content: "응원 고마워요.",
createdAtUtc: "2026-07-28T04:00:00Z",
} as const;
const updateReplyResponse = {
fanTalkId: 7102,
writerId: 501,
writerNickname: "루나",
writerProfileImageUrl: "https://cdn.example.com/characters/luna.png",
content: "수정한 답변입니다.",
createdAtUtc: "2026-07-28T05:00:00Z",
creatorReplies: [],
} as const;
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
function installFanTalkHandlers(options: { readonly failDelete?: boolean; readonly failPost?: boolean; readonly failPut?: boolean; readonly requests: Request[]; readonly state: { items: readonly FanTalkFixture[] } }) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101/fan-talks`, ({ request }) => {
options.requests.push(request);
return HttpResponse.json({ success: true, message: null, data: { fanTalkCount: options.state.items.length, fanTalks: options.state.items, page: 0, size: 20, hasNext: false }, errorProperty: null });
}),
http.post(`${apiBaseUrl}/api/v2/admin/ai-characters/101/fan-talks/7001/replies`, async ({ request }) => {
options.requests.push(request);
if (options.failPost === true) {
options.state.items = [
{ ...pendingFanTalk, creatorReplies: [{ ...existingReply, fanTalkId: 7101, content: "서버에서 이미 저장된 답변입니다." }] },
answeredFanTalk,
];
return HttpResponse.json({ success: false, message: "저장 실패", data: null, errorProperty: "serverError" }, { status: 500 });
}
const body: unknown = await request.json();
expect(body).toEqual({ content: "응원 고마워요." });
options.state.items = [
{ ...pendingFanTalk, creatorReplies: [{ fanTalkId: 7001, writerId: 501, writerNickname: "루나", writerProfileImageUrl: "https://cdn.example.com/characters/luna.png", content: replyResponse.content, createdAtUtc: replyResponse.createdAtUtc }] },
answeredFanTalk,
];
return HttpResponse.json({ success: true, message: null, data: replyResponse, errorProperty: null });
}),
http.put(`${apiBaseUrl}/api/v2/admin/ai-characters/101/fan-talks/7002/replies/7102`, async ({ request }) => {
options.requests.push(request);
if (options.failPut === true) {
return HttpResponse.json({ success: false, message: "수정 실패", data: null, errorProperty: "serverError" }, { status: 500 });
}
const body: unknown = await request.json();
expect(body).toEqual({ content: "수정한 답변입니다." });
options.state.items = [
pendingFanTalk,
{ ...answeredFanTalk, creatorReplies: [{ ...existingReply, content: updateReplyResponse.content, createdAtUtc: updateReplyResponse.createdAtUtc }] },
];
return HttpResponse.json({ success: true, message: null, data: updateReplyResponse, errorProperty: null });
}),
http.delete(`${apiBaseUrl}/api/v2/admin/ai-characters/101/fan-talks/7002`, async ({ request }) => {
options.requests.push(request);
expect(await request.text()).toBe("");
if (options.failDelete === true) {
return HttpResponse.json({ success: false, message: "삭제 실패", data: null, errorProperty: "serverError" }, { status: 500 });
}
options.state.items = [pendingFanTalk];
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
}),
);
}
async function renderFanTalkPage(state: { items: readonly FanTalkFixture[] }, requests: Request[], options: { readonly failDelete?: boolean; readonly failPost?: boolean; readonly failPut?: boolean } = {}) {
installFanTalkHandlers({ ...options, requests, state });
window.history.pushState({}, "", "/ai-characters/101/fan-talks?page=0&size=20");
render(<App />);
expect(await screen.findByRole("heading", { name: "FanTalk" })).toBeInTheDocument();
}
function clickFirstButton(name: string) {
const button = screen.getAllByRole("button", { name })[0];
if (button === undefined) {
throw new Error(`expected button: ${name}`);
}
fireEvent.click(button);
}
test("FanTalk reply form links and focuses the content error when empty content is submitted", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests);
clickFirstButton("첫 번째 응원입니다. 답변하기");
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
const contentInput = within(dialog).getByLabelText("답변 내용");
// When
fireEvent.click(within(dialog).getByRole("button", { name: "답변 등록" }));
// Then
const error = await within(dialog).findByRole("alert");
expect(error).toHaveAttribute("id", "fan-talk-reply-error");
expect(contentInput).toHaveAttribute("aria-describedby", "fan-talk-reply-error");
expect(contentInput).toHaveAttribute("aria-invalid", "true");
expect(contentInput).toHaveFocus();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("FanTalk reply form creates unanswered items with POST and edits existing replies with PUT", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests);
// When
clickFirstButton("첫 번째 응원입니다. 답변하기");
// Then
const replyDialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
expect(within(replyDialog).getByLabelText("답변 내용")).toBeInTheDocument();
expect(within(replyDialog).getByRole("button", { name: "답변 등록" })).toBeInTheDocument();
// When
fireEvent.click(within(replyDialog).getByRole("button", { name: "닫기" }));
clickFirstButton("두 번째로 온 응원입니다. 답변 보기");
// Then
const readonlyDialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
expect(readonlyDialog).toHaveTextContent("이미 답변했습니다.");
expect(within(readonlyDialog).getByLabelText("답변 내용")).toHaveValue("이미 답변했습니다.");
// When
fireEvent.change(within(readonlyDialog).getByLabelText("답변 내용"), { target: { value: "수정한 답변입니다." } });
fireEvent.click(within(readonlyDialog).getByRole("button", { name: "답변 수정" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
const putRequest = requests.find((request) => request.method === "PUT");
expect(putRequest?.url).toContain("/fan-talks/7002/replies/7102");
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
const updateStatus = await within(readonlyDialog).findByRole("status", { name: "답변 저장 성공" });
expect(updateStatus).toHaveTextContent("답변이 수정되었습니다.");
expect(updateStatus).not.toHaveTextContent("reply");
expect(updateStatus).not.toHaveTextContent("2026-07-28T05:00:00Z");
await waitFor(() => expect(requests.filter((request) => request.method === "GET" && new URL(request.url).search === "?page=0&size=20").length).toBeGreaterThanOrEqual(2));
expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument();
});
test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests);
clickFirstButton("첫 번째 응원입니다. 답변하기");
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
expect(dialog).toHaveTextContent("2026. 07. 28. 10:00");
expect(within(dialog).queryByText("2026-07-28T01:00:00Z")).not.toBeInTheDocument();
// When
fireEvent.change(within(dialog).getByLabelText("답변 내용"), { target: { value: "응원 고마워요." } });
const submitButton = within(dialog).getByRole("button", { name: "답변 등록" });
fireEvent.click(submitButton);
fireEvent.click(submitButton);
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
const createStatus = await within(dialog).findByRole("status", { name: "답변 저장 성공" });
expect(createStatus).toHaveTextContent("답변이 등록되었습니다.");
expect(createStatus).not.toHaveTextContent("fanTalk");
expect(createStatus).not.toHaveTextContent("reply");
expect(createStatus).not.toHaveTextContent("creator");
expect(createStatus).not.toHaveTextContent("2026-07-28T04:00:00Z");
expect(screen.getByRole("dialog", { name: "FanTalk 답변" })).toHaveTextContent("응원 고마워요.");
expect(dialog).toHaveTextContent("2026. 07. 28. 13:00");
expect(within(dialog).queryByText("2026-07-28T04:00:00Z")).not.toBeInTheDocument();
expect(screen.queryByLabelText("답변 내용")).not.toBeInTheDocument();
await waitFor(() => expect(requests.filter((request) => request.method === "GET" && new URL(request.url).search === "?page=0&size=20").length).toBeGreaterThanOrEqual(2));
});
test("FanTalk reply generic server error shows server message, refetches current page, and does not branch by status or key", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests, { failPost: true });
clickFirstButton("첫 번째 응원입니다. 답변하기");
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
// When
fireEvent.change(within(dialog).getByLabelText("답변 내용"), { target: { value: "응원 고마워요." } });
fireEvent.click(within(dialog).getByRole("button", { name: "답변 등록" }));
// Then
expect(await within(dialog).findByRole("alert")).toHaveTextContent("저장 실패");
expect(screen.queryByText("serverError")).not.toBeInTheDocument();
await waitFor(() => expect(requests.filter((request) => request.method === "GET" && new URL(request.url).search === "?page=0&size=20").length).toBeGreaterThanOrEqual(2));
await screen.findAllByText("서버에서 이미 저장된 답변입니다.");
expect(screen.queryByLabelText("답변 내용")).not.toBeInTheDocument();
});
test("FanTalk root delete sends no body, closes the sheet, and refetches current page", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests);
clickFirstButton("두 번째로 온 응원입니다. 답변 보기");
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
// When
fireEvent.click(within(dialog).getByRole("button", { name: "FanTalk 원글 삭제" }));
fireEvent.click(screen.getByRole("button", { name: "삭제 확인" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "DELETE")).toHaveLength(1));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "FanTalk 답변" })).not.toBeInTheDocument());
await waitFor(() => expect(requests.filter((request) => request.method === "GET" && new URL(request.url).search === "?page=0&size=20").length).toBeGreaterThanOrEqual(2));
expect(screen.queryByText("두 번째로 온 응원입니다.")).not.toBeInTheDocument();
});