diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ff26bf2..f9063c3 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -37,7 +37,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) | | GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO | | POST /api/calendar/regenerate-token | 501 | Returns Not Implemented | -| POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO | +| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) | | POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO | ### Pages (7 total) @@ -84,6 +84,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) | | `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) | | `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) | +| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -290,15 +291,22 @@ Full feature set for production use. - **Why:** Users need visibility into their Garmin connection - **Depends On:** P0.1, P0.2, P2.1 -### P2.4: POST /api/cron/garmin-sync Implementation -- [ ] Daily sync of all Garmin data for all users +### P2.4: POST /api/cron/garmin-sync Implementation ✅ COMPLETE +- [x] Daily sync of all Garmin data for all users - **Files:** - - `src/app/api/cron/garmin-sync/route.ts` - Iterate users, fetch data, store DailyLog + - `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog - **Tests:** - - `src/app/api/cron/garmin-sync/route.test.ts` - Test auth, user iteration, data persistence + - `src/app/api/cron/garmin-sync/route.test.ts` - 22 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling +- **Features Implemented:** + - Fetches all users with garminConnected=true + - Skips users with expired tokens + - Decrypts OAuth2 tokens and fetches HRV, Body Battery, Intensity Minutes + - Calculates cycle day, phase, phase limit, remaining minutes + - Computes training decision using decision engine + - Creates DailyLog entries for each user + - Returns sync summary (usersProcessed, errors, skippedExpired, timestamp) - **Why:** Automated data sync is required for morning notifications - **Depends On:** P2.1, P2.2 -- **Note:** Route exists with CRON_SECRET auth check, needs core logic ### P2.5: POST /api/cron/notifications Implementation - [ ] Send daily email notifications at user's preferred time @@ -568,6 +576,7 @@ P2.14 Mini calendar - [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2) - [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2) - [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3) +- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4) ### Pages - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts new file mode 100644 index 0000000..eef3ca9 --- /dev/null +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -0,0 +1,385 @@ +// ABOUTME: Unit tests for Garmin sync cron endpoint. +// ABOUTME: Tests daily sync of Garmin biometric data for all connected users. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/types"; + +// Mock users returned by PocketBase +let mockUsers: User[] = []; +// Track DailyLog creations +const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" }); + +// Mock PocketBase +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({ + collection: vi.fn((name: string) => ({ + getFullList: vi.fn(async () => { + if (name === "users") { + return mockUsers; + } + return []; + }), + create: mockPbCreate, + })), + })), +})); + +// Mock decryption +const mockDecrypt = vi.fn((ciphertext: string) => { + // Return mock OAuth2 token JSON + if (ciphertext.includes("oauth2")) { + return JSON.stringify({ accessToken: "mock-token-123" }); + } + return ciphertext.replace("encrypted:", ""); +}); + +vi.mock("@/lib/encryption", () => ({ + decrypt: (ciphertext: string) => mockDecrypt(ciphertext), +})); + +// Mock Garmin API functions +const mockFetchHrvStatus = vi.fn().mockResolvedValue("Balanced"); +const mockFetchBodyBattery = vi + .fn() + .mockResolvedValue({ current: 85, yesterdayLow: 45 }); +const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60); +const mockIsTokenExpired = vi.fn().mockReturnValue(false); + +vi.mock("@/lib/garmin", () => ({ + fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args), + fetchBodyBattery: (...args: unknown[]) => mockFetchBodyBattery(...args), + fetchIntensityMinutes: (...args: unknown[]) => + mockFetchIntensityMinutes(...args), + isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args), +})); + +import { POST } from "./route"; + +describe("POST /api/cron/garmin-sync", () => { + const validSecret = "test-cron-secret"; + + // Helper to create a mock user + function createMockUser(overrides: Partial = {}): User { + return { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted:oauth1-token", + garminOauth2Token: "encrypted:oauth2-token", + garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + calendarToken: "cal-token", + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 28, + notificationTime: "07:00", + timezone: "America/New_York", + activeOverrides: [], + created: new Date("2024-01-01"), + updated: new Date("2025-01-10"), + ...overrides, + }; + } + + // Helper to create mock request with optional auth header + function createMockRequest(authHeader?: string): Request { + const headers = new Headers(); + if (authHeader) { + headers.set("authorization", authHeader); + } + return { + headers, + } as unknown as Request; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockUsers = []; + process.env.CRON_SECRET = validSecret; + }); + + describe("Authentication", () => { + it("returns 401 when authorization header is missing", async () => { + const response = await POST(createMockRequest()); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 when secret is incorrect", async () => { + const response = await POST(createMockRequest("Bearer wrong-secret")); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 when CRON_SECRET env var is not set", async () => { + process.env.CRON_SECRET = ""; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(401); + }); + }); + + describe("User fetching", () => { + it("fetches users with garminConnected=true", async () => { + mockUsers = [createMockUser()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.usersProcessed).toBe(1); + }); + + it("skips users without Garmin connection", async () => { + mockUsers = [ + createMockUser({ id: "user1", garminConnected: true }), + createMockUser({ id: "user2", garminConnected: false }), + ]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.usersProcessed).toBe(1); + }); + + it("returns success with zero users when none are connected", async () => { + mockUsers = []; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.usersProcessed).toBe(0); + expect(body.success).toBe(true); + }); + }); + + describe("Token handling", () => { + it("decrypts OAuth2 token before making Garmin API calls", async () => { + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token"); + }); + + it("skips users with expired tokens", async () => { + mockIsTokenExpired.mockReturnValue(true); + mockUsers = [createMockUser()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.skippedExpired).toBe(1); + expect(mockFetchHrvStatus).not.toHaveBeenCalled(); + }); + + it("processes users with valid tokens", async () => { + mockIsTokenExpired.mockReturnValue(false); + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockFetchHrvStatus).toHaveBeenCalled(); + expect(mockFetchBodyBattery).toHaveBeenCalled(); + expect(mockFetchIntensityMinutes).toHaveBeenCalled(); + }); + }); + + describe("Garmin data fetching", () => { + it("fetches HRV status with today's date", async () => { + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockFetchHrvStatus).toHaveBeenCalledWith( + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + "mock-token-123", + ); + }); + + it("fetches body battery with today's date", async () => { + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockFetchBodyBattery).toHaveBeenCalledWith( + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + "mock-token-123", + ); + }); + + it("fetches intensity minutes", async () => { + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockFetchIntensityMinutes).toHaveBeenCalledWith("mock-token-123"); + }); + }); + + describe("DailyLog creation", () => { + it("creates DailyLog entry with fetched data", async () => { + mockUsers = [createMockUser({ lastPeriodDate: new Date("2025-01-01") })]; + mockFetchHrvStatus.mockResolvedValue("Balanced"); + mockFetchBodyBattery.mockResolvedValue({ current: 90, yesterdayLow: 50 }); + mockFetchIntensityMinutes.mockResolvedValue(45); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + user: "user123", + hrvStatus: "Balanced", + bodyBatteryCurrent: 90, + bodyBatteryYesterdayLow: 50, + weekIntensityMinutes: 45, + }), + ); + }); + + it("includes cycle day and phase in DailyLog", async () => { + // Set lastPeriodDate to make cycle day calculable + const lastPeriodDate = new Date(); + lastPeriodDate.setDate(lastPeriodDate.getDate() - 5); // 6 days ago = cycle day 6 + mockUsers = [createMockUser({ lastPeriodDate, cycleLength: 28 })]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + cycleDay: expect.any(Number), + phase: expect.stringMatching( + /^(MENSTRUAL|FOLLICULAR|OVULATION|EARLY_LUTEAL|LATE_LUTEAL)$/, + ), + }), + ); + }); + + it("includes phase limit and remaining minutes in DailyLog", async () => { + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + phaseLimit: expect.any(Number), + remainingMinutes: expect.any(Number), + }), + ); + }); + + it("includes training decision in DailyLog", async () => { + mockUsers = [createMockUser()]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + trainingDecision: expect.stringMatching( + /^(REST|GENTLE|LIGHT|REDUCED|TRAIN)$/, + ), + decisionReason: expect.any(String), + }), + ); + }); + + it("sets date to today's date string", async () => { + mockUsers = [createMockUser()]; + const today = new Date().toISOString().split("T")[0]; + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + date: today, + }), + ); + }); + }); + + describe("Error handling", () => { + it("continues processing other users when one fails", async () => { + mockUsers = [ + createMockUser({ id: "user1" }), + createMockUser({ id: "user2" }), + ]; + // First user fails, second succeeds + mockFetchHrvStatus + .mockRejectedValueOnce(new Error("API error")) + .mockResolvedValueOnce("Balanced"); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.errors).toBe(1); + expect(body.usersProcessed).toBe(1); + }); + + it("handles decryption errors gracefully", async () => { + mockUsers = [createMockUser()]; + mockDecrypt.mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.errors).toBe(1); + }); + + it("handles body battery null values", async () => { + mockUsers = [createMockUser()]; + mockFetchBodyBattery.mockResolvedValue({ + current: null, + yesterdayLow: null, + }); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + bodyBatteryCurrent: null, + bodyBatteryYesterdayLow: null, + }), + ); + }); + }); + + describe("Response format", () => { + it("returns summary with counts", async () => { + mockUsers = [ + createMockUser({ id: "user1" }), + createMockUser({ id: "user2" }), + ]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ + success: true, + usersProcessed: 2, + errors: 0, + skippedExpired: 0, + }); + }); + + it("includes timestamp in response", async () => { + mockUsers = []; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + const body = await response.json(); + expect(body.timestamp).toBeDefined(); + expect(new Date(body.timestamp)).toBeInstanceOf(Date); + }); + }); +}); diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index a46ace3..4ef32b9 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -2,6 +2,26 @@ // ABOUTME: Fetches body battery, HRV, and intensity minutes for all users. import { NextResponse } from "next/server"; +import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle"; +import { getDecisionWithOverrides } from "@/lib/decision-engine"; +import { decrypt } from "@/lib/encryption"; +import { + fetchBodyBattery, + fetchHrvStatus, + fetchIntensityMinutes, + isTokenExpired, +} from "@/lib/garmin"; +import { createPocketBaseClient } from "@/lib/pocketbase"; +import type { GarminTokens, User } from "@/types"; + +interface SyncResult { + success: boolean; + usersProcessed: number; + errors: number; + skippedExpired: number; + timestamp: string; +} + export async function POST(request: Request) { // Verify cron secret const authHeader = request.headers.get("authorization"); @@ -11,6 +31,93 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // TODO: Implement Garmin data sync - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); + const result: SyncResult = { + success: true, + usersProcessed: 0, + errors: 0, + skippedExpired: 0, + timestamp: new Date().toISOString(), + }; + + const pb = createPocketBaseClient(); + + // Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues) + const allUsers = await pb.collection("users").getFullList(); + const users = allUsers.filter((u) => u.garminConnected); + + const today = new Date().toISOString().split("T")[0]; + + for (const user of users) { + try { + // Check if tokens are expired + const tokens: GarminTokens = { + oauth1: user.garminOauth1Token, + oauth2: user.garminOauth2Token, + expires_at: user.garminTokenExpiresAt.toISOString(), + }; + + if (isTokenExpired(tokens)) { + result.skippedExpired++; + continue; + } + + // Decrypt OAuth2 token + const oauth2Json = decrypt(user.garminOauth2Token); + const oauth2Data = JSON.parse(oauth2Json); + const accessToken = oauth2Data.accessToken; + + // Fetch Garmin data + const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([ + fetchHrvStatus(today, accessToken), + fetchBodyBattery(today, accessToken), + fetchIntensityMinutes(accessToken), + ]); + + // Calculate cycle info + const cycleDay = getCycleDay( + user.lastPeriodDate, + user.cycleLength, + new Date(), + ); + const phase = getPhase(cycleDay); + const phaseLimit = getPhaseLimit(phase); + const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes); + + // Calculate training decision + const decision = getDecisionWithOverrides( + { + hrvStatus, + bbYesterdayLow: bodyBattery.yesterdayLow ?? 100, + phase, + weekIntensity: weekIntensityMinutes, + phaseLimit, + bbCurrent: bodyBattery.current ?? 100, + }, + user.activeOverrides, + ); + + // Create DailyLog entry + await pb.collection("dailyLogs").create({ + user: user.id, + date: today, + cycleDay, + phase, + bodyBatteryCurrent: bodyBattery.current, + bodyBatteryYesterdayLow: bodyBattery.yesterdayLow, + hrvStatus, + weekIntensityMinutes, + phaseLimit, + remainingMinutes, + trainingDecision: decision.status, + decisionReason: decision.reason, + notificationSentAt: null, + }); + + result.usersProcessed++; + } catch { + result.errors++; + } + } + + return NextResponse.json(result); }