Files
page-builder/tests/unit/EditorProjectSaveLoad.spec.tsx
T
jaybe 6ad731b6e2
CI / test (push) Failing after 13m50s
CI / pr_and_merge (push) Has been cancelled
에디터 정리 및 버그 수정
2025-12-01 13:34:16 +09:00

616 lines
19 KiB
TypeScript

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:<slug>)와 서버(/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<ProjectConfig>) => {
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) => <a href={href}>{children}</a>,
};
});
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(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: 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(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
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("저장 테스트 프로젝트");
expect(Array.isArray(parsed.contentJson)).toBe(true);
expect(parsed.contentJson[0].id).toBe("blk_1");
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("저장 테스트 프로젝트");
expect(Array.isArray(body.contentJson)).toBe(true);
expect(body.contentJson[0].id).toBe("blk_1");
const message = await screen.findByText("프로젝트가 저장되었습니다: 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(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
rerender(<EditorPage />);
const saveButton = screen.getByText("저장 (로컬 + 서버)");
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: savedBlocks,
}),
);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
mockState.projectConfig.slug = slug;
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
const loadButton = screen.getByText("불러오기");
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(`로컬에서 프로젝트를 불러왔습니다: ${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,
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
} as any);
vi.stubGlobal("fetch", fetchMock);
mockState.projectConfig.slug = slug;
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
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(`프로젝트를 불러왔습니다: ${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: complexBlocks }),
{
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(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
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(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const deleteMenuItem = screen.getByText("프로젝트 삭제");
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: serverBlocks }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
searchParamsSlug = slug;
render(<EditorPage />);
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);
});
});