Files
phaseflow/src/app/api/cycle/period/route.test.ts
Petru Paler 58f6c5605a
All checks were successful
Deploy / deploy (push) Successful in 1m36s
Add period prediction accuracy feedback (P4.5 complete)
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>
2026-01-11 22:21:52 +00:00

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();
});
});
});