All checks were successful
Deploy / deploy (push) Successful in 1m39s
The ~ contains operator doesn't work with PocketBase date fields. Use >= and < operators with YYYY-MM-DD format instead, matching the working /api/history pattern. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
591 lines
18 KiB
TypeScript
591 lines
18 KiB
TypeScript
// 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> = {}): 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> = {}): 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");
|
|
});
|
|
});
|
|
});
|