// ABOUTME: Unit tests for the Calendar page component. // ABOUTME: Tests calendar rendering, month navigation, ICS subscription, and token regeneration. import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock next/navigation const mockPush = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush, }), })); // Mock fetch const mockFetch = vi.fn(); global.fetch = mockFetch; import CalendarPage from "./page"; describe("CalendarPage", () => { const mockUser = { id: "user123", email: "test@example.com", cycleLength: 28, lastPeriodDate: "2026-01-01", calendarToken: "abc123def456", garminConnected: false, activeOverrides: [], }; beforeEach(() => { vi.clearAllMocks(); mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(mockUser), }); }); describe("rendering", () => { it("renders the calendar heading", async () => { render(); await waitFor(() => { expect( screen.getByRole("heading", { name: /^calendar$/i, level: 1 }), ).toBeInTheDocument(); }); }); it("renders the MonthView component with current month", async () => { render(); const today = new Date(); const expectedMonth = today.toLocaleDateString("en-US", { month: "long", year: "numeric", }); await waitFor(() => { expect(screen.getByText(expectedMonth)).toBeInTheDocument(); }); }); it("renders day-of-week headers from MonthView", async () => { render(); await waitFor(() => { expect(screen.getByText("Sun")).toBeInTheDocument(); expect(screen.getByText("Mon")).toBeInTheDocument(); }); }); it("renders calendar days", async () => { render(); await waitFor(() => { // January has 31 days expect(screen.getByText("15")).toBeInTheDocument(); expect(screen.getByText("31")).toBeInTheDocument(); }); }); it("renders back link to dashboard", async () => { render(); await waitFor(() => { expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute( "href", "/", ); }); }); }); describe("data loading", () => { it("fetches user data on mount", async () => { render(); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/user"); }); }); it("shows loading state while fetching", async () => { let resolveUser: (value: unknown) => void = () => {}; const userPromise = new Promise((resolve) => { resolveUser = resolve; }); mockFetch.mockReturnValue({ ok: true, json: () => userPromise, }); render(); expect(screen.getByText(/loading/i)).toBeInTheDocument(); resolveUser(mockUser); await waitFor(() => { expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); }); }); it("shows error if fetching fails", async () => { mockFetch.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to fetch user" }), }); render(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); }); }); describe("month navigation", () => { it("navigates to previous month when clicking previous button", async () => { render(); const today = new Date(); const currentMonth = today.toLocaleDateString("en-US", { month: "long", year: "numeric", }); const prevMonth = new Date(today.getFullYear(), today.getMonth() - 1); const prevMonthStr = prevMonth.toLocaleDateString("en-US", { month: "long", year: "numeric", }); await waitFor(() => { expect(screen.getByText(currentMonth)).toBeInTheDocument(); }); const prevButton = screen.getByRole("button", { name: /previous month/i, }); fireEvent.click(prevButton); expect(screen.getByText(prevMonthStr)).toBeInTheDocument(); }); it("navigates to next month when clicking next button", async () => { render(); const today = new Date(); const currentMonth = today.toLocaleDateString("en-US", { month: "long", year: "numeric", }); const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1); const nextMonthStr = nextMonth.toLocaleDateString("en-US", { month: "long", year: "numeric", }); await waitFor(() => { expect(screen.getByText(currentMonth)).toBeInTheDocument(); }); const nextButton = screen.getByRole("button", { name: /next month/i }); fireEvent.click(nextButton); expect(screen.getByText(nextMonthStr)).toBeInTheDocument(); }); it("returns to current month when clicking Today button", async () => { render(); const today = new Date(); const currentMonth = today.toLocaleDateString("en-US", { month: "long", year: "numeric", }); const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1); const nextMonthStr = nextMonth.toLocaleDateString("en-US", { month: "long", year: "numeric", }); await waitFor(() => { expect(screen.getByText(currentMonth)).toBeInTheDocument(); }); // Navigate away first const nextButton = screen.getByRole("button", { name: /next month/i }); fireEvent.click(nextButton); expect(screen.getByText(nextMonthStr)).toBeInTheDocument(); // Click Today to return const todayButton = screen.getByRole("button", { name: /^today$/i }); fireEvent.click(todayButton); expect(screen.getByText(currentMonth)).toBeInTheDocument(); }); }); describe("ICS subscription", () => { it("renders ICS subscription section", async () => { render(); await waitFor(() => { expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument(); }); }); it("displays calendar subscription URL when token exists", async () => { render(); await waitFor(() => { // The URL input should contain the token const urlInput = screen.getByRole("textbox") as HTMLInputElement; expect(urlInput.value).toContain("abc123def456.ics"); }); }); it("shows instructions for subscribing", async () => { render(); await waitFor(() => { expect( screen.getByText(/subscribe to this calendar/i), ).toBeInTheDocument(); }); }); it("renders copy URL button", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /copy/i }), ).toBeInTheDocument(); }); }); it("renders regenerate token button", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /regenerate/i }), ).toBeInTheDocument(); }); }); }); describe("token regeneration", () => { it("calls regenerate API when clicking regenerate button", async () => { const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "newtoken123", url: "http://localhost/api/calendar/user123/newtoken123.ics", }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /regenerate/i }), ).toBeInTheDocument(); }); const regenerateButton = screen.getByRole("button", { name: /regenerate/i, }); fireEvent.click(regenerateButton); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( "/api/calendar/regenerate-token", { method: "POST", }, ); }); confirmSpy.mockRestore(); }); it("updates displayed URL after regeneration", async () => { const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "newtoken123", url: "http://localhost/api/calendar/user123/newtoken123.ics", }), }); render(); await waitFor(() => { const urlInput = screen.getByRole("textbox") as HTMLInputElement; expect(urlInput.value).toContain("abc123def456.ics"); }); const regenerateButton = screen.getByRole("button", { name: /regenerate/i, }); fireEvent.click(regenerateButton); await waitFor(() => { const urlInput = screen.getByRole("textbox") as HTMLInputElement; expect(urlInput.value).toContain("newtoken123.ics"); }); confirmSpy.mockRestore(); }); it("shows confirmation dialog before regenerating", async () => { // Mock window.confirm const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "newtoken123", url: "http://localhost/api/calendar/user123/newtoken123.ics", }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /regenerate/i }), ).toBeInTheDocument(); }); const regenerateButton = screen.getByRole("button", { name: /regenerate/i, }); fireEvent.click(regenerateButton); expect(confirmSpy).toHaveBeenCalled(); confirmSpy.mockRestore(); }); it("does not regenerate if user cancels confirmation", async () => { const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /regenerate/i }), ).toBeInTheDocument(); }); const regenerateButton = screen.getByRole("button", { name: /regenerate/i, }); fireEvent.click(regenerateButton); // Should not call the regenerate API expect(mockFetch).toHaveBeenCalledTimes(1); // Only initial fetch confirmSpy.mockRestore(); }); }); describe("phase legend", () => { it("displays phase legend from MonthView", async () => { render(); await waitFor(() => { expect(screen.getByText(/menstrual/i)).toBeInTheDocument(); expect(screen.getByText(/follicular/i)).toBeInTheDocument(); expect(screen.getByText(/ovulation/i)).toBeInTheDocument(); expect(screen.getByText(/early luteal/i)).toBeInTheDocument(); expect(screen.getByText(/late luteal/i)).toBeInTheDocument(); }); }); }); describe("no calendar token", () => { it("shows generate button when no token exists", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUser, calendarToken: null }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /generate calendar url/i }), ).toBeInTheDocument(); }); }); it("does not show URL when no token exists", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUser, calendarToken: null }), }); render(); await waitFor(() => { expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument(); }); expect(screen.queryByText(/\.ics/i)).not.toBeInTheDocument(); }); }); });