import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { expect, test } from "vitest"; import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage"; import type { CharacterDetail } from "@/features/characters/model/types"; import type { ApiClient, ApiRequestOptions } from "@/shared/api/client"; type CapturedRequest = { readonly body?: BodyInit | null; readonly method?: string; readonly path: string; }; const activeCharacter: CharacterDetail = { id: 101, characterUUID: "character-101", name: "루나", imageUrl: null, description: "차분한 상담형 캐릭터", systemPrompt: "친절하게 답한다.", characterType: "Character", age: null, gender: null, mbti: null, speechPattern: null, speechStyle: null, appearance: null, region: "KR", isActive: true, tags: ["상담"], hobbies: [], values: [], goals: [], relationships: [], personalities: [], backgrounds: [], memories: [], originalWork: null, }; function createDetailClient(requests: CapturedRequest[]): ApiClient { return { async request(options: ApiRequestOptions): Promise { requests.push({ body: options.body, method: options.method, path: options.path }); if (options.path === "/api/v2/admin/ai-characters/101" && options.method === undefined) { return options.responseSchema.parse(activeCharacter); } return options.responseSchema.parse(null); }, }; } function requireFormData(body: BodyInit | null | undefined): FormData { if (body instanceof FormData) { return body; } throw new TypeError("Expected FormData body"); } async function readJsonPart(part: FormDataEntryValue | null): Promise { if (part instanceof Blob) { return JSON.parse(await part.text()); } if (typeof part === "string") { return JSON.parse(part); } return null; } test("CharacterDetailPage soft deletes an active character after confirmation", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101"); // When render(); fireEvent.click(await screen.findByRole("button", { name: "비활성화" })); // Then const dialog = screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }); expect(dialog).toHaveTextContent("복원은 지원하지 않습니다."); expect(dialog).toHaveTextContent("완전 삭제는 아니며 목록에서 제외됩니다."); expect(dialog).not.toHaveTextContent("hard delete"); // When fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" })); // Then await waitFor(() => expect(window.location.pathname).toBe("/ai-characters")); expect(requests).toMatchObject([ { path: "/api/v2/admin/ai-characters/101", method: undefined }, { path: "/api/v2/admin/ai-characters/101", method: "PUT" }, ]); expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false }); }); test("CharacterDetailPage sends one deactivate request while pending and shows retry guidance on failure", async () => { // Given const requests: CapturedRequest[] = []; let rejectDeactivate: () => void = () => { throw new Error("Deactivate request was not started"); }; const client: ApiClient = { async request(options: ApiRequestOptions): Promise { requests.push({ body: options.body, method: options.method, path: options.path }); if (options.method === undefined) { return options.responseSchema.parse(activeCharacter); } await new Promise((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); }); return options.responseSchema.parse(null); }, }; window.history.pushState({}, "", "/ai-characters/101"); render(); fireEvent.click(await screen.findByRole("button", { name: "비활성화" })); // When fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" })); fireEvent.click(screen.getByRole("button", { name: "처리 중" })); rejectDeactivate(); // Then expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1); expect(await screen.findByRole("alert")).toHaveTextContent("비활성화하지 못했습니다. 다시 시도하세요."); }); test("CharacterDetailPage routes active characters to edit", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101"); // When render(); fireEvent.click(await screen.findByRole("button", { name: "수정" })); // Then expect(window.location.pathname).toBe("/ai-characters/101/edit"); }); test("CharacterDetailPage shows characterUUID as read-only profile data", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101"); // When render(); // Then const profile = await screen.findByRole("region", { name: "프로필" }); expect(within(profile).getByText("캐릭터 UUID")).toBeInTheDocument(); expect(within(profile).getByText("character-101")).toBeInTheDocument(); expect(screen.queryByText("externalCharacterId")).not.toBeInTheDocument(); });