// ABOUTME: Unit tests for Garmin sync cron endpoint. // ABOUTME: Tests daily sync of Garmin biometric data for all connected users. import { afterEach, 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" }); // Track user updates const mockPbUpdate = vi.fn().mockResolvedValue({}); // Helper to parse date values - handles both Date objects and ISO strings function parseDate(value: unknown): Date | null { if (!value) return null; if (value instanceof Date) return value; if (typeof value !== "string") return null; const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } // 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, update: mockPbUpdate, authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }), })), })), mapRecordToUser: vi.fn((record: Record) => ({ id: record.id, email: record.email, garminConnected: record.garminConnected, garminOauth1Token: record.garminOauth1Token, garminOauth2Token: record.garminOauth2Token, garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt), garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt), calendarToken: record.calendarToken, lastPeriodDate: parseDate(record.lastPeriodDate), cycleLength: record.cycleLength, notificationTime: record.notificationTime, timezone: record.timezone, activeOverrides: record.activeOverrides || [], created: new Date(record.created as string), updated: new Date(record.updated as string), })), })); // Mock decryption const mockDecrypt = vi.fn((ciphertext: string) => { // Return mock OAuth2 token JSON if (ciphertext.includes("oauth2")) { return JSON.stringify({ access_token: "mock-token-123" }); } // Return mock OAuth1 token JSON (needed for refresh flow) if (ciphertext.includes("oauth1")) { return JSON.stringify({ oauth_token: "mock-oauth1-token", oauth_token_secret: "mock-oauth1-secret", }); } 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); const mockDaysUntilExpiry = vi.fn().mockReturnValue(30); vi.mock("@/lib/garmin", () => ({ fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args), fetchBodyBattery: (...args: unknown[]) => mockFetchBodyBattery(...args), fetchIntensityMinutes: (...args: unknown[]) => mockFetchIntensityMinutes(...args), isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args), daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args), })); // Mock email sending const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined); const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined); vi.mock("@/lib/email", () => ({ sendTokenExpirationWarning: (...args: unknown[]) => mockSendTokenExpirationWarning(...args), sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args), sendPeriodConfirmationEmail: (...args: unknown[]) => mockSendPeriodConfirmationEmail(...args), })); // Mock logger (required for route to run without side effects) vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), }, })); 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 garminRefreshTokenExpiresAt: null, 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(); vi.resetModules(); mockUsers = []; mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation process.env.CRON_SECRET = validSecret; process.env.POCKETBASE_ADMIN_EMAIL = "admin@test.com"; process.env.POCKETBASE_ADMIN_PASSWORD = "test-password"; }); 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); }); it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => { process.env.POCKETBASE_ADMIN_EMAIL = ""; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("Server misconfiguration"); }); it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => { process.env.POCKETBASE_ADMIN_PASSWORD = ""; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("Server misconfiguration"); }); }); 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); }); it("handles date fields as ISO strings from PocketBase", async () => { // PocketBase returns date fields as ISO strings, not Date objects // This simulates the raw response from pb.collection("users").getFullList() const rawPocketBaseRecord = { id: "user123", email: "test@example.com", garminConnected: true, garminOauth1Token: "encrypted:oauth1-token", garminOauth2Token: "encrypted:oauth2-token", garminTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date garminRefreshTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date calendarToken: "cal-token", lastPeriodDate: "2025-01-01T00:00:00.000Z", // ISO string, not Date cycleLength: 28, notificationTime: "07:00", timezone: "America/New_York", activeOverrides: [], created: "2024-01-01T00:00:00.000Z", updated: "2025-01-10T00:00:00.000Z", }; // Cast to User to simulate what getFullList() returns mockUsers = [rawPocketBaseRecord as unknown as User]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); const body = await response.json(); expect(body.usersProcessed).toBe(1); expect(body.errors).toBe(0); }); }); 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 refresh tokens", async () => { // Set refresh token to expired (in the past) const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago mockUsers = [ createMockUser({ garminRefreshTokenExpiresAt: expiredDate }), ]; 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 with today's date", async () => { mockUsers = [createMockUser()]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockFetchIntensityMinutes).toHaveBeenCalledWith( expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), "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("stores default value 100 when body battery is null from Garmin", async () => { // When Garmin API returns null for body battery values (no data available), // we store the default value 100 instead of null. // This prevents PocketBase's number field null-to-0 coercion from causing // the dashboard to display 0 instead of a meaningful value. mockUsers = [createMockUser()]; mockFetchBodyBattery.mockResolvedValue({ current: null, yesterdayLow: null, }); await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockPbCreate).toHaveBeenCalledWith( expect.objectContaining({ bodyBatteryCurrent: 100, bodyBatteryYesterdayLow: 100, }), ); }); }); 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); }); it("includes warningsSent in response", async () => { mockUsers = []; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); const body = await response.json(); expect(body.warningsSent).toBeDefined(); expect(body.warningsSent).toBe(0); }); }); describe("Token expiration warnings", () => { // Use fake timers to ensure consistent date calculations beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-01-15T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); // Helper to create a date N days from now function daysFromNow(days: number): Date { return new Date(Date.now() + days * 24 * 60 * 60 * 1000); } it("sends warning email when refresh token expires in exactly 14 days", async () => { mockUsers = [ createMockUser({ email: "user@example.com", garminRefreshTokenExpiresAt: daysFromNow(14), }), ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user@example.com", 14, "user123", ); const body = await response.json(); expect(body.warningsSent).toBe(1); }); it("sends warning email when refresh token expires in exactly 7 days", async () => { mockUsers = [ createMockUser({ email: "user@example.com", garminRefreshTokenExpiresAt: daysFromNow(7), }), ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user@example.com", 7, "user123", ); const body = await response.json(); expect(body.warningsSent).toBe(1); }); it("does not send warning when refresh token expires in 30 days", async () => { mockUsers = [ createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); it("does not send warning when refresh token expires in 15 days", async () => { mockUsers = [ createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); it("does not send warning when refresh token expires in 8 days", async () => { mockUsers = [ createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); it("does not send warning when refresh token expires in 6 days", async () => { mockUsers = [ createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); it("sends warnings for multiple users on different thresholds", async () => { mockUsers = [ createMockUser({ id: "user1", email: "user1@example.com", garminRefreshTokenExpiresAt: daysFromNow(14), }), createMockUser({ id: "user2", email: "user2@example.com", garminRefreshTokenExpiresAt: daysFromNow(7), }), ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2); expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user1@example.com", 14, "user1", ); expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user2@example.com", 7, "user2", ); const body = await response.json(); expect(body.warningsSent).toBe(2); }); it("continues processing sync even if warning email fails", async () => { mockUsers = [ createMockUser({ email: "user@example.com", garminRefreshTokenExpiresAt: daysFromNow(14), }), ]; mockSendTokenExpirationWarning.mockRejectedValueOnce( new Error("Email failed"), ); const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); const body = await response.json(); expect(body.usersProcessed).toBe(1); }); it("does not send warning for expired refresh tokens", async () => { // Expired refresh tokens are skipped entirely (not synced), so no warning const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago mockUsers = [ createMockUser({ garminRefreshTokenExpiresAt: expiredDate }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); }); }); // Note: Structured logging is implemented in the route but testing the mock // integration is complex due to vitest module hoisting. The logging calls // (logger.info for sync start/complete, logger.error for failures) are // verified through manual testing and code review. See route.ts lines 79, 146, 162. });