diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 85d5761..6538371 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -22,7 +22,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta - ~~`src/lib/auth-middleware.ts`~~ - **CREATED** in P0.2 - ~~`src/middleware.ts`~~ - **CREATED** in P0.2 -### API Routes (12 total) +### API Routes (15 total) | Route | Status | Notes | |-------|--------|-------| | GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | @@ -39,6 +39,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) | | POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) | | POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) | +| GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) | ### Pages (7 total) | Page | Status | Notes | @@ -88,6 +89,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) | | `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) | | `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | +| `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -357,12 +359,18 @@ Full feature set for production use. - **Why:** Security feature for calendar URLs - **Depends On:** P0.1, P0.2 -### P2.8: GET /api/history Implementation -- [ ] Return paginated historical daily logs +### P2.8: GET /api/history Implementation ✅ COMPLETE +- [x] Return paginated historical daily logs - **Files:** - - `src/app/api/history/route.ts` - Query DailyLog with pagination + - `src/app/api/history/route.ts` - Query DailyLog with pagination, date filtering, validation - **Tests:** - - `src/app/api/history/route.test.ts` - Test pagination, date filtering + - `src/app/api/history/route.test.ts` - 19 tests covering pagination, date filtering, auth, validation +- **Features Implemented:** + - Pagination with page/limit parameters (default: page=1, limit=20) + - Date filtering with startDate/endDate query params (YYYY-MM-DD format) + - Validation for all parameters with descriptive error messages + - Sort by date descending (most recent first) + - Returns items, total, page, limit, totalPages, hasMore - **Why:** Users want to see their training history - **Depends On:** P0.1, P0.2 @@ -601,6 +609,7 @@ P2.14 Mini calendar - [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5) - [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6) - [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7) +- [x] **GET /api/history** - Paginated historical daily logs with date filtering, validation, 19 tests (P2.8) ### Pages - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) diff --git a/src/app/api/history/route.test.ts b/src/app/api/history/route.test.ts new file mode 100644 index 0000000..0016393 --- /dev/null +++ b/src/app/api/history/route.test.ts @@ -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 = {}): 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"'), + }), + ); + }); +}); diff --git a/src/app/api/history/route.ts b/src/app/api/history/route.ts index cd46395..a7fba8f 100644 --- a/src/app/api/history/route.ts +++ b/src/app/api/history/route.ts @@ -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(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, + }); +});