feat(ai-character): 캐릭터 관리 기능 구현
This commit is contained in:
154
src/features/characters/tests/CharacterDetailPage.test.tsx
Normal file
154
src/features/characters/tests/CharacterDetailPage.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user