All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Apply 2x multiplier for vigorous intensity minutes (matches Garmin) - Use calendar week (Mon-Sun) instead of trailing 7 days for intensity - Add HRV yesterday fallback when today's data returns empty - Add user-configurable phase intensity goals with new defaults: - Menstrual: 75, Follicular: 150, Ovulation: 100 - Early Luteal: 120, Late Luteal: 50 - Update garmin-sync and today routes to use user-specific phase limits Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
// ABOUTME: Unit tests for Garmin tokens API route.
|
|
// ABOUTME: Tests POST and DELETE /api/garmin/tokens for token storage and deletion.
|
|
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;
|
|
|
|
// Track PocketBase update calls
|
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
|
|
|
// Track PocketBase getOne calls - returns user with garminConnected: true after update
|
|
const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true });
|
|
|
|
// Create mock PocketBase client
|
|
const mockPb = {
|
|
collection: vi.fn(() => ({
|
|
update: mockPbUpdate,
|
|
getOne: mockPbGetOne,
|
|
})),
|
|
};
|
|
|
|
// Track encryption calls
|
|
const mockEncrypt = vi.fn((plaintext: string) => `encrypted:${plaintext}`);
|
|
|
|
// Mock encryption module
|
|
vi.mock("@/lib/encryption", () => ({
|
|
encrypt: (plaintext: string) => mockEncrypt(plaintext),
|
|
}));
|
|
|
|
// 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 { DELETE, POST } from "./route";
|
|
|
|
describe("POST /api/garmin/tokens", () => {
|
|
const mockUser: User = {
|
|
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: [],
|
|
intensityGoalMenstrual: 75,
|
|
intensityGoalFollicular: 150,
|
|
intensityGoalOvulation: 100,
|
|
intensityGoalEarlyLuteal: 120,
|
|
intensityGoalLateLuteal: 50,
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
currentMockUser = null;
|
|
});
|
|
|
|
// Helper to create mock request with JSON body
|
|
function createMockRequest(body: Record<string, unknown>): NextRequest {
|
|
return {
|
|
json: vi.fn().mockResolvedValue(body),
|
|
} as unknown as NextRequest;
|
|
}
|
|
|
|
it("returns 401 when not authenticated", async () => {
|
|
currentMockUser = null;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
oauth2: { accessToken: "token123" },
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
|
|
it("stores encrypted tokens successfully", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
oauth2: { accessToken: "token123" },
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.garminConnected).toBe(true);
|
|
});
|
|
|
|
it("encrypts oauth1 and oauth2 tokens before storing", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const oauth1 = { token: "abc", secret: "xyz" };
|
|
const oauth2 = { accessToken: "token123" };
|
|
const mockRequest = createMockRequest({
|
|
oauth1,
|
|
oauth2,
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
await POST(mockRequest);
|
|
|
|
// Verify encrypt was called with JSON stringified tokens
|
|
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(oauth1));
|
|
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(oauth2));
|
|
});
|
|
|
|
it("updates user record with encrypted tokens and expiry", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const oauth1 = { token: "abc", secret: "xyz" };
|
|
const oauth2 = { accessToken: "token123" };
|
|
const expiresAt = "2025-04-01T00:00:00Z";
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1,
|
|
oauth2,
|
|
expires_at: expiresAt,
|
|
});
|
|
await POST(mockRequest);
|
|
|
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
|
|
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
|
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
|
garminTokenExpiresAt: expiresAt,
|
|
garminRefreshTokenExpiresAt: expect.any(String),
|
|
garminConnected: true,
|
|
});
|
|
});
|
|
|
|
it("returns 400 when oauth1 is missing", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth2: { accessToken: "token123" },
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("oauth1");
|
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when oauth2 is missing", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("oauth2");
|
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when expires_at is missing", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
oauth2: { accessToken: "token123" },
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("expires_at");
|
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when expires_at is not a valid date", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
oauth2: { accessToken: "token123" },
|
|
expires_at: "not-a-date",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("expires_at");
|
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when oauth1 is not an object", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: "not-an-object",
|
|
oauth2: { accessToken: "token123" },
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("oauth1");
|
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when oauth2 is not an object", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
oauth2: "not-an-object",
|
|
expires_at: "2025-04-01T00:00:00Z",
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("oauth2");
|
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns daysUntilExpiry in response", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
// Set expires_at to 30 days from now
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 30);
|
|
|
|
const mockRequest = createMockRequest({
|
|
oauth1: { token: "abc", secret: "xyz" },
|
|
oauth2: { accessToken: "token123" },
|
|
expires_at: futureDate.toISOString(),
|
|
});
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.daysUntilExpiry).toBeGreaterThanOrEqual(29);
|
|
expect(body.daysUntilExpiry).toBeLessThanOrEqual(30);
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/garmin/tokens", () => {
|
|
const mockUser: User = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted-token-1",
|
|
garminOauth2Token: "encrypted-token-2",
|
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2025-01-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:30",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
intensityGoalMenstrual: 75,
|
|
intensityGoalFollicular: 150,
|
|
intensityGoalOvulation: 100,
|
|
intensityGoalEarlyLuteal: 120,
|
|
intensityGoalLateLuteal: 50,
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
currentMockUser = null;
|
|
});
|
|
|
|
it("returns 401 when not authenticated", async () => {
|
|
currentMockUser = null;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await DELETE(mockRequest);
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
|
|
it("clears tokens and sets garminConnected to false", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await DELETE(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
garminTokenExpiresAt: null,
|
|
garminRefreshTokenExpiresAt: null,
|
|
garminConnected: false,
|
|
});
|
|
});
|
|
|
|
it("returns success response after deletion", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await DELETE(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.garminConnected).toBe(false);
|
|
});
|
|
|
|
it("works even when user has no tokens", async () => {
|
|
currentMockUser = {
|
|
...mockUser,
|
|
garminConnected: false,
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
};
|
|
|
|
const mockRequest = {} as NextRequest;
|
|
const response = await DELETE(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.success).toBe(true);
|
|
});
|
|
});
|