Implement Garmin token management endpoints (P2.2, P2.3)

Added three Garmin API endpoints for token management:

- POST /api/garmin/tokens: Accepts oauth1, oauth2, expires_at;
  encrypts tokens using AES-256-GCM; stores in user record;
  returns daysUntilExpiry

- DELETE /api/garmin/tokens: Clears encrypted tokens from user
  record and sets garminConnected to false

- GET /api/garmin/status: Returns connection status, days until
  expiry, expired flag, and warning level (critical ≤7 days,
  warning 8-14 days)

All endpoints use withAuth() middleware for authentication.
Added 26 tests covering encryption, validation, auth, and
warning level thresholds.

Also added pb_data/ to .gitignore for PocketBase data.

🤖 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:45:16 +00:00
parent 24b7c0fd3e
commit 0fc25a49f1
6 changed files with 832 additions and 23 deletions

View File

@@ -0,0 +1,333 @@
// 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;
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(),
})),
}));
// 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);
};
}),
}));
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"),
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,
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,
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"),
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,
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,
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,
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,
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,
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"),
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();
});
});