Add period history UI with CRUD operations
All checks were successful
Deploy / deploy (push) Successful in 2m27s
All checks were successful
Deploy / deploy (push) Successful in 2m27s
- Add GET /api/period-history route with pagination, cycle length calculation, and prediction accuracy tracking - Add PATCH/DELETE /api/period-logs/[id] routes for editing and deleting period entries with ownership validation - Add /period-history page with table view, edit/delete modals, and pagination controls - Include 61 new tests covering all functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
423
src/app/api/period-history/route.test.ts
Normal file
423
src/app/api/period-history/route.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
// 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"),
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user