대시보드, GNB 추가
CI / test (push) Successful in 4m58s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m45s

This commit is contained in:
2025-12-08 06:59:52 +09:00
parent b40be693ee
commit 9d8c4538c7
18 changed files with 1595 additions and 86 deletions
+87 -2
View File
@@ -146,11 +146,96 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
expect(backLink.closest("a")?.getAttribute("href")).toBe("/projects");
});
backLink.click();
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(<AllProjectSubmissionsPage />);
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("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(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
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(<AllProjectSubmissionsPage />);
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");
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import DashboardPage from "@/app/dashboard/page";
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("DashboardPage - GNB와 테마", () => {
it("GNB에서 현재 페이지인 '대시보드' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<DashboardPage />);
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(dashboardLink.getAttribute("aria-current")).toBe("page");
expect(projectsLink.getAttribute("aria-current")).toBeNull();
expect(submissionsLink.getAttribute("aria-current")).toBeNull();
});
it("헤더에 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<DashboardPage />);
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);
});
});
+4 -4
View File
@@ -24,7 +24,7 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -82,7 +82,7 @@ describe("LoginPage", () => {
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -129,7 +129,7 @@ describe("LoginPage", () => {
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
it("이미 로그인된 상태에서 /login 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
@@ -144,7 +144,7 @@ describe("LoginPage", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
});
+155 -1
View File
@@ -112,6 +112,119 @@ describe("ProjectsPage - 프로젝트 목록", () => {
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("대시보드");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/dashboard");
});
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: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
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: "테마 전환" });
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 = [
{
@@ -227,6 +340,44 @@ describe("ProjectsPage - 프로젝트 목록", () => {
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("선택 삭제");
expect(toolbar.querySelector("a[href='/editor?new=1']")?.textContent).toContain("새 프로젝트 만들기");
});
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
const projects = [
{
@@ -336,7 +487,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
});
});
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
it("헤더의 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
const projects = [
{
id: "1",
@@ -376,6 +527,9 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalled();
});
const menuButton = await screen.findByRole("button", { name: "메뉴" });
fireEvent.click(menuButton);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
+4 -4
View File
@@ -24,7 +24,7 @@ describe("SignupPage", () => {
vi.clearAllMocks();
});
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -82,7 +82,7 @@ describe("SignupPage", () => {
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -129,7 +129,7 @@ describe("SignupPage", () => {
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
it("이미 로그인된 상태에서 /signup 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
@@ -144,7 +144,7 @@ describe("SignupPage", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
});