- 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>
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
// ABOUTME: Unit tests for Garmin status API route.
|
|
// ABOUTME: Tests GET /api/garmin/status for connection status and token expiry.
|
|
import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
import { 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 a mock PocketBase client that returns the current mock user
|
|
const createMockPb = () => ({
|
|
collection: vi.fn(() => ({
|
|
getOne: vi.fn(() =>
|
|
Promise.resolve(
|
|
currentMockUser
|
|
? {
|
|
...currentMockUser,
|
|
garminTokenExpiresAt:
|
|
currentMockUser.garminTokenExpiresAt?.toISOString(),
|
|
}
|
|
: null,
|
|
),
|
|
),
|
|
})),
|
|
});
|
|
|
|
// Mock PocketBase
|
|
vi.mock("@/lib/pocketbase", () => ({
|
|
createPocketBaseClient: vi.fn(() => createMockPb()),
|
|
}));
|
|
|
|
// 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 });
|
|
}
|
|
const mockPb = createMockPb();
|
|
return handler(request, currentMockUser, mockPb);
|
|
};
|
|
}),
|
|
}));
|
|
|
|
import { GET } from "./route";
|
|
|
|
describe("GET /api/garmin/status", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
currentMockUser = null;
|
|
});
|
|
|
|
it("returns 401 when not authenticated", async () => {
|
|
currentMockUser = null;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
|
|
it("returns connected false when user has no tokens", async () => {
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: false,
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.connected).toBe(false);
|
|
});
|
|
|
|
it("returns connected true when user has valid tokens", async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 30);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: futureDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.connected).toBe(true);
|
|
});
|
|
|
|
it("returns daysUntilExpiry when connected", async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 30);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: futureDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.daysUntilExpiry).toBeGreaterThanOrEqual(29);
|
|
expect(body.daysUntilExpiry).toBeLessThanOrEqual(30);
|
|
});
|
|
|
|
it("returns null daysUntilExpiry when not connected", async () => {
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: false,
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.daysUntilExpiry).toBeNull();
|
|
});
|
|
|
|
it("returns expired true when tokens have expired", async () => {
|
|
const pastDate = new Date();
|
|
pastDate.setDate(pastDate.getDate() - 5);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: pastDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.connected).toBe(true);
|
|
expect(body.expired).toBe(true);
|
|
expect(body.daysUntilExpiry).toBeLessThan(0);
|
|
});
|
|
|
|
it("returns expired false when tokens are valid", async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 30);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: futureDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.expired).toBe(false);
|
|
});
|
|
|
|
it("returns warningLevel 'critical' when expiring in 7 days or less", async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 5);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: futureDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.warningLevel).toBe("critical");
|
|
});
|
|
|
|
it("returns warningLevel 'warning' when expiring in 8-14 days", async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 10);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: futureDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.warningLevel).toBe("warning");
|
|
});
|
|
|
|
it("returns warningLevel null when more than 14 days until expiry", async () => {
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 30);
|
|
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token",
|
|
garminOauth2Token: "encrypted-token",
|
|
garminTokenExpiresAt: futureDate,
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.warningLevel).toBeNull();
|
|
});
|
|
|
|
it("returns warningLevel null when not connected", async () => {
|
|
currentMockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: false,
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await GET(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.warningLevel).toBeNull();
|
|
});
|
|
});
|