From 9d8c4538c7684167fa89cc6cc883c4caf6a8ed4b Mon Sep 17 00:00:00 2001 From: Jaybe Date: Mon, 8 Dec 2025 06:59:52 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C,=20GNB=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/dashboard/overview/route.ts | 220 +++++++++++ src/app/dashboard/page.tsx | 345 ++++++++++++++++++ src/app/layout.tsx | 2 +- src/app/login/page.tsx | 6 +- src/app/page.tsx | 19 +- src/app/projects/page.tsx | 143 ++++++-- src/app/projects/submissions/page.tsx | 113 +++++- src/app/signup/page.tsx | 4 +- tailwind.config.js | 1 + tests/api/dashboard.spec.ts | 220 +++++++++++ tests/e2e/auth.spec.ts | 8 +- tests/e2e/dashboard.spec.ts | 140 +++++++ tests/e2e/projects.spec.ts | 105 +++++- tests/unit/AllProjectSubmissionsPage.spec.tsx | 89 ++++- tests/unit/DashboardPage.spec.tsx | 94 +++++ tests/unit/LoginPage.spec.tsx | 8 +- tests/unit/ProjectsPage.spec.tsx | 156 +++++++- tests/unit/SignupPage.spec.tsx | 8 +- 18 files changed, 1595 insertions(+), 86 deletions(-) create mode 100644 src/app/api/dashboard/overview/route.ts create mode 100644 src/app/dashboard/page.tsx create mode 100644 tests/api/dashboard.spec.ts create mode 100644 tests/e2e/dashboard.spec.ts create mode 100644 tests/unit/DashboardPage.spec.tsx diff --git a/src/app/api/dashboard/overview/route.ts b/src/app/api/dashboard/overview/route.ts new file mode 100644 index 0000000..24374ae --- /dev/null +++ b/src/app/api/dashboard/overview/route.ts @@ -0,0 +1,220 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; +import { verifyAccessToken } from "@/features/auth/authCrypto"; + +// 대시보드 개요 API (/api/dashboard/overview) +// - 로그인한 사용자의 프로젝트/폼 제출 통계를 집계해 요약/프로젝트별 지표를 반환한다. +// - DB 스키마: Project, FormSubmission +// - 인증: pb_access 쿠키(JWT)를 검증해 현재 사용자 식별 + +const prisma = new PrismaClient(); + +interface AuthUser { + id: string; + email: string; + tokenVersion: number; +} + +// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다. +function extractTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const parts = cookieHeader.split(";"); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("pb_access=")) { + const value = trimmed.substring("pb_access=".length); + return decodeURIComponent(value); + } + } + return null; +} + +// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다. +async function getAuthUserFromRequest(request: Request): Promise { + const cookieHeader = request.headers.get("cookie"); + const token = extractTokenFromCookieHeader(cookieHeader); + if (!token) return null; + + const payload = await verifyAccessToken(token); + if (!payload) return null; + + return { + id: payload.sub, + email: payload.email, + tokenVersion: payload.tokenVersion, + }; +} + +interface SummaryStats { + totalProjects: number; + totalSubmissions: number; + todaySubmissions: number; + last7DaysSubmissions: number; +} + +interface DailySubmissionItem { + // 일별 제출 수 그래프를 위한 집계 데이터 + date: string; // YYYY-MM-DD (UTC 기준) + count: number; +} + +interface ProjectStatsItem { + projectId: string; + title: string; + slug: string; + status: string; + totalSubmissions: number; + lastSubmissionAt: string | null; +} + +// GET /api/dashboard/overview +// - 현재 로그인 사용자의 프로젝트 목록/폼 제출 내역을 조회해 통계를 계산한다. +export async function GET(request: Request) { + const authUser = await getAuthUserFromRequest(request); + if (!authUser) { + return NextResponse.json( + { message: "대시보드를 조회하려면 로그인이 필요합니다." }, + { status: 401 }, + ); + } + + try { + // 현재 사용자 소유 프로젝트와, 해당 사용자가 소유한 제출 내역을 한 번에 로드한다. + const [projects, submissions] = await Promise.all([ + prisma.project.findMany({ + where: { userId: authUser.id }, + select: { + id: true, + title: true, + slug: true, + status: true, + }, + }), + prisma.formSubmission.findMany({ + where: { userId: authUser.id }, + select: { + id: true, + projectId: true, + projectSlug: true, + createdAt: true, + }, + }), + ]); + + const totalProjects = projects.length; + const totalSubmissions = submissions.length; + + // 오늘/최근 7일 기준 시각 계산 + const now = new Date(); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + let todaySubmissions = 0; + let last7DaysSubmissions = 0; + + for (const submission of submissions) { + const createdAt = submission.createdAt instanceof Date + ? submission.createdAt + : new Date(submission.createdAt as unknown as string); + + if (createdAt >= startOfToday) { + todaySubmissions += 1; + } + + if (createdAt >= sevenDaysAgo) { + last7DaysSubmissions += 1; + } + } + + // 프로젝트별 통계 맵을 구성한다. + const statsByProjectId = new Map< + string, + { + projectId: string; + title: string; + slug: string; + status: string; + totalSubmissions: number; + lastSubmissionAt: Date | null; + } + >(); + + for (const project of projects) { + statsByProjectId.set(project.id, { + projectId: project.id, + title: project.title, + slug: project.slug, + status: project.status, + totalSubmissions: 0, + lastSubmissionAt: null, + }); + } + + for (const submission of submissions) { + const stat = submission.projectId ? statsByProjectId.get(submission.projectId) : null; + if (!stat) continue; + + stat.totalSubmissions += 1; + + const createdAt = submission.createdAt instanceof Date + ? submission.createdAt + : new Date(submission.createdAt as unknown as string); + + if (!stat.lastSubmissionAt || createdAt > stat.lastSubmissionAt) { + stat.lastSubmissionAt = createdAt; + } + } + + const projectStats: ProjectStatsItem[] = Array.from(statsByProjectId.values()).map((item) => ({ + projectId: item.projectId, + title: item.title, + slug: item.slug, + status: item.status, + totalSubmissions: item.totalSubmissions, + lastSubmissionAt: item.lastSubmissionAt ? item.lastSubmissionAt.toISOString() : null, + })); + + const summaryStats: SummaryStats = { + totalProjects, + totalSubmissions, + todaySubmissions, + last7DaysSubmissions, + }; + + // 일별 전체 제출 수를 집계해 그래프용 데이터로 변환한다. + const countsByDate = new Map(); + + for (const submission of submissions) { + const createdAt = submission.createdAt instanceof Date + ? submission.createdAt + : new Date(submission.createdAt as unknown as string); + + const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD + const prev = countsByDate.get(dateKey) ?? 0; + countsByDate.set(dateKey, prev + 1); + } + + const dailySubmissions: DailySubmissionItem[] = Array.from(countsByDate.entries()) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + + return NextResponse.json( + { + summaryStats, + projectStats, + dailySubmissions, + }, + { status: 200 }, + ); + } catch (error: any) { + return NextResponse.json( + { + message: "대시보드 데이터를 계산하는 중 오류가 발생했습니다.", + error: error?.message, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..482f5c0 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react"; + +// 대시보드 페이지 (/dashboard) +// - /api/dashboard/overview 에서 요약/프로젝트별 통계를 가져와 카드 형태로 렌더링한다. +// - 비로그인(401) 응답 시 로그인 페이지로 리다이렉트하고 에러 메시지를 표시한다. + +interface SummaryStats { + totalProjects: number; + totalSubmissions: number; + todaySubmissions: number; + last7DaysSubmissions: number; +} + +interface DailySubmissionItem { + // 일별 제출 수 그래프를 위한 집계 데이터 + date: string; // YYYY-MM-DD + count: number; +} + +interface ProjectStatsItem { + projectId: string; + title: string; + slug: string; + status: string; + totalSubmissions: number; + lastSubmissionAt: string | null; +} + +type PageStatus = "idle" | "loading" | "error"; + +export default function DashboardPage() { + const router = useRouter(); + + const [summary, setSummary] = useState(null); + const [projects, setProjects] = useState([]); + const [dailySubmissions, setDailySubmissions] = useState([]); + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + // 마운트 시 대시보드 개요 데이터를 불러온다. + useEffect(() => { + let cancelled = false; + + const load = async () => { + try { + setStatus("loading"); + setErrorMessage(null); + + const res = await fetch("/api/dashboard/overview"); + + if (!res.ok) { + if (cancelled) return; + + if (res.status === 401) { + setStatus("error"); + setErrorMessage("대시보드를 보려면 로그인이 필요합니다. 다시 로그인해 주세요."); + router.push("/login"); + return; + } + + setStatus("error"); + setErrorMessage("대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); + return; + } + + const data = (await res.json()) as { + summaryStats: SummaryStats; + projectStats: ProjectStatsItem[]; + dailySubmissions?: DailySubmissionItem[]; + }; + + if (cancelled) return; + + setSummary(data.summaryStats ?? null); + setProjects(Array.isArray(data.projectStats) ? data.projectStats : []); + setDailySubmissions( + Array.isArray(data.dailySubmissions) ? data.dailySubmissions : [], + ); + setStatus("idle"); + setErrorMessage(null); + } catch { + if (!cancelled) { + setStatus("error"); + setErrorMessage( + "대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + ); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [router]); + + const safeSummary: SummaryStats = + summary ?? { + totalProjects: 0, + totalSubmissions: 0, + todaySubmissions: 0, + last7DaysSubmissions: 0, + }; + + const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { + method: "POST", + }); + + if (!res.ok) { + return; + } + + router.push("/login"); + } catch { + } + }; + + const handleToggleTheme = () => { + if (typeof document === "undefined") { + return; + } + + const root = document.documentElement; + if (!root) { + return; + } + + root.classList.toggle("dark"); + }; + + return ( +
+
+
+

