// ABOUTME: Unit tests for period logging API route. // ABOUTME: Tests POST /api/cycle/period for period start date logging. import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { User } from "@/types"; // Module-level variable to control mock user in tests let currentMockUser: User | null = null; // Mock PocketBase client for database operations const mockPbUpdate = vi.fn(); const mockPbCreate = vi.fn(); vi.mock("@/lib/pocketbase", () => ({ createPocketBaseClient: vi.fn(() => ({ collection: vi.fn((_name: string) => ({ update: mockPbUpdate, create: mockPbCreate, })), })), loadAuthFromCookies: vi.fn(), isAuthenticated: vi.fn(() => currentMockUser !== null), getCurrentUser: vi.fn(() => currentMockUser), })); // 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); }; }), })); import { POST } from "./route"; describe("POST /api/cycle/period", () => { const mockUser: User = { id: "user123", email: "test@example.com", garminConnected: false, garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-06-01"), calendarToken: "cal-secret-token", lastPeriodDate: new Date("2024-12-15"), cycleLength: 28, notificationTime: "07:00", timezone: "America/New_York", activeOverrides: [], created: new Date("2024-01-01"), updated: new Date("2025-01-10"), }; beforeEach(() => { vi.clearAllMocks(); currentMockUser = null; mockPbUpdate.mockResolvedValue({}); mockPbCreate.mockResolvedValue({ id: "periodlog123" }); }); it("returns 401 when not authenticated", async () => { currentMockUser = null; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); }); it("returns 400 when startDate is missing", async () => { currentMockUser = mockUser; const mockRequest = { json: vi.fn().mockResolvedValue({}), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("startDate"); }); it("returns 400 when startDate is invalid format", async () => { currentMockUser = mockUser; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "invalid-date" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("Invalid"); }); it("returns 400 when startDate is in the future", async () => { currentMockUser = mockUser; // Set a date far in the future const futureDate = new Date(); futureDate.setFullYear(futureDate.getFullYear() + 1); const futureDateStr = futureDate.toISOString().split("T")[0]; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: futureDateStr }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("future"); }); it("updates user lastPeriodDate and creates PeriodLog", async () => { currentMockUser = mockUser; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); // Verify user record was updated expect(mockPbUpdate).toHaveBeenCalledWith( "user123", expect.objectContaining({ lastPeriodDate: "2025-01-10", }), ); // Verify PeriodLog was created expect(mockPbCreate).toHaveBeenCalledWith( expect.objectContaining({ user: "user123", startDate: "2025-01-10", }), ); }); it("returns updated cycle information", async () => { currentMockUser = mockUser; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Should return cycle info expect(body.lastPeriodDate).toBe("2025-01-10"); expect(body.cycleDay).toBeTypeOf("number"); expect(body.phase).toBeTypeOf("string"); expect(body.message).toBe("Period start date logged successfully"); }); it("calculates correct cycle day for new period", async () => { currentMockUser = mockUser; // If period started today, cycle day should be 1 const today = new Date().toISOString().split("T")[0]; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: today }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.cycleDay).toBe(1); expect(body.phase).toBe("MENSTRUAL"); }); it("handles database update errors gracefully", async () => { currentMockUser = mockUser; mockPbUpdate.mockRejectedValue(new Error("Database error")); const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("Failed to update period date"); }); describe("prediction accuracy tracking", () => { it("calculates and stores predictedDate based on previous cycle", async () => { // User's last period was 2024-12-15 with 28-day cycle // Predicted next period: 2024-12-15 + 28 days = 2025-01-12 currentMockUser = mockUser; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); // Verify PeriodLog was created with predictedDate expect(mockPbCreate).toHaveBeenCalledWith( expect.objectContaining({ user: "user123", startDate: "2025-01-10", predictedDate: "2025-01-12", // lastPeriodDate (Dec 15) + cycleLength (28) }), ); }); it("returns prediction accuracy information in response", async () => { currentMockUser = mockUser; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.predictedDate).toBe("2025-01-12"); expect(body.daysEarly).toBe(2); // Arrived 2 days early }); it("handles period arriving late (positive daysLate)", async () => { currentMockUser = mockUser; // Period arrives 3 days after predicted (2025-01-15 instead of 2025-01-12) const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-15" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.predictedDate).toBe("2025-01-12"); expect(body.daysLate).toBe(3); }); it("sets predictedDate to null when user has no previous lastPeriodDate", async () => { // First period log - no previous cycle data currentMockUser = { ...mockUser, lastPeriodDate: null as unknown as Date, }; const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); // Should not include predictedDate for first log expect(mockPbCreate).toHaveBeenCalledWith( expect.objectContaining({ user: "user123", startDate: "2025-01-10", predictedDate: null, }), ); }); it("handles period arriving on predicted date exactly", async () => { currentMockUser = mockUser; // Period arrives exactly on predicted date (2025-01-12) const mockRequest = { json: vi.fn().mockResolvedValue({ startDate: "2025-01-12" }), } as unknown as NextRequest; const response = await POST(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.predictedDate).toBe("2025-01-12"); expect(body.daysEarly).toBeUndefined(); expect(body.daysLate).toBeUndefined(); }); }); });