import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, cleanup, waitFor } from "@testing-library/react"; import AllProjectSubmissionsPage from "@/app/projects/submissions/page"; // /projects/submissions 페이지 유닛 테스트 // - /api/projects/submissions 응답을 테이블로 렌더링하는지 // - 401/404/기타 에러에 대해 올바른 메시지를 표시하는지 // - 상단에 "프로젝트 목록" 링크가 있고 클릭 시 /projects 로 이동하는지 export const pushMock = vi.fn(); vi.mock("next/navigation", () => { return { __esModule: true, useRouter: () => ({ push: pushMock }), }; }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); }); describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => { it("/api/projects/submissions 응답을 프로젝트/필드 정보가 포함된 테이블로 렌더링해야 한다", async () => { const submissions = [ { id: "1", createdAt: "2025-01-01T12:00:00.000Z", projectSlug: "proj-1", projectTitle: "프로젝트 1", 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: "proj-2", projectTitle: "프로젝트 2", 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/submissions"); expect(options).toBeUndefined(); expect(await screen.findByText("전체 폼 제출 내역")).toBeTruthy(); expect(screen.getByText("프로젝트 1")).toBeTruthy(); expect(screen.getByText("proj-1")).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("프로젝트 2")).toBeTruthy(); expect(screen.getByText("proj-2")).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 () => { 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 가 500 등을 반환하면 에러 메시지를 화면에 표시해야 한다", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ message: "서버 에러" }), { status: 500, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchMock as any); render(); const errorText = await screen.findByText( "폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", ); expect(errorText).toBeTruthy(); }); it("상단에 '프로젝트 목록' 링크가 있고 클릭 시 /projects 로 이동해야 한다", async () => { 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.closest("a")?.getAttribute("href")).toBe("/projects"); }); it("상단 GNB 에 대시보드/프로젝트 목록/전체 제출 내역 링크가 있어야 한다", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchMock as any); render(); const dashboardLink = await screen.findByRole("link", { name: "대시보드" }); expect(dashboardLink.closest("a")?.getAttribute("href")).toBe("/dashboard"); const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" }); expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects"); const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" }); expect(submissionsLink.closest("a")?.getAttribute("href")).toBe("/projects/submissions"); }); it("전체 제출 내역 테이블은 라이트 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => { const submissions = [ { id: "1", createdAt: "2025-01-01T12:00:00.000Z", projectSlug: "proj-1", projectTitle: "프로젝트 1", 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"); }); it("GNB에서 현재 페이지인 '전체 제출 내역' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => { 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(submissionsLink.getAttribute("href")).toBe("/projects/submissions"); expect(submissionsLink.getAttribute("aria-current")).toBe("page"); expect(dashboardLink.getAttribute("aria-current")).toBeNull(); expect(projectsLink.getAttribute("aria-current")).toBeNull(); }); it("상단 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => { 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/submissions" && method === "GET") { return Promise.resolve( new Response(JSON.stringify([]), { 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 as any); render(); const menuButton = await screen.findByRole("button", { name: "메뉴" }); menuButton.click(); const logoutButton = await screen.findByRole("button", { name: "로그아웃" }); logoutButton.click(); 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"); }); }); });