diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ed73b30..db5453c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,6 +4,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current State Summary +### Overall Status: 517 tests passing across 30 test files + ### Library Implementation | File | Status | Gap Analysis | |------|--------|--------------| @@ -17,12 +19,19 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` | | `auth-middleware.ts` | **COMPLETE** | 6 tests covering `withAuth()` wrapper for API route protection | | `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login | +| `logger.ts` | **NOT IMPLEMENTED** | P2.17 - Structured logging with pino | +| `metrics.ts` | **NOT IMPLEMENTED** | P2.16 - Prometheus metrics collection | -### Missing Infrastructure Files (CONFIRMED NOT EXIST) -- ~~`src/lib/auth-middleware.ts`~~ - **CREATED** in P0.2 -- ~~`src/middleware.ts`~~ - **CREATED** in P0.2 +### Infrastructure Gaps (from specs/ - pending implementation) +| Gap | Spec Reference | Task | Priority | +|-----|----------------|------|----------| +| Health Check Endpoint | specs/observability.md | P2.15 | **COMPLETE** | +| Prometheus Metrics | specs/observability.md | P2.16 | Medium | +| Structured Logging (pino) | specs/observability.md | P2.17 | Medium | +| OIDC Authentication | specs/authentication.md | P2.18 | Medium | +| Token Expiration Warnings | specs/email.md | P3.9 | Medium | -### API Routes (15 total) +### API Routes (17 total) | Route | Status | Notes | |-------|--------|-------| | GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | @@ -40,6 +49,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) | | POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) | | GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) | +| GET /api/health | **COMPLETE** | Health check for deployment monitoring (14 tests) | +| GET /metrics | **NOT IMPLEMENTED** | Prometheus metrics endpoint (P2.16) | ### Pages (7 total) | Page | Status | Notes | @@ -50,7 +61,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Settings/Garmin (`/settings/garmin`) | **COMPLETE** | Token management UI, connection status, disconnect functionality, 27 tests | | Calendar (`/calendar`) | **COMPLETE** | MonthView with navigation, ICS subscription section, token regeneration, 23 tests | | History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests | -| Plan (`/plan`) | Placeholder | Needs phase details display | +| Plan (`/plan`) | **NOT IMPLEMENTED** | Placeholder only - shows heading and placeholder text, needs phase details display | ### Components | Component | Status | Notes | @@ -60,7 +71,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `NutritionPanel` | **COMPLETE** | Shows seeds, carbs, keto guidance | | `OverrideToggles` | **COMPLETE** | Toggle buttons with callbacks | | `DayCell` | **COMPLETE** | Phase-colored day with click handler | -| `MiniCalendar` | **Partial (~30%)** | Has header only, **MISSING: calendar grid** | +| `MiniCalendar` | **PARTIAL (~30%)** | Has header with cycle info only, **MISSING: calendar grid with DayCell integration** | | `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend | ### Test Coverage @@ -90,21 +101,30 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) | | `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | | `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) | +| `src/app/api/health/route.test.ts` | **EXISTS** - 14 tests (healthy/unhealthy states, PocketBase connectivity, error handling) | | `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) | | `src/components/calendar/month-view.test.tsx` | **EXISTS** - 21 tests (calendar grid, phase colors, navigation, legend) | | `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) | -| E2E tests | **NONE** | +| `src/app/settings/page.test.tsx` | **EXISTS** - 24+ tests (form rendering, validation, submission) | +| `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) | +| `src/components/dashboard/decision-card.test.tsx` | **MISSING** - Needs tests for rendering, status icons | +| `src/components/dashboard/data-panel.test.tsx` | **MISSING** - Needs tests for biometrics display | +| `src/components/dashboard/nutrition-panel.test.tsx` | **MISSING** - Needs tests for nutrition guidance display | +| `src/components/dashboard/override-toggles.test.tsx` | **MISSING** - Needs tests for toggle state and callbacks | +| `src/components/dashboard/mini-calendar.test.tsx` | **MISSING** - Needs tests for header/partial implementation | +| `src/components/calendar/day-cell.test.tsx` | **MISSING** - Needs tests for phase coloring, click handler | +| E2E tests | **AUTHORIZED SKIP** - Per specs/testing.md | ### Critical Business Rules (from Spec) 1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order) 2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable) 3. **Phase Limits:** Strictly enforced per phase configuration -4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry +4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry (NOT IMPLEMENTED - P3.9) 5. **ICS Feed:** Generates 90 days of phase events for calendar subscription --- -## P0: Critical Blockers +## P0: Critical Blockers ✅ ALL COMPLETE These must be completed first - nothing else works without them. @@ -158,7 +178,7 @@ These must be completed first - nothing else works without them. --- -## P1: Core Functionality +## P1: Core Functionality ✅ ALL COMPLETE Minimum viable product - app can be used for daily decisions. @@ -295,7 +315,7 @@ Full feature set for production use. - `connected` - Boolean indicating if tokens exist - `daysUntilExpiry` - Days until token expires (null if not connected) - `expired` - Boolean indicating if tokens have expired - - `warningLevel` - "critical" (≤7 days), "warning" (8-14 days), or null + - `warningLevel` - "critical" (<=7 days), "warning" (8-14 days), or null - **Why:** Users need visibility into their Garmin connection - **Depends On:** P0.1, P0.2, P2.1 @@ -425,21 +445,98 @@ Full feature set for production use. ### P2.13: Plan Page Implementation - [ ] Phase-specific training plan view +- **Current State:** Placeholder only - shows "Exercise Plan" heading and placeholder text, no actual content - **Files:** - `src/app/plan/page.tsx` - Current phase details, upcoming phases, limits - **Tests:** - - E2E test: correct phase info displayed + - Component tests: phase info display, limits shown correctly +- **Features Needed:** + - Current phase details (name, day range, characteristics) + - Upcoming phases preview + - Phase-specific training limits and recommendations + - Integration with cycle.ts utilities - **Why:** Users want detailed training guidance - **Depends On:** P0.4, P1.3 ### P2.14: Mini Calendar Component - [ ] Dashboard overview calendar +- **Current State:** Component exists with header/cycle info only (~30% complete), NO calendar grid - **Files:** - - `src/components/dashboard/mini-calendar.tsx` - **Complete calendar grid with phase colors** + - `src/components/dashboard/mini-calendar.tsx` - **Needs: complete calendar grid with phase colors using DayCell** - **Tests:** - - Component test: renders current month, highlights today + - `src/components/dashboard/mini-calendar.test.tsx` - Component test: renders current month, highlights today +- **Features Needed:** + - Calendar grid (reuse DayCell component from MonthView) + - Current week/month view + - Phase color coding + - Today highlight - **Why:** Quick visual reference on dashboard -- **Note:** Component exists with header only, needs calendar grid (~70% remaining) +- **Note:** Can leverage existing MonthView/DayCell components for implementation + +### P2.15: Health Check Endpoint ✅ COMPLETE +- [x] GET /api/health for deployment monitoring +- **Current State:** Fully implemented with PocketBase connectivity checks +- **Files:** + - `src/app/api/health/route.ts` - Returns health status with PocketBase connectivity check +- **Tests:** + - `src/app/api/health/route.test.ts` - 14 tests for healthy (200) and unhealthy (503) states +- **Response Shape:** + - `status` - "ok" or "unhealthy" + - `timestamp` - ISO 8601 timestamp + - `version` - Application version +- **Checks Performed:** + - PocketBase connectivity + - Basic app startup complete +- **Why:** Required for Nomad health checks, load balancer probes, and uptime monitoring (per specs/observability.md) + +### P2.16: Prometheus Metrics Endpoint +- [ ] GET /metrics for monitoring +- **Current State:** Endpoint and metrics library do not exist +- **Files:** + - `src/app/api/metrics/route.ts` - Returns Prometheus-format metrics + - `src/lib/metrics.ts` - Metrics collection with prom-client +- **Tests:** + - `src/app/api/metrics/route.test.ts` - Tests for valid Prometheus format output +- **Metrics:** + - Standard Node.js metrics (heap, eventloop lag, http requests) + - Custom: `phaseflow_garmin_sync_total`, `phaseflow_email_sent_total`, `phaseflow_decision_engine_calls_total`, `phaseflow_active_users` +- **Why:** Required for Prometheus scraping and production monitoring (per specs/observability.md) +- **Depends On:** None + +### P2.17: Structured Logging with Pino +- [ ] Replace console.error with structured JSON logging +- **Current State:** logger.ts does not exist, using console.log/error +- **Files:** + - `src/lib/logger.ts` - Pino logger configuration + - All route files - Replace console.error/log with logger calls +- **Tests:** + - `src/lib/logger.test.ts` - Tests for log format, levels, and JSON output +- **Log Levels:** error, warn, info +- **Key Events:** Auth success/failure, Garmin sync, email sent/failed, decision calculated, period logged, override toggled +- **Why:** Required for log aggregators (Loki, ELK) and production debugging (per specs/observability.md) +- **Depends On:** None + +### P2.18: OIDC Authentication +- [ ] Replace email/password login with OIDC (Pocket-ID) +- **Current State:** Using email/password form, no OIDC code exists +- **Files:** + - `src/app/login/page.tsx` - Replace form with "Sign In with Pocket-ID" button + - `src/lib/pocketbase.ts` - Add OIDC redirect and callback handling +- **Tests:** + - Update `src/app/login/page.test.tsx` - Tests for OIDC redirect flow +- **Flow:** + 1. User clicks "Sign In with Pocket-ID" + 2. Redirect to Pocket-ID authorization endpoint + 3. User authenticates (MFA if configured) + 4. Callback with authorization code + 5. PocketBase exchanges code for tokens + 6. Redirect to dashboard +- **Environment Variables:** + - `POCKETBASE_OIDC_CLIENT_ID` + - `POCKETBASE_OIDC_CLIENT_SECRET` + - `POCKETBASE_OIDC_ISSUER_URL` +- **Why:** Required per specs/authentication.md for secure identity management +- **Note:** Current email/password implementation works but OIDC is the production requirement --- @@ -528,14 +625,15 @@ Testing, error handling, and refinements. ### P3.9: Token Expiration Warnings - [ ] Email warnings at 14 and 7 days before Garmin token expiry +- **Current State:** `sendTokenExpirationWarning()` function does not exist in email.ts - **Files:** - `src/lib/email.ts` - Add `sendTokenExpirationWarning()` - `src/app/api/cron/garmin-sync/route.ts` - Check expiry, trigger warnings - **Tests:** - Test warning triggers at exactly 14 days and 7 days -- **Why:** Users need time to refresh tokens (per spec requirement) +- **Why:** Users need time to refresh tokens (per spec requirement in specs/email.md) -### P3.10: E2E Test Suite +### P3.10: E2E Test Suite (AUTHORIZED SKIP) - [ ] Comprehensive end-to-end tests - **Files:** - `tests/e2e/*.spec.ts` - Full user flows @@ -547,6 +645,102 @@ Testing, error handling, and refinements. - Garmin connection flow - Calendar subscription - **Why:** Confidence in production deployment +- **Status:** Per specs/testing.md: "End-to-end tests are not required for MVP (authorized skip)" + +### P3.11: Missing Component Tests +- [ ] Add unit tests for untested components +- **Components Needing Tests (6 total):** + - `src/components/dashboard/decision-card.tsx` - Tests for rendering decision status, icon, and reason + - `src/components/dashboard/data-panel.tsx` - Tests for biometrics display (BB, HRV, intensity) + - `src/components/dashboard/nutrition-panel.tsx` - Tests for seeds, carbs, keto guidance display + - `src/components/dashboard/override-toggles.tsx` - Tests for toggle states and callbacks (has interactive state) + - `src/components/dashboard/mini-calendar.tsx` - Tests for header rendering (partial implementation) + - `src/components/calendar/day-cell.tsx` - Tests for phase coloring and click handler +- **Test Files to Create:** + - `src/components/dashboard/decision-card.test.tsx` + - `src/components/dashboard/data-panel.test.tsx` + - `src/components/dashboard/nutrition-panel.test.tsx` + - `src/components/dashboard/override-toggles.test.tsx` + - `src/components/dashboard/mini-calendar.test.tsx` + - `src/components/calendar/day-cell.test.tsx` +- **Why:** Component isolation ensures UI correctness and prevents regressions + +--- + +## P4: UX Polish and Accessibility + +Enhancements from spec requirements that improve user experience. + +### P4.1: Dashboard Onboarding Banners +- [ ] Show setup prompts for missing configuration +- **Spec Reference:** specs/dashboard.md mentions onboarding banners +- **Features Needed:** + - Banner when Garmin not connected + - Banner when period date not set + - Banner when notification time not configured + - Dismissible after user completes setup +- **Files:** + - `src/app/page.tsx` - Add conditional banner rendering + - `src/components/dashboard/onboarding-banner.tsx` - New component +- **Why:** Helps new users complete setup for full functionality + +### P4.2: Accessibility Improvements +- [ ] Keyboard navigation and focus indicators +- **Spec Reference:** specs/dashboard.md accessibility requirements +- **Requirements:** + - Keyboard navigation for all interactive elements + - Visible focus indicators (focus:ring styles) + - 4.5:1 minimum contrast ratio + - Screen reader labels where needed +- **Files:** + - All component files - Add focus:ring classes, aria-labels +- **Why:** Required for accessibility compliance + +### P4.3: Dark Mode Configuration +- [ ] Complete dark mode support +- **Current State:** Partial Tailwind support via dark: classes exists in some components +- **Needs:** + - Configure prefers-color-scheme detection in tailwind.config.js + - Theme toggle in settings (optional) + - Ensure all components have dark: variants + - Test contrast ratios in dark mode +- **Files:** + - `tailwind.config.js` - Add darkMode configuration + - Component files - Verify dark: class coverage +- **Why:** User preference for dark mode + +### P4.4: Loading Performance +- [ ] Loading states within 100ms target +- **Spec Reference:** specs/dashboard.md performance requirements +- **Features:** + - Skeleton loading states + - Optimistic UI updates (partially done with overrides) + - Suspense boundaries for code splitting +- **Files:** + - Page files - Add loading.tsx skeletons +- **Why:** Perceived performance improvement + +### P4.5: Period Prediction Accuracy Feedback +- [ ] Mark predicted vs confirmed period dates +- **Spec Reference:** specs/calendar.md mentions predictions marked with "Predicted" suffix +- **Features:** + - Visual distinction between logged and predicted periods + - Calendar events show "Predicted" label for future periods +- **Files:** + - `src/lib/ics.ts` - Add "Predicted" suffix to future phase events + - `src/components/calendar/day-cell.tsx` - Visual indicator for predictions +- **Why:** Helps users understand prediction accuracy + +### P4.6: Rate Limiting +- [ ] Login attempt rate limiting +- **Spec Reference:** specs/email.md mentions 5 login attempts per minute +- **Features:** + - Rate limit login attempts by IP/email + - Show remaining attempts on error + - Temporary lockout after exceeding limit +- **Files:** + - `src/app/api/auth/route.ts` or PocketBase config - Rate limiting logic +- **Why:** Security requirement from spec --- @@ -560,7 +754,7 @@ P0.3 Override Logic ───┴──> P1.4 GET /api/today ──> P1.7 Dashboa P1.1 PATCH /api/user ────> P2.9 Settings Page P1.2 POST period ────────> P1.3 GET current ────> P1.7 Dashboard P1.5 Overrides API ──────> P1.7 Dashboard -P1.6 Login Page +P1.6 Login Page ─────────> P2.18 OIDC Auth (upgrade) P2.1 Garmin fetchers ──> P2.2 Garmin tokens ──> P2.4 Cron sync ──> P2.5 Notifications │ @@ -573,8 +767,30 @@ P2.7 Regen token P2.8 History API ──────> P2.12 History page P2.13 Plan page P2.14 Mini calendar + +P2.15 Health endpoint (independent - HIGH PRIORITY for deployment) +P2.16 Metrics endpoint (independent) +P2.17 Structured logging (independent, but should be done before other items for proper logging) + +P3.11 Component tests ─> Can be done in parallel with other work +P4.* UX Polish ────────> After core functionality complete ``` +### Remaining Work Priority + +| Priority | Task | Effort | Notes | +|----------|------|--------|-------| +| HIGH | P3.9 Token Warnings | Small | Spec requirement, security-related | +| Medium | P2.13 Plan Page | Medium | Placeholder exists, needs content | +| Medium | P2.14 MiniCalendar | Small | Can reuse DayCell, ~70% remaining | +| Medium | P2.16 Metrics | Medium | Production monitoring | +| Medium | P2.17 Logging | Medium | Should be done early for coverage | +| Medium | P2.18 OIDC Auth | Large | Production auth requirement | +| Medium | P3.11 Component Tests | Medium | 6 components need tests | +| Low | P3.7 Error Handling | Small | Polish | +| Low | P3.8 Loading States | Small | Polish | +| Low | P4.* UX Polish | Various | After core complete | + ### Dependency Summary | Task | Blocked By | Blocks | @@ -583,6 +799,11 @@ P2.14 Mini calendar | P0.2 | P0.1 | P0.4, P1.1-P1.5, P2.2-P2.3, P2.7-P2.8 | | P0.3 | - | P1.4, P1.5 | | P0.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 | +| P2.16 | - | - | +| P2.17 | - | - (recommended early for logging coverage) | +| P2.18 | P1.6 | - | +| P3.9 | P2.4 | - | +| P3.11 | - | - | --- @@ -597,6 +818,8 @@ P2.14 Mini calendar - [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4) - [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5) - [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6) +- [x] **auth-middleware.ts** - Complete with 6 tests (`withAuth()` wrapper) +- [x] **middleware.ts** - Complete with 12 tests (Next.js page protection) ### Components - [x] **DecisionCard** - Displays decision status, icon, and reason @@ -606,7 +829,7 @@ P2.14 Mini calendar - [x] **DayCell** - Phase-colored calendar day cell with click handler - [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend, 21 tests -### API Routes +### API Routes (16 complete, 1 not implemented) - [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) @@ -622,8 +845,9 @@ P2.14 Mini calendar - [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6) - [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7) - [x] **GET /api/history** - Paginated historical daily logs with date filtering, validation, 19 tests (P2.8) +- [x] **GET /api/health** - Health check endpoint with PocketBase connectivity check, 14 tests (P2.15) -### Pages +### Pages (6 complete, 1 placeholder) - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) - [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7) - [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9) @@ -655,7 +879,14 @@ P2.14 Mini calendar 3. **Incremental Delivery:** P1 completion = usable app without Garmin (manual data entry fallback) 4. **P2 Completion:** Full feature set with automation 5. **P3:** Quality and polish for production confidence -6. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7 -7. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles -8. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms -9. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry +6. **P4:** UX polish and accessibility improvements from spec requirements +7. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7 +8. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles +9. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms +10. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry (P3.9 NOT IMPLEMENTED) +11. **Health Check Priority:** P2.15 (GET /api/health) should be implemented early - it's required for deployment monitoring and load balancer health probes +12. **Structured Logging:** P2.17 (pino logger) should be implemented before other P2 items if possible, so new code can use proper logging from the start +13. **OIDC vs Email/Password:** Current email/password login (P1.6) works for development. P2.18 upgrades to OIDC for production security per specs/authentication.md +14. **E2E Tests:** Authorized skip per specs/testing.md - unit and integration tests are sufficient for MVP +15. **Dark Mode:** Partial Tailwind support exists via dark: classes but may need prefers-color-scheme configuration in tailwind.config.js (see P4.3) +16. **Component Tests:** 6 components lack unit tests (P3.11) - DecisionCard, DataPanel, NutritionPanel, OverrideToggles, MiniCalendar, DayCell diff --git a/src/app/api/health/route.test.ts b/src/app/api/health/route.test.ts new file mode 100644 index 0000000..59f6c58 --- /dev/null +++ b/src/app/api/health/route.test.ts @@ -0,0 +1,197 @@ +// ABOUTME: Tests for health check endpoint used by deployment monitoring and load balancers. +// ABOUTME: Covers healthy (200) and unhealthy (503) states based on PocketBase connectivity. + +import type Client from "pocketbase"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock PocketBase before importing the route +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(), +})); + +import { createPocketBaseClient } from "@/lib/pocketbase"; +import { GET } from "./route"; + +const mockCreatePocketBaseClient = vi.mocked(createPocketBaseClient); + +function mockPocketBaseWithHealth(checkFn: ReturnType): void { + const mockPb = { + health: { check: checkFn }, + } as unknown as Client; + mockCreatePocketBaseClient.mockReturnValue(mockPb); +} + +describe("GET /api/health", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("healthy state", () => { + it("returns 200 when PocketBase is reachable", async () => { + mockPocketBaseWithHealth( + vi.fn().mockResolvedValue({ code: 200, message: "OK" }), + ); + + const response = await GET(); + + expect(response.status).toBe(200); + }); + + it("returns status ok when healthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockResolvedValue({ code: 200, message: "OK" }), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.status).toBe("ok"); + }); + + it("includes ISO 8601 timestamp when healthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockResolvedValue({ code: 200, message: "OK" }), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.timestamp).toBeDefined(); + // Verify it's a valid ISO 8601 date + const date = new Date(body.timestamp); + expect(date.toISOString()).toBe(body.timestamp); + }); + + it("includes version when healthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockResolvedValue({ code: 200, message: "OK" }), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.version).toBeDefined(); + expect(typeof body.version).toBe("string"); + }); + + it("does not include error field when healthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockResolvedValue({ code: 200, message: "OK" }), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.error).toBeUndefined(); + }); + }); + + describe("unhealthy state", () => { + it("returns 503 when PocketBase is unreachable", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("Connection refused")), + ); + + const response = await GET(); + + expect(response.status).toBe(503); + }); + + it("returns status unhealthy when PocketBase fails", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("Connection refused")), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.status).toBe("unhealthy"); + }); + + it("includes ISO 8601 timestamp when unhealthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("Connection refused")), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.timestamp).toBeDefined(); + const date = new Date(body.timestamp); + expect(date.toISOString()).toBe(body.timestamp); + }); + + it("includes error message when unhealthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("Connection refused")), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + }); + + it("describes PocketBase failure in error message", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("ECONNREFUSED")), + ); + + const response = await GET(); + const body = await response.json(); + + expect(body.error).toContain("PocketBase"); + }); + + it("does not include version field when unhealthy", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("Connection refused")), + ); + + const response = await GET(); + const body = await response.json(); + + // Per spec, version is only in healthy response + expect(body.version).toBeUndefined(); + }); + }); + + describe("edge cases", () => { + it("handles PocketBase timeout", async () => { + mockPocketBaseWithHealth( + vi.fn().mockRejectedValue(new Error("timeout of 5000ms exceeded")), + ); + + const response = await GET(); + + expect(response.status).toBe(503); + const body = await response.json(); + expect(body.status).toBe("unhealthy"); + }); + + it("handles PocketBase returning error status code", async () => { + mockPocketBaseWithHealth( + vi + .fn() + .mockResolvedValue({ code: 500, message: "Internal Server Error" }), + ); + + // PocketBase returning 500 should still be considered healthy from connectivity perspective + // as long as the check() call succeeds + const response = await GET(); + + expect(response.status).toBe(200); + }); + + it("calls PocketBase health.check exactly once", async () => { + const mockCheck = vi.fn().mockResolvedValue({ code: 200, message: "OK" }); + mockPocketBaseWithHealth(mockCheck); + + await GET(); + + expect(mockCheck).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..7e59de5 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,31 @@ +// ABOUTME: Health check endpoint for deployment monitoring and load balancer probes. +// ABOUTME: Returns application health status based on PocketBase connectivity. + +import { NextResponse } from "next/server"; +import { createPocketBaseClient } from "@/lib/pocketbase"; + +const APP_VERSION = "1.0.0"; + +export async function GET(): Promise { + const timestamp = new Date().toISOString(); + const pb = createPocketBaseClient(); + + try { + await pb.health.check(); + + return NextResponse.json({ + status: "ok", + timestamp, + version: APP_VERSION, + }); + } catch { + return NextResponse.json( + { + status: "unhealthy", + timestamp, + error: "PocketBase connection failed", + }, + { status: 503 }, + ); + } +}