import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import ProjectSubmissionsPage from "@/app/projects/[slug]/submissions/page";
// next/navigation 의 useRouter/useParams 를 목으로 대체해
// - URL 의 slug 파라미터에 따라 API 요청 경로가 달라지는지
// - 인증 실패 시 /login 으로 리다이렉트하는지
// 를 검증한다.
export const pushMock = vi.fn();
export const useParamsMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
useParams: () => useParamsMock(),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
useParamsMock.mockReset();
});
describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
it("/api/projects/[slug]/submissions 로부터 받은 제출 내역을 테이블로 렌더링해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "test-project",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
{
id: "2",
createdAt: "2025-01-02T08:30:00.000Z",
projectSlug: "test-project",
payload: {
name: "김철수",
email: "another@example.com",
phone: "010-0000-0000",
birthdate: "1995-05-05",
message: "두 번째 문의입니다.",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/projects/test-project/submissions");
expect(options).toBeUndefined();
expect(await screen.findByText("폼 제출 내역")).toBeTruthy();
expect(screen.getByText("test-project")).toBeTruthy();
expect(screen.getByText("홍길동")).toBeTruthy();
expect(screen.getByText("user@example.com")).toBeTruthy();
expect(screen.getByText("010-1234-5678")).toBeTruthy();
expect(screen.getByText("1990-01-01")).toBeTruthy();
expect(screen.getByText("message: 안녕하세요")).toBeTruthy();
expect(screen.getByText("김철수")).toBeTruthy();
expect(screen.getByText("another@example.com")).toBeTruthy();
expect(screen.getByText("010-0000-0000")).toBeTruthy();
expect(screen.getByText("1995-05-05")).toBeTruthy();
expect(screen.getByText("message: 두 번째 문의입니다.")).toBeTruthy();
});
it("API 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("API 가 404 를 반환하면 에러 메시지를 화면에 표시해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "unknown-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "프로젝트를 찾을 수 없습니다." }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const errorText = await screen.findByText(
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
);
expect(errorText).toBeTruthy();
});
it("상단에 '프로젝트 목록' 링크가 있고 href 가 /projects 이어야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
expect(backLink.getAttribute("href")).toBe("/projects");
});
it("상단 헤더에 공통 GNB와 테마 전환 버튼이 노출되고 테마 토글이 html 요소의 dark 클래스를 토글해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(dashboardLink.getAttribute("href")).toBe("/dashboard");
expect(projectsLink.getAttribute("href")).toBe("/projects");
expect(submissionsLink.getAttribute("href")).toBe("/projects/submissions");
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
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("프로젝트별 제출 내역 테이블은 라이트 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "test-project",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
const { container } = render();
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const headerRow = container.querySelector("thead tr") as HTMLElement | null;
expect(headerRow).not.toBeNull();
const headerClass = headerRow!.className;
expect(headerClass).toContain("border-slate-200");
expect(headerClass).toContain("text-slate-600");
expect(headerClass).toContain("dark:border-slate-800");
expect(headerClass).toContain("dark:text-slate-400");
const nameCellNode = await screen.findByText("홍길동");
const nameCell = nameCellNode.closest("td") as HTMLElement | null;
expect(nameCell).not.toBeNull();
const nameClass = nameCell!.className;
expect(nameClass).toContain("text-slate-900");
expect(nameClass).toContain("dark:text-slate-100");
});
});