diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a1b7d07..fa55f74 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -14,7 +14,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `encryption.ts` | **Complete** | AES-256-GCM encrypt/decrypt implemented. **MISSING: tests** | | `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | | `garmin.ts` | **Minimal (~30%)** | Has fetchGarminData, isTokenExpired, daysUntilExpiry. **MISSING: fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes** | -| `pocketbase.ts` | **Basic** | Has pb client only. **MISSING: getCurrentUser(), isAuthenticated(), loadAuthFromCookies()** | +| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` | ### Missing Infrastructure Files (CONFIRMED NOT EXIST) - `src/lib/auth-middleware.ts` - Does NOT exist, needs creation @@ -65,12 +65,12 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta |-----------|--------| | `src/lib/cycle.test.ts` | **EXISTS** - 9 tests | | `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) | +| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) | | `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** | -| `src/lib/pocketbase.test.ts` | **MISSING** | | API route tests | **NONE** | | E2E tests | **NONE** | @@ -87,12 +87,12 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta These must be completed first - nothing else works without them. -### P0.1: PocketBase Auth Helpers -- [ ] Add authentication utilities to pocketbase.ts +### P0.1: PocketBase Auth Helpers ✅ COMPLETE +- [x] Add authentication utilities to pocketbase.ts - **Files:** - - `src/lib/pocketbase.ts` - Add `getCurrentUser()`, `isAuthenticated()`, `loadAuthFromCookies()` + - `src/lib/pocketbase.ts` - Added `createPocketBaseClient()`, `getCurrentUser()`, `isAuthenticated()`, `loadAuthFromCookies()` - **Tests:** - - `src/lib/pocketbase.test.ts` - Test auth state management, cookie loading + - `src/lib/pocketbase.test.ts` - 9 tests covering auth state management, cookie loading - **Why:** Every protected route and page depends on these helpers - **Blocking:** P0.2, P0.4, P1.1-P1.7, P2.2-P2.13 @@ -462,6 +462,7 @@ P2.14 Mini calendar ### Library - [x] **cycle.ts** - Complete with 9 tests (`getCycleDay`, `getPhase`, `getPhaseConfig`, `getPhaseLimit`) - [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`) +- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`) ### Components - [x] **DecisionCard** - Displays decision status, icon, and reason @@ -479,7 +480,7 @@ P2.14 Mini calendar - [ ] `src/lib/auth-middleware.ts` does not exist - must be created in P0.2 - [ ] `src/app/middleware.ts` does not exist - must be created in P0.2 - [ ] `garmin.ts` is only ~30% complete - missing specific biometric fetchers -- [ ] `pocketbase.ts` missing all auth helper functions +- [x] ~~`pocketbase.ts` missing all auth helper functions~~ - FIXED in P0.1 --- diff --git a/src/lib/pocketbase.test.ts b/src/lib/pocketbase.test.ts new file mode 100644 index 0000000..0d67501 --- /dev/null +++ b/src/lib/pocketbase.test.ts @@ -0,0 +1,185 @@ +// ABOUTME: Unit tests for PocketBase authentication helper utilities. +// ABOUTME: Tests isAuthenticated, getCurrentUser, and loadAuthFromCookies functions. +import { describe, expect, it, vi } from "vitest"; + +import { + createPocketBaseClient, + getCurrentUser, + isAuthenticated, + loadAuthFromCookies, +} from "./pocketbase"; + +describe("isAuthenticated", () => { + it("returns false when authStore is not valid", () => { + const mockPb = { + authStore: { + isValid: false, + model: null, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + expect(isAuthenticated(mockPb as any)).toBe(false); + }); + + it("returns true when authStore is valid", () => { + const mockPb = { + authStore: { + isValid: true, + model: { id: "user123" }, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + expect(isAuthenticated(mockPb as any)).toBe(true); + }); +}); + +describe("getCurrentUser", () => { + it("returns null when not authenticated", () => { + const mockPb = { + authStore: { + isValid: false, + model: null, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + expect(getCurrentUser(mockPb as any)).toBeNull(); + }); + + it("returns null when authStore is valid but model is null", () => { + const mockPb = { + authStore: { + isValid: true, + model: null, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + expect(getCurrentUser(mockPb as any)).toBeNull(); + }); + + it("returns mapped user when authenticated", () => { + const mockRecord = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted1", + garminOauth2Token: "encrypted2", + garminTokenExpiresAt: "2025-06-01T00:00:00Z", + calendarToken: "cal-token-123", + lastPeriodDate: "2025-01-01", + cycleLength: 28, + notificationTime: "07:00", + timezone: "America/New_York", + activeOverrides: ["flare"], + created: "2024-01-01T00:00:00Z", + updated: "2024-06-01T00:00:00Z", + }; + + const mockPb = { + authStore: { + isValid: true, + model: mockRecord, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + const user = getCurrentUser(mockPb as any); + + expect(user).not.toBeNull(); + expect(user?.id).toBe("user123"); + expect(user?.email).toBe("test@example.com"); + expect(user?.garminConnected).toBe(true); + expect(user?.cycleLength).toBe(28); + expect(user?.activeOverrides).toEqual(["flare"]); + expect(user?.timezone).toBe("America/New_York"); + }); + + it("handles empty activeOverrides array", () => { + const mockRecord = { + id: "user456", + email: "user@test.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: "", + calendarToken: "token", + lastPeriodDate: "2025-01-15", + cycleLength: 31, + notificationTime: "08:00", + timezone: "UTC", + activeOverrides: [], + created: "2024-01-01T00:00:00Z", + updated: "2024-01-01T00:00:00Z", + }; + + const mockPb = { + authStore: { + isValid: true, + model: mockRecord, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + const user = getCurrentUser(mockPb as any); + + expect(user).not.toBeNull(); + expect(user?.activeOverrides).toEqual([]); + }); +}); + +describe("loadAuthFromCookies", () => { + it("does nothing when pb_auth cookie is not present", () => { + const loadFromCookie = vi.fn(); + const mockPb = { + authStore: { + isValid: false, + model: null, + loadFromCookie, + }, + }; + + const mockCookieStore = { + get: vi.fn().mockReturnValue(undefined), + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + loadAuthFromCookies(mockPb as any, mockCookieStore as any); + + expect(mockCookieStore.get).toHaveBeenCalledWith("pb_auth"); + expect(loadFromCookie).not.toHaveBeenCalled(); + }); + + it("loads auth from cookie when pb_auth is present", () => { + const loadFromCookie = vi.fn(); + const mockPb = { + authStore: { + isValid: false, + model: null, + loadFromCookie, + }, + }; + + const mockCookieStore = { + get: vi + .fn() + .mockReturnValue({ name: "pb_auth", value: "token-value-here" }), + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + loadAuthFromCookies(mockPb as any, mockCookieStore as any); + + expect(mockCookieStore.get).toHaveBeenCalledWith("pb_auth"); + expect(loadFromCookie).toHaveBeenCalledWith("pb_auth=token-value-here"); + }); +}); + +describe("createPocketBaseClient", () => { + it("creates a new PocketBase instance", () => { + const client = createPocketBaseClient(); + expect(client).toBeDefined(); + expect(client.authStore).toBeDefined(); + }); +}); diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index bedc403..14e4f47 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -1,10 +1,82 @@ -// ABOUTME: PocketBase client initialization and utilities. +// ABOUTME: PocketBase client initialization and authentication utilities. // ABOUTME: Provides typed access to the PocketBase backend for auth and data. -import PocketBase from "pocketbase"; +import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import PocketBase, { type RecordModel } from "pocketbase"; + +import type { OverrideType, User } from "@/types"; const POCKETBASE_URL = process.env.POCKETBASE_URL || "http://localhost:8090"; +/** + * Singleton PocketBase client for client-side usage. + * For server-side, use createPocketBaseClient() to get a fresh instance. + */ export const pb = new PocketBase(POCKETBASE_URL); // Disable auto-cancellation for server-side usage pb.autoCancellation(false); + +/** + * Creates a new PocketBase client instance. + * Use this in server components and API routes to get a fresh client + * that can be loaded with request-specific auth. + */ +export function createPocketBaseClient(): PocketBase { + const client = new PocketBase(POCKETBASE_URL); + client.autoCancellation(false); + return client; +} + +/** + * Checks if the PocketBase client has a valid authenticated session. + */ +export function isAuthenticated(pbClient: PocketBase): boolean { + return pbClient.authStore.isValid; +} + +/** + * Gets the current authenticated user from the PocketBase auth store. + * Returns null if not authenticated or if the model is not available. + */ +export function getCurrentUser(pbClient: PocketBase): User | null { + if (!pbClient.authStore.isValid || !pbClient.authStore.model) { + return null; + } + return mapRecordToUser(pbClient.authStore.model); +} + +/** + * Loads authentication state from Next.js cookies into the PocketBase client. + * Call this in server components/routes before checking auth. + */ +export function loadAuthFromCookies( + pbClient: PocketBase, + cookieStore: ReadonlyRequestCookies, +): void { + const cookie = cookieStore.get("pb_auth"); + if (cookie) { + pbClient.authStore.loadFromCookie(`pb_auth=${cookie.value}`); + } +} + +/** + * Maps a PocketBase record to our typed User interface. + */ +function mapRecordToUser(record: RecordModel): User { + return { + id: record.id, + email: record.email as string, + garminConnected: record.garminConnected as boolean, + garminOauth1Token: record.garminOauth1Token as string, + garminOauth2Token: record.garminOauth2Token as string, + garminTokenExpiresAt: new Date(record.garminTokenExpiresAt as string), + calendarToken: record.calendarToken as string, + lastPeriodDate: new Date(record.lastPeriodDate as string), + cycleLength: record.cycleLength as number, + notificationTime: record.notificationTime as string, + timezone: record.timezone as string, + activeOverrides: (record.activeOverrides as OverrideType[]) || [], + created: new Date(record.created as string), + updated: new Date(record.updated as string), + }; +}