리팩터링
This commit is contained in:
@@ -166,8 +166,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
|
||||
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");
|
||||
// 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",
|
||||
@@ -181,8 +186,13 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
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");
|
||||
// 서버로 전송되는 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();
|
||||
@@ -257,7 +267,15 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
localKey,
|
||||
JSON.stringify({
|
||||
title: "로컬 프로젝트",
|
||||
contentJson: savedBlocks,
|
||||
// 새 스펙: contentJson 은 blocks 와 projectConfig 를 함께 가지는 스냅샷 객체다.
|
||||
contentJson: {
|
||||
blocks: savedBlocks,
|
||||
projectConfig: {
|
||||
title: "로컬 프로젝트",
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -319,7 +337,19 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
|
||||
// 서버에서도 contentJson 은 { blocks, projectConfig } 형태로 반환된다고 가정한다.
|
||||
json: async () => ({
|
||||
slug,
|
||||
title: "서버 프로젝트",
|
||||
contentJson: {
|
||||
blocks: serverBlocks,
|
||||
projectConfig: {
|
||||
title: "서버 프로젝트",
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig,
|
||||
},
|
||||
}),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -366,7 +396,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
|
||||
it("서버에서 복잡한 프로젝트 contentJson 스냅샷을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
|
||||
const slug = "roundtrip-slug";
|
||||
|
||||
const complexBlocks: Block[] = [
|
||||
@@ -454,7 +484,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
|
||||
JSON.stringify({
|
||||
slug,
|
||||
title: "복잡한 프로젝트",
|
||||
contentJson: {
|
||||
blocks: complexBlocks,
|
||||
projectConfig: {
|
||||
title: "복잡한 프로젝트",
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -576,7 +617,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
|
||||
JSON.stringify({
|
||||
slug,
|
||||
title: "쿼리 로드 프로젝트",
|
||||
contentJson: {
|
||||
blocks: serverBlocks,
|
||||
projectConfig: {
|
||||
title: "쿼리 로드 프로젝트",
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -72,10 +72,10 @@ describe("ProjectPropertiesPanel SEO 메타", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Canonical URL 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
it("공유 URL(og:url) 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("Canonical URL") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Share URL (og:url)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "https://example.com/landing" } });
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
@@ -104,7 +104,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const link = screen.getByText("All submissions");
|
||||
@@ -136,7 +136,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const link = screen.getByText("Dashboard");
|
||||
@@ -168,7 +168,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
|
||||
@@ -212,7 +212,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const toolbar = await screen.findByTestId("projects-toolbar");
|
||||
@@ -276,7 +276,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
|
||||
@@ -314,7 +314,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const html = document.documentElement;
|
||||
@@ -377,6 +377,60 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("프로젝트 행의 퍼블릭 페이지 링크는 새 탭에서 열려야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
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 === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ emailVerified: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response("not found", { status: 404 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
|
||||
const publicLinkText = await screen.findByText("Open public page");
|
||||
const anchor = publicLinkText.closest("a") as HTMLAnchorElement | null;
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor!.getAttribute("href")).toBe("/p/test-project-a");
|
||||
expect(anchor!.getAttribute("target")).toBe("_blank");
|
||||
|
||||
const rel = anchor!.getAttribute("rel") ?? "";
|
||||
expect(rel).toContain("noopener");
|
||||
expect(rel).toContain("noreferrer");
|
||||
});
|
||||
|
||||
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
@@ -594,7 +648,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -671,7 +725,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -817,4 +871,247 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("상태 드롭다운에서 값을 변경하면 PATCH /api/projects/[slug] 가 호출되고 UI 가 갱신되어야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const updatedProject = {
|
||||
...projects[0],
|
||||
status: "PUBLISHED",
|
||||
updatedAt: "2025-01-01T01:00:00.000Z",
|
||||
};
|
||||
|
||||
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 === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ emailVerified: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-a" && method === "PATCH") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(updatedProject), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response("not found", { status: 404 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
|
||||
const statusSelect = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(statusSelect.value).toBe("DRAFT");
|
||||
|
||||
fireEvent.change(statusSelect, { target: { value: "PUBLISHED" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const patchCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/projects/test-project-a" && options?.method === "PATCH";
|
||||
});
|
||||
|
||||
expect(patchCall).toBeDefined();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const updatedSelect = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(updatedSelect.value).toBe("PUBLISHED");
|
||||
});
|
||||
});
|
||||
|
||||
it("상태 컬럼 헤더에는 상태 설명을 보여주는 ? 아이콘이 i18n 텍스트로 렌더링되어야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const statusHeader = await screen.findByText("Status");
|
||||
expect(statusHeader).toBeTruthy();
|
||||
|
||||
const helpButton = screen.getByLabelText("Project status help");
|
||||
expect(helpButton).toBeTruthy();
|
||||
|
||||
// 초기에는 상태 설명 텍스트가 화면에 보이지 않아야 한다.
|
||||
expect(
|
||||
screen.queryByText(/Draft: Public page is blocked \(404\) and form submissions are not accepted\./),
|
||||
).toBeNull();
|
||||
|
||||
// ? 아이콘을 클릭하면 상태 설명 팝오버가 열려야 한다.
|
||||
helpButton.click();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Draft: Public page is blocked \(404\) and form submissions are not accepted\./,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Published: Public page is live and form submissions are accepted\./),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Archived: Public page is live but new form submissions are blocked\./),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("상태 드롭다운 옵션 텍스트는 i18n 상태 라벨을 사용해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
const optionTexts = Array.from(select.options).map((opt) => opt.textContent || "");
|
||||
|
||||
expect(optionTexts).toContain("Draft (blocked)");
|
||||
expect(optionTexts).toContain("Published (live)");
|
||||
expect(optionTexts).toContain("Archived (read-only)");
|
||||
});
|
||||
|
||||
it("프로젝트 목록 테이블에서 제출 내역 컬럼에 오늘/전체 제출 수를 [아이콘] today/total 형식으로 표시해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
todaySubmissions: 1,
|
||||
totalSubmissions: 2,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
todaySubmissions: 0,
|
||||
totalSubmissions: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
const rowA = (await screen.findByText("테스트 프로젝트 A")).closest("tr") as HTMLElement | null;
|
||||
const rowB = (await screen.findByText("테스트 프로젝트 B")).closest("tr") as HTMLElement | null;
|
||||
expect(rowA).not.toBeNull();
|
||||
expect(rowB).not.toBeNull();
|
||||
|
||||
expect(rowA!.textContent || "").toContain("1/2");
|
||||
expect(rowB!.textContent || "").toContain("0/5");
|
||||
});
|
||||
|
||||
it("제출 내역 컬럼 헤더 텍스트만으로 today/total 의미를 전달하고 별도 툴팁 아이콘은 없어야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
todaySubmissions: 1,
|
||||
totalSubmissions: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
// 헤더 텍스트 자체에 today/total 의미가 드러나야 한다.
|
||||
const header = await screen.findByText("Submissions (today/total)");
|
||||
expect(header).toBeTruthy();
|
||||
|
||||
// 별도의 제출 내역 툴팁 아이콘은 존재하지 않아야 한다.
|
||||
expect(screen.queryByLabelText("Submission counts help")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user