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:
@@ -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/lib/auth-middleware.ts`~~ - **CREATED** in P0.2
|
||||||
- ~~`src/middleware.ts`~~ - **CREATED** in P0.2
|
- ~~`src/middleware.ts`~~ - **CREATED** in P0.2
|
||||||
|
|
||||||
### API Routes (12 total)
|
### API Routes (15 total)
|
||||||
| Route | Status | Notes |
|
| Route | Status | Notes |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
|
| 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/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/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) |
|
| 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)
|
### Pages (7 total)
|
||||||
| Page | Status | Notes |
|
| 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/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/[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/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** |
|
| E2E tests | **NONE** |
|
||||||
|
|
||||||
### Critical Business Rules (from Spec)
|
### Critical Business Rules (from Spec)
|
||||||
@@ -357,12 +359,18 @@ Full feature set for production use.
|
|||||||
- **Why:** Security feature for calendar URLs
|
- **Why:** Security feature for calendar URLs
|
||||||
- **Depends On:** P0.1, P0.2
|
- **Depends On:** P0.1, P0.2
|
||||||
|
|
||||||
### P2.8: GET /api/history Implementation
|
### P2.8: GET /api/history Implementation ✅ COMPLETE
|
||||||
- [ ] Return paginated historical daily logs
|
- [x] Return paginated historical daily logs
|
||||||
- **Files:**
|
- **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:**
|
- **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
|
- **Why:** Users want to see their training history
|
||||||
- **Depends On:** P0.1, P0.2
|
- **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] **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] **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] **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
|
### Pages
|
||||||
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
||||||
|
|||||||
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.
|
// ABOUTME: Returns paginated list of past training decisions and data.
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
// TODO: Implement history retrieval
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
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