All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Apply 2x multiplier for vigorous intensity minutes (matches Garmin) - Use calendar week (Mon-Sun) instead of trailing 7 days for intensity - Add HRV yesterday fallback when today's data returns empty - Add user-configurable phase intensity goals with new defaults: - Menstrual: 75, Follicular: 150, Ovulation: 100 - Early Luteal: 120, Late Luteal: 50 - Update garmin-sync and today routes to use user-specific phase limits Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
302 lines
8.3 KiB
TypeScript
302 lines
8.3 KiB
TypeScript
// 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<string, User> = 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,
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|