155 lines
5.4 KiB
TypeScript
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();
|
|
});
|