All checks were successful
Deploy / deploy (push) Successful in 1m36s
Implements visual feedback for cycle prediction accuracy in ICS calendar feeds: - Add predictedDate field to PeriodLog type for tracking predicted vs actual dates - POST /api/cycle/period now calculates and stores predictedDate based on previous lastPeriodDate + cycleLength, returns daysEarly/daysLate in response - ICS feed generates "(Predicted)" events when actual period start differs from predicted, with descriptions like "period arrived 2 days early" - Calendar route fetches period logs and passes them to ICS generator This creates an accuracy feedback loop helping users understand their cycle variability over time per calendar.md spec. 807 tests passing across 43 test files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
310 lines
9.1 KiB
TypeScript
310 lines
9.1 KiB
TypeScript
// ABOUTME: Unit tests for period logging API route.
|
|
// ABOUTME: Tests POST /api/cycle/period for period start date logging.
|
|
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;
|
|
|
|
// Mock PocketBase client for database operations
|
|
const mockPbUpdate = vi.fn();
|
|
const mockPbCreate = vi.fn();
|
|
|
|
vi.mock("@/lib/pocketbase", () => ({
|
|
createPocketBaseClient: vi.fn(() => ({
|
|
collection: vi.fn((_name: string) => ({
|
|
update: mockPbUpdate,
|
|
create: mockPbCreate,
|
|
})),
|
|
})),
|
|
loadAuthFromCookies: vi.fn(),
|
|
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
|
getCurrentUser: vi.fn(() => currentMockUser),
|
|
}));
|
|
|
|
// 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);
|
|
};
|
|
}),
|
|
}));
|
|
|
|
import { POST } from "./route";
|
|
|
|
describe("POST /api/cycle/period", () => {
|
|
const mockUser: User = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: false,
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
|
calendarToken: "cal-secret-token",
|
|
lastPeriodDate: new Date("2024-12-15"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:00",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
currentMockUser = null;
|
|
mockPbUpdate.mockResolvedValue({});
|
|
mockPbCreate.mockResolvedValue({ id: "periodlog123" });
|
|
});
|
|
|
|
it("returns 401 when not authenticated", async () => {
|
|
currentMockUser = null;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(401);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Unauthorized");
|
|
});
|
|
|
|
it("returns 400 when startDate is missing", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({}),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("startDate");
|
|
});
|
|
|
|
it("returns 400 when startDate is invalid format", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "invalid-date" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("Invalid");
|
|
});
|
|
|
|
it("returns 400 when startDate is in the future", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
// Set a date far in the future
|
|
const futureDate = new Date();
|
|
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
const futureDateStr = futureDate.toISOString().split("T")[0];
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: futureDateStr }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = await response.json();
|
|
expect(body.error).toContain("future");
|
|
});
|
|
|
|
it("updates user lastPeriodDate and creates PeriodLog", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
// Verify user record was updated
|
|
expect(mockPbUpdate).toHaveBeenCalledWith(
|
|
"user123",
|
|
expect.objectContaining({
|
|
lastPeriodDate: "2025-01-10",
|
|
}),
|
|
);
|
|
|
|
// Verify PeriodLog was created
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user: "user123",
|
|
startDate: "2025-01-10",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns updated cycle information", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
// Should return cycle info
|
|
expect(body.lastPeriodDate).toBe("2025-01-10");
|
|
expect(body.cycleDay).toBeTypeOf("number");
|
|
expect(body.phase).toBeTypeOf("string");
|
|
expect(body.message).toBe("Period start date logged successfully");
|
|
});
|
|
|
|
it("calculates correct cycle day for new period", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
// If period started today, cycle day should be 1
|
|
const today = new Date().toISOString().split("T")[0];
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: today }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.cycleDay).toBe(1);
|
|
expect(body.phase).toBe("MENSTRUAL");
|
|
});
|
|
|
|
it("handles database update errors gracefully", async () => {
|
|
currentMockUser = mockUser;
|
|
mockPbUpdate.mockRejectedValue(new Error("Database error"));
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(500);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Failed to update period date");
|
|
});
|
|
|
|
describe("prediction accuracy tracking", () => {
|
|
it("calculates and stores predictedDate based on previous cycle", async () => {
|
|
// User's last period was 2024-12-15 with 28-day cycle
|
|
// Predicted next period: 2024-12-15 + 28 days = 2025-01-12
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
// Verify PeriodLog was created with predictedDate
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user: "user123",
|
|
startDate: "2025-01-10",
|
|
predictedDate: "2025-01-12", // lastPeriodDate (Dec 15) + cycleLength (28)
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns prediction accuracy information in response", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.predictedDate).toBe("2025-01-12");
|
|
expect(body.daysEarly).toBe(2); // Arrived 2 days early
|
|
});
|
|
|
|
it("handles period arriving late (positive daysLate)", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
// Period arrives 3 days after predicted (2025-01-15 instead of 2025-01-12)
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-15" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.predictedDate).toBe("2025-01-12");
|
|
expect(body.daysLate).toBe(3);
|
|
});
|
|
|
|
it("sets predictedDate to null when user has no previous lastPeriodDate", async () => {
|
|
// First period log - no previous cycle data
|
|
currentMockUser = {
|
|
...mockUser,
|
|
lastPeriodDate: null as unknown as Date,
|
|
};
|
|
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
// Should not include predictedDate for first log
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user: "user123",
|
|
startDate: "2025-01-10",
|
|
predictedDate: null,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("handles period arriving on predicted date exactly", async () => {
|
|
currentMockUser = mockUser;
|
|
|
|
// Period arrives exactly on predicted date (2025-01-12)
|
|
const mockRequest = {
|
|
json: vi.fn().mockResolvedValue({ startDate: "2025-01-12" }),
|
|
} as unknown as NextRequest;
|
|
|
|
const response = await POST(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
|
|
expect(body.predictedDate).toBe("2025-01-12");
|
|
expect(body.daysEarly).toBeUndefined();
|
|
expect(body.daysLate).toBeUndefined();
|
|
});
|
|
});
|
|
});
|