Implement history API endpoint (P2.8)
Add GET /api/history for paginated historical daily logs with: - Pagination support (page/limit query params, default 20 per page) - Date filtering (startDate/endDate in YYYY-MM-DD format) - Validation for all parameters with descriptive error messages - Sort by date descending (most recent first) - Response includes items, total, page, limit, totalPages, hasMore Includes 19 tests covering pagination, date filtering, auth, and validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
440
src/app/api/history/route.test.ts
Normal file
440
src/app/api/history/route.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
// ABOUTME: Unit tests for historical daily logs API route.
|
||||
// ABOUTME: Tests GET /api/history for pagination, date filtering, and auth.
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DailyLog, 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();
|
||||
|
||||
// Mock PocketBase
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({
|
||||
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);
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("GET /api/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 mockDailyLog: DailyLog = {
|
||||
id: "log1",
|
||||
user: "user123",
|
||||
date: new Date("2025-01-10"),
|
||||
cycleDay: 5,
|
||||
phase: "FOLLICULAR",
|
||||
bodyBatteryCurrent: 85,
|
||||
bodyBatteryYesterdayLow: 45,
|
||||
hrvStatus: "Balanced",
|
||||
weekIntensityMinutes: 60,
|
||||
phaseLimit: 120,
|
||||
remainingMinutes: 60,
|
||||
trainingDecision: "TRAIN",
|
||||
decisionReason: "All metrics look good - full training today!",
|
||||
notificationSentAt: new Date("2025-01-10T07:30:00Z"),
|
||||
created: new Date("2025-01-10T06: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/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 daily logs for authenticated user", async () => {
|
||||
currentMockUser = mockUser;
|
||||
const logs = [
|
||||
mockDailyLog,
|
||||
{
|
||||
...mockDailyLog,
|
||||
id: "log2",
|
||||
date: new Date("2025-01-09"),
|
||||
cycleDay: 4,
|
||||
},
|
||||
];
|
||||
mockGetList.mockResolvedValue({
|
||||
items: logs,
|
||||
totalItems: 2,
|
||||
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(2);
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.page).toBe(1);
|
||||
expect(body.limit).toBe(20);
|
||||
expect(body.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
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: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("filters by startDate", async () => {
|
||||
currentMockUser = mockUser;
|
||||
mockGetList.mockResolvedValue({
|
||||
items: [mockDailyLog],
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ startDate: "2025-01-05" });
|
||||
await GET(mockRequest);
|
||||
|
||||
expect(mockGetList).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.objectContaining({
|
||||
filter: expect.stringMatching(/user="user123".*date>="2025-01-05"/),
|
||||
sort: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by endDate", async () => {
|
||||
currentMockUser = mockUser;
|
||||
mockGetList.mockResolvedValue({
|
||||
items: [mockDailyLog],
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({ endDate: "2025-01-15" });
|
||||
await GET(mockRequest);
|
||||
|
||||
expect(mockGetList).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.objectContaining({
|
||||
filter: expect.stringMatching(/user="user123".*date<="2025-01-15"/),
|
||||
sort: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by both startDate and endDate", async () => {
|
||||
currentMockUser = mockUser;
|
||||
mockGetList.mockResolvedValue({
|
||||
items: [mockDailyLog],
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest({
|
||||
startDate: "2025-01-01",
|
||||
endDate: "2025-01-15",
|
||||
});
|
||||
await GET(mockRequest);
|
||||
|
||||
expect(mockGetList).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.objectContaining({
|
||||
filter: expect.stringMatching(
|
||||
/user="user123".*date>="2025-01-01".*date<="2025-01-15"/,
|
||||
),
|
||||
sort: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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(mockDailyLog),
|
||||
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: [mockDailyLog],
|
||||
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("returns 400 for invalid startDate format", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = createMockRequest({ startDate: "not-a-date" });
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("startDate");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid endDate format", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = createMockRequest({ endDate: "not-a-date" });
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("endDate");
|
||||
});
|
||||
|
||||
it("sorts by date descending (most recent first)", async () => {
|
||||
currentMockUser = mockUser;
|
||||
const logs = [
|
||||
{ ...mockDailyLog, id: "log1", date: new Date("2025-01-10") },
|
||||
{ ...mockDailyLog, id: "log2", date: new Date("2025-01-09") },
|
||||
{ ...mockDailyLog, id: "log3", date: new Date("2025-01-08") },
|
||||
];
|
||||
mockGetList.mockResolvedValue({
|
||||
items: logs,
|
||||
totalItems: 3,
|
||||
totalPages: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const mockRequest = createMockRequest();
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetList).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.objectContaining({
|
||||
sort: "-date",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,95 @@
|
||||
// ABOUTME: Returns paginated list of past training decisions and data.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
// TODO: Implement history retrieval
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
import { withAuth } from "@/lib/auth-middleware";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
import type { DailyLog } from "@/types";
|
||||
|
||||
// Validation constants
|
||||
const MIN_PAGE = 1;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Validates that a date string is in YYYY-MM-DD format and represents a valid date.
|
||||
*/
|
||||
function isValidDateFormat(dateStr: string): boolean {
|
||||
if (!DATE_REGEX.test(dateStr)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(dateStr);
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
export const GET = withAuth(async (request, user) => {
|
||||
const { searchParams } = request.nextUrl;
|
||||
|
||||
// Parse and validate page parameter
|
||||
const pageParam = searchParams.get("page");
|
||||
const page = pageParam ? Number.parseInt(pageParam, 10) : 1;
|
||||
if (Number.isNaN(page) || page < MIN_PAGE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid page: must be a positive integer (minimum ${MIN_PAGE})`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate limit parameter
|
||||
const limitParam = searchParams.get("limit");
|
||||
const limit = limitParam ? Number.parseInt(limitParam, 10) : DEFAULT_LIMIT;
|
||||
if (Number.isNaN(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid limit: must be between ${MIN_LIMIT} and ${MAX_LIMIT}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate date filters
|
||||
const startDate = searchParams.get("startDate");
|
||||
if (startDate && !isValidDateFormat(startDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid startDate: must be in YYYY-MM-DD format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const endDate = searchParams.get("endDate");
|
||||
if (endDate && !isValidDateFormat(endDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid endDate: must be in YYYY-MM-DD format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build filter expression
|
||||
const filters: string[] = [`user="${user.id}"`];
|
||||
if (startDate) {
|
||||
filters.push(`date>="${startDate}"`);
|
||||
}
|
||||
if (endDate) {
|
||||
filters.push(`date<="${endDate}"`);
|
||||
}
|
||||
const filter = filters.join(" && ");
|
||||
|
||||
// Query PocketBase
|
||||
const pb = createPocketBaseClient();
|
||||
const result = await pb
|
||||
.collection("dailyLogs")
|
||||
.getList<DailyLog>(page, limit, {
|
||||
filter,
|
||||
sort: "-date",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit,
|
||||
totalPages: result.totalPages,
|
||||
hasMore: result.page < result.totalPages,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user