import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react"; import EditorPage from "@/app/editor/page"; import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; // EditorPage 프로젝트 저장/불러오기 UX TDD // - 프로젝트 저장 버튼 클릭 시 현재 blocks 를 로컬스토리지(pb:project:)와 서버(/api/projects) 모두에 저장해야 한다. // - 불러오기 버튼 클릭 시 로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 replaceBlocks 해야 한다. // - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다. let mockState: any; let searchParamsSlug: string | null = null; beforeEach(() => { const baseProjectConfig: ProjectConfig = { title: "프로젝트 저장/불러오기 테스트", slug: "editor-project-test", canvasPreset: "full", } as ProjectConfig; mockState = { blocks: [ { id: "blk_1", type: "text", props: { text: "프로젝트 저장/불러오기 테스트 블록", align: "left", size: "base", }, }, ] as Block[], projectConfig: baseProjectConfig, selectedBlockId: null as string | null, selectedListItemId: null as string | null, // EditorPage 가 참조하는 액션들: 기본적으로 mock 으로 채우되, // replaceBlocks 는 실제 store.blocks 도 업데이트하도록 구현한다. undo: vi.fn(), redo: vi.fn(), removeBlock: vi.fn(), duplicateBlock: vi.fn(), selectBlock: vi.fn(), selectListItem: vi.fn(), replaceBlocks: vi.fn((nextBlocks: Block[]) => { mockState.blocks = nextBlocks; }), reorderBlocks: vi.fn(), moveBlock: vi.fn(), addTextBlock: vi.fn(), addButtonBlock: vi.fn(), addImageBlock: vi.fn(), addDividerBlock: vi.fn(), addListBlock: vi.fn(), addSectionBlock: vi.fn(), addFormBlock: vi.fn(), addFormInputBlock: vi.fn(), addFormSelectBlock: vi.fn(), addFormCheckboxBlock: vi.fn(), addFormRadioBlock: vi.fn(), addHeroTemplateSection: vi.fn(), addFeaturesTemplateSection: vi.fn(), addCtaTemplateSection: vi.fn(), addFaqTemplateSection: vi.fn(), addPricingTemplateSection: vi.fn(), addTestimonialsTemplateSection: vi.fn(), addBlogTemplateSection: vi.fn(), addTeamTemplateSection: vi.fn(), addFooterTemplateSection: vi.fn(), updateBlock: vi.fn(), updateProjectConfig: vi.fn((partial: Partial) => { mockState.projectConfig = { ...(mockState.projectConfig as ProjectConfig), ...partial, } as ProjectConfig; }), resetHistory: vi.fn(), }; window.localStorage.clear(); searchParamsSlug = null; }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); vi.mock("@/features/editor/state/editorStore", () => { const useEditorStore = (selector: (state: any) => any) => selector(mockState); (useEditorStore as any).getState = () => mockState; return { __esModule: true, useEditorStore, }; }); vi.mock("next/link", () => { return { __esModule: true, default: ({ href, children }: any) => {children}, }; }); vi.mock("next/navigation", () => { return { __esModule: true, useRouter: () => ({ push: vi.fn(), }), useSearchParams: () => ({ get: (key: string) => (key === "slug" ? searchParamsSlug : null), }), }; }); describe("EditorPage - 프로젝트 저장/불러오기", () => { it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ slug: "save-test-slug" }), } as any); vi.stubGlobal("fetch", fetchMock); const { rerender } = render(); const menuButton = screen.getByText("Menu"); fireEvent.click(menuButton); const projectMenuItem = screen.getByText("Save / load project"); fireEvent.click(projectMenuItem); const modalTitle = screen.getByText("Save / load project"); const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement; const titleInput = within(projectModal).getByLabelText("Project title") as HTMLInputElement; const slugInput = within(projectModal).getByLabelText("Project address (e.g. my-landing)") as HTMLInputElement; expect(titleInput.value).toBe("프로젝트 저장/불러오기 테스트"); expect(slugInput.value).toBe("editor-project-test"); fireEvent.change(titleInput, { target: { value: "저장 테스트 프로젝트" } }); fireEvent.change(slugInput, { target: { value: "save-test-slug" } }); expect(mockState.projectConfig.title).toBe("저장 테스트 프로젝트"); expect(mockState.projectConfig.slug).toBe("save-test-slug"); rerender(); const saveButton = screen.getByText("Save (local + server)"); fireEvent.click(saveButton); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === "/api/projects" && options?.method === "POST", ), ).toBe(true); }); const localKey = "pb:project:save-test-slug"; const raw = window.localStorage.getItem(localKey); expect(raw).not.toBeNull(); const parsed = JSON.parse(raw as string); expect(parsed.title).toBe("저장 테스트 프로젝트"); // contentJson 은 이제 { blocks, projectConfig } 스냅샷 객체로 저장된다. expect(parsed.contentJson).toBeTruthy(); expect(Array.isArray(parsed.contentJson.blocks)).toBe(true); expect(parsed.contentJson.blocks[0].id).toBe("blk_1"); expect(parsed.contentJson.projectConfig).toBeTruthy(); expect(parsed.contentJson.projectConfig.title).toBe("저장 테스트 프로젝트"); expect(parsed.contentJson.projectConfig.slug).toBe("save-test-slug"); const projectCall = fetchMock.mock.calls.find( ([url, options]: any[]) => url === "/api/projects" && options?.method === "POST", ) as any; const [url, options] = projectCall; expect(url).toBe("/api/projects"); expect(options.method).toBe("POST"); expect(options.headers["Content-Type"]).toBe("application/json"); const body = JSON.parse(options.body); expect(body.slug).toBe("save-test-slug"); expect(body.title).toBe("저장 테스트 프로젝트"); // 서버로 전송되는 contentJson 도 { blocks, projectConfig } 스냅샷이어야 한다. expect(body.contentJson).toBeTruthy(); expect(Array.isArray(body.contentJson.blocks)).toBe(true); expect(body.contentJson.blocks[0].id).toBe("blk_1"); expect(body.contentJson.projectConfig).toBeTruthy(); expect(body.contentJson.projectConfig.title).toBe("저장 테스트 프로젝트"); expect(body.contentJson.projectConfig.slug).toBe("save-test-slug"); const message = await screen.findByText("Project saved: save-test-slug"); expect(message).toBeTruthy(); }); it("서버가 409 를 반환하면 이미 사용 중인 프로젝트 주소라는 에러 메시지를 보여줘야 한다", async () => { const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => { const url = typeof input === "string" ? input : input.url; const method = init?.method ?? "GET"; if (url === "/api/projects" && method === "POST") { return Promise.resolve({ ok: false, status: 409, json: async () => ({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }), } as any); } // 인증 가드용 /api/auth/me 등은 성공 응답만 내려주면 된다. return Promise.resolve({ ok: true, status: 200, json: async () => ({}), } as any); }); vi.stubGlobal("fetch", fetchMock); const { rerender } = render(); const menuButton = screen.getByText("Menu"); fireEvent.click(menuButton); const projectMenuItem = screen.getByText("Save / load project"); fireEvent.click(projectMenuItem); const modalTitle = screen.getByText("Save / load project"); const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement; const titleInput = within(projectModal).getByLabelText("Project title") as HTMLInputElement; const slugInput = within(projectModal).getByLabelText("Project address (e.g. my-landing)") as HTMLInputElement; fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } }); fireEvent.change(slugInput, { target: { value: "conflict-slug" } }); rerender(); const saveButton = screen.getByText("Save (local + server)"); fireEvent.click(saveButton); const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다."); expect(message).toBeTruthy(); }); it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => { const slug = "local-test-slug"; const localKey = `pb:project:${slug}`; const savedBlocks: Block[] = [ { id: "saved_1", type: "text", props: { text: "로컬 저장된 블록", align: "left", size: "base", }, } as any, ]; window.localStorage.setItem( localKey, JSON.stringify({ title: "로컬 프로젝트", // 새 스펙: contentJson 은 blocks 와 projectConfig 를 함께 가지는 스냅샷 객체다. contentJson: { blocks: savedBlocks, projectConfig: { title: "로컬 프로젝트", slug, canvasPreset: "full", } as ProjectConfig, }, }), ); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); mockState.projectConfig.slug = slug; render(); const menuButton = screen.getByText("Menu"); fireEvent.click(menuButton); const projectMenuItem = screen.getByText("Save / load project"); fireEvent.click(projectMenuItem); const modalTitle = screen.getByText("Save / load project"); const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement; const loadButton = screen.getByText("Load"); fireEvent.click(loadButton); await waitFor(() => { expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1); }); expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any); expect( (mockState.updateProjectConfig as any).mock.calls.some( ([arg]: any[]) => arg && arg.title === "로컬 프로젝트" && arg.slug === slug, ), ).toBe(true); const serverCalls = fetchMock.mock.calls.filter( ([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"), ); expect(serverCalls.length).toBe(0); const message = await screen.findByText(`Loaded project from local: ${slug}`); expect(message).toBeTruthy(); }); it("로컬스토리지에 없으면 서버에서 프로젝트를 불러와야 한다", async () => { const slug = "server-test-slug"; const serverBlocks: Block[] = [ { id: "server_1", type: "text", props: { text: "서버에서 불러온 블록", align: "left", size: "base", }, } as any, ]; const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, // 서버에서도 contentJson 은 { blocks, projectConfig } 형태로 반환된다고 가정한다. json: async () => ({ slug, title: "서버 프로젝트", contentJson: { blocks: serverBlocks, projectConfig: { title: "서버 프로젝트", slug, canvasPreset: "full", } as ProjectConfig, }, }), } as any); vi.stubGlobal("fetch", fetchMock); mockState.projectConfig.slug = slug; render(); const menuButton = screen.getByText("Menu"); fireEvent.click(menuButton); const projectMenuItem = screen.getByText("Save / load project"); fireEvent.click(projectMenuItem); const loadButton = screen.getByText("Load"); fireEvent.click(loadButton); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url]: any[]) => url === `/api/projects/${slug}`, ), ).toBe(true); }); const serverCall = fetchMock.mock.calls.find( ([url]: any[]) => url === `/api/projects/${slug}`, ) as any; const [url] = serverCall; expect(url).toBe(`/api/projects/${slug}`); await waitFor(() => { expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any); }); expect( (mockState.updateProjectConfig as any).mock.calls.some( ([arg]: any[]) => arg && arg.title === "서버 프로젝트" && arg.slug === slug, ), ).toBe(true); const message = await screen.findByText(`Loaded project from server: ${slug}`); expect(message).toBeTruthy(); }); it("서버에서 복잡한 프로젝트 contentJson 스냅샷을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => { const slug = "roundtrip-slug"; const complexBlocks: Block[] = [ { id: "text_1", type: "text", props: { text: "복잡한 텍스트", align: "center", size: "xl", colorCustom: "#ff0000", backgroundColorCustom: "#123456", }, } as any, { id: "button_1", type: "button", props: { label: "CTA 버튼", href: "/pricing", variant: "outline", size: "lg", align: "right", widthMode: "fixed", widthPx: 320, borderRadius: "full", colorPalette: "secondary", textColorCustom: "#111111", fillColorCustom: "#222222", strokeColorCustom: "#333333", }, } as any, { id: "section_1", type: "section", props: { paddingY: "lg", background: "image", backgroundImageUrl: "https://example.com/bg.png", columns: [ { id: "col_1", span: 8 }, { id: "col_2", span: 4 }, ], }, } as any, { id: "form_input_1", type: "formInput", props: { label: "이메일", inputType: "email", required: true, labelMode: "text", labelLayout: "stacked", labelGapPx: 12, widthMode: "custom", widthPx: 360, borderRadius: "lg", paddingX: 16, paddingY: 12, textColorCustom: "#fafafa", fillColorCustom: "#0f172a", strokeColorCustom: "#1e293b", formFieldName: "email-1", }, } as any, ]; const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => { const url = typeof input === "string" ? input : input.url; const method = init?.method ?? "GET"; if (url === "/api/auth/me" && method === "GET") { return Promise.resolve( new Response( JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), ); } if (url === `/api/projects/${slug}` && method === "GET") { return Promise.resolve( new Response( JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: { blocks: complexBlocks, projectConfig: { title: "복잡한 프로젝트", slug, canvasPreset: "full", } as ProjectConfig, }, }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), ); } return Promise.resolve(new Response(null, { status: 200 })); }); vi.stubGlobal("fetch", fetchMock as any); mockState.projectConfig.slug = slug; render(); const menuButton = screen.getByText("Menu"); fireEvent.click(menuButton); const projectMenuItem = screen.getByText("Save / load project"); fireEvent.click(projectMenuItem); const loadButton = screen.getByText("Load"); fireEvent.click(loadButton); await waitFor(() => { expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1); }); const [loadedBlocks] = (mockState.replaceBlocks as any).mock.calls[0]; expect(loadedBlocks).toEqual(complexBlocks as any); expect( (mockState.updateProjectConfig as any).mock.calls.some( ([arg]: any[]) => arg && arg.title === "복잡한 프로젝트" && arg.slug === slug, ), ).toBe(true); }); it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => { const slug = "delete-test-slug"; mockState.projectConfig.slug = slug; // 로컬 저장/자동저장 스냅샷이 존재하는 상황을 준비한다. window.localStorage.setItem(`pb:project:${slug}`, "stored-project"); window.localStorage.setItem(`pb:autosave:${slug}`, "stored-autosave"); const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, } as any); vi.stubGlobal("fetch", fetchMock); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any); render(); const menuButton = screen.getByText("Menu"); fireEvent.click(menuButton); const deleteMenuItem = screen.getByText("Delete project"); fireEvent.click(deleteMenuItem); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE", ), ).toBe(true); }); const deleteCall = fetchMock.mock.calls.find( ([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE", ) as any; const [url, options] = deleteCall; expect(url).toBe(`/api/projects/${slug}`); expect(options.method).toBe("DELETE"); expect(window.localStorage.getItem(`pb:project:${slug}`)).toBeNull(); expect(window.localStorage.getItem(`pb:autosave:${slug}`)).toBeNull(); confirmSpy.mockRestore(); }); it("URL 쿼리의 slug 가 있으면 해당 slug 프로젝트를 자동으로 서버에서 불러와야 한다", async () => { const slug = "auto-load-slug"; const serverBlocks: Block[] = [ { id: "server_1", type: "text", props: { text: "쿼리 로드 블록", align: "left", size: "base", }, } as any, ]; const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => { const url = typeof input === "string" ? input : input.url; const method = init?.method ?? "GET"; if (url === "/api/auth/me" && method === "GET") { return Promise.resolve( new Response( JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), ); } if (url === `/api/projects/${slug}` && method === "GET") { return Promise.resolve( new Response( JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: { blocks: serverBlocks, projectConfig: { title: "쿼리 로드 프로젝트", slug, canvasPreset: "full", } as ProjectConfig, }, }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), ); } return Promise.resolve(new Response(null, { status: 200 })); }); vi.stubGlobal("fetch", fetchMock as any); searchParamsSlug = slug; render(); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === `/api/projects/${slug}` && (options?.method ?? "GET") === "GET", ), ).toBe(true); }); await waitFor(() => { expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any); }); expect( (mockState.updateProjectConfig as any).mock.calls.some( ([arg]: any[]) => arg && arg.title === "쿼리 로드 프로젝트" && arg.slug === slug, ), ).toBe(true); }); });