Files
phaseflow/src/app/api/period-history/route.test.ts
Petru Paler 6cd0c06396
All checks were successful
Deploy / deploy (push) Successful in 2m38s
Fix Garmin intensity minutes and add user-configurable phase goals
- 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>
2026-01-16 20:18:20 +00:00

430 lines
11 KiB
TypeScript

// ABOUTME: Unit tests for period history API route.
// ABOUTME: Tests GET /api/period-history for pagination, cycle length calculation, and auth.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PeriodLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetList = vi.fn();
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getList: mockGetList,
})),
};
// 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, mockPb);
};
}),
}));
import { GET } from "./route";
describe("GET /api/period-history", () => {
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: "cal-secret-token",
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"),
};
const mockPeriodLogs: PeriodLog[] = [
{
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
},
{
id: "period2",
user: "user123",
startDate: new Date("2024-12-18"),
predictedDate: new Date("2024-12-19"),
created: new Date("2024-12-18T10:00:00Z"),
},
{
id: "period3",
user: "user123",
startDate: new Date("2024-11-20"),
predictedDate: null,
created: new Date("2024-11-20T10:00:00Z"),
},
];
// Helper to create mock request with query parameters
function createMockRequest(params: Record<string, string> = {}): NextRequest {
const url = new URL("http://localhost:3000/api/period-history");
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return {
url: url.toString(),
nextUrl: url,
} as unknown as NextRequest;
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns paginated period logs for authenticated user", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(3);
expect(body.total).toBe(3);
expect(body.page).toBe(1);
expect(body.limit).toBe(20);
expect(body.hasMore).toBe(false);
});
it("calculates cycle lengths between consecutive periods", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Period 1 (Jan 15) - Period 2 (Dec 18) = 28 days
expect(body.items[0].cycleLength).toBe(28);
// Period 2 (Dec 18) - Period 3 (Nov 20) = 28 days
expect(body.items[1].cycleLength).toBe(28);
// Period 3 is the first log, no previous period to calculate from
expect(body.items[2].cycleLength).toBeNull();
});
it("calculates average cycle length", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Average of 28 and 28 = 28
expect(body.averageCycleLength).toBe(28);
});
it("returns null average when only one period exists", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockPeriodLogs[2]], // Only one period
totalItems: 1,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.averageCycleLength).toBeNull();
});
it("uses default pagination values (page=1, limit=20)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("respects page parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 3,
page: 2,
});
const mockRequest = createMockRequest({ page: "2" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
2,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("respects limit parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 5,
page: 1,
});
const mockRequest = createMockRequest({ limit: "10" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
10,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("returns empty array when no logs exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(0);
expect(body.total).toBe(0);
expect(body.hasMore).toBe(false);
expect(body.averageCycleLength).toBeNull();
});
it("returns 400 for invalid page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for non-numeric page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "abc" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for invalid limit value (too low)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns 400 for invalid limit value (too high)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "101" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns hasMore=true when more pages exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: Array(20).fill(mockPeriodLogs[0]),
totalItems: 50,
totalPages: 3,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(true);
});
it("returns hasMore=false on last page", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockPeriodLogs[0]],
totalItems: 41,
totalPages: 3,
page: 3,
});
const mockRequest = createMockRequest({ page: "3" });
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(false);
});
it("sorts by startDate descending (most recent first)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
sort: "-startDate",
}),
);
});
it("only returns logs for the authenticated user", async () => {
currentMockUser = { ...mockUser, id: "different-user" };
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="different-user"'),
}),
);
});
it("includes prediction accuracy for each period", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Period 1: actual Jan 15, predicted Jan 16 -> 1 day early
expect(body.items[0].daysEarly).toBe(1);
expect(body.items[0].daysLate).toBe(0);
// Period 2: actual Dec 18, predicted Dec 19 -> 1 day early
expect(body.items[1].daysEarly).toBe(1);
expect(body.items[1].daysLate).toBe(0);
// Period 3: no prediction (first log)
expect(body.items[2].daysEarly).toBeNull();
expect(body.items[2].daysLate).toBeNull();
});
});