Implement calendar ICS feed and token regeneration (P2.6, P2.7)

Add two calendar-related API endpoints:

P2.6 - GET /api/calendar/[userId]/[token].ics:
- Token-based authentication (no session required)
- Validates calendar token against user record
- Generates 90 days of phase events using generateIcsFeed()
- Returns proper Content-Type and Cache-Control headers
- 404 for non-existent users, 401 for invalid tokens
- 10 tests covering all scenarios

P2.7 - POST /api/calendar/regenerate-token:
- Requires authentication via withAuth() middleware
- Generates cryptographically secure 32-character hex token
- Updates user's calendarToken field in database
- Returns new token and formatted calendar URL
- Old tokens immediately invalidated
- 9 tests covering token generation and auth

Total: 19 new tests, 360 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 20:02:07 +00:00
parent 901543cb4d
commit 532d49f570
5 changed files with 527 additions and 23 deletions

View File

@@ -0,0 +1,231 @@
// 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 { User } from "@/types";
// Module-level variable to control mock user lookup
let mockUsers: Map<string, User> = new Map();
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
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,
lastPeriodDate: user.lastPeriodDate.toISOString(),
cycleLength: user.cycleLength,
garminConnected: user.garminConnected,
};
}),
})),
})),
}));
// 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"),
calendarToken: "valid-calendar-token-abc123def",
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();
mockUsers = new Map();
mockUsers.set("user123", mockUser);
});
// 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);
});
});