// ABOUTME: Unit tests for the Dashboard page component. // ABOUTME: Tests data fetching, component rendering, override toggles, and error handling. import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock showToast utility with vi.hoisted to avoid hoisting issues const mockShowToast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), })); vi.mock("@/components/ui/toaster", () => ({ showToast: mockShowToast, })); // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; import Dashboard from "./page"; // Mock response data matching /api/today shape const mockTodayResponse = { decision: { status: "TRAIN", reason: "All systems go! Body battery and HRV look good.", icon: "๐Ÿ’ช", }, cycleDay: 12, phase: "FOLLICULAR", phaseConfig: { name: "FOLLICULAR", days: [6, 13], weeklyLimit: 300, dailyAvg: 43, trainingType: "Build strength and endurance", }, daysUntilNextPhase: 2, cycleLength: 31, biometrics: { hrvStatus: "Balanced", bodyBatteryCurrent: 75, bodyBatteryYesterdayLow: 45, weekIntensityMinutes: 120, phaseLimit: 300, }, nutrition: { seeds: "Flax & Pumpkin seeds", carbRange: "100-150g", ketoGuidance: "Optional", }, }; const mockUserResponse = { id: "user123", email: "test@example.com", activeOverrides: [], cycleLength: 31, lastPeriodDate: "2024-01-01", garminConnected: true, notificationTime: "07:00", timezone: "America/New_York", }; describe("Dashboard", () => { beforeEach(() => { vi.clearAllMocks(); mockShowToast.success.mockClear(); mockShowToast.error.mockClear(); mockShowToast.info.mockClear(); }); describe("rendering", () => { it("renders the PhaseFlow header", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); expect(screen.getByText("PhaseFlow")).toBeInTheDocument(); }); it("renders the settings link", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); const settingsLink = screen.getByRole("link", { name: /settings/i }); expect(settingsLink).toBeInTheDocument(); expect(settingsLink).toHaveAttribute("href", "/settings"); }); it("shows loading state initially", () => { mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves render(); // Check for skeleton components which have aria-label "Loading ..." expect( screen.getByRole("region", { name: /loading decision/i }), ).toBeInTheDocument(); }); }); describe("data fetching", () => { it("fetches data from /api/today on mount", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/today"); }); }); it("fetches user data from /api/user on mount", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/user"); }); }); }); describe("DecisionCard", () => { it("displays the decision status", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("TRAIN")).toBeInTheDocument(); }); }); it("displays the decision icon", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("๐Ÿ’ช")).toBeInTheDocument(); }); }); it("displays the decision reason", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/all systems go/i)).toBeInTheDocument(); }); }); }); describe("DataPanel", () => { it("displays body battery current", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/body battery.*75/i)).toBeInTheDocument(); }); }); it("displays HRV status", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced"); }); }); it("displays week intensity and phase limit", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/120\/300 min/i)).toBeInTheDocument(); }); }); }); describe("NutritionPanel", () => { it("displays seed guidance", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/flax.*pumpkin/i)).toBeInTheDocument(); }); }); it("displays carb range", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/100-150g/i)).toBeInTheDocument(); }); }); it("displays keto guidance", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/keto.*optional/i)).toBeInTheDocument(); }); }); }); describe("OverrideToggles", () => { it("displays all override options", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("Flare Mode")).toBeInTheDocument(); expect(screen.getByText("High Stress")).toBeInTheDocument(); expect(screen.getByText("Poor Sleep")).toBeInTheDocument(); expect(screen.getByText("PMS Symptoms")).toBeInTheDocument(); }); }); it("shows active overrides as checked", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, activeOverrides: ["flare", "sleep"], }), }); render(); await waitFor(() => { const flareCheckbox = screen.getByRole("checkbox", { name: /flare mode/i, }); const sleepCheckbox = screen.getByRole("checkbox", { name: /poor sleep/i, }); const stressCheckbox = screen.getByRole("checkbox", { name: /high stress/i, }); expect(flareCheckbox).toBeChecked(); expect(sleepCheckbox).toBeChecked(); expect(stressCheckbox).not.toBeChecked(); }); }); it("calls POST /api/overrides when toggling on an override", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("Flare Mode")).toBeInTheDocument(); }); // Clear mock to track new calls mockFetch.mockClear(); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ activeOverrides: ["flare"] }), }); const flareCheckbox = screen.getByRole("checkbox", { name: /flare mode/i, }); fireEvent.click(flareCheckbox); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ override: "flare" }), }); }); }); it("calls DELETE /api/overrides when toggling off an override", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, activeOverrides: ["flare"], }), }); render(); await waitFor(() => { const flareCheckbox = screen.getByRole("checkbox", { name: /flare mode/i, }); expect(flareCheckbox).toBeChecked(); }); // Clear mock to track new calls mockFetch.mockClear(); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ activeOverrides: [] }), }); const flareCheckbox = screen.getByRole("checkbox", { name: /flare mode/i, }); fireEvent.click(flareCheckbox); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/overrides", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ override: "flare" }), }); }); }); it("refetches today data after toggle to update decision", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("Flare Mode")).toBeInTheDocument(); }); // Clear mock and set up for toggle + refetch mockFetch.mockClear(); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ activeOverrides: ["flare"] }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockTodayResponse, decision: { status: "REST", reason: "Flare mode active - rest is essential.", icon: "๐Ÿ”ฅ", }, }), }); const flareCheckbox = screen.getByRole("checkbox", { name: /flare mode/i, }); fireEvent.click(flareCheckbox); await waitFor(() => { expect(screen.getByText("REST")).toBeInTheDocument(); expect(screen.getByText(/flare mode active/i)).toBeInTheDocument(); }); }); it("shows error toast when toggle fails", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("Flare Mode")).toBeInTheDocument(); }); // Clear mock and set up for failed toggle mockFetch.mockClear(); mockFetch.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to update override" }), }); const flareCheckbox = screen.getByRole("checkbox", { name: /flare mode/i, }); fireEvent.click(flareCheckbox); await waitFor(() => { expect(mockShowToast.error).toHaveBeenCalledWith( "Failed to update override", ); }); }); }); describe("error handling", () => { it("shows error message when /api/today fails", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({ error: "Internal server error" }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); expect(screen.getByText(/error/i)).toBeInTheDocument(); }); }); it("shows setup message when user has no lastPeriodDate", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); await waitFor(() => { // Multiple alerts may be present (error alert + onboarding banner) const alerts = screen.getAllByRole("alert"); expect(alerts.length).toBeGreaterThan(0); // Check for the specific help text about getting started expect( screen.getByText(/please log your period start date to get started/i), ).toBeInTheDocument(); }); }); }); describe("cycle information", () => { it("displays current cycle day", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText(/day 12/i)).toBeInTheDocument(); }); }); it("displays current phase", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { // Check for phase in the cycle info header (uppercase, with Day X prefix) expect(screen.getByText(/Day 12 ยท FOLLICULAR/)).toBeInTheDocument(); }); }); }); describe("onboarding banners", () => { it("shows no onboarding banners when setup is complete", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUserResponse), }); render(); await waitFor(() => { expect(screen.getByText("TRAIN")).toBeInTheDocument(); }); // Should not have any onboarding messages expect( screen.queryByText(/Connect your Garmin to get started/i), ).not.toBeInTheDocument(); expect( screen.queryByText(/Set your last period date/i), ).not.toBeInTheDocument(); expect( screen.queryByText(/Set your preferred notification time/i), ).not.toBeInTheDocument(); }); it("shows Garmin banner when not connected", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, garminConnected: false, }), }); render(); await waitFor(() => { expect( screen.getByText(/Connect your Garmin to get started/i), ).toBeInTheDocument(); }); // Verify the link points to Garmin settings const link = screen.getByRole("link", { name: /Connect/i }); expect(link).toHaveAttribute("href", "/settings/garmin"); }); it("shows notification time banner when not set", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, notificationTime: "", }), }); render(); await waitFor(() => { expect( screen.getByText(/Set your preferred notification time/i), ).toBeInTheDocument(); }); // Verify the link points to settings const link = screen.getByRole("link", { name: /Configure/i }); expect(link).toHaveAttribute("href", "/settings"); }); it("shows multiple banners when multiple items need setup", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, garminConnected: false, notificationTime: "", }), }); render(); await waitFor(() => { expect( screen.getByText(/Connect your Garmin to get started/i), ).toBeInTheDocument(); expect( screen.getByText(/Set your preferred notification time/i), ).toBeInTheDocument(); }); }); it("shows period date banner with action button", async () => { // Note: When lastPeriodDate is null, /api/today returns 400 error // But we still want to show the onboarding banner if userData shows no period // This test checks that the banner appears when userData indicates no period mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); // The error state handles this case with a specific message await waitFor(() => { expect( screen.getByText(/please log your period start date to get started/i), ).toBeInTheDocument(); }); }); }); describe("period date modal flow", () => { it("shows onboarding banner when todayData fails but userData shows no lastPeriodDate", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); await waitFor(() => { expect( screen.getByText(/Set your last period date for accurate tracking/i), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /set date/i }), ).toBeInTheDocument(); }); }); it("opens period date modal when clicking Set date button", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /set date/i }), ).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /set date/i })); await waitFor(() => { expect( screen.getByRole("dialog", { name: /set period date/i }), ).toBeInTheDocument(); }); }); it("calls POST /api/cycle/period when submitting date in modal", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /set date/i }), ).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /set date/i })); await waitFor(() => { expect( screen.getByRole("dialog", { name: /set period date/i }), ).toBeInTheDocument(); }); // Set up mock for the period POST and subsequent refetch mockFetch.mockClear(); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ message: "Period start date logged successfully", lastPeriodDate: "2024-01-15", cycleDay: 1, phase: "MENSTRUAL", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: "2024-01-15", }), }); const dateInput = screen.getByLabelText( /when did your last period start/i, ); fireEvent.change(dateInput, { target: { value: "2024-01-15" } }); fireEvent.click(screen.getByRole("button", { name: /save/i })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/cycle/period", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ startDate: "2024-01-15" }), }); }); }); it("closes modal and refetches data after successful submission", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /set date/i }), ).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /set date/i })); await waitFor(() => { expect( screen.getByRole("dialog", { name: /set period date/i }), ).toBeInTheDocument(); }); // Set up mock for the period POST and successful refetch mockFetch.mockClear(); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ message: "Period start date logged successfully", lastPeriodDate: "2024-01-15", cycleDay: 1, phase: "MENSTRUAL", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodayResponse), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: "2024-01-15", }), }); const dateInput = screen.getByLabelText( /when did your last period start/i, ); fireEvent.change(dateInput, { target: { value: "2024-01-15" } }); fireEvent.click(screen.getByRole("button", { name: /save/i })); // Modal should close and dashboard should show normal content await waitFor(() => { expect( screen.queryByRole("dialog", { name: /set period date/i }), ).not.toBeInTheDocument(); // Dashboard should now show the decision card expect(screen.getByText("TRAIN")).toBeInTheDocument(); }); }); it("shows error in modal when API call fails", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ error: "User has no lastPeriodDate set. Please log your period start date first.", }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUserResponse, lastPeriodDate: null, }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /set date/i }), ).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /set date/i })); await waitFor(() => { expect( screen.getByRole("dialog", { name: /set period date/i }), ).toBeInTheDocument(); }); // Set up mock for failed API call mockFetch.mockClear(); mockFetch.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({ error: "Failed to update period date" }), }); const dateInput = screen.getByLabelText( /when did your last period start/i, ); fireEvent.change(dateInput, { target: { value: "2024-01-15" } }); fireEvent.click(screen.getByRole("button", { name: /save/i })); // Error should appear in modal (there may be multiple alerts - dashboard error + modal error) await waitFor(() => { const alerts = screen.getAllByRole("alert"); const modalError = alerts.find((alert) => alert.textContent?.includes("Failed to update period date"), ); expect(modalError).toBeInTheDocument(); }); // Modal should still be open expect( screen.getByRole("dialog", { name: /set period date/i }), ).toBeInTheDocument(); }); }); });