// ABOUTME: Unit tests for ICS calendar feed API route. // ABOUTME: Tests GET /api/calendar/[userId]/[token].ics for token validation and ICS generation. import type { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PeriodLog, User } from "@/types"; // Module-level variable to control mock user lookup let mockUsers: Map = new Map(); let mockPeriodLogs: PeriodLog[] = []; // Mock PocketBase vi.mock("@/lib/pocketbase", () => ({ createPocketBaseClient: vi.fn(() => ({ collection: vi.fn((name: string) => { if (name === "users") { return { getOne: vi.fn((userId: string) => { const user = mockUsers.get(userId); if (!user) { const error = new Error("Not found"); (error as unknown as { status: number }).status = 404; throw error; } return { id: user.id, email: user.email, calendarToken: user.calendarToken, // biome-ignore lint/style/noNonNullAssertion: mock user has valid date lastPeriodDate: user.lastPeriodDate!.toISOString(), cycleLength: user.cycleLength, garminConnected: user.garminConnected, }; }), }; } if (name === "period_logs") { return { getFullList: vi.fn(() => mockPeriodLogs.map((log) => ({ id: log.id, user: log.user, startDate: log.startDate.toISOString(), predictedDate: log.predictedDate?.toISOString() ?? null, created: log.created.toISOString(), })), ), }; } return {}; }), })), })); // Mock ICS generation const mockGenerateIcsFeed = vi.fn().mockReturnValue(`BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PhaseFlow//EN BEGIN:VEVENT SUMMARY:🔵 MENSTRUAL DTSTART:20250101 DTEND:20250105 END:VEVENT END:VCALENDAR`); vi.mock("@/lib/ics", () => ({ generateIcsFeed: (options: { lastPeriodDate: Date; cycleLength: number }) => mockGenerateIcsFeed(options), })); import { GET } from "./route"; describe("GET /api/calendar/[userId]/[token].ics", () => { 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: "valid-calendar-token-abc123def", 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(); mockUsers = new Map(); mockUsers.set("user123", mockUser); mockPeriodLogs = []; }); // Helper to create route context with params function createRouteContext(userId: string, token: string) { return { params: Promise.resolve({ userId, token }), }; } it("returns 401 for invalid token", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext("user123", "wrong-token"); const response = await GET(mockRequest, context); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toContain("Unauthorized"); }); it("returns 404 for non-existent user", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext("nonexistent", "some-token"); const response = await GET(mockRequest, context); expect(response.status).toBe(404); const body = await response.json(); expect(body.error).toContain("not found"); }); it("returns 401 when user has no calendar token set", async () => { mockUsers.set("user456", { ...mockUser, id: "user456", calendarToken: "", }); const mockRequest = {} as NextRequest; const context = createRouteContext("user456", "any-token"); const response = await GET(mockRequest, context); expect(response.status).toBe(401); }); it("returns ICS content for valid token", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext( "user123", "valid-calendar-token-abc123def", ); const response = await GET(mockRequest, context); expect(response.status).toBe(200); const text = await response.text(); expect(text).toContain("BEGIN:VCALENDAR"); expect(text).toContain("END:VCALENDAR"); }); it("returns correct Content-Type header", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext( "user123", "valid-calendar-token-abc123def", ); const response = await GET(mockRequest, context); expect(response.headers.get("Content-Type")).toBe( "text/calendar; charset=utf-8", ); }); it("calls generateIcsFeed with correct parameters", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext( "user123", "valid-calendar-token-abc123def", ); await GET(mockRequest, context); expect(mockGenerateIcsFeed).toHaveBeenCalledWith( expect.objectContaining({ lastPeriodDate: expect.any(Date), cycleLength: 28, }), ); }); it("generates 90 days of events (monthsAhead = 3)", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext( "user123", "valid-calendar-token-abc123def", ); await GET(mockRequest, context); expect(mockGenerateIcsFeed).toHaveBeenCalledWith( expect.objectContaining({ monthsAhead: 3, }), ); }); it("handles different cycle lengths", async () => { mockUsers.set("user789", { ...mockUser, id: "user789", calendarToken: "token789", cycleLength: 35, }); const mockRequest = {} as NextRequest; const context = createRouteContext("user789", "token789"); await GET(mockRequest, context); expect(mockGenerateIcsFeed).toHaveBeenCalledWith( expect.objectContaining({ cycleLength: 35, }), ); }); it("includes Cache-Control header for caching", async () => { const mockRequest = {} as NextRequest; const context = createRouteContext( "user123", "valid-calendar-token-abc123def", ); const response = await GET(mockRequest, context); // Calendar feeds should be cacheable but refresh periodically const cacheControl = response.headers.get("Cache-Control"); expect(cacheControl).toBeDefined(); expect(cacheControl).toContain("max-age"); }); it("is case-sensitive for token matching", async () => { const mockRequest = {} as NextRequest; // Token with different case should fail const context = createRouteContext( "user123", "VALID-CALENDAR-TOKEN-ABC123DEF", ); const response = await GET(mockRequest, context); expect(response.status).toBe(401); }); it("passes period logs to ICS generator for prediction accuracy", async () => { mockPeriodLogs = [ { id: "log1", user: "user123", startDate: new Date("2025-01-10"), predictedDate: new Date("2025-01-12"), // 2 days early created: new Date("2025-01-10"), }, { id: "log2", user: "user123", startDate: new Date("2024-12-15"), predictedDate: null, // First log, no prediction created: new Date("2024-12-15"), }, ]; const mockRequest = {} as NextRequest; const context = createRouteContext( "user123", "valid-calendar-token-abc123def", ); await GET(mockRequest, context); expect(mockGenerateIcsFeed).toHaveBeenCalledWith( expect.objectContaining({ periodLogs: expect.arrayContaining([ expect.objectContaining({ id: "log1", startDate: expect.any(Date), predictedDate: expect.any(Date), }), expect.objectContaining({ id: "log2", predictedDate: null, }), ]), }), ); }); });