대시보드

+

프로젝트와 폼 제출 현황을 한눈에 확인할 수 있는 요약 화면입니다.

+
+
+ + +
+ + {isMenuOpen && ( +
+ +
+ )} +
+
+
+ +
+ {status === "error" && errorMessage && ( +

{errorMessage}

+ )} + + {status === "loading" && ( +

대시보드 데이터를 불러오는 중입니다...

+ )} + + {/* 상단 요약 위젯 영역 */} +
+
+ 프로젝트 수 + {safeSummary.totalProjects} +
+ +
+ 전체 폼 제출 수 + {safeSummary.totalSubmissions} +
+ +
+ 오늘 제출 수 + {safeSummary.todaySubmissions} +
+ +
+ 최근 7일 제출 수 + {safeSummary.last7DaysSubmissions} +
+
+ + {/* 일별 제출 수 간단 그래프 영역 */} +
+
+

최근 제출 추이 (일별)

+ 최근 활동이 있는 날짜만 표시됩니다. +
+ + {dailySubmissions.length === 0 ? ( +

아직 일별 제출 내역이 없습니다.

+ ) : ( +
+ {dailySubmissions.map((item) => ( +
+
+ {item.count} + {item.date} +
+ ))} +
+ )} +
+ + {/* 프로젝트 카드 리스트 영역 */} +
+

