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>
335 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|