// 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);
});
});
});