리팩터링
CI / test (push) Successful in 5m38s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m12s

This commit is contained in:
2025-12-16 17:49:25 +09:00
parent 87cdc1868b
commit 7491dacba2
19 changed files with 1857 additions and 99 deletions
+61 -9
View File
@@ -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" } });
+306 -9
View File
@@ -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();
});
});