821 lines
27 KiB
TypeScript
821 lines
27 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
|
import ProjectsPage from "@/app/projects/page";
|
|
|
|
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
|
export const pushMock = vi.fn();
|
|
|
|
vi.mock("next/navigation", () => {
|
|
return {
|
|
__esModule: true,
|
|
useRouter: () => ({ push: pushMock }),
|
|
};
|
|
});
|
|
|
|
// ProjectsPage 프로젝트 목록 TDD
|
|
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
|
|
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
|
|
// - 각 프로젝트 행에는 프로젝트를 삭제하는 "삭제" 버튼이 있고, 클릭 시 서버에 DELETE 요청을 보내고 목록/로컬스토리지를 갱신해야 한다.
|
|
// - 체크박스로 여러 프로젝트를 선택한 뒤 상단의 "선택 삭제" 버튼으로 일괄 삭제할 수 있어야 한다.
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("ProjectsPage - 프로젝트 목록", () => {
|
|
it("/api/projects 로부터 받은 프로젝트 목록을 제목/slug 기준으로 렌더링하고, 편집/미리보기 링크를 노출해야 한다", 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",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "테스트 프로젝트 B",
|
|
slug: "test-project-b",
|
|
status: "PUBLISHED",
|
|
createdAt: "2025-01-02T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00: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).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const [url, options] = fetchMock.mock.calls[0] as any;
|
|
expect(url).toBe("/api/projects");
|
|
expect(options).toBeUndefined();
|
|
|
|
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
|
expect(screen.getByText("test-project-a")).toBeTruthy();
|
|
|
|
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
|
expect(screen.getByText("test-project-b")).toBeTruthy();
|
|
|
|
const editLinks = screen.getAllByText("Edit");
|
|
expect(editLinks).toHaveLength(2);
|
|
expect(editLinks[0].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-a");
|
|
expect(editLinks[1].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-b");
|
|
|
|
const previewLinks = screen.getAllByText("Preview");
|
|
expect(previewLinks).toHaveLength(2);
|
|
expect(previewLinks[0].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-a");
|
|
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
|
|
});
|
|
|
|
it("헤더에 전체 제출 내역 페이지(/projects/submissions)로 이동하는 링크가 있어야 한다", 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).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const link = screen.getByText("All submissions");
|
|
expect(link).toBeTruthy();
|
|
expect(link.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
|
|
});
|
|
|
|
it("헤더에 대시보드 페이지(/dashboard)로 이동하는 링크가 있어야 한다", 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).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const link = screen.getByText("Dashboard");
|
|
expect(link).toBeTruthy();
|
|
expect(link.closest("a")?.getAttribute("href")).toBe("/dashboard");
|
|
});
|
|
|
|
it("프로젝트 목록 테이블은 라이트/다크 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", 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 as any);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
|
|
const titleCell = titleCellNode.closest("td") as HTMLElement | null;
|
|
expect(titleCell).not.toBeNull();
|
|
|
|
const titleClassName = titleCell!.className;
|
|
expect(titleClassName).toContain("text-slate-900");
|
|
expect(titleClassName).toContain("dark:text-slate-100");
|
|
|
|
const slugCellNode = screen.getByText("test-project-a");
|
|
const slugCell = slugCellNode.closest("td") as HTMLElement | null;
|
|
expect(slugCell).not.toBeNull();
|
|
|
|
const slugClassName = slugCell!.className;
|
|
expect(slugClassName).toContain("text-slate-600");
|
|
expect(slugClassName).toContain("dark:text-slate-300");
|
|
});
|
|
|
|
it("상단 툴바/액션 링크/페이지네이션은 라이트/다크 테마 모두에서 읽기 쉬운 색상 클래스를 사용해야 한다", 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 as any);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const toolbar = await screen.findByTestId("projects-toolbar");
|
|
const toolbarClass = toolbar.className;
|
|
expect(toolbarClass).toContain("text-slate-600");
|
|
expect(toolbarClass).toContain("dark:text-slate-300");
|
|
|
|
const editLink = screen.getByText("Edit").closest("a") as HTMLElement | null;
|
|
expect(editLink).not.toBeNull();
|
|
const editClass = editLink!.className;
|
|
expect(editClass).toContain("text-sky-600");
|
|
expect(editClass).toContain("dark:text-sky-300");
|
|
|
|
const previewLink = screen.getByText("Preview").closest("a") as HTMLElement | null;
|
|
expect(previewLink).not.toBeNull();
|
|
const previewClass = previewLink!.className;
|
|
expect(previewClass).toContain("text-slate-600");
|
|
expect(previewClass).toContain("dark:text-slate-300");
|
|
|
|
const submissionsLink = screen.getByText("Form submissions").closest("a") as HTMLElement | null;
|
|
expect(submissionsLink).not.toBeNull();
|
|
const submissionsClass = submissionsLink!.className;
|
|
expect(submissionsClass).toContain("text-emerald-600");
|
|
expect(submissionsClass).toContain("dark:text-emerald-300");
|
|
|
|
const deleteButton = screen.getByText("Delete").closest("button") as HTMLElement | null;
|
|
expect(deleteButton).not.toBeNull();
|
|
const deleteClass = deleteButton!.className;
|
|
expect(deleteClass).toContain("text-red-600");
|
|
expect(deleteClass).toContain("dark:text-red-300");
|
|
|
|
const prevButton = screen.getByRole("button", { name: "Previous" });
|
|
const prevClass = prevButton.className;
|
|
expect(prevClass).toContain("border-slate-300");
|
|
expect(prevClass).toContain("text-slate-700");
|
|
expect(prevClass).toContain("dark:border-slate-700");
|
|
expect(prevClass).toContain("dark:text-slate-300");
|
|
});
|
|
|
|
it("GNB에서 현재 페이지인 '프로젝트 목록' 탭에 aria-current=\"page\"가 설정되어야 한다", 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).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const dashboardLink = await screen.findByRole("link", { name: "Dashboard" });
|
|
const projectsLink = await screen.findByRole("link", { name: "Projects" });
|
|
const submissionsLink = await screen.findByRole("link", { name: "All submissions" });
|
|
|
|
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
|
|
expect(projectsLink.closest("a")?.getAttribute("aria-current")).toBe("page");
|
|
|
|
expect(dashboardLink.closest("a")?.getAttribute("aria-current")).toBeNull();
|
|
expect(submissionsLink.closest("a")?.getAttribute("aria-current")).toBeNull();
|
|
});
|
|
|
|
it("헤더에 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", 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).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const html = document.documentElement;
|
|
html.classList.remove("dark");
|
|
|
|
const themeToggleButton = await screen.findByRole("button", { name: "Toggle theme" });
|
|
|
|
expect(html.classList.contains("dark")).toBe(false);
|
|
|
|
themeToggleButton.click();
|
|
|
|
expect(html.classList.contains("dark")).toBe(true);
|
|
|
|
themeToggleButton.click();
|
|
|
|
expect(html.classList.contains("dark")).toBe(false);
|
|
});
|
|
|
|
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", 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",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "테스트 프로젝트 B",
|
|
slug: "test-project-b",
|
|
status: "PUBLISHED",
|
|
createdAt: "2025-01-02T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00: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 />);
|
|
|
|
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
|
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
|
|
|
const submissionLinks = screen.getAllByText("Form submissions");
|
|
expect(submissionLinks).toHaveLength(2);
|
|
expect(submissionLinks[0].closest("a")?.getAttribute("href")).toBe(
|
|
"/projects/test-project-a/submissions",
|
|
);
|
|
expect(submissionLinks[1].closest("a")?.getAttribute("href")).toBe(
|
|
"/projects/test-project-b/submissions",
|
|
);
|
|
});
|
|
|
|
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", 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",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "테스트 프로젝트 B",
|
|
slug: "test-project-b",
|
|
status: "PUBLISHED",
|
|
createdAt: "2025-01-02T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
window.localStorage.setItem("pb:project:test-project-a", "stored-project");
|
|
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave");
|
|
|
|
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/projects/test-project-a" && method === "DELETE") {
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
}
|
|
|
|
return Promise.resolve(new Response(null, { status: 500 }));
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
|
|
|
const deleteButtons = screen.getAllByText("Delete");
|
|
expect(deleteButtons.length).toBeGreaterThan(0);
|
|
|
|
deleteButtons[0].click();
|
|
|
|
await waitFor(() => {
|
|
const deleteCall = fetchMock.mock.calls.find(([url, options]) => {
|
|
return url === "/api/projects/test-project-a" && options?.method === "DELETE";
|
|
});
|
|
|
|
expect(deleteCall).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
|
|
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
|
|
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it("프로젝트 목록 상단 툴바에 '새 프로젝트 만들기' 버튼과 '선택 삭제' 버튼이 함께 보여야 한다", 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",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "테스트 프로젝트 B",
|
|
slug: "test-project-b",
|
|
status: "PUBLISHED",
|
|
createdAt: "2025-01-02T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00: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 />);
|
|
|
|
// 상단 툴바에서 선택 삭제와 새 프로젝트 만들기 버튼이 함께 보여야 한다.
|
|
const toolbar = await screen.findByTestId("projects-toolbar");
|
|
|
|
expect(toolbar.querySelector("button[type='button']")?.textContent).toContain("Delete selected");
|
|
expect(toolbar.querySelector("a[href='/editor?new=1']")?.textContent).toContain("Create new project");
|
|
});
|
|
|
|
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", 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",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "테스트 프로젝트 B",
|
|
slug: "test-project-b",
|
|
status: "PUBLISHED",
|
|
createdAt: "2025-01-02T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
window.localStorage.setItem("pb:project:test-project-a", "stored-project-a");
|
|
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave-a");
|
|
window.localStorage.setItem("pb:project:test-project-b", "stored-project-b");
|
|
window.localStorage.setItem("pb:autosave:test-project-b", "stored-autosave-b");
|
|
|
|
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/projects/test-project-a" && method === "DELETE") {
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
}
|
|
|
|
if (url === "/api/projects/test-project-b" && method === "DELETE") {
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
}
|
|
|
|
return Promise.resolve(new Response(null, { status: 500 }));
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
|
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
|
|
|
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();
|
|
|
|
const checkboxA = rowA!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
|
const checkboxB = rowB!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
|
expect(checkboxA).not.toBeNull();
|
|
expect(checkboxB).not.toBeNull();
|
|
|
|
fireEvent.click(checkboxA!);
|
|
fireEvent.click(checkboxB!);
|
|
|
|
const bulkDeleteButton = screen.getByText("Delete selected");
|
|
fireEvent.click(bulkDeleteButton);
|
|
|
|
await waitFor(() => {
|
|
const deleteCalls = fetchMock.mock.calls.filter(([, options]) => options?.method === "DELETE");
|
|
const hasA = deleteCalls.some(([url]) => url === "/api/projects/test-project-a");
|
|
const hasB = deleteCalls.some(([url]) => url === "/api/projects/test-project-b");
|
|
|
|
expect(hasA).toBe(true);
|
|
expect(hasB).toBe(true);
|
|
|
|
// DELETE 요청이 성공적으로 처리된 뒤에는 목록에서 해당 프로젝트들이 사라져야 한다.
|
|
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
|
|
expect(screen.queryByText("테스트 프로젝트 B")).toBeNull();
|
|
});
|
|
|
|
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
|
|
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
|
|
expect(window.localStorage.getItem("pb:project:test-project-b")).toBeNull();
|
|
expect(window.localStorage.getItem("pb:autosave:test-project-b")).toBeNull();
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it("/api/projects 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
|
});
|
|
});
|
|
|
|
it("헤더의 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", 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().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/logout" && method === "POST") {
|
|
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
|
|
}
|
|
|
|
return Promise.resolve(new Response(null, { status: 500 }));
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
|
|
const menuButton = await screen.findByRole("button", { name: "Menu" });
|
|
fireEvent.click(menuButton);
|
|
|
|
const logoutButton = await screen.findByRole("button", { name: "Log out" });
|
|
fireEvent.click(logoutButton);
|
|
|
|
await waitFor(() => {
|
|
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
|
|
return url === "/api/auth/logout" && options?.method === "POST";
|
|
});
|
|
|
|
expect(logoutCall).toBeDefined();
|
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
|
});
|
|
});
|
|
|
|
it("/api/projects 가 500 을 반환하면 에러 메시지를 표시해야 한다", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response("server error", {
|
|
status: 500,
|
|
headers: { "Content-Type": "text/plain" },
|
|
}),
|
|
);
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"An error occurred while loading your projects. Please try again later.",
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("단일 프로젝트 삭제가 실패하면 에러 메시지를 표시해야 한다", async () => {
|
|
const projects = [
|
|
{
|
|
id: "1",
|
|
title: "삭제 실패 프로젝트",
|
|
slug: "delete-fail-project",
|
|
status: "DRAFT",
|
|
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/projects/delete-fail-project" && method === "DELETE") {
|
|
return Promise.resolve(new Response("fail", { status: 500 }));
|
|
}
|
|
|
|
return Promise.resolve(new Response(null, { status: 500 }));
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
|
|
|
|
const deleteButton = screen.getByText("Delete");
|
|
fireEvent.click(deleteButton);
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"An error occurred while deleting the project. Please try again later.",
|
|
),
|
|
).toBeTruthy();
|
|
|
|
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
|
|
expect(screen.getByText("삭제 실패 프로젝트")).toBeTruthy();
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it("일괄 삭제에서 일부만 성공하면 에러 메시지를 표시해야 한다", async () => {
|
|
const projects = [
|
|
{
|
|
id: "1",
|
|
title: "성공 프로젝트",
|
|
slug: "success-project",
|
|
status: "DRAFT",
|
|
createdAt: "2025-01-01T00:00:00.000Z",
|
|
updatedAt: "2025-01-01T00:00:00.000Z",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "실패 프로젝트",
|
|
slug: "fail-project",
|
|
status: "DRAFT",
|
|
createdAt: "2025-01-02T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00: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/projects/success-project" && method === "DELETE") {
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
}
|
|
|
|
if (url === "/api/projects/fail-project" && method === "DELETE") {
|
|
return Promise.resolve(new Response("fail", { status: 500 }));
|
|
}
|
|
|
|
return Promise.resolve(new Response(null, { status: 500 }));
|
|
});
|
|
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
|
|
|
render(<ProjectsPage />);
|
|
|
|
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
|
|
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
|
|
|
|
const successRow = (await screen.findByText("성공 프로젝트")).closest("tr") as HTMLElement | null;
|
|
const failRow = (await screen.findByText("실패 프로젝트")).closest("tr") as HTMLElement | null;
|
|
expect(successRow).not.toBeNull();
|
|
expect(failRow).not.toBeNull();
|
|
|
|
const checkboxSuccess = successRow!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
|
const checkboxFail = failRow!.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
|
expect(checkboxSuccess).not.toBeNull();
|
|
expect(checkboxFail).not.toBeNull();
|
|
|
|
fireEvent.click(checkboxSuccess!);
|
|
fireEvent.click(checkboxFail!);
|
|
|
|
const bulkDeleteButton = screen.getByText("Delete selected");
|
|
fireEvent.click(bulkDeleteButton);
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"Failed to delete some projects. Please refresh and review the list.",
|
|
),
|
|
).toBeTruthy();
|
|
|
|
// 성공 프로젝트는 목록에서 사라지고, 실패 프로젝트는 남아 있어야 한다.
|
|
expect(screen.queryByText("성공 프로젝트")).toBeNull();
|
|
expect(screen.getByText("실패 프로젝트")).toBeTruthy();
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
});
|