Files
voiceon-character-admin/src/features/characters/tests/CharacterDetailPage.test.tsx

155 lines
5.4 KiB
TypeScript

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<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.path === "/api/v2/admin/ai-characters/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<unknown> {
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(<CharacterDetailPage apiClient={createDetailClient(requests)} characterId="101" />);
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<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
requests.push({ body: options.body, method: options.method, path: options.path });
if (options.method === undefined) {
return options.responseSchema.parse(activeCharacter);
}
await new Promise<void>((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); });
return options.responseSchema.parse(null);
},
};
window.history.pushState({}, "", "/ai-characters/101");
render(<CharacterDetailPage apiClient={client} characterId="101" />);
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(<CharacterDetailPage apiClient={createDetailClient(requests)} characterId="101" />);
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(<CharacterDetailPage apiClient={createDetailClient(requests)} characterId="101" />);
// 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();
});