import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage"; import type { CharacterDetail } from "@/features/characters/model/types"; import { ApiError } from "@/shared/api/api-error"; import type { ApiClient, ApiRequestOptions } from "@/shared/api/client"; import type { CropRenderRequest } from "@/shared/lib/crop-image"; import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; // allow: SIZE_OK — P3-R1 create/update optional contract matrix is intentionally kept in one page test. 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, }; const characterWithOptionalFields: CharacterDetail = { ...activeCharacter, age: 24, gender: "여성", mbti: "INFJ", speechPattern: "존댓말", speechStyle: "부드러움", appearance: "긴 머리", characterType: "Character", tags: ["상담", "힐링"], hobbies: ["독서", "산책"], values: ["안전"], goals: ["도움"], relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }], personalities: [{ trait: "차분함", description: "침착하게 응대" }], backgrounds: [{ topic: "출신", description: "달빛 상담소" }], memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }], }; const inactiveCharacter: CharacterDetail = { ...activeCharacter, isActive: false, }; const activeCharacterOptionalPayload = { age: null, gender: null, mbti: null, speechPattern: null, speechStyle: null, appearance: null, characterType: "Character", tags: null, hobbies: null, values: null, goals: null, relationships: null, personalities: null, backgrounds: null, memories: null, } as const; function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): 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(character); } return options.responseSchema.parse(null); }, }; } function createRejectOnceEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient { let submitCount = 0; 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(character); } submitCount += 1; if (submitCount === 1) { throw new ApiError({ status: 500, message: "서버에서 캐릭터 저장을 거부했습니다.", errorProperty: null }); } return options.responseSchema.parse(null); }, }; } function createOriginalWorkEditClient(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); } if (options.path.startsWith("/api/v2/admin/ai-characters/original-works/search")) { return options.responseSchema.parse([ { id: 8, title: "별빛 기록실", contentType: "NOVEL", category: "드라마", isAdult: false, description: "기록형 원작", originalWork: null, originalLink: null, writer: "이든", studio: "소다스튜디오", originalLinks: [], tags: ["기록"], imageUrl: null, }, ]); } 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; } function requireFile(part: FormDataEntryValue | null): File { if (part instanceof File) { return part; } throw new TypeError("Expected File part"); } function createCropSource(file: File): Promise { return Promise.resolve({ file, height: 1200, previewUrl: `blob:${file.name}`, width: 1200 }); } function renderOriginalCrop(request: CropRenderRequest): Promise { return Promise.resolve(request.file); } test("CharacterEditPage submits changed fields without region or active state", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } }); fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } }); fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } }); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101")); 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({ ...activeCharacterOptionalPayload, name: "루나 수정", systemPrompt: "짧고 안전하게 답한다.", description: "업데이트된 상담형 캐릭터", }); }); test("CharacterEditPage blocks the direct edit route for inactive characters", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When await screen.findByText("비활성화된 AI 캐릭터는 수정할 수 없습니다."); // Then expect(screen.queryByRole("form", { name: "AI 캐릭터 수정 form" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "저장" })).not.toBeInTheDocument(); expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]); }); test("CharacterEditPage links required errors and focuses the first invalid field", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("이름"), { target: { value: " " } }); fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: " " } }); fireEvent.change(screen.getByLabelText("설명"), { target: { value: " " } }); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then const nameInput = screen.getByLabelText("이름"); const systemPromptInput = screen.getByLabelText("시스템 프롬프트"); const descriptionInput = screen.getByLabelText("설명"); expect(await screen.findByText("이름을 입력하세요.")).toHaveAttribute("id", "character-edit-name-error"); expect(screen.getByText("시스템 프롬프트를 입력하세요.")).toHaveAttribute("id", "character-edit-system-prompt-error"); expect(screen.getByText("설명을 입력하세요.")).toHaveAttribute("id", "character-edit-description-error"); expect(nameInput).toHaveAttribute("aria-describedby", "character-edit-name-error"); expect(systemPromptInput).toHaveAttribute("aria-describedby", "character-edit-system-prompt-error"); expect(descriptionInput).toHaveAttribute("aria-describedby", "character-edit-description-error"); expect(nameInput).toHaveAttribute("aria-invalid", "true"); expect(systemPromptInput).toHaveAttribute("aria-invalid", "true"); expect(descriptionInput).toHaveAttribute("aria-invalid", "true"); expect(nameInput).toHaveFocus(); expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]); }); test("CharacterEditPage keeps entered values and retries after an ApiError rejection", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } }); fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } }); fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } }); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then expect(await screen.findByText("서버에서 캐릭터 저장을 거부했습니다.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(); expect(screen.getByLabelText("이름")).toHaveValue("루나 수정"); expect(screen.getByLabelText("시스템 프롬프트")).toHaveValue("짧고 안전하게 답한다."); expect(screen.getByLabelText("설명")).toHaveValue("업데이트된 상담형 캐릭터"); // When fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101")); expect(requests).toHaveLength(3); }); test("CharacterEditPage submits optional scalar and array fields without region", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("원작 제목"), { target: { value: " 달빛 상담소 " } }); fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: " https://example.com/original " } }); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(requests).toHaveLength(2)); expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나", systemPrompt: "친절하게 답한다.", description: "차분한 상담형 캐릭터", age: "24", gender: "여성", mbti: "INFJ", speechPattern: "존댓말", speechStyle: "부드러움", appearance: "긴 머리", originalTitle: "달빛 상담소", originalLink: "https://example.com/original", characterType: "Character", tags: ["상담", "힐링"], hobbies: ["독서", "산책"], values: ["안전"], goals: ["도움"], relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }], personalities: [{ trait: "차분함", description: "침착하게 응대" }], backgrounds: [{ topic: "출신", description: "달빛 상담소" }], memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }], }); }); test("CharacterEditPage serializes cleared optional scalar and array fields as null", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("나이"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("성별"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("MBTI"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("말투 패턴"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("말투 스타일"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("외형"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("원작 제목"), { target: { value: "임시 원작" } }); fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: "https://example.com/temp" } }); fireEvent.change(screen.getByLabelText("원작 제목"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("캐릭터 유형"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("태그"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("취미"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("가치관"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("목표"), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "관계 삭제" })); fireEvent.click(screen.getByRole("button", { name: "성격 삭제" })); fireEvent.click(screen.getByRole("button", { name: "배경 삭제" })); fireEvent.click(screen.getByRole("button", { name: "기억 삭제" })); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(requests).toHaveLength(2)); expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나", systemPrompt: "친절하게 답한다.", description: "차분한 상담형 캐릭터", age: null, gender: null, mbti: null, speechPattern: null, speechStyle: null, appearance: null, originalTitle: null, originalLink: null, characterType: null, tags: null, hobbies: null, values: null, goals: null, relationships: null, personalities: null, backgrounds: null, memories: null, }); }); test("CharacterEditPage asks before leaving with dirty changes", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } }); fireEvent.click(screen.getByRole("button", { name: "상세로 돌아가기" })); // Then const dialog = screen.getByRole("alertdialog", { name: "수정을 취소하시겠습니까?" }); expect(dialog).toHaveTextContent("저장하지 않은 변경사항이 사라집니다."); expect(window.location.pathname).toBe("/ai-characters/101/edit"); // When fireEvent.click(screen.getByRole("button", { name: "떠나기" })); // Then expect(window.location.pathname).toBe("/ai-characters/101"); }); test("CharacterEditPage crops a replacement image before update", async () => { // Given const requests: CapturedRequest[] = []; const originalImage = new File(["original"], "luna.png", { type: "image/png" }); const croppedImage = new File(["cropped"], "luna-cropped.png", { type: "image/png" }); const renderCrop = () => Promise.resolve(croppedImage); window.history.pushState({}, "", "/ai-characters/101/edit"); render( Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 })} renderCrop={renderCrop} />, ); // When fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } }); fireEvent.click(await screen.findByRole("button", { name: "적용" })); await waitFor(() => expect(screen.getByText("luna-cropped.png")).toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(requests).toHaveLength(2)); const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image")); expect(submittedImage.name).toBe("luna-cropped.png"); expect(await submittedImage.text()).toBe("cropped"); }); test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => { // Given let resolveCropSource: (source: CropSourceImage) => void = () => undefined; const cropSourceReady = new Promise((resolve) => { resolveCropSource = resolve; }); const requests: CapturedRequest[] = []; const image = new File(["raw"], "luna.png", { type: "image/png" }); window.history.pushState({}, "", "/ai-characters/101/edit"); render( cropSourceReady} />); // When fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [image] } }); // Then expect(screen.getByRole("button", { name: "저장" })).toBeDisabled(); // When fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then expect(requests).toHaveLength(1); // When resolveCropSource({ file: image, height: 1200, previewUrl: "blob:luna", width: 1200 }); // Then expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument(); }); test("CharacterEditPage ignores a stale replacement crop source from an earlier selection", async () => { // Given let resolveStaleCropSource: (source: CropSourceImage) => void = () => undefined; const staleCropSource = new Promise((resolve) => { resolveStaleCropSource = resolve; }); const requests: CapturedRequest[] = []; const staleImage = new File(["stale"], "stale.png", { type: "image/png" }); const freshImage = new File(["fresh"], "fresh.png", { type: "image/png" }); const createRaceCropSource = (file: File) => file.name === "stale.png" ? staleCropSource : createCropSource(file); window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [staleImage] } }); fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [freshImage] } }); await screen.findByRole("dialog", { name: "이미지 crop" }); await act(async () => { resolveStaleCropSource({ file: staleImage, height: 1200, previewUrl: "blob:stale", width: 1200 }); await staleCropSource; }); fireEvent.click(screen.getByRole("button", { name: "적용" })); await waitFor(() => expect(screen.getByText("fresh.png")).toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(requests).toHaveLength(2)); const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image")); expect(submittedImage.name).toBe("fresh.png"); }); test("CharacterEditPage shows an image error when replacement crop source preparation rejects", async () => { // Given const requests: CapturedRequest[] = []; const image = new File(["raw"], "luna.png", { type: "image/png" }); window.history.pushState({}, "", "/ai-characters/101/edit"); render( Promise.reject(new Error("preview failed"))} />); // When fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [image] } }); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then expect(await screen.findByText("이미지 미리보기 준비에 실패했습니다.")).toBeInTheDocument(); expect(requests).toHaveLength(1); }); test.each([ ["jpg file with PNG MIME", new File(["image"], "luna.jpg", { type: "image/png" })], ["png file with JPEG MIME", new File(["image"], "luna.png", { type: "image/jpeg" })], ])("CharacterEditPage rejects %s before crop or submit", async (_caseName, image) => { // Given const requests: CapturedRequest[] = []; const createCropSource = vi.fn((file: File) => Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 })); window.history.pushState({}, "", "/ai-characters/101/edit"); render(); // When fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [image] } }); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then expect(await screen.findByText("JPEG 또는 PNG 파일만 업로드하세요.")).toBeInTheDocument(); expect(createCropSource).not.toHaveBeenCalled(); expect(requests).toHaveLength(1); }); test("CharacterEditPage keeps the existing image when replacement crop is cancelled", async () => { // Given const requests: CapturedRequest[] = []; const originalImage = new File(["original"], "luna.png", { type: "image/png" }); window.history.pushState({}, "", "/ai-characters/101/edit"); render( Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 })} />, ); // When fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } }); fireEvent.click(await screen.findByRole("button", { name: "취소" })); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(requests).toHaveLength(2)); expect(requireFormData(requests[1]?.body).get("image")).toBeNull(); expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument(); }); test("P10-T1 CharacterEditPage searches v2 original works and omits originalWorkId after clearing selection", async () => { // Given const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/edit"); render(); const originalWorkSearchInput = await screen.findByLabelText("원작 검색어"); vi.useFakeTimers(); // When fireEvent.change(originalWorkSearchInput, { target: { value: "별빛" } }); await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); vi.useRealTimers(); fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" })); expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" })); fireEvent.click(screen.getByRole("button", { name: "저장" })); // Then await waitFor(() => expect(requests).toHaveLength(3)); expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B"); expect(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({ ...activeCharacterOptionalPayload, name: "루나", systemPrompt: "친절하게 답한다.", description: "차분한 상담형 캐릭터", }); });