리팩터링
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
+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();
});
});