From 18c34916ca3b2b83658375ff4e0684d41b0ccbf6 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 19:14:12 +0000 Subject: [PATCH] Implement PATCH /api/user endpoint (P1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add profile update functionality with validation for: - cycleLength: number, range 21-45 days - notificationTime: string, HH:MM format (24-hour) - timezone: non-empty string Security: Ignores attempts to update non-updatable fields (email, tokens). Returns updated user profile excluding sensitive fields. 17 tests covering validation, persistence, and security scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- IMPLEMENTATION_PLAN.md | 18 ++- src/app/api/user/route.test.ts | 283 ++++++++++++++++++++++++++++++++- src/app/api/user/route.ts | 132 ++++++++++++++- 3 files changed, 422 insertions(+), 11 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a32c158..7f10838 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -26,7 +26,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Route | Status | Notes | |-------|--------|-------| | GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | -| PATCH /api/user | 501 | Returns Not Implemented | +| PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) | | POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) | | GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) | | GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) | @@ -70,7 +70,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) | | `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/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) | | `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) | | `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) | | `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) | @@ -149,12 +149,17 @@ These must be completed first - nothing else works without them. Minimum viable product - app can be used for daily decisions. -### P1.1: PATCH /api/user Implementation -- [ ] Allow profile updates (cycleLength, notificationTime, timezone) +### P1.1: PATCH /api/user Implementation ✅ COMPLETE +- [x] Allow profile updates (cycleLength, notificationTime, timezone) - **Files:** - - `src/app/api/user/route.ts` - Implement PATCH handler with validation + - `src/app/api/user/route.ts` - Implemented PATCH handler with validation - **Tests:** - - `src/app/api/user/route.test.ts` - Test field validation, persistence + - `src/app/api/user/route.test.ts` - 17 tests covering field validation, persistence, security +- **Validation Rules:** + - `cycleLength`: number, range 21-45 days + - `notificationTime`: string, HH:MM format (24-hour) + - `timezone`: non-empty string +- **Security:** Ignores attempts to update non-updatable fields (email, tokens) - **Why:** Users need to configure their cycle and preferences - **Depends On:** P0.1, P0.2 @@ -495,6 +500,7 @@ P2.14 Mini calendar ### API Routes - [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4) +- [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1) - [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2) - [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3) - [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4) diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts index ae6e466..a672927 100644 --- a/src/app/api/user/route.test.ts +++ b/src/app/api/user/route.test.ts @@ -1,5 +1,5 @@ // ABOUTME: Unit tests for user profile API route. -// ABOUTME: Tests GET /api/user for profile retrieval with authentication. +// ABOUTME: Tests GET and PATCH /api/user for profile retrieval and updates. import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +9,18 @@ import type { User } from "@/types"; // Module-level variable to control mock user in tests let currentMockUser: User | null = null; +// Track PocketBase update calls +const mockPbUpdate = vi.fn().mockResolvedValue({}); + +// Mock PocketBase +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({ + collection: vi.fn(() => ({ + update: mockPbUpdate, + })), + })), +})); + // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ withAuth: vi.fn((handler) => { @@ -21,7 +33,7 @@ vi.mock("@/lib/auth-middleware", () => ({ }), })); -import { GET } from "./route"; +import { GET, PATCH } from "./route"; describe("GET /api/user", () => { const mockUser: User = { @@ -44,6 +56,7 @@ describe("GET /api/user", () => { beforeEach(() => { vi.clearAllMocks(); currentMockUser = null; + mockPbUpdate.mockClear(); }); it("returns user profile when authenticated", async () => { @@ -99,3 +112,269 @@ describe("GET /api/user", () => { expect(body.error).toBe("Unauthorized"); }); }); + +describe("PATCH /api/user", () => { + 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: ["flare"], + created: new Date("2024-01-01"), + updated: new Date("2025-01-10"), + }; + + beforeEach(() => { + vi.clearAllMocks(); + currentMockUser = null; + mockPbUpdate.mockClear(); + }); + + // Helper to create mock request with JSON body + function createMockRequest(body: Record): NextRequest { + return { + json: vi.fn().mockResolvedValue(body), + } as unknown as NextRequest; + } + + it("returns 401 when not authenticated", async () => { + currentMockUser = null; + + const mockRequest = createMockRequest({ cycleLength: 30 }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("updates cycleLength successfully", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ cycleLength: 30 }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.cycleLength).toBe(30); + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 }); + }); + + it("updates notificationTime successfully", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ notificationTime: "08:00" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.notificationTime).toBe("08:00"); + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { + notificationTime: "08:00", + }); + }); + + it("updates timezone successfully", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ timezone: "Europe/London" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.timezone).toBe("Europe/London"); + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { + timezone: "Europe/London", + }); + }); + + it("updates multiple fields at once", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ + cycleLength: 35, + notificationTime: "06:00", + timezone: "Asia/Tokyo", + }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.cycleLength).toBe(35); + expect(body.notificationTime).toBe("06:00"); + expect(body.timezone).toBe("Asia/Tokyo"); + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { + cycleLength: 35, + notificationTime: "06:00", + timezone: "Asia/Tokyo", + }); + }); + + it("returns 400 when cycleLength is below minimum (21)", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ cycleLength: 20 }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("cycleLength"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when cycleLength is above maximum (45)", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ cycleLength: 46 }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("cycleLength"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when cycleLength is not a number", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ cycleLength: "thirty" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("cycleLength"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when notificationTime has invalid format", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ notificationTime: "8am" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("notificationTime"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when notificationTime has invalid hours", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ notificationTime: "25:00" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("notificationTime"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when notificationTime has invalid minutes", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ notificationTime: "12:60" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("notificationTime"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when timezone is not a string", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ timezone: 123 }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("timezone"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 when timezone is empty string", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ timezone: "" }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("timezone"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("returns 400 with empty body", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({}); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("No valid fields"); + expect(mockPbUpdate).not.toHaveBeenCalled(); + }); + + it("ignores non-updatable fields like email", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ + email: "hacker@evil.com", + cycleLength: 30, + }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + // Email should NOT be changed + expect(body.email).toBe("test@example.com"); + // Only cycleLength should be in the update + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 }); + }); + + it("ignores attempts to update sensitive fields", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ + garminOauth1Token: "stolen-token", + calendarToken: "new-calendar-token", + cycleLength: 30, + }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + // Only cycleLength should be in the update + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 }); + }); + + it("returns updated user profile after successful update", async () => { + currentMockUser = mockUser; + + const mockRequest = createMockRequest({ cycleLength: 32 }); + const response = await PATCH(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + // Should return full profile with updated value + expect(body.id).toBe("user123"); + expect(body.email).toBe("test@example.com"); + expect(body.cycleLength).toBe(32); + expect(body.notificationTime).toBe("07:30"); + expect(body.timezone).toBe("America/New_York"); + // Should not expose sensitive fields + expect(body.garminOauth1Token).toBeUndefined(); + expect(body.garminOauth2Token).toBeUndefined(); + expect(body.calendarToken).toBeUndefined(); + }); +}); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 0be5595..0e10274 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -1,8 +1,15 @@ // ABOUTME: API route for user profile management. // ABOUTME: Handles GET for profile retrieval and PATCH for updates. +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; +import { createPocketBaseClient } from "@/lib/pocketbase"; + +// Validation constants +const CYCLE_LENGTH_MIN = 21; +const CYCLE_LENGTH_MAX = 45; +const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/; /** * GET /api/user @@ -27,7 +34,126 @@ export const GET = withAuth(async (_request, user) => { }); }); -export async function PATCH() { - // TODO: Implement user profile update - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); +/** + * Validates cycleLength field. + * Must be a number between CYCLE_LENGTH_MIN and CYCLE_LENGTH_MAX. + */ +function validateCycleLength(value: unknown): string | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return "cycleLength must be a number"; + } + if (value < CYCLE_LENGTH_MIN || value > CYCLE_LENGTH_MAX) { + return `cycleLength must be between ${CYCLE_LENGTH_MIN} and ${CYCLE_LENGTH_MAX}`; + } + return null; } + +/** + * Validates notificationTime field. + * Must be in HH:MM format (24-hour). + */ +function validateNotificationTime(value: unknown): string | null { + if (typeof value !== "string") { + return "notificationTime must be a string in HH:MM format"; + } + if (!TIME_FORMAT_REGEX.test(value)) { + return "notificationTime must be in HH:MM format (00:00-23:59)"; + } + return null; +} + +/** + * Validates timezone field. + * Must be a non-empty string. + */ +function validateTimezone(value: unknown): string | null { + if (typeof value !== "string") { + return "timezone must be a string"; + } + if (value.trim() === "") { + return "timezone cannot be empty"; + } + return null; +} + +/** + * PATCH /api/user + * Updates the authenticated user's profile. + * Allowed fields: cycleLength, notificationTime, timezone + */ +export const PATCH = withAuth(async (request: NextRequest, user) => { + const body = await request.json(); + + // Build update object with only valid, updatable fields + const updates: Record = {}; + const errors: string[] = []; + + // Validate and collect cycleLength + if (body.cycleLength !== undefined) { + const error = validateCycleLength(body.cycleLength); + if (error) { + errors.push(error); + } else { + updates.cycleLength = body.cycleLength; + } + } + + // Validate and collect notificationTime + if (body.notificationTime !== undefined) { + const error = validateNotificationTime(body.notificationTime); + if (error) { + errors.push(error); + } else { + updates.notificationTime = body.notificationTime; + } + } + + // Validate and collect timezone + if (body.timezone !== undefined) { + const error = validateTimezone(body.timezone); + if (error) { + errors.push(error); + } else { + updates.timezone = body.timezone; + } + } + + // Return validation errors if any + if (errors.length > 0) { + return NextResponse.json({ error: errors.join("; ") }, { status: 400 }); + } + + // Check if there are any fields to update + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No valid fields to update" }, + { status: 400 }, + ); + } + + // Update the user record in PocketBase + const pb = createPocketBaseClient(); + await pb.collection("users").update(user.id, updates); + + // Build updated user profile for response + const updatedUser = { + ...user, + ...updates, + }; + + // Format date for consistent API response + const lastPeriodDate = updatedUser.lastPeriodDate + ? updatedUser.lastPeriodDate.toISOString().split("T")[0] + : null; + + return NextResponse.json({ + id: updatedUser.id, + email: updatedUser.email, + garminConnected: updatedUser.garminConnected, + cycleLength: updatedUser.cycleLength, + lastPeriodDate, + notificationTime: updatedUser.notificationTime, + timezone: updatedUser.timezone, + activeOverrides: updatedUser.activeOverrides, + }); +});