feat(ai-character): 댓글과 팬톡 운영 기능 구현
This commit is contained in:
103
src/features/comments/api/comment-api.ts
Normal file
103
src/features/comments/api/comment-api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
47
src/features/comments/components/CommentForm.tsx
Normal file
47
src/features/comments/components/CommentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/features/comments/components/CommentItem.tsx
Normal file
47
src/features/comments/components/CommentItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/features/comments/components/CommentThread.tsx
Normal file
182
src/features/comments/components/CommentThread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/features/comments/model/types.ts
Normal file
48
src/features/comments/model/types.ts
Normal 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>;
|
||||
200
src/features/comments/tests/comment-contract.test.ts
Normal file
200
src/features/comments/tests/comment-contract.test.ts
Normal 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 }] });
|
||||
});
|
||||
});
|
||||
56
src/features/comments/tests/comment-thread-pending.test.tsx
Normal file
56
src/features/comments/tests/comment-thread-pending.test.tsx
Normal 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();
|
||||
});
|
||||
317
src/features/comments/tests/comment-thread.test.tsx
Normal file
317
src/features/comments/tests/comment-thread.test.tsx
Normal 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();
|
||||
});
|
||||
90
src/features/fan-talks/api/fan-talk-api.ts
Normal file
90
src/features/fan-talks/api/fan-talk-api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
44
src/features/fan-talks/components/FanTalkList.tsx
Normal file
44
src/features/fan-talks/components/FanTalkList.tsx
Normal 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} />;
|
||||
}
|
||||
22
src/features/fan-talks/components/FanTalkListItem.tsx
Normal file
22
src/features/fan-talks/components/FanTalkListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/features/fan-talks/components/FanTalkReplyForm.tsx
Normal file
47
src/features/fan-talks/components/FanTalkReplyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/features/fan-talks/components/FanTalkReplySheet.tsx
Normal file
123
src/features/fan-talks/components/FanTalkReplySheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/features/fan-talks/model/types.ts
Normal file
32
src/features/fan-talks/model/types.ts
Normal 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>;
|
||||
101
src/features/fan-talks/pages/FanTalkListPage.tsx
Normal file
101
src/features/fan-talks/pages/FanTalkListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/features/fan-talks/schemas/fan-talk-reply-schema.ts
Normal file
25
src/features/fan-talks/schemas/fan-talk-reply-schema.ts
Normal 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;
|
||||
147
src/features/fan-talks/tests/fan-talk-contract.test.ts
Normal file
147
src/features/fan-talks/tests/fan-talk-contract.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
181
src/features/fan-talks/tests/fan-talk-list.test.tsx
Normal file
181
src/features/fan-talks/tests/fan-talk-list.test.tsx
Normal 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);
|
||||
});
|
||||
299
src/features/fan-talks/tests/fan-talk-reply.test.tsx
Normal file
299
src/features/fan-talks/tests/fan-talk-reply.test.tsx
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user