diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 01ab4fc..5dbe843 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -27,7 +27,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta |-------|--------|-------| | GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | | PATCH /api/user | 501 | Returns Not Implemented | -| POST /api/cycle/period | 501 | Returns Not Implemented | +| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) | | GET /api/cycle/current | 501 | Returns Not Implemented | | GET /api/today | 501 | Returns Not Implemented | | POST /api/overrides | 501 | Returns Not Implemented | @@ -71,6 +71,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/auth-middleware.test.ts` | **EXISTS** - 6 tests (withAuth wrapper, error handling) | | `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) | | `src/app/api/user/route.test.ts` | **EXISTS** - 4 tests (GET profile, auth, sensitive field exclusion) | +| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) | | `src/lib/nutrition.test.ts` | **MISSING** | | `src/lib/email.test.ts` | **MISSING** | | `src/lib/ics.test.ts` | **MISSING** | @@ -154,12 +155,12 @@ Minimum viable product - app can be used for daily decisions. - **Why:** Users need to configure their cycle and preferences - **Depends On:** P0.1, P0.2 -### P1.2: POST /api/cycle/period Implementation -- [ ] Log period start date, update user record, create PeriodLog +### P1.2: POST /api/cycle/period Implementation ✅ COMPLETE +- [x] Log period start date, update user record, create PeriodLog - **Files:** - - `src/app/api/cycle/period/route.ts` - Implement POST handler + - `src/app/api/cycle/period/route.ts` - Implemented POST handler with validation - **Tests:** - - `src/app/api/cycle/period/route.test.ts` - Test date validation, user update, log creation + - `src/app/api/cycle/period/route.test.ts` - 8 tests covering auth, date validation, user update, PeriodLog creation - **Why:** Cycle tracking is the foundation of all recommendations - **Depends On:** P0.1, P0.2 @@ -481,6 +482,7 @@ P2.14 Mini calendar ### API Routes - [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4) +- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2) --- diff --git a/src/app/api/cycle/period/route.test.ts b/src/app/api/cycle/period/route.test.ts new file mode 100644 index 0000000..8645b12 --- /dev/null +++ b/src/app/api/cycle/period/route.test.ts @@ -0,0 +1,208 @@ +// ABOUTME: Unit tests for period logging API route. +// ABOUTME: Tests POST /api/cycle/period for period start date logging. +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/types"; + +// Module-level variable to control mock user in tests +let currentMockUser: User | null = null; + +// Mock PocketBase client for database operations +const mockPbUpdate = vi.fn(); +const mockPbCreate = vi.fn(); + +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({ + collection: vi.fn((_name: string) => ({ + update: mockPbUpdate, + create: mockPbCreate, + })), + })), + loadAuthFromCookies: vi.fn(), + isAuthenticated: vi.fn(() => currentMockUser !== null), + getCurrentUser: vi.fn(() => currentMockUser), +})); + +// 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 { POST } from "./route"; + +describe("POST /api/cycle/period", () => { + const mockUser: User = { + id: "user123", + email: "test@example.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: new Date("2025-06-01"), + calendarToken: "cal-secret-token", + lastPeriodDate: new Date("2024-12-15"), + cycleLength: 28, + notificationTime: "07:00", + timezone: "America/New_York", + activeOverrides: [], + created: new Date("2024-01-01"), + updated: new Date("2025-01-10"), + }; + + beforeEach(() => { + vi.clearAllMocks(); + currentMockUser = null; + mockPbUpdate.mockResolvedValue({}); + mockPbCreate.mockResolvedValue({ id: "periodlog123" }); + }); + + it("returns 401 when not authenticated", async () => { + currentMockUser = null; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when startDate is missing", async () => { + currentMockUser = mockUser; + + const mockRequest = { + json: vi.fn().mockResolvedValue({}), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("startDate"); + }); + + it("returns 400 when startDate is invalid format", async () => { + currentMockUser = mockUser; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "invalid-date" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Invalid"); + }); + + it("returns 400 when startDate is in the future", async () => { + currentMockUser = mockUser; + + // Set a date far in the future + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + const futureDateStr = futureDate.toISOString().split("T")[0]; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: futureDateStr }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("future"); + }); + + it("updates user lastPeriodDate and creates PeriodLog", async () => { + currentMockUser = mockUser; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + + // Verify user record was updated + expect(mockPbUpdate).toHaveBeenCalledWith( + "user123", + expect.objectContaining({ + lastPeriodDate: "2025-01-10", + }), + ); + + // Verify PeriodLog was created + expect(mockPbCreate).toHaveBeenCalledWith( + expect.objectContaining({ + user: "user123", + startDate: "2025-01-10", + }), + ); + }); + + it("returns updated cycle information", async () => { + currentMockUser = mockUser; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + // Should return cycle info + expect(body.lastPeriodDate).toBe("2025-01-10"); + expect(body.cycleDay).toBeTypeOf("number"); + expect(body.phase).toBeTypeOf("string"); + expect(body.message).toBe("Period start date logged successfully"); + }); + + it("calculates correct cycle day for new period", async () => { + currentMockUser = mockUser; + + // If period started today, cycle day should be 1 + const today = new Date().toISOString().split("T")[0]; + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: today }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.cycleDay).toBe(1); + expect(body.phase).toBe("MENSTRUAL"); + }); + + it("handles database update errors gracefully", async () => { + currentMockUser = mockUser; + mockPbUpdate.mockRejectedValue(new Error("Database error")); + + const mockRequest = { + json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }), + } as unknown as NextRequest; + + const response = await POST(mockRequest); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Failed to update period date"); + }); +}); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 5e62a08..18fd27b 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -1,8 +1,96 @@ // ABOUTME: API route for logging period start dates. // ABOUTME: Recalculates all phase dates when period is logged. +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -export async function POST() { - // TODO: Implement period logging - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); +import { withAuth } from "@/lib/auth-middleware"; +import { getCycleDay, getPhase } from "@/lib/cycle"; +import { createPocketBaseClient } from "@/lib/pocketbase"; + +interface PeriodLogRequest { + startDate?: string; } + +/** + * Validates a date string is in YYYY-MM-DD format and represents a valid date. + */ +function isValidDateFormat(dateStr: string): boolean { + const regex = /^\d{4}-\d{2}-\d{2}$/; + if (!regex.test(dateStr)) { + return false; + } + const date = new Date(dateStr); + return !Number.isNaN(date.getTime()); +} + +/** + * Checks if a date is in the future (after today). + */ +function isFutureDate(dateStr: string): boolean { + const inputDate = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + inputDate.setHours(0, 0, 0, 0); + return inputDate > today; +} + +export const POST = withAuth(async (request: NextRequest, user) => { + try { + const body = (await request.json()) as PeriodLogRequest; + + // Validate startDate is present + if (!body.startDate) { + return NextResponse.json( + { error: "startDate is required" }, + { status: 400 }, + ); + } + + // Validate date format + if (!isValidDateFormat(body.startDate)) { + return NextResponse.json( + { error: "Invalid date format. Use YYYY-MM-DD" }, + { status: 400 }, + ); + } + + // Validate date is not in the future + if (isFutureDate(body.startDate)) { + return NextResponse.json( + { error: "startDate cannot be in the future" }, + { status: 400 }, + ); + } + + const pb = createPocketBaseClient(); + + // Update user's lastPeriodDate + await pb.collection("users").update(user.id, { + lastPeriodDate: body.startDate, + }); + + // Create PeriodLog record + await pb.collection("period_logs").create({ + user: user.id, + startDate: body.startDate, + }); + + // Calculate updated cycle information + const lastPeriodDate = new Date(body.startDate); + const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date()); + const phase = getPhase(cycleDay); + + return NextResponse.json({ + message: "Period start date logged successfully", + lastPeriodDate: body.startDate, + cycleDay, + phase, + }); + } catch (error) { + console.error("Period logging error:", error); + return NextResponse.json( + { error: "Failed to update period date" }, + { status: 500 }, + ); + } +});