// ABOUTME: Unit tests for the Settings page component. // ABOUTME: Tests form rendering, data loading, validation, and save flow. 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 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 const mockFetch = vi.fn(); global.fetch = mockFetch; import SettingsPage from "./page"; describe("SettingsPage", () => { const mockUser = { id: "user123", email: "test@example.com", cycleLength: 28, notificationTime: "08:00", timezone: "America/New_York", garminConnected: false, activeOverrides: [], lastPeriodDate: "2024-01-01", intensityGoalMenstrual: 75, intensityGoalFollicular: 150, intensityGoalOvulation: 100, intensityGoalEarlyLuteal: 120, intensityGoalLateLuteal: 50, }; beforeEach(() => { vi.clearAllMocks(); mockShowToast.success.mockClear(); mockShowToast.error.mockClear(); mockShowToast.info.mockClear(); mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(mockUser), }); }); describe("rendering", () => { it("renders the settings heading", async () => { render(); await waitFor(() => { expect( screen.getByRole("heading", { name: /settings/i }), ).toBeInTheDocument(); }); }); it("renders cycle length input", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); }); it("renders notification time input", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/notification time/i)).toBeInTheDocument(); }); }); it("renders timezone input", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/timezone/i)).toBeInTheDocument(); }); }); it("renders a save button", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /save/i }), ).toBeInTheDocument(); }); }); it("renders a back link to dashboard", async () => { render(); await waitFor(() => { expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute( "href", "/", ); }); }); it("displays email as read-only", async () => { render(); await waitFor(() => { expect(screen.getByText("test@example.com")).toBeInTheDocument(); }); }); it("renders Garmin connection section with manage link", async () => { render(); await waitFor(() => { expect(screen.getByText(/garmin connection/i)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /manage/i })).toHaveAttribute( "href", "/settings/garmin", ); }); }); it("shows not connected when garminConnected is false", async () => { render(); await waitFor(() => { expect(screen.getByText(/not connected/i)).toBeInTheDocument(); }); }); it("shows connected when garminConnected is true", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUser, garminConnected: true }), }); render(); await waitFor(() => { expect(screen.getByText(/connected to garmin/i)).toBeInTheDocument(); }); }); }); 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 () => { // Create a promise that we can control 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("pre-fills form with current user values", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toHaveValue(28); expect(screen.getByLabelText(/notification time/i)).toHaveValue( "08:00", ); expect(screen.getByLabelText(/timezone/i)).toHaveValue( "America/New_York", ); }); }); 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(); expect(screen.getByText(/failed to fetch user/i)).toBeInTheDocument(); }); }); }); describe("form submission", () => { it("calls PATCH /api/user with updated values on save", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUser, cycleLength: 30 }), }); render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const cycleLengthInput = screen.getByLabelText(/cycle length/i); fireEvent.change(cycleLengthInput, { target: { value: "30" } }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/user", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cycleLength: 30, notificationTime: "08:00", timezone: "America/New_York", intensityGoalMenstrual: 75, intensityGoalFollicular: 150, intensityGoalOvulation: 100, intensityGoalEarlyLuteal: 120, intensityGoalLateLuteal: 50, }), }); }); }); it("shows loading state while saving", async () => { let resolveSave: (value: unknown) => void = () => {}; const savePromise = new Promise((resolve) => { resolveSave = resolve; }); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockReturnValueOnce({ ok: true, json: () => savePromise, }); render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect( screen.getByRole("button", { name: /saving/i }), ).toBeInTheDocument(); }); resolveSave(mockUser); }); it("disables inputs while saving", async () => { let resolveSave: (value: unknown) => void = () => {}; const savePromise = new Promise((resolve) => { resolveSave = resolve; }); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockReturnValueOnce({ ok: true, json: () => savePromise, }); render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const cycleLengthInput = screen.getByLabelText(/cycle length/i); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(cycleLengthInput).toBeDisabled(); expect(screen.getByLabelText(/notification time/i)).toBeDisabled(); expect(screen.getByLabelText(/timezone/i)).toBeDisabled(); expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled(); }); resolveSave(mockUser); }); it("shows success toast on save", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUser, cycleLength: 30 }), }); render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(mockShowToast.success).toHaveBeenCalledWith( "Settings saved successfully", ); }); }); it("shows error toast on save failure", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "cycleLength must be between 21 and 45" }), }); render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(mockShowToast.error).toHaveBeenCalledWith( "cycleLength must be between 21 and 45", ); }); }); it("re-enables form after error", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to save" }), }); render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(mockShowToast.error).toHaveBeenCalled(); }); expect(screen.getByLabelText(/cycle length/i)).not.toBeDisabled(); expect(screen.getByLabelText(/notification time/i)).not.toBeDisabled(); expect(screen.getByLabelText(/timezone/i)).not.toBeDisabled(); expect(screen.getByRole("button", { name: /save/i })).not.toBeDisabled(); }); }); describe("validation", () => { it("validates cycle length minimum (21)", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const cycleLengthInput = screen.getByLabelText(/cycle length/i); expect(cycleLengthInput).toHaveAttribute("min", "21"); }); it("validates cycle length maximum (45)", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const cycleLengthInput = screen.getByLabelText(/cycle length/i); expect(cycleLengthInput).toHaveAttribute("max", "45"); }); it("has cycle length input as number type", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument(); }); const cycleLengthInput = screen.getByLabelText(/cycle length/i); expect(cycleLengthInput).toHaveAttribute("type", "number"); }); it("has notification time input as time type", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/notification time/i)).toBeInTheDocument(); }); const notificationTimeInput = screen.getByLabelText(/notification time/i); expect(notificationTimeInput).toHaveAttribute("type", "time"); }); it("requires timezone to be non-empty", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/timezone/i)).toBeInTheDocument(); }); const timezoneInput = screen.getByLabelText(/timezone/i); expect(timezoneInput).toHaveAttribute("required"); }); }); describe("toast notifications", () => { it("shows toast with fetch error on load failure", async () => { mockFetch.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to fetch user" }), }); render(); await waitFor(() => { expect(mockShowToast.error).toHaveBeenCalledWith( "Unable to fetch data. Retry?", ); }); }); }); describe("accessibility", () => { it("wraps content in a main element", async () => { render(); await waitFor(() => { expect(screen.getByRole("main")).toBeInTheDocument(); }); }); it("has proper heading structure with h1", async () => { render(); await waitFor(() => { const heading = screen.getByRole("heading", { level: 1 }); expect(heading).toBeInTheDocument(); expect(heading).toHaveTextContent(/settings/i); }); }); }); describe("logout", () => { it("renders a logout button", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /log out/i }), ).toBeInTheDocument(); }); }); it("calls POST /api/auth/logout when logout button clicked", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, message: "Logged out successfully", redirectTo: "/login", }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /log out/i }), ).toBeInTheDocument(); }); const logoutButton = screen.getByRole("button", { name: /log out/i }); fireEvent.click(logoutButton); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/auth/logout", { method: "POST", }); }); }); it("redirects to login page after logout", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, message: "Logged out successfully", redirectTo: "/login", }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /log out/i }), ).toBeInTheDocument(); }); const logoutButton = screen.getByRole("button", { name: /log out/i }); fireEvent.click(logoutButton); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/login"); }); }); it("shows loading state while logging out", async () => { let resolveLogout: (value: unknown) => void = () => {}; const logoutPromise = new Promise((resolve) => { resolveLogout = resolve; }); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockReturnValueOnce({ ok: true, json: () => logoutPromise, }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /log out/i }), ).toBeInTheDocument(); }); const logoutButton = screen.getByRole("button", { name: /log out/i }); fireEvent.click(logoutButton); await waitFor(() => { expect( screen.getByRole("button", { name: /logging out/i }), ).toBeInTheDocument(); }); resolveLogout({ success: true, message: "Logged out successfully", redirectTo: "/login", }); }); it("shows error toast if logout fails", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Logout failed" }), }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /log out/i }), ).toBeInTheDocument(); }); const logoutButton = screen.getByRole("button", { name: /log out/i }); fireEvent.click(logoutButton); await waitFor(() => { expect(mockShowToast.error).toHaveBeenCalledWith("Logout failed"); }); }); }); describe("intensity goals section", () => { it("renders Weekly Intensity Goals section heading", async () => { render(); await waitFor(() => { expect( screen.getByRole("heading", { name: /weekly intensity goals/i }), ).toBeInTheDocument(); }); }); it("renders input for menstrual phase goal", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument(); }); }); it("renders input for follicular phase goal", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument(); }); }); it("renders input for ovulation phase goal", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument(); }); }); it("renders input for early luteal phase goal", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument(); }); }); it("renders input for late luteal phase goal", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument(); }); }); it("pre-fills intensity goal inputs with current user values", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toHaveValue(75); expect(screen.getByLabelText(/follicular/i)).toHaveValue(150); expect(screen.getByLabelText(/ovulation/i)).toHaveValue(100); expect(screen.getByLabelText(/early luteal/i)).toHaveValue(120); expect(screen.getByLabelText(/late luteal/i)).toHaveValue(50); }); }); it("includes intensity goals in PATCH request when saving", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ...mockUser, intensityGoalMenstrual: 80 }), }); render(); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument(); }); const menstrualInput = screen.getByLabelText(/menstrual/i); fireEvent.change(menstrualInput, { target: { value: "80" } }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/user", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: expect.stringContaining('"intensityGoalMenstrual":80'), }); }); }); it("has number type for all intensity goal inputs", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute( "type", "number", ); expect(screen.getByLabelText(/follicular/i)).toHaveAttribute( "type", "number", ); expect(screen.getByLabelText(/ovulation/i)).toHaveAttribute( "type", "number", ); expect(screen.getByLabelText(/early luteal/i)).toHaveAttribute( "type", "number", ); expect(screen.getByLabelText(/late luteal/i)).toHaveAttribute( "type", "number", ); }); }); it("validates minimum value of 0 for intensity goals", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute("min", "0"); }); }); it("disables intensity goal inputs while saving", async () => { let resolveSave: (value: unknown) => void = () => {}; const savePromise = new Promise((resolve) => { resolveSave = resolve; }); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockUser), }) .mockReturnValueOnce({ ok: true, json: () => savePromise, }); render(); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument(); }); const saveButton = screen.getByRole("button", { name: /save/i }); fireEvent.click(saveButton); await waitFor(() => { expect(screen.getByLabelText(/menstrual/i)).toBeDisabled(); expect(screen.getByLabelText(/follicular/i)).toBeDisabled(); expect(screen.getByLabelText(/ovulation/i)).toBeDisabled(); expect(screen.getByLabelText(/early luteal/i)).toBeDisabled(); expect(screen.getByLabelText(/late luteal/i)).toBeDisabled(); }); resolveSave(mockUser); }); }); });