import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; import ProjectsPage from "@/app/projects/page"; // 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(); 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("편집"); 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("미리보기"); 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("삭제 버튼 클릭 시 서버에 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(); expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy(); const deleteButtons = screen.getAllByText("삭제"); 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", }, ]; 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(); expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy(); expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy(); const checkboxA = screen.getByLabelText("테스트 프로젝트 A 선택"); const checkboxB = screen.getByLabelText("테스트 프로젝트 B 선택"); fireEvent.click(checkboxA); fireEvent.click(checkboxB); const bulkDeleteButton = screen.getByText("선택 삭제"); 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(); }); });