import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, cleanup, waitFor } from "@testing-library/react"; import EditorPage from "@/app/editor/page"; import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; let mockState: any; let searchParamsSlug: string | null = null; beforeEach(() => { const baseProjectConfig: ProjectConfig = { title: "서버 자동저장 테스트", slug: "my-landing", 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, undo: vi.fn(), redo: vi.fn(), removeBlock: vi.fn(), duplicateBlock: vi.fn(), selectBlock: vi.fn(), selectListItem: vi.fn(), replaceBlocks: vi.fn(), 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; (process.env as any).NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS = "10"; }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); delete (process.env as any).NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS; }); 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("로그인된 사용자가 새 페이지를 편집하면 자동 생성된 slug 로 /api/projects 에 주기적으로 저장해야 한다", 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/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" && method === "POST") { return Promise.resolve( new Response( JSON.stringify({ slug: JSON.parse((init?.body as string) ?? "{}").slug }), { status: 201, headers: { "Content-Type": "application/json" }, }, ), ); } return Promise.resolve(new Response(null, { status: 200 })); }); vi.stubGlobal("fetch", fetchMock as any); render(); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === "/api/auth/me" && (options?.method ?? "GET") === "GET", ), ).toBe(true); }); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST", ), ).toBe(true); }); const projectCall = fetchMock.mock.calls.find( ([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST", ) as any; const [, options] = projectCall; const body = JSON.parse((options.body as string) ?? "{}"); expect(typeof body.slug).toBe("string"); expect(body.slug).not.toBe("my-landing"); expect(body.slug.startsWith("page-")).toBe(false); expect(mockState.projectConfig.slug).toBe(body.slug); }); it("authUserId 가 없으면 /api/projects 자동 저장을 호출하지 않아야 한다", 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/auth/me" && method === "GET") { return Promise.resolve(new Response(null, { status: 200 })); } return Promise.resolve(new Response(null, { status: 200 })); }); vi.stubGlobal("fetch", fetchMock as any); render(); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url]: any[]) => url === "/api/auth/me", ), ).toBe(true); }); const projectCalls = fetchMock.mock.calls.filter( ([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST", ); expect(projectCalls.length).toBe(0); }); it("slug 쿼리 파라미터로 기존 프로젝트를 열었을 때는 해당 slug 로 서버 자동 저장을 해야 한다", async () => { searchParamsSlug = "existing-slug"; mockState.projectConfig.slug = "my-landing" 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/existing-slug" && method === "GET") { return Promise.resolve( new Response( JSON.stringify({ slug: "existing-slug", title: "기존 프로젝트", contentJson: mockState.blocks, }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), ); } if (url === "/api/projects" && method === "POST") { return Promise.resolve( new Response( JSON.stringify({ slug: JSON.parse((init?.body as string) ?? "{}").slug }), { status: 201, headers: { "Content-Type": "application/json" }, }, ), ); } return Promise.resolve(new Response(null, { status: 200 })); }); vi.stubGlobal("fetch", fetchMock as any); render(); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === "/api/projects/existing-slug" && (options?.method ?? "GET") === "GET", ), ).toBe(true); }); await waitFor(() => { expect( fetchMock.mock.calls.some( ([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST", ), ).toBe(true); }); const projectCall = fetchMock.mock.calls.find( ([url, options]: any[]) => url === "/api/projects" && (options?.method ?? "GET") === "POST", ) as any; expect(projectCall).toBeTruthy(); const [, options] = projectCall; const body = JSON.parse((options.body as string) ?? "{}"); expect(body.slug).toBe("existing-slug"); expect(mockState.projectConfig.slug).toBe("existing-slug"); }); });