All checks were successful
Deploy / deploy (push) Successful in 1m39s
- Fix dailyLog upsert to use range query (matches today route pattern) - Properly distinguish 404 errors from other failures in upsert logic - Add logging for dailyLog create/update operations - Add Settings UI section for weekly intensity goals per phase - Add unit tests for upsert behavior and intensity goals UI - Add E2E tests for intensity goals settings flow This fixes the issue where Garmin sync was creating new dailyLog records instead of updating existing ones (322 vs 222 intensity minutes bug, Unknown HRV bug). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
821 lines
23 KiB
TypeScript
821 lines
23 KiB
TypeScript
// 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(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("heading", { name: /settings/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders cycle length input", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders notification time input", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/notification time/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders timezone input", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/timezone/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders a save button", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /save/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders a back link to dashboard", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute(
|
|
"href",
|
|
"/",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("displays email as read-only", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders Garmin connection section with manage link", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/connected to garmin/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("data loading", () => {
|
|
it("fetches user data on mount", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
|
"Unable to fetch data. Retry?",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("accessibility", () => {
|
|
it("wraps content in a main element", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("main")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("has proper heading structure with h1", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("heading", { name: /weekly intensity goals/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders input for menstrual phase goal", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders input for follicular phase goal", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders input for ovulation phase goal", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders input for early luteal phase goal", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders input for late luteal phase goal", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("pre-fills intensity goal inputs with current user values", async () => {
|
|
render(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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(<SettingsPage />);
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|