// ABOUTME: Unit tests for user profile API route. // ABOUTME: Tests GET and PATCH /api/user for profile retrieval and updates. 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 the current mock user data const mockPbGetOne = vi.fn().mockImplementation(() => { if (!currentMockUser) { throw new Error("User not found"); } return Promise.resolve({ id: currentMockUser.id, email: currentMockUser.email, garminConnected: currentMockUser.garminConnected, lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(), cycleLength: currentMockUser.cycleLength, notificationTime: currentMockUser.notificationTime, timezone: currentMockUser.timezone, activeOverrides: currentMockUser.activeOverrides, }); }); // Create mock PocketBase client const mockPb = { collection: vi.fn(() => ({ update: mockPbUpdate, getOne: mockPbGetOne, })), }; // 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 { GET, PATCH } from "./route"; describe("GET /api/user", () => { 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: ["flare"], created: new Date("2024-01-01"), updated: new Date("2025-01-10"), }; beforeEach(() => { vi.clearAllMocks(); currentMockUser = null; mockPbUpdate.mockClear(); mockPbGetOne.mockClear(); }); it("returns user profile when authenticated", async () => { currentMockUser = mockUser; const mockRequest = {} as NextRequest; const response = await GET(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Should include these fields expect(body.id).toBe("user123"); expect(body.email).toBe("test@example.com"); expect(body.garminConnected).toBe(true); expect(body.cycleLength).toBe(28); expect(body.lastPeriodDate).toBe("2025-01-15"); expect(body.notificationTime).toBe("07:30"); expect(body.timezone).toBe("America/New_York"); }); it("does not expose sensitive token fields", async () => { currentMockUser = mockUser; const mockRequest = {} as NextRequest; const response = await GET(mockRequest); const body = await response.json(); // Should NOT include encrypted tokens expect(body.garminOauth1Token).toBeUndefined(); expect(body.garminOauth2Token).toBeUndefined(); expect(body.calendarToken).toBeUndefined(); }); it("includes activeOverrides array", async () => { currentMockUser = mockUser; const mockRequest = {} as NextRequest; const response = await GET(mockRequest); const body = await response.json(); expect(body.activeOverrides).toEqual(["flare"]); }); 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"); }); }); describe("PATCH /api/user", () => { 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: ["flare"], created: new Date("2024-01-01"), updated: new Date("2025-01-10"), }; beforeEach(() => { vi.clearAllMocks(); currentMockUser = null; mockPbUpdate.mockClear(); mockPbGetOne.mockClear(); }); // 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({ cycleLength: 30 }); const response = await PATCH(mockRequest); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); }); it("updates cycleLength successfully", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ cycleLength: 30 }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.cycleLength).toBe(30); expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 }); }); it("updates notificationTime successfully", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ notificationTime: "08:00" }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.notificationTime).toBe("08:00"); expect(mockPbUpdate).toHaveBeenCalledWith("user123", { notificationTime: "08:00", }); }); it("updates timezone successfully", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ timezone: "Europe/London" }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.timezone).toBe("Europe/London"); expect(mockPbUpdate).toHaveBeenCalledWith("user123", { timezone: "Europe/London", }); }); it("updates multiple fields at once", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ cycleLength: 35, notificationTime: "06:00", timezone: "Asia/Tokyo", }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); const body = await response.json(); expect(body.cycleLength).toBe(35); expect(body.notificationTime).toBe("06:00"); expect(body.timezone).toBe("Asia/Tokyo"); expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 35, notificationTime: "06:00", timezone: "Asia/Tokyo", }); }); it("returns 400 when cycleLength is below minimum (21)", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ cycleLength: 20 }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("cycleLength"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when cycleLength is above maximum (45)", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ cycleLength: 46 }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("cycleLength"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when cycleLength is not a number", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ cycleLength: "thirty" }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("cycleLength"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when notificationTime has invalid format", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ notificationTime: "8am" }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("notificationTime"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when notificationTime has invalid hours", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ notificationTime: "25:00" }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("notificationTime"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when notificationTime has invalid minutes", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ notificationTime: "12:60" }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("notificationTime"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when timezone is not a string", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ timezone: 123 }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("timezone"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 when timezone is empty string", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ timezone: "" }); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("timezone"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("returns 400 with empty body", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({}); const response = await PATCH(mockRequest); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toContain("No valid fields"); expect(mockPbUpdate).not.toHaveBeenCalled(); }); it("ignores non-updatable fields like email", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ email: "hacker@evil.com", cycleLength: 30, }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Email should NOT be changed expect(body.email).toBe("test@example.com"); // Only cycleLength should be in the update expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 }); }); it("ignores attempts to update sensitive fields", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ garminOauth1Token: "stolen-token", calendarToken: "new-calendar-token", cycleLength: 30, }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); // Only cycleLength should be in the update expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 }); }); it("returns updated user profile after successful update", async () => { currentMockUser = mockUser; const mockRequest = createMockRequest({ cycleLength: 32 }); const response = await PATCH(mockRequest); expect(response.status).toBe(200); const body = await response.json(); // Should return full profile with updated value expect(body.id).toBe("user123"); expect(body.email).toBe("test@example.com"); expect(body.cycleLength).toBe(32); expect(body.notificationTime).toBe("07:30"); expect(body.timezone).toBe("America/New_York"); // Should not expose sensitive fields expect(body.garminOauth1Token).toBeUndefined(); expect(body.garminOauth2Token).toBeUndefined(); expect(body.calendarToken).toBeUndefined(); }); });