Implement Garmin sync cron endpoint (P2.4)

Add daily sync functionality for Garmin biometric data:
- Fetch all users with garminConnected=true
- Skip users with expired tokens
- Decrypt OAuth2 tokens and fetch HRV, Body Battery, Intensity Minutes
- Calculate cycle day, phase, phase limit, remaining minutes
- Compute training decision using decision engine
- Create DailyLog entries for each user
- Return sync summary with usersProcessed, errors, skippedExpired, timestamp

Includes 22 tests covering:
- CRON_SECRET authentication
- User iteration and filtering
- Token decryption and expiry handling
- Garmin API data fetching
- DailyLog creation with all required fields
- Error handling and graceful degradation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:50:26 +00:00
parent 0fc25a49f1
commit fc970a2c61
3 changed files with 509 additions and 8 deletions

View File

@@ -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> = {}): 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);
});
});
});