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>
430 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|