Files
phaseflow/src/app/api/garmin/tokens/route.test.ts
Petru Paler 2408839b8b Fix 404 error when saving user preferences
Routes using withAuth were creating new unauthenticated PocketBase
clients, causing 404 errors when trying to update records. Modified
withAuth to pass the authenticated pb client to handlers so they can
use it for database operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:45:55 +00:00

335 lines
9.7 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({});
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
update: mockPbUpdate,
})),
};
// 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"),
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"),
};
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,
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"),
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"),
};
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,
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);
});
});