// ABOUTME: Unit tests for period history API route. // ABOUTME: Tests GET /api/period-history for pagination, cycle length calculation, and auth. import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PeriodLog, User } from "@/types"; // Module-level variable to control mock user in tests let currentMockUser: User | null = null; // Track PocketBase collection calls const mockGetList = vi.fn(); // Create mock PocketBase client const mockPb = { collection: vi.fn(() => ({ getList: mockGetList, })), }; // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ withAuth: vi.fn((handler) => { return async (request: NextRequest) => { if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } return handler(request, currentMockUser, mockPb); }; }), })); import { GET } from "./route"; describe("GET /api/period-history", () => { const mockUser: User = { id: "user123", email: "test@example.com", garminConnected: true, garminOauth1Token: "encrypted-token-1", garminOauth2Token: "encrypted-token-2", garminTokenExpiresAt: new Date("2025-06-01"), garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-15"), cycleLength: 28, notificationTime: "07:30", timezone: "America/New_York", activeOverrides: [], intensityGoalMenstrual: 75, intensityGoalFollicular: 150, intensityGoalOvulation: 100, intensityGoalEarlyLuteal: 120, intensityGoalLateLuteal: 50, created: new Date("2024-01-01"), updated: new Date("2025-01-10"), }; const mockPeriodLogs: PeriodLog[] = [ { id: "period1", user: "user123", startDate: new Date("2025-01-15"), predictedDate: new Date("2025-01-16"), created: new Date("2025-01-15T10:00:00Z"), }, { id: "period2", user: "user123", startDate: new Date("2024-12-18"), predictedDate: new Date("2024-12-19"), created: new Date("2024-12-18T10:00:00Z"), }, { id: "period3", user: "user123", startDate: new Date("2024-11-20"), predictedDate: null, created: new Date("2024-11-20T10:00:00Z"), }, ]; // Helper to create mock request with query parameters function createMockRequest(params: Record = {}): NextRequest { const url = new URL("http://localhost:3000/api/period-history"); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } return { url: url.toString(), nextUrl: url, } as unknown as NextRequest; } beforeEach(() => { vi.clearAllMocks(); currentMockUser = null; mockGetList.mockReset(); }); it("returns 401 when not authenticated", async () => { currentMockUser = null; const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); }); it("returns paginated period logs for authenticated user", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: mockPeriodLogs, totalItems: 3, totalPages: 1, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.items).toHaveLength(3); expect(body.total).toBe(3); expect(body.page).toBe(1); expect(body.limit).toBe(20); expect(body.hasMore).toBe(false); }); it("calculates cycle lengths between consecutive periods", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: mockPeriodLogs, totalItems: 3, totalPages: 1, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Period 1 (Jan 15) - Period 2 (Dec 18) = 28 days expect(body.items[0].cycleLength).toBe(28); // Period 2 (Dec 18) - Period 3 (Nov 20) = 28 days expect(body.items[1].cycleLength).toBe(28); // Period 3 is the first log, no previous period to calculate from expect(body.items[2].cycleLength).toBeNull(); }); it("calculates average cycle length", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: mockPeriodLogs, totalItems: 3, totalPages: 1, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Average of 28 and 28 = 28 expect(body.averageCycleLength).toBe(28); }); it("returns null average when only one period exists", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: [mockPeriodLogs[2]], // Only one period totalItems: 1, totalPages: 1, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.averageCycleLength).toBeNull(); }); it("uses default pagination values (page=1, limit=20)", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: [], totalItems: 0, totalPages: 0, page: 1, }); const mockRequest = createMockRequest(); await GET(mockRequest); expect(mockGetList).toHaveBeenCalledWith( 1, 20, expect.objectContaining({ filter: expect.stringContaining('user="user123"'), sort: "-startDate", }), ); }); it("respects page parameter", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: [], totalItems: 50, totalPages: 3, page: 2, }); const mockRequest = createMockRequest({ page: "2" }); await GET(mockRequest); expect(mockGetList).toHaveBeenCalledWith( 2, 20, expect.objectContaining({ filter: expect.stringContaining('user="user123"'), sort: "-startDate", }), ); }); it("respects limit parameter", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: [], totalItems: 50, totalPages: 5, page: 1, }); const mockRequest = createMockRequest({ limit: "10" }); await GET(mockRequest); expect(mockGetList).toHaveBeenCalledWith( 1, 10, expect.objectContaining({ filter: expect.stringContaining('user="user123"'), sort: "-startDate", }), ); }); it("returns empty array when no logs exist", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: [], totalItems: 0, totalPages: 0, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.items).toHaveLength(0); expect(body.total).toBe(0); expect(body.hasMore).toBe(false); expect(body.averageCycleLength).toBeNull(); }); it("returns 400 for invalid page value", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ page: "0" }); const response = await GET(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("page"); }); it("returns 400 for non-numeric page value", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ page: "abc" }); const response = await GET(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("page"); }); it("returns 400 for invalid limit value (too low)", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ limit: "0" }); const response = await GET(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("limit"); }); it("returns 400 for invalid limit value (too high)", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ limit: "101" }); const response = await GET(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("limit"); }); it("returns hasMore=true when more pages exist", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: Array(20).fill(mockPeriodLogs[0]), totalItems: 50, totalPages: 3, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.hasMore).toBe(true); }); it("returns hasMore=false on last page", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: [mockPeriodLogs[0]], totalItems: 41, totalPages: 3, page: 3, }); const mockRequest = createMockRequest({ page: "3" }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.hasMore).toBe(false); }); it("sorts by startDate descending (most recent first)", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: mockPeriodLogs, totalItems: 3, totalPages: 1, page: 1, }); const mockRequest = createMockRequest(); await GET(mockRequest); expect(mockGetList).toHaveBeenCalledWith( 1, 20, expect.objectContaining({ sort: "-startDate", }), ); }); it("only returns logs for the authenticated user", async () => { currentMockUser = { ...mockUser, id: "different-user" }; mockGetList.mockResolvedValue({ items: [], totalItems: 0, totalPages: 0, page: 1, }); const mockRequest = createMockRequest(); await GET(mockRequest); expect(mockGetList).toHaveBeenCalledWith( 1, 20, expect.objectContaining({ filter: expect.stringContaining('user="different-user"'), }), ); }); it("includes prediction accuracy for each period", async () => { currentMockUser = mockUser; mockGetList.mockResolvedValue({ items: mockPeriodLogs, totalItems: 3, totalPages: 1, page: 1, }); const mockRequest = createMockRequest(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Period 1: actual Jan 15, predicted Jan 16 -> 1 day early expect(body.items[0].daysEarly).toBe(1); expect(body.items[0].daysLate).toBe(0); // Period 2: actual Dec 18, predicted Dec 19 -> 1 day early expect(body.items[1].daysEarly).toBe(1); expect(body.items[1].daysLate).toBe(0); // Period 3: no prediction (first log) expect(body.items[2].daysEarly).toBeNull(); expect(body.items[2].daysLate).toBeNull(); }); });