// 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): 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); }); });