프로젝트별 제출 현황

+ + {projects.length === 0 && status === "idle" && !errorMessage && ( +

아직 생성된 프로젝트가 없거나 제출 내역이 없습니다.

+ )} + + {projects.length > 0 && ( +
+ {projects.map((project) => { + const lastSubmitted = project.lastSubmissionAt + ? new Date(project.lastSubmissionAt).toLocaleString() + : "제출 내역 없음"; + + return ( +
+
+
+

{project.title}

+

{project.slug}

+
+ + {project.status} + +
+ +
+ + 총 제출 {project.totalSubmissions}건 + + 최근 제출: {lastSubmitted} +
+ +
+ + 폼 제출 내역 보기 + + + 퍼블릭 페이지 열기 + +
+
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 212ebf7..f7e850b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,7 +10,7 @@ export const metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9e83cf2..46517aa 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -23,7 +23,7 @@ export default function LoginPage() { try { const res = await fetch("/api/auth/me"); if (!cancelled && res.ok) { - router.push("/projects"); + router.push("/dashboard"); } } catch { // ignore @@ -61,8 +61,8 @@ export default function LoginPage() { return; } - // 성공 시에는 /projects 로 이동한다. - router.push("/projects"); + // 성공 시에는 /dashboard 로 이동한다. + router.push("/dashboard"); } catch { setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); } finally { diff --git a/src/app/page.tsx b/src/app/page.tsx index 061b6ee..c1680e8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + export default function HomePage() { + const router = useRouter(); + + useEffect(() => { + // 메인 페이지 진입 시 대시보드로 안내한다. + router.push("/dashboard"); + }, [router]); + return (
-
-

Page Builder MVP

-

- 에디터와 블록 시스템을 이 위에서 TDD로 구현합니다. -

-
+

대시보드로 이동 중입니다...

); } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 0b76404..3326055 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -11,6 +11,9 @@ import { ListChecks, ChevronLeft, ChevronRight, + LayoutDashboard, + FolderKanban, + SunMoon, } from "lucide-react"; interface ProjectListItem { @@ -29,6 +32,7 @@ export default function ProjectsPage() { const [errorMessage, setErrorMessage] = useState(null); const [selectedSlugs, setSelectedSlugs] = useState([]); const [currentPage, setCurrentPage] = useState(1); + const [isMenuOpen, setIsMenuOpen] = useState(false); const pageSize = 10; useEffect(() => { @@ -179,7 +183,7 @@ export default function ProjectsPage() { window.localStorage.removeItem(`pb:autosave:${slug}`); } } - + if (okSlugs.length !== slugs.length) { setStatus("error"); setErrorMessage( @@ -195,51 +199,101 @@ export default function ProjectsPage() { } }; + const handleToggleTheme = () => { + if (typeof document === "undefined") { + return; + } + + const root = document.documentElement; + if (!root) { + return; + } + + root.classList.toggle("dark"); + }; + return ( -
-
+
+
-

프로젝트 목록

-

저장된 프로젝트들을 한 눈에 볼 수 있는 목록

+

프로젝트 목록

+

저장된 프로젝트들을 한 눈에 볼 수 있는 목록

-
+
+ - -
-
+
{status === "error" && ( -

+

{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}

)} {projects.length === 0 && status === "idle" && ( -

아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.

+

아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.

)} {projects.length > 0 && ( <> -
+
- +
+ + +
diff --git a/src/app/projects/submissions/page.tsx b/src/app/projects/submissions/page.tsx index 4b3a56d..9f2f468 100644 --- a/src/app/projects/submissions/page.tsx +++ b/src/app/projects/submissions/page.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react"; interface SubmissionItem { id: string; @@ -18,6 +20,7 @@ export default function AllProjectSubmissionsPage() { const [submissions, setSubmissions] = useState([]); const [status, setStatus] = useState("idle"); const [errorMessage, setErrorMessage] = useState(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); useEffect(() => { let cancelled = false; @@ -82,39 +85,113 @@ export default function AllProjectSubmissionsPage() { .join("\n"); }; + const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { + method: "POST", + }); + + if (!res.ok) { + setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); + return; + } + + router.push("/login"); + } catch { + setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); + } + }; + return ( -
-
+
+
-

전체 폼 제출 내역

-

로그인한 계정의 모든 프로젝트에 대한 폼 제출 데이터를 확인할 수 있습니다.

+

전체 폼 제출 내역

+

로그인한 계정의 모든 프로젝트에 대한 폼 제출 데이터를 확인할 수 있습니다.

-
-
+
{status === "error" && errorMessage && ( -

{errorMessage}

+

{errorMessage}

)} {status === "loading" && ( -

폼 제출 내역을 불러오는 중입니다...

+

폼 제출 내역을 불러오는 중입니다...

)} {submissions.length === 0 && status === "idle" && !errorMessage && ( -

아직 수집된 폼 제출 내역이 없습니다.

+

아직 수집된 폼 제출 내역이 없습니다.

)} {submissions.length > 0 && ( diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 298537e..d8e4e85 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -23,7 +23,7 @@ export default function SignupPage() { try { const res = await fetch("/api/auth/me"); if (!cancelled && res.ok) { - router.push("/projects"); + router.push("/dashboard"); } } catch { // ignore @@ -61,7 +61,7 @@ export default function SignupPage() { return; } - router.push("/projects"); + router.push("/dashboard"); } catch { setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."); } finally { diff --git a/tailwind.config.js b/tailwind.config.js index 17953f3..effa170 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { + darkMode: "class", content: [ "./src/app/**/*.{ts,tsx}", "./src/components/**/*.{ts,tsx}", diff --git a/tests/api/dashboard.spec.ts b/tests/api/dashboard.spec.ts new file mode 100644 index 0000000..293c650 --- /dev/null +++ b/tests/api/dashboard.spec.ts @@ -0,0 +1,220 @@ +import "dotenv/config"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { signAccessToken } from "@/features/auth/authCrypto"; + +// /api/dashboard/overview TDD +// - 로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다. +// - 로그인하지 않은 경우 401 을 반환해야 한다. +// - 프로젝트/제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다. + +const BASE_URL = "http://localhost"; + +const inMemoryProjects: any[] = []; +const inMemoryFormSubmissions: any[] = []; + +vi.mock("@prisma/client", () => { + class PrismaClientMock { + project = { + findMany: async (args: any = {}) => { + const where = args.where ?? {}; + + let items = [...inMemoryProjects]; + + if (where.userId) { + items = items.filter((p) => p.userId === where.userId); + } + + return items; + }, + }; + + formSubmission = { + findMany: async (args: any = {}) => { + const where = args.where ?? {}; + + let items = [...inMemoryFormSubmissions]; + + if (where.userId) { + items = items.filter((s) => s.userId === where.userId); + } + + return items; + }, + }; + } + + return { PrismaClient: PrismaClientMock }; +}); + +const TEST_USER = { id: "user-1", email: "dashboard@example.com", tokenVersion: 1 }; +const OTHER_USER = { id: "user-2", email: "dashboard2@example.com", tokenVersion: 1 }; + +async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) { + const token = await signAccessToken(user); + return { cookie: `pb_access=${token}` }; +} + +async function buildAuthHeaders() { + return buildAuthHeadersFor(TEST_USER); +} + +beforeEach(() => { + process.env.AUTH_JWT_SECRET = "test-jwt-secret-dashboard"; + inMemoryProjects.length = 0; + inMemoryFormSubmissions.length = 0; +}); + +describe("/api/dashboard/overview", () => { + it("로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다", async () => { + const project1 = { + id: "proj-1", + userId: TEST_USER.id, + title: "테스트 프로젝트 A", + slug: "test-project-a", + status: "DRAFT", + }; + + const project2 = { + id: "proj-2", + userId: TEST_USER.id, + title: "테스트 프로젝트 B", + slug: "test-project-b", + status: "PUBLISHED", + }; + + const projectOther = { + id: "proj-3", + userId: OTHER_USER.id, + title: "다른 유저 프로젝트", + slug: "other-project", + status: "DRAFT", + }; + + inMemoryProjects.push(project1, project2, projectOther); + + const now = new Date("2025-01-03T12:00:00.000Z"); + const yesterday = new Date("2025-01-02T12:00:00.000Z"); + const eightDaysAgo = new Date("2024-12-26T12:00:00.000Z"); + + inMemoryFormSubmissions.push( + { + id: "sub-1", + projectId: project1.id, + projectSlug: project1.slug, + userId: TEST_USER.id, + createdAt: now, + payloadJson: { message: "A-1" }, + sensitiveEnc: undefined, + metaJson: null, + }, + { + id: "sub-2", + projectId: project1.id, + projectSlug: project1.slug, + userId: TEST_USER.id, + createdAt: yesterday, + payloadJson: { message: "A-2" }, + sensitiveEnc: undefined, + metaJson: null, + }, + { + id: "sub-3", + projectId: project2.id, + projectSlug: project2.slug, + userId: TEST_USER.id, + createdAt: eightDaysAgo, + payloadJson: { message: "B-1" }, + sensitiveEnc: undefined, + metaJson: null, + }, + { + id: "sub-4", + projectId: projectOther.id, + projectSlug: projectOther.slug, + userId: OTHER_USER.id, + createdAt: now, + payloadJson: { message: "OTHER" }, + sensitiveEnc: undefined, + metaJson: null, + }, + ); + + const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route"); + + const headers = await buildAuthHeaders(); + + const res = await getDashboardOverview( + new Request(`${BASE_URL}/api/dashboard/overview`, { + headers, + }), + ); + + expect(res.status).toBe(200); + + const json = (await res.json()) as any; + + expect(json.summaryStats.totalProjects).toBe(2); + expect(json.summaryStats.totalSubmissions).toBe(3); + + expect(Array.isArray(json.projectStats)).toBe(true); + expect(json.projectStats.length).toBe(2); + + const proj1Stats = json.projectStats.find((p: any) => p.slug === project1.slug); + const proj2Stats = json.projectStats.find((p: any) => p.slug === project2.slug); + + expect(proj1Stats).toBeTruthy(); + expect(proj1Stats.totalSubmissions).toBe(2); + expect(proj1Stats.lastSubmissionAt).toBe(now.toISOString()); + + expect(proj2Stats).toBeTruthy(); + expect(proj2Stats.totalSubmissions).toBe(1); + expect(proj2Stats.lastSubmissionAt).toBe(eightDaysAgo.toISOString()); + + const daily = (json as any).dailySubmissions; + + expect(Array.isArray(daily)).toBe(true); + expect(daily.length).toBe(3); + + const dates = daily.map((item: any) => item.date).sort(); + expect(dates).toEqual(["2024-12-26", "2025-01-02", "2025-01-03"]); + + const countsByDate = (daily as any[]).reduce((acc: Record, item: any) => { + acc[item.date] = item.count; + return acc; + }, {} as Record); + + expect(countsByDate["2025-01-03"]).toBe(1); + expect(countsByDate["2025-01-02"]).toBe(1); + expect(countsByDate["2024-12-26"]).toBe(1); + }); + + it("로그인하지 않은 경우 401 을 반환해야 한다", async () => { + const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route"); + + const res = await getDashboardOverview( + new Request(`${BASE_URL}/api/dashboard/overview`), + ); + + expect(res.status).toBe(401); + }); + + it("프로젝트와 제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다", async () => { + const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route"); + + const headers = await buildAuthHeaders(); + + const res = await getDashboardOverview( + new Request(`${BASE_URL}/api/dashboard/overview`, { + headers, + }), + ); + + expect(res.status).toBe(200); + + const json = (await res.json()) as any; + expect(json.summaryStats.totalProjects).toBe(0); + expect(json.summaryStats.totalSubmissions).toBe(0); + expect(Array.isArray(json.projectStats)).toBe(true); + expect(json.projectStats.length).toBe(0); + }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 6d79041..48d04ac 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -48,7 +48,7 @@ test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다 await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible(); }); -test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => { +test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => { const email = `e2e+${Date.now()}@example.com`; const password = "securePass1"; @@ -98,9 +98,9 @@ test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 await page.getByLabel("비밀번호").fill(password); await page.getByRole("button", { name: "회원가입" }).click(); - // /projects 로 이동했는지, 헤더가 보이는지 확인 - await expect(page).toHaveURL(/\/projects/); - await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible(); + // /dashboard 로 이동했는지, 헤더가 보이는지 확인 + await expect(page).toHaveURL(/\/dashboard/); + await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible(); // 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다. await page.goto("/editor"); diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..6953392 --- /dev/null +++ b/tests/e2e/dashboard.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from "@playwright/test"; + +// 대시보드 E2E TDD +// - 비로그인 사용자는 /dashboard 접근 시 /login 으로 리다이렉트되어야 한다. +// - 로그인 사용자는 /dashboard 에서 요약 지표 + 프로젝트 카드 리스트를 볼 수 있어야 한다. +// - 백엔드/DB 에 의존하지 않도록 /api/dashboard/overview 를 Playwright 라우팅으로 목킹한다. + +test("비로그인 사용자가 /dashboard 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => { + await page.route("**/api/dashboard/overview", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ message: "로그인이 필요합니다." }), + }); + }); + + await page.goto("/dashboard"); + + await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible(); +}); + +test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로젝트 카드 리스트, 일별 그래프를 볼 수 있어야 한다", async ({ page }) => { + await page.route("**/api/dashboard/overview", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + summaryStats: { + totalProjects: 2, + totalSubmissions: 3, + todaySubmissions: 1, + last7DaysSubmissions: 3, + }, + projectStats: [ + { + projectId: "proj-1", + title: "테스트 프로젝트 A", + slug: "test-project-a", + status: "DRAFT", + totalSubmissions: 2, + lastSubmissionAt: "2025-01-02T12:00:00.000Z", + }, + { + projectId: "proj-2", + title: "테스트 프로젝트 B", + slug: "test-project-b", + status: "PUBLISHED", + totalSubmissions: 1, + lastSubmissionAt: "2025-01-03T09:30:00.000Z", + }, + ], + dailySubmissions: [ + { date: "2024-12-26", count: 1 }, + { date: "2025-01-02", count: 1 }, + { date: "2025-01-03", count: 1 }, + ], + }), + }); + }); + + await page.goto("/dashboard"); + + // 대시보드 헤더가 보여야 한다. + await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible(); + + // 상단 헤더 내비게이션이 보여야 한다. + const dashboardLink = page.getByRole("link", { name: "대시보드" }); + await expect(dashboardLink).toBeVisible(); + + const projectsLink = page.getByRole("link", { name: "프로젝트 목록" }); + await expect(projectsLink).toBeVisible(); + + const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" }); + await expect(submissionsLink).toBeVisible(); + + const menuButton = page.getByRole("button", { name: "메뉴" }); + await expect(menuButton).toBeVisible(); + + // 상단 요약 위젯의 지표들이 기대 값으로 렌더되어야 한다. + const totalProjects = page.getByTestId("dashboard-summary-total-projects"); + await expect(totalProjects).toContainText("2"); + + const totalSubmissions = page.getByTestId("dashboard-summary-total-submissions"); + await expect(totalSubmissions).toContainText("3"); + + const todaySubmissions = page.getByTestId("dashboard-summary-today-submissions"); + await expect(todaySubmissions).toContainText("1"); + + const last7DaysSubmissions = page.getByTestId( + "dashboard-summary-last7days-submissions", + ); + await expect(last7DaysSubmissions).toContainText("3"); + + // 프로젝트 카드 리스트가 2개 렌더링되어야 한다. + const projectCards = page.getByTestId("dashboard-project-card"); + await expect(projectCards).toHaveCount(2); + + await expect(projectCards.nth(0)).toContainText("테스트 프로젝트 A"); + await expect(projectCards.nth(0)).toContainText("test-project-a"); + await expect(projectCards.nth(0)).toContainText("2"); + + await expect(projectCards.nth(1)).toContainText("테스트 프로젝트 B"); + await expect(projectCards.nth(1)).toContainText("test-project-b"); + await expect(projectCards.nth(1)).toContainText("1"); + + // 일별 제출 수 그래프가 렌더링되어야 한다. + const dailyChart = page.getByTestId("dashboard-daily-chart"); + await expect(dailyChart).toBeVisible(); + + const dailyBars = page.getByTestId("dashboard-daily-chart-bar"); + await expect(dailyBars).toHaveCount(3); + + // 메뉴 버튼을 클릭하면 로그아웃 항목이 보여야 한다. + await menuButton.click(); + await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible(); +}); + +test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한다", async ({ page }) => { + await page.route("**/api/dashboard/overview", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + summaryStats: { + totalProjects: 1, + totalSubmissions: 0, + todaySubmissions: 0, + last7DaysSubmissions: 0, + }, + projectStats: [], + dailySubmissions: [], + }), + }); + }); + + await page.goto("/"); + + await expect(page).toHaveURL(/\/dashboard/); + await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible(); +}); diff --git a/tests/e2e/projects.spec.ts b/tests/e2e/projects.spec.ts index e553c34..630d60d 100644 --- a/tests/e2e/projects.spec.ts +++ b/tests/e2e/projects.spec.ts @@ -219,7 +219,110 @@ test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해 await expect(page.getByText("홍길동")).toBeVisible(); await expect(page.getByText("user@example.com")).toBeVisible(); - await expect(page.getByText("010-1234-5678")).toBeVisible(); + await expect(page.getByText("message: 안녕하세요")).toBeVisible(); await expect(page.getByText("1990-01-01")).toBeVisible(); await expect(page.getByText("message: 안녕하세요")).toBeVisible(); }); + +test("프로젝트 목록 헤더에서 대시보드로 이동할 수 있어야 한다", async ({ page }) => { + await page.route("**/api/auth/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }), + }); + }); + + await page.route("**/api/projects", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + + if (url.pathname === "/api/projects" && method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: "1", + title: "테스트 프로젝트 A", + slug: "test-project-a", + status: "DRAFT", + createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(), + updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(), + }, + ]), + }); + return; + } + + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ message: "Not implemented in E2E mock" }), + }); + }); + + await page.route("**/api/dashboard/overview", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + summaryStats: { + totalProjects: 1, + totalSubmissions: 0, + todaySubmissions: 0, + last7DaysSubmissions: 0, + }, + projectStats: [], + dailySubmissions: [], + }), + }); + }); + + await page.goto("/projects"); + + await expect(page.getByText("테스트 프로젝트 A")).toBeVisible(); + + await page.getByRole("link", { name: "대시보드" }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible(); +}); + +test("전체 제출 내역 페이지에서 공통 GNB와 메뉴가 보여야 한다", async ({ page }) => { + await page.route("**/api/projects/submissions", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/api/auth/logout", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ message: "ok" }), + }); + }); + + await page.goto("/projects/submissions"); + + await expect(page.getByRole("heading", { name: "전체 폼 제출 내역" })).toBeVisible(); + + const dashboardLink = page.getByRole("link", { name: "대시보드" }); + await expect(dashboardLink).toBeVisible(); + + const projectsLink = page.getByRole("link", { name: "프로젝트 목록" }); + await expect(projectsLink).toBeVisible(); + + const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" }); + await expect(submissionsLink).toBeVisible(); + + const menuButton = page.getByRole("button", { name: "메뉴" }); + await expect(menuButton).toBeVisible(); + + await menuButton.click(); + await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible(); +}); diff --git a/tests/unit/AllProjectSubmissionsPage.spec.tsx b/tests/unit/AllProjectSubmissionsPage.spec.tsx index 52baedf..81d1bc4 100644 --- a/tests/unit/AllProjectSubmissionsPage.spec.tsx +++ b/tests/unit/AllProjectSubmissionsPage.spec.tsx @@ -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(); + + 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(); 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(); + + 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"); }); }); }); diff --git a/tests/unit/DashboardPage.spec.tsx b/tests/unit/DashboardPage.spec.tsx new file mode 100644 index 0000000..d0c4711 --- /dev/null +++ b/tests/unit/DashboardPage.spec.tsx @@ -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(); + + 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(); + + 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); + }); +}); diff --git a/tests/unit/LoginPage.spec.tsx b/tests/unit/LoginPage.spec.tsx index 0f713ba..bda4543 100644 --- a/tests/unit/LoginPage.spec.tsx +++ b/tests/unit/LoginPage.spec.tsx @@ -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"); }); }); }); diff --git a/tests/unit/ProjectsPage.spec.tsx b/tests/unit/ProjectsPage.spec.tsx index eb69091..3b26f15 100644 --- a/tests/unit/ProjectsPage.spec.tsx +++ b/tests/unit/ProjectsPage.spec.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + // 상단 툴바에서 선택 삭제와 새 프로젝트 만들기 버튼이 함께 보여야 한다. + 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); diff --git a/tests/unit/SignupPage.spec.tsx b/tests/unit/SignupPage.spec.tsx index 869f336..efa67a5 100644 --- a/tests/unit/SignupPage.spec.tsx +++ b/tests/unit/SignupPage.spec.tsx @@ -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"); }); }); }); -- 2.52.0