Implement GET /api/user endpoint (P0.4)

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 18:48:19 +00:00
parent 76a46439b3
commit d3ba01d1e1
4 changed files with 143 additions and 10 deletions

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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

View File

@@ -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}"],