616 lines
19 KiB
TypeScript
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("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(<EditorPage />);
|
|
|
|
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("저장 테스트 프로젝트");
|
|
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("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(<EditorPage />);
|
|
|
|
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(<EditorPage />);
|
|
|
|
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: savedBlocks,
|
|
}),
|
|
);
|
|
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
mockState.projectConfig.slug = slug;
|
|
|
|
render(<EditorPage />);
|
|
|
|
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,
|
|
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
|
|
} as any);
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
mockState.projectConfig.slug = slug;
|
|
|
|
render(<EditorPage />);
|
|
|
|
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: 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("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(<EditorPage />);
|
|
|
|
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: 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);
|
|
});
|
|
});
|