// ABOUTME: Unit tests for today's daily snapshot API route. // ABOUTME: Tests GET /api/today for decision, biometrics, and nutrition data. import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DailyLog, User } from "@/types"; // Module-level variable to control mock user in tests let currentMockUser: User | null = null; // Module-level variable to control mock daily log in tests let currentMockDailyLog: DailyLog | null = null; // Track the filter string passed to getFirstListItem let lastDailyLogFilter: string | null = null; // Create mock PocketBase client const mockPb = { collection: vi.fn((collectionName: string) => ({ // Mock getOne for fetching fresh user data getOne: vi.fn(async () => { if (collectionName === "users" && currentMockUser) { // Return user data in PocketBase record format return { id: currentMockUser.id, email: currentMockUser.email, lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(), cycleLength: currentMockUser.cycleLength, activeOverrides: currentMockUser.activeOverrides, garminConnected: currentMockUser.garminConnected, }; } throw new Error("Record not found"); }), getFirstListItem: vi.fn(async (filter: string) => { // Capture the filter for testing if (collectionName === "dailyLogs") { lastDailyLogFilter = filter; } if (!currentMockDailyLog) { const error = new Error("No DailyLog found"); (error as { status?: number }).status = 404; throw error; } return currentMockDailyLog; }), })), }; // 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/today", () => { const createMockUser = (overrides: Partial = {}): User => ({ id: "user123", email: "test@example.com", garminConnected: false, garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date("2025-06-01"), garminRefreshTokenExpiresAt: null, calendarToken: "cal-secret-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 31, notificationTime: "07:00", timezone: "America/New_York", activeOverrides: [], created: new Date("2024-01-01"), updated: new Date("2025-01-10"), ...overrides, }); const createMockDailyLog = (overrides: Partial = {}): DailyLog => ({ id: "log123", user: "user123", date: new Date("2025-01-10"), cycleDay: 10, phase: "FOLLICULAR", bodyBatteryCurrent: 85, bodyBatteryYesterdayLow: 40, hrvStatus: "Balanced", weekIntensityMinutes: 45, phaseLimit: 120, remainingMinutes: 75, trainingDecision: "TRAIN", decisionReason: "All systems go", notificationSentAt: null, created: new Date("2025-01-10"), ...overrides, }); const mockRequest = {} as NextRequest; beforeEach(() => { vi.clearAllMocks(); currentMockUser = null; currentMockDailyLog = null; lastDailyLogFilter = null; // Mock current date to 2025-01-10 for predictable testing vi.useFakeTimers(); vi.setSystemTime(new Date("2025-01-10T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); describe("authentication", () => { it("returns 401 when not authenticated", async () => { currentMockUser = null; const response = await GET(mockRequest); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); }); }); describe("validation", () => { it("returns 400 when user has no lastPeriodDate", async () => { currentMockUser = createMockUser({ lastPeriodDate: null as unknown as Date, }); const response = await GET(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("lastPeriodDate"); }); }); describe("response structure", () => { it("returns complete daily snapshot with all required fields", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Decision fields expect(body).toHaveProperty("decision"); expect(body.decision).toHaveProperty("status"); expect(body.decision).toHaveProperty("reason"); expect(body.decision).toHaveProperty("icon"); // Cycle fields expect(body).toHaveProperty("cycleDay"); expect(body).toHaveProperty("phase"); expect(body).toHaveProperty("phaseConfig"); expect(body).toHaveProperty("daysUntilNextPhase"); expect(body).toHaveProperty("cycleLength"); // Biometric fields expect(body).toHaveProperty("biometrics"); expect(body.biometrics).toHaveProperty("hrvStatus"); expect(body.biometrics).toHaveProperty("bodyBatteryCurrent"); expect(body.biometrics).toHaveProperty("bodyBatteryYesterdayLow"); expect(body.biometrics).toHaveProperty("weekIntensityMinutes"); expect(body.biometrics).toHaveProperty("phaseLimit"); // Nutrition fields expect(body).toHaveProperty("nutrition"); expect(body.nutrition).toHaveProperty("seeds"); expect(body.nutrition).toHaveProperty("carbRange"); expect(body.nutrition).toHaveProperty("ketoGuidance"); }); }); describe("decision calculation", () => { it("returns TRAIN when all biometrics are good", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 50, weekIntensityMinutes: 30, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("TRAIN"); expect(body.decision.icon).toBe("✅"); }); it("returns REST when HRV is unbalanced", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Unbalanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 50, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REST"); expect(body.decision.reason).toContain("HRV"); }); it("returns REST when body battery was depleted yesterday", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 25, // Below 30 threshold }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REST"); expect(body.decision.reason).toContain("depleted"); }); it("returns LIGHT when current body battery is low", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 70, // Below 75 threshold bodyBatteryYesterdayLow: 50, weekIntensityMinutes: 30, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("LIGHT"); }); it("returns REDUCED when current body battery is medium", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 80, // Between 75 and 85 bodyBatteryYesterdayLow: 50, weekIntensityMinutes: 30, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REDUCED"); }); it("returns REST when weekly limit is reached", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 50, weekIntensityMinutes: 125, // Above 120 limit for FOLLICULAR phaseLimit: 120, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REST"); expect(body.decision.reason).toContain("WEEKLY LIMIT"); }); }); describe("override handling", () => { it("returns REST with flare override active", async () => { currentMockUser = createMockUser({ activeOverrides: ["flare"], }); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 50, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REST"); expect(body.decision.reason).toContain("flare"); }); it("returns REST with stress override active", async () => { currentMockUser = createMockUser({ activeOverrides: ["stress"], }); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REST"); expect(body.decision.reason).toContain("stress"); }); it("prioritizes flare over stress when both active", async () => { currentMockUser = createMockUser({ activeOverrides: ["stress", "flare"], }); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.decision.status).toBe("REST"); expect(body.decision.reason).toContain("flare"); }); }); describe("cycle data", () => { it("returns correct cycle day and phase", async () => { // lastPeriodDate: 2025-01-01, current: 2025-01-10 = cycle day 10 currentMockUser = createMockUser({ lastPeriodDate: new Date("2025-01-01"), cycleLength: 31, }); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.cycleDay).toBe(10); expect(body.phase).toBe("FOLLICULAR"); expect(body.cycleLength).toBe(31); }); it("returns phase configuration", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.phaseConfig.name).toBe("FOLLICULAR"); expect(body.phaseConfig.weeklyLimit).toBe(120); }); it("returns days until next phase", async () => { // Cycle day 10 in FOLLICULAR (days 4-15 for 31-day cycle) // Next phase (OVULATION) starts day 16, so 6 days away currentMockUser = createMockUser({ lastPeriodDate: new Date("2025-01-01"), }); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.daysUntilNextPhase).toBe(6); }); }); describe("phase-specific decisions", () => { it("returns GENTLE during MENSTRUAL phase", async () => { // Set lastPeriodDate so cycle day = 2 (MENSTRUAL) currentMockUser = createMockUser({ lastPeriodDate: new Date("2025-01-09"), // 1 day ago = day 2 }); currentMockDailyLog = createMockDailyLog({ phase: "MENSTRUAL", cycleDay: 2, hrvStatus: "Balanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 50, weekIntensityMinutes: 0, phaseLimit: 30, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.phase).toBe("MENSTRUAL"); expect(body.decision.status).toBe("GENTLE"); }); it("returns GENTLE during LATE_LUTEAL phase", async () => { // Set lastPeriodDate so cycle day = 28 (LATE_LUTEAL) currentMockUser = createMockUser({ lastPeriodDate: new Date("2024-12-14"), // 27 days ago = day 28 }); currentMockDailyLog = createMockDailyLog({ phase: "LATE_LUTEAL", cycleDay: 28, hrvStatus: "Balanced", bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 50, weekIntensityMinutes: 0, phaseLimit: 50, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.phase).toBe("LATE_LUTEAL"); expect(body.decision.status).toBe("GENTLE"); }); }); describe("nutrition guidance", () => { it("returns correct nutrition for cycle day 10 (FOLLICULAR)", async () => { currentMockUser = createMockUser({ lastPeriodDate: new Date("2025-01-01"), // cycle day 10 }); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Day 10 is in days 7-14 range per nutrition.ts expect(body.nutrition.seeds).toBe("Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)"); expect(body.nutrition.carbRange).toBe("20-100g"); expect(body.nutrition.ketoGuidance).toBe( "OPTIONAL - optimal keto window", ); }); it("returns correct nutrition during luteal phase", async () => { // Set to cycle day 20 (EARLY_LUTEAL) currentMockUser = createMockUser({ lastPeriodDate: new Date("2024-12-22"), // 19 days ago = day 20 }); currentMockDailyLog = createMockDailyLog({ cycleDay: 20, phase: "EARLY_LUTEAL", }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Day 20 is in days 17-24 range (sesame+sunflower) expect(body.nutrition.seeds).toBe( "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)", ); expect(body.nutrition.carbRange).toBe("75-125g"); }); it("returns seed switch alert on day 15", async () => { // Set to cycle day 15 - the seed switch day currentMockUser = createMockUser({ lastPeriodDate: new Date("2024-12-27"), // 14 days ago = day 15 }); currentMockDailyLog = createMockDailyLog({ cycleDay: 15, phase: "OVULATION", }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.nutrition.seedSwitchAlert).toBe( "🌱 SWITCH TODAY! Start Sesame + Sunflower", ); }); it("returns null seed switch alert on other days", async () => { currentMockUser = createMockUser({ lastPeriodDate: new Date("2025-01-01"), // cycle day 10 }); currentMockDailyLog = createMockDailyLog(); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.nutrition.seedSwitchAlert).toBeNull(); }); }); describe("dailyLog query", () => { it("queries dailyLogs with YYYY-MM-DD date format using range operators", async () => { // PocketBase accepts simple YYYY-MM-DD in comparison operators // Use >= today and < tomorrow for exact day match currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog(); await GET(mockRequest); // Verify filter uses YYYY-MM-DD format with range operators expect(lastDailyLogFilter).toBeDefined(); expect(lastDailyLogFilter).toContain('date>="2025-01-10"'); expect(lastDailyLogFilter).toContain('date<"2025-01-11"'); // Should NOT contain ISO format with T separator expect(lastDailyLogFilter).not.toContain("T"); }); }); describe("biometrics data", () => { it("returns biometrics from daily log when available", async () => { currentMockUser = createMockUser(); currentMockDailyLog = createMockDailyLog({ hrvStatus: "Balanced", bodyBatteryCurrent: 85, bodyBatteryYesterdayLow: 40, weekIntensityMinutes: 45, phaseLimit: 120, }); const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.biometrics.hrvStatus).toBe("Balanced"); expect(body.biometrics.bodyBatteryCurrent).toBe(85); expect(body.biometrics.bodyBatteryYesterdayLow).toBe(40); expect(body.biometrics.weekIntensityMinutes).toBe(45); expect(body.biometrics.phaseLimit).toBe(120); }); it("returns default biometrics when no daily log exists", async () => { currentMockUser = createMockUser(); currentMockDailyLog = null; // No daily log const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Defaults when no Garmin data expect(body.biometrics.hrvStatus).toBe("Unknown"); expect(body.biometrics.bodyBatteryCurrent).toBe(100); expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100); expect(body.biometrics.weekIntensityMinutes).toBe(0); }); it("uses TRAIN decision with default biometrics (no Garmin data)", async () => { currentMockUser = createMockUser(); currentMockDailyLog = null; const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // With defaults (BB=100, HRV=Unknown), should allow training // unless in restrictive phase expect(body.decision.status).toBe("TRAIN"); }); }); });