- Email subject now follows spec format: PhaseFlow: [STATUS] - Day [cycleDay] ([phase]) - Daily email includes seed switch alert on day 15 (using getSeedSwitchAlert) - Data panel HRV status now color-coded: green=Balanced, red=Unbalanced, gray=Unknown - Data panel shows progress bar for week intensity vs phase limit with color thresholds Adds 13 new tests (990 total). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1083 lines
30 KiB
TypeScript
1083 lines
30 KiB
TypeScript
// 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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
// 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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
// 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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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(<Dashboard />);
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|