From d3ba01d1e18090c46b3a7bf1d26eed1f6e91e767 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 18:48:19 +0000 Subject: [PATCH] Implement GET /api/user endpoint (P0.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add authenticated user profile retrieval endpoint using withAuth wrapper. Returns user profile with safe fields, excluding encrypted tokens. Changes: - Implement GET handler in src/app/api/user/route.ts - Add 4 tests for auth, response shape, sensitive field exclusion - Add path alias resolution to vitest.config.ts for @/* imports - Update IMPLEMENTATION_PLAN.md to mark P0.4 complete Response includes: id, email, garminConnected, cycleLength, lastPeriodDate, notificationTime, timezone, activeOverrides 🤖 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 | 101 +++++++++++++++++++++++++++++++++ src/app/api/user/route.ts | 28 +++++++-- vitest.config.ts | 6 ++ 4 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 src/app/api/user/route.test.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index c4d29c4..01ab4fc 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -25,7 +25,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ### API Routes (12 total) | Route | Status | Notes | |-------|--------|-------| -| GET /api/user | 501 | Returns Not Implemented | +| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | | PATCH /api/user | 501 | Returns Not Implemented | | POST /api/cycle/period | 501 | Returns Not Implemented | | GET /api/cycle/current | 501 | Returns Not Implemented | @@ -70,12 +70,12 @@ 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/lib/nutrition.test.ts` | **MISSING** | | `src/lib/email.test.ts` | **MISSING** | | `src/lib/ics.test.ts` | **MISSING** | | `src/lib/encryption.test.ts` | **MISSING** | | `src/lib/garmin.test.ts` | **MISSING** | -| API route tests | **NONE** | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -126,12 +126,15 @@ These must be completed first - nothing else works without them. - **Why:** Overrides are core to the user experience per spec - **Blocking:** P1.4, P1.5 -### P0.4: GET /api/user Implementation -- [ ] Return authenticated user profile +### P0.4: GET /api/user Implementation ✅ COMPLETE +- [x] Return authenticated user profile - **Files:** - - `src/app/api/user/route.ts` - Implement GET handler with auth middleware + - `src/app/api/user/route.ts` - Implemented GET handler with `withAuth()` wrapper - **Tests:** - - `src/app/api/user/route.test.ts` - Test auth required, correct response shape + - `src/app/api/user/route.test.ts` - 4 tests covering auth, response shape, sensitive field exclusion +- **Response Shape:** + - `id`, `email`, `garminConnected`, `cycleLength`, `lastPeriodDate`, `notificationTime`, `timezone`, `activeOverrides` + - Excludes sensitive fields: `garminOauth1Token`, `garminOauth2Token`, `calendarToken` - **Why:** Dashboard and all pages need user context - **Depends On:** P0.1, P0.2 - **Blocking:** P1.7, P2.9, P2.10 @@ -476,6 +479,9 @@ P2.14 Mini calendar - [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms - [x] **DayCell** - Phase-colored calendar day cell with click handler +### API Routes +- [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4) + --- ## Discovered Issues diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts new file mode 100644 index 0000000..ae6e466 --- /dev/null +++ b/src/app/api/user/route.test.ts @@ -0,0 +1,101 @@ +// ABOUTME: Unit tests for user profile API route. +// ABOUTME: Tests GET /api/user for profile retrieval with authentication. +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 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/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; + }); + + it("returns user profile when authenticated", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + // Should include these fields + expect(body.id).toBe("user123"); + expect(body.email).toBe("test@example.com"); + expect(body.garminConnected).toBe(true); + expect(body.cycleLength).toBe(28); + expect(body.lastPeriodDate).toBe("2025-01-15"); + expect(body.notificationTime).toBe("07:30"); + expect(body.timezone).toBe("America/New_York"); + }); + + it("does not expose sensitive token fields", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + const body = await response.json(); + + // Should NOT include encrypted tokens + expect(body.garminOauth1Token).toBeUndefined(); + expect(body.garminOauth2Token).toBeUndefined(); + expect(body.calendarToken).toBeUndefined(); + }); + + it("includes activeOverrides array", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + const body = await response.json(); + + expect(body.activeOverrides).toEqual(["flare"]); + }); + + it("returns 401 when not authenticated", async () => { + currentMockUser = null; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); +}); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 64ecc45..0be5595 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -2,10 +2,30 @@ // ABOUTME: Handles GET for profile retrieval and PATCH for updates. import { NextResponse } from "next/server"; -export async function GET() { - // TODO: Implement user profile retrieval - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); -} +import { withAuth } from "@/lib/auth-middleware"; + +/** + * GET /api/user + * Returns the authenticated user's profile. + * Excludes sensitive fields like encrypted tokens. + */ +export const GET = withAuth(async (_request, user) => { + // Format date for consistent API response + const lastPeriodDate = user.lastPeriodDate + ? user.lastPeriodDate.toISOString().split("T")[0] + : null; + + return NextResponse.json({ + id: user.id, + email: user.email, + garminConnected: user.garminConnected, + cycleLength: user.cycleLength, + lastPeriodDate, + notificationTime: user.notificationTime, + timezone: user.timezone, + activeOverrides: user.activeOverrides, + }); +}); export async function PATCH() { // TODO: Implement user profile update diff --git a/vitest.config.ts b/vitest.config.ts index 39c38dd..5eb1a9c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,16 @@ // ABOUTME: Vitest configuration for unit and integration testing. // ABOUTME: Configures jsdom environment for React component testing. +import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"],