- Complete MonthView component with calendar grid, DayCell integration, navigation controls (prev/next month, Today button), and phase legend - Implement Calendar page with MonthView, month navigation state, ICS subscription section with URL display, copy, and token regeneration - Add 21 tests for MonthView component (calendar grid, phase colors, navigation, legend, cycle rollover) - Add 23 tests for Calendar page (rendering, navigation, ICS subscription, token regeneration, error handling) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
// ABOUTME: Unit tests for the Calendar page component.
|
|
// ABOUTME: Tests calendar rendering, month navigation, ICS subscription, and token regeneration.
|
|
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 fetch
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
import CalendarPage from "./page";
|
|
|
|
describe("CalendarPage", () => {
|
|
const mockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
cycleLength: 28,
|
|
lastPeriodDate: "2026-01-01",
|
|
calendarToken: "abc123def456",
|
|
garminConnected: false,
|
|
activeOverrides: [],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockUser),
|
|
});
|
|
});
|
|
|
|
describe("rendering", () => {
|
|
it("renders the calendar heading", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("heading", { name: /^calendar$/i, level: 1 }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders the MonthView component with current month", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
const today = new Date();
|
|
const expectedMonth = today.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(expectedMonth)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders day-of-week headers from MonthView", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Sun")).toBeInTheDocument();
|
|
expect(screen.getByText("Mon")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders calendar days", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
// January has 31 days
|
|
expect(screen.getByText("15")).toBeInTheDocument();
|
|
expect(screen.getByText("31")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders back link to dashboard", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute(
|
|
"href",
|
|
"/",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("data loading", () => {
|
|
it("fetches user data on mount", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetch).toHaveBeenCalledWith("/api/user");
|
|
});
|
|
});
|
|
|
|
it("shows loading state while fetching", async () => {
|
|
let resolveUser: (value: unknown) => void = () => {};
|
|
const userPromise = new Promise((resolve) => {
|
|
resolveUser = resolve;
|
|
});
|
|
mockFetch.mockReturnValue({
|
|
ok: true,
|
|
json: () => userPromise,
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
|
|
|
resolveUser(mockUser);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows error if fetching fails", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
json: () => Promise.resolve({ error: "Failed to fetch user" }),
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("month navigation", () => {
|
|
it("navigates to previous month when clicking previous button", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
const today = new Date();
|
|
const currentMonth = today.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
const prevMonth = new Date(today.getFullYear(), today.getMonth() - 1);
|
|
const prevMonthStr = prevMonth.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
|
});
|
|
|
|
const prevButton = screen.getByRole("button", {
|
|
name: /previous month/i,
|
|
});
|
|
fireEvent.click(prevButton);
|
|
|
|
expect(screen.getByText(prevMonthStr)).toBeInTheDocument();
|
|
});
|
|
|
|
it("navigates to next month when clicking next button", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
const today = new Date();
|
|
const currentMonth = today.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1);
|
|
const nextMonthStr = nextMonth.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
|
});
|
|
|
|
const nextButton = screen.getByRole("button", { name: /next month/i });
|
|
fireEvent.click(nextButton);
|
|
|
|
expect(screen.getByText(nextMonthStr)).toBeInTheDocument();
|
|
});
|
|
|
|
it("returns to current month when clicking Today button", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
const today = new Date();
|
|
const currentMonth = today.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1);
|
|
const nextMonthStr = nextMonth.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
|
});
|
|
|
|
// Navigate away first
|
|
const nextButton = screen.getByRole("button", { name: /next month/i });
|
|
fireEvent.click(nextButton);
|
|
expect(screen.getByText(nextMonthStr)).toBeInTheDocument();
|
|
|
|
// Click Today to return
|
|
const todayButton = screen.getByRole("button", { name: /^today$/i });
|
|
fireEvent.click(todayButton);
|
|
|
|
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("ICS subscription", () => {
|
|
it("renders ICS subscription section", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("displays calendar subscription URL when token exists", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
// The URL input should contain the token
|
|
const urlInput = screen.getByRole("textbox") as HTMLInputElement;
|
|
expect(urlInput.value).toContain("abc123def456.ics");
|
|
});
|
|
});
|
|
|
|
it("shows instructions for subscribing", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/subscribe to this calendar/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders copy URL button", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /copy/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders regenerate token button", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /regenerate/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("token regeneration", () => {
|
|
it("calls regenerate API when clicking regenerate button", async () => {
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockUser),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
token: "newtoken123",
|
|
url: "http://localhost/api/calendar/user123/newtoken123.ics",
|
|
}),
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /regenerate/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
const regenerateButton = screen.getByRole("button", {
|
|
name: /regenerate/i,
|
|
});
|
|
fireEvent.click(regenerateButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"/api/calendar/regenerate-token",
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
});
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it("updates displayed URL after regeneration", async () => {
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockUser),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
token: "newtoken123",
|
|
url: "http://localhost/api/calendar/user123/newtoken123.ics",
|
|
}),
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
const urlInput = screen.getByRole("textbox") as HTMLInputElement;
|
|
expect(urlInput.value).toContain("abc123def456.ics");
|
|
});
|
|
|
|
const regenerateButton = screen.getByRole("button", {
|
|
name: /regenerate/i,
|
|
});
|
|
fireEvent.click(regenerateButton);
|
|
|
|
await waitFor(() => {
|
|
const urlInput = screen.getByRole("textbox") as HTMLInputElement;
|
|
expect(urlInput.value).toContain("newtoken123.ics");
|
|
});
|
|
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it("shows confirmation dialog before regenerating", async () => {
|
|
// Mock window.confirm
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockUser),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
token: "newtoken123",
|
|
url: "http://localhost/api/calendar/user123/newtoken123.ics",
|
|
}),
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /regenerate/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
const regenerateButton = screen.getByRole("button", {
|
|
name: /regenerate/i,
|
|
});
|
|
fireEvent.click(regenerateButton);
|
|
|
|
expect(confirmSpy).toHaveBeenCalled();
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it("does not regenerate if user cancels confirmation", async () => {
|
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /regenerate/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
const regenerateButton = screen.getByRole("button", {
|
|
name: /regenerate/i,
|
|
});
|
|
fireEvent.click(regenerateButton);
|
|
|
|
// Should not call the regenerate API
|
|
expect(mockFetch).toHaveBeenCalledTimes(1); // Only initial fetch
|
|
confirmSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("phase legend", () => {
|
|
it("displays phase legend from MonthView", async () => {
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/menstrual/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/follicular/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/ovulation/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("no calendar token", () => {
|
|
it("shows generate button when no token exists", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ ...mockUser, calendarToken: null }),
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("button", { name: /generate calendar url/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("does not show URL when no token exists", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ ...mockUser, calendarToken: null }),
|
|
});
|
|
|
|
render(<CalendarPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByText(/\.ics/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|