- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint - Track refresh token expiry (~30 days) instead of access token expiry (~21 hours) - Auto-refresh access tokens in cron sync before they expire - Update Python script to output refresh_token_expires_at - Add garminRefreshTokenExpiresAt field to User type and database schema - Fix token input UX: show when warning active, not just when disconnected - Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data - Add oauth-1.0a package for OAuth1 signature generation The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token, so users only need to re-run the Python auth script every ~30 days (when refresh token expires) instead of every ~21 hours (when access token expires). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
257 lines
7.8 KiB
TypeScript
257 lines
7.8 KiB
TypeScript
// ABOUTME: Unit tests for current cycle phase API route.
|
|
// ABOUTME: Tests GET /api/cycle/current for cycle day and phase information.
|
|
import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { User } from "@/types";
|
|
|
|
// Module-level variable to control mock user in tests
|
|
let currentMockUser: User | null = null;
|
|
|
|
// Create mock PocketBase getOne function that returns fresh user data
|
|
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
|
if (!currentMockUser) {
|
|
throw new Error("User not found");
|
|
}
|
|
return Promise.resolve({
|
|
id: currentMockUser.id,
|
|
email: currentMockUser.email,
|
|
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
|
cycleLength: currentMockUser.cycleLength,
|
|
});
|
|
});
|
|
|
|
// Create mock PocketBase client
|
|
const mockPb = {
|
|
collection: vi.fn(() => ({
|
|
getOne: mockPbGetOne,
|
|
})),
|
|
};
|
|
|
|
// Mock PocketBase client for database operations
|
|
vi.mock("@/lib/pocketbase", () => ({
|
|
createPocketBaseClient: vi.fn(() => mockPb),
|
|
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, mockPb);
|
|
};
|
|
}),
|
|
}));
|
|
|
|
import { GET } from "./route";
|
|
|
|
describe("GET /api/cycle/current", () => {
|
|
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 mockRequest = {} as NextRequest;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
currentMockUser = 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();
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("returns current cycle day and phase information", async () => {
|
|
// lastPeriodDate: 2025-01-01, current: 2025-01-10
|
|
// That's 9 days difference, so cycle day = 10
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2025-01-01"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("returns complete phase configuration", async () => {
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2025-01-01"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.phaseConfig).toBeDefined();
|
|
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
|
expect(body.phaseConfig.weeklyLimit).toBe(120);
|
|
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
|
|
// Phase configs days are for reference; actual boundaries are calculated dynamically
|
|
expect(body.phaseConfig.days).toEqual([4, 15]);
|
|
expect(body.phaseConfig.dailyAvg).toBe(17);
|
|
});
|
|
|
|
it("calculates daysUntilNextPhase correctly", async () => {
|
|
// Cycle day 10, in FOLLICULAR (days 4-15 for 31-day cycle)
|
|
// Days until OVULATION starts (day 16): 16 - 10 = 6
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2025-01-01"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.daysUntilNextPhase).toBe(6);
|
|
});
|
|
|
|
it("returns correct data for MENSTRUAL phase", async () => {
|
|
// Set lastPeriodDate to 2025-01-08 so cycle day = 3 on 2025-01-10
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2025-01-08"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.cycleDay).toBe(3);
|
|
expect(body.phase).toBe("MENSTRUAL");
|
|
expect(body.phaseConfig.weeklyLimit).toBe(30);
|
|
expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR
|
|
});
|
|
|
|
it("returns correct data for OVULATION phase", async () => {
|
|
// For 31-day cycle, OVULATION is days 16-17
|
|
// Set lastPeriodDate so cycle day = 16 (start of OVULATION)
|
|
// If current is 2025-01-10, need lastPeriodDate = 2024-12-26 (15 days ago)
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2024-12-26"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.cycleDay).toBe(16);
|
|
expect(body.phase).toBe("OVULATION");
|
|
expect(body.phaseConfig.weeklyLimit).toBe(80);
|
|
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
|
|
});
|
|
|
|
it("returns correct data for LATE_LUTEAL phase", async () => {
|
|
// Set lastPeriodDate so cycle day = 28 (in LATE_LUTEAL days 25-31)
|
|
// If current is 2025-01-10, need lastPeriodDate = 2024-12-14 (27 days ago)
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2024-12-14"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.cycleDay).toBe(28);
|
|
expect(body.phase).toBe("LATE_LUTEAL");
|
|
expect(body.phaseConfig.weeklyLimit).toBe(50);
|
|
// Days until next cycle starts (new MENSTRUAL): 31 - 28 + 1 = 4
|
|
expect(body.daysUntilNextPhase).toBe(4);
|
|
});
|
|
|
|
it("handles cycle rollover correctly", async () => {
|
|
// Set lastPeriodDate so we're past day 31 with cycleLength 31
|
|
// 40 days ago: (40 % 31) + 1 = 10
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2024-12-01"),
|
|
cycleLength: 31,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
// 40 days: (40 % 31) + 1 = 10
|
|
expect(body.cycleDay).toBe(10);
|
|
expect(body.phase).toBe("FOLLICULAR");
|
|
});
|
|
|
|
it("works with custom cycle length", async () => {
|
|
// With 28 day cycle, same offset gives different results
|
|
// lastPeriodDate: 2025-01-01, current: 2025-01-10
|
|
// 9 days difference, so cycle day = 10 (within 28)
|
|
currentMockUser = createMockUser({
|
|
lastPeriodDate: new Date("2025-01-01"),
|
|
cycleLength: 28,
|
|
});
|
|
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.cycleDay).toBe(10);
|
|
expect(body.cycleLength).toBe(28);
|
|
});
|
|
});
|