Implement GET /api/today endpoint (P1.4)
Add the core daily snapshot API that powers the dashboard. Returns: - Training decision (status, reason, icon) using decision engine - Cycle data (cycleDay, phase, phaseConfig, daysUntilNextPhase) - Biometrics (hrvStatus, bodyBattery, weekIntensity, phaseLimit) - Nutrition guidance (seeds, carbRange, ketoGuidance) When no DailyLog exists (Garmin not synced), returns sensible defaults: hrvStatus="Unknown", bodyBattery=100, weekIntensity=0. This allows the app to function without Garmin integration. 22 tests covering auth, validation, all decision paths, override handling, phase-specific logic, and nutrition guidance. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
519
src/app/api/today/route.test.ts
Normal file
519
src/app/api/today/route.test.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
// 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;
|
||||
|
||||
// Mock PocketBase client for database operations
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({
|
||||
collection: vi.fn(() => ({
|
||||
getFirstListItem: vi.fn(async () => {
|
||||
if (!currentMockDailyLog) {
|
||||
const error = new Error("No DailyLog found");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return currentMockDailyLog;
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
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 { 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"),
|
||||
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;
|
||||
// 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-14)
|
||||
// Next phase (OVULATION) starts day 15, so 5 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(5);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user