Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
- NutritionPanel: Display seed switch alert on day 15 per dashboard spec - MonthView: Add phase emojis to legend (🩸🌱🌸🌙🌑) per calendar spec - DayCell: Show period indicator (🩸) for days 1-3 per calendar spec - Auth middleware: Log client IP from x-forwarded-for/x-real-ip per observability spec - Updated NutritionGuidance type to include seedSwitchAlert field - /api/today now returns seedSwitchAlert in nutrition response Test coverage: 1005 tests (15 new tests added) - nutrition-panel.test.tsx: +4 tests - month-view.test.tsx: +1 test - day-cell.test.tsx: +5 tests - auth-middleware.test.ts: +3 tests - today/route.test.ts: +2 tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
|
|
||||||
## Current State Summary
|
## Current State Summary
|
||||||
|
|
||||||
### Overall Status: 990 unit tests passing across 50 test files + 64 E2E tests across 6 files
|
### Overall Status: 1005 unit tests passing across 50 test files + 64 E2E tests across 6 files
|
||||||
|
|
||||||
### Library Implementation
|
### Library Implementation
|
||||||
| File | Status | Gap Analysis |
|
| File | Status | Gap Analysis |
|
||||||
@@ -17,7 +17,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
|
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
|
||||||
| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation |
|
| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation |
|
||||||
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
|
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
|
||||||
| `auth-middleware.ts` | **COMPLETE** | 9 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures |
|
| `auth-middleware.ts` | **COMPLETE** | 12 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures, IP address logging |
|
||||||
| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login |
|
| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login |
|
||||||
| `logger.ts` | **COMPLETE** | 16 tests covering JSON output, log levels, error stack traces, child loggers |
|
| `logger.ts` | **COMPLETE** | 16 tests covering JSON output, log levels, error stack traces, child loggers |
|
||||||
| `metrics.ts` | **COMPLETE** | 33 tests covering metrics collection, counters, gauges, histograms, Prometheus format |
|
| `metrics.ts` | **COMPLETE** | 33 tests covering metrics collection, counters, gauges, histograms, Prometheus format |
|
||||||
@@ -39,7 +39,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) |
|
| PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) |
|
||||||
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog with prediction tracking (13 tests) |
|
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog with prediction tracking (13 tests) |
|
||||||
| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 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) |
|
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (24 tests) |
|
||||||
| POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) |
|
| POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) |
|
||||||
| DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) |
|
| DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) |
|
||||||
| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) |
|
| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) |
|
||||||
@@ -88,14 +88,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/lib/cycle.test.ts` | **EXISTS** - 22 tests |
|
| `src/lib/cycle.test.ts` | **EXISTS** - 22 tests |
|
||||||
| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) |
|
| `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/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
|
||||||
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 9 tests (withAuth wrapper, error handling, structured logging) |
|
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 12 tests (withAuth wrapper, error handling, structured logging, IP address logging) |
|
||||||
| `src/lib/logger.test.ts` | **EXISTS** - 16 tests (JSON format, log levels, error serialization, child loggers) |
|
| `src/lib/logger.test.ts` | **EXISTS** - 16 tests (JSON format, log levels, error serialization, child loggers) |
|
||||||
| `src/lib/metrics.test.ts` | **EXISTS** - 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) |
|
| `src/lib/metrics.test.ts` | **EXISTS** - 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) |
|
||||||
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
|
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
|
||||||
| `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) |
|
| `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** - 13 tests (POST period, auth, validation, date checks, prediction tracking) |
|
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 13 tests (POST period, auth, validation, date checks, prediction tracking) |
|
||||||
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
|
| `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) |
|
| `src/app/api/today/route.test.ts` | **EXISTS** - 24 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics, seed switch alert) |
|
||||||
| `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) |
|
| `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) |
|
||||||
| `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) |
|
| `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) |
|
||||||
| `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) |
|
| `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) |
|
||||||
@@ -117,18 +117,18 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
|
| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
|
||||||
| `src/app/period-history/page.test.tsx` | **EXISTS** - 27 tests (rendering, edit/delete modals, pagination, prediction accuracy) |
|
| `src/app/period-history/page.test.tsx` | **EXISTS** - 27 tests (rendering, edit/delete modals, pagination, prediction accuracy) |
|
||||||
| `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) |
|
| `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) |
|
||||||
| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 30 tests (calendar grid, phase colors, navigation, legend, keyboard navigation) |
|
| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 31 tests (calendar grid, phase colors, navigation, legend, keyboard navigation, emojis) |
|
||||||
| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) |
|
| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) |
|
||||||
| `src/app/settings/page.test.tsx` | **EXISTS** - 34 tests (form rendering, validation, submission, accessibility, logout functionality) |
|
| `src/app/settings/page.test.tsx` | **EXISTS** - 34 tests (form rendering, validation, submission, accessibility, logout functionality) |
|
||||||
| `src/app/api/auth/logout/route.test.ts` | **EXISTS** - 5 tests (cookie clearing, success response, error handling) |
|
| `src/app/api/auth/logout/route.test.ts` | **EXISTS** - 5 tests (cookie clearing, success response, error handling) |
|
||||||
| `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) |
|
| `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) |
|
||||||
| `src/components/dashboard/decision-card.test.tsx` | **EXISTS** - 19 tests (rendering, status icons, styling, color-coded backgrounds) |
|
| `src/components/dashboard/decision-card.test.tsx` | **EXISTS** - 19 tests (rendering, status icons, styling, color-coded backgrounds) |
|
||||||
| `src/components/dashboard/data-panel.test.tsx` | **EXISTS** - 29 tests (biometrics display, null handling, styling, HRV status color-coding, intensity progress bar) |
|
| `src/components/dashboard/data-panel.test.tsx` | **EXISTS** - 29 tests (biometrics display, null handling, styling, HRV status color-coding, intensity progress bar) |
|
||||||
| `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 12 tests (seeds, carbs, keto guidance) |
|
| `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 16 tests (seeds, carbs, keto guidance, seed switch alert) |
|
||||||
| `src/components/dashboard/override-toggles.test.tsx` | **EXISTS** - 18 tests (toggle states, callbacks, styling) |
|
| `src/components/dashboard/override-toggles.test.tsx` | **EXISTS** - 18 tests (toggle states, callbacks, styling) |
|
||||||
| `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) |
|
| `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) |
|
||||||
| `src/components/dashboard/onboarding-banner.test.tsx` | **EXISTS** - 16 tests (setup prompts, icons, action buttons, interactions, dismissal) |
|
| `src/components/dashboard/onboarding-banner.test.tsx` | **EXISTS** - 16 tests (setup prompts, icons, action buttons, interactions, dismissal) |
|
||||||
| `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 27 tests (phase coloring, today highlighting, click handling, accessibility) |
|
| `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 32 tests (phase coloring, today highlighting, click handling, accessibility, period indicator) |
|
||||||
| `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) |
|
| `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) |
|
||||||
| `src/app/layout.test.tsx` | **EXISTS** - 4 tests (skip navigation link rendering, accessibility, Toaster rendering) |
|
| `src/app/layout.test.tsx` | **EXISTS** - 4 tests (skip navigation link rendering, accessibility, Toaster rendering) |
|
||||||
| `src/components/ui/toaster.test.tsx` | **EXISTS** - 23 tests (toast rendering, types, auto-dismiss, error persistence, accessibility) |
|
| `src/components/ui/toaster.test.tsx` | **EXISTS** - 23 tests (toast rendering, types, auto-dismiss, error persistence, accessibility) |
|
||||||
@@ -158,10 +158,10 @@ These must be completed first - nothing else works without them.
|
|||||||
### P0.2: Auth Middleware for API Routes ✅ COMPLETE
|
### P0.2: Auth Middleware for API Routes ✅ COMPLETE
|
||||||
- [x] Create reusable auth middleware for protected API endpoints
|
- [x] Create reusable auth middleware for protected API endpoints
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers
|
- `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers with IP address logging
|
||||||
- `src/middleware.ts` - Added Next.js middleware for page protection
|
- `src/middleware.ts` - Added Next.js middleware for page protection
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/lib/auth-middleware.test.ts` - 6 tests covering unauthorized rejection, user context passing, error handling
|
- `src/lib/auth-middleware.test.ts` - 12 tests covering unauthorized rejection, user context passing, error handling, IP address logging
|
||||||
- `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets
|
- `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets
|
||||||
- **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth
|
- **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth
|
||||||
- **Depends On:** P0.1
|
- **Depends On:** P0.1
|
||||||
@@ -239,11 +239,11 @@ Minimum viable product - app can be used for daily decisions.
|
|||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition
|
- `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/app/api/today/route.test.ts` - 22 tests covering auth, validation, decision calculation, overrides, phases, nutrition
|
- `src/app/api/today/route.test.ts` - 24 tests covering auth, validation, decision calculation, overrides, phases, nutrition, seed switch alert
|
||||||
- **Response Shape:**
|
- **Response Shape:**
|
||||||
- `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength`
|
- `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength`
|
||||||
- `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit)
|
- `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit)
|
||||||
- `nutrition` (seeds, carbRange, ketoGuidance)
|
- `nutrition` (seeds, carbRange, ketoGuidance, seedSwitchAlert)
|
||||||
- **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0
|
- **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0
|
||||||
- **Why:** This is THE core API for the dashboard
|
- **Why:** This is THE core API for the dashboard
|
||||||
- **Depends On:** P0.1, P0.2, P0.3, P1.3
|
- **Depends On:** P0.1, P0.2, P0.3, P1.3
|
||||||
@@ -447,9 +447,9 @@ Full feature set for production use.
|
|||||||
- [x] In-app calendar with phase visualization
|
- [x] In-app calendar with phase visualization
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration
|
- `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration
|
||||||
- `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend
|
- `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend with emojis
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/components/calendar/month-view.test.tsx` - 21 tests covering calendar grid, phase colors, navigation, legend
|
- `src/components/calendar/month-view.test.tsx` - 31 tests covering calendar grid, phase colors, navigation, legend, emojis
|
||||||
- `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration
|
- `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration
|
||||||
- **Why:** Planning ahead is a key user need
|
- **Why:** Planning ahead is a key user need
|
||||||
- **Depends On:** P2.6
|
- **Depends On:** P2.6
|
||||||
@@ -646,7 +646,7 @@ Testing, error handling, and refinements.
|
|||||||
- [x] Replace console.error with structured pino logger
|
- [x] Replace console.error with structured pino logger
|
||||||
- [x] Add logging for key events per observability spec
|
- [x] Add logging for key events per observability spec
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging
|
- `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging with IP address
|
||||||
- `src/app/api/cycle/period/route.ts` - Added "Period logged" event logging, structured error logging
|
- `src/app/api/cycle/period/route.ts` - Added "Period logged" event logging, structured error logging
|
||||||
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Replaced console.error with structured logger
|
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Replaced console.error with structured logger
|
||||||
- `src/app/api/overrides/route.ts` - Added "Override toggled" event logging
|
- `src/app/api/overrides/route.ts` - Added "Override toggled" event logging
|
||||||
@@ -654,9 +654,9 @@ Testing, error handling, and refinements.
|
|||||||
- `src/app/api/cron/garmin-sync/route.ts` - Added "Garmin sync start", "Garmin sync complete", "Garmin sync failure" logging
|
- `src/app/api/cron/garmin-sync/route.ts` - Added "Garmin sync start", "Garmin sync complete", "Garmin sync failure" logging
|
||||||
- `src/app/api/auth/logout/route.ts` - Added "User logged out" logging
|
- `src/app/api/auth/logout/route.ts` - Added "User logged out" logging
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/lib/auth-middleware.test.ts` - Added 3 tests for structured logging (9 total)
|
- `src/lib/auth-middleware.test.ts` - Added 6 tests for structured logging and IP address logging (12 total)
|
||||||
- **Events Logged (per observability spec):**
|
- **Events Logged (per observability spec):**
|
||||||
- Auth failure (warn): reason
|
- Auth failure (warn): reason, ip (from x-forwarded-for or x-real-ip headers)
|
||||||
- Period logged (info): userId, date
|
- Period logged (info): userId, date
|
||||||
- Override toggled (info): userId, override, enabled
|
- Override toggled (info): userId, override, enabled
|
||||||
- Decision calculated (info): userId, decision, reason
|
- Decision calculated (info): userId, decision, reason
|
||||||
@@ -713,16 +713,16 @@ Testing, error handling, and refinements.
|
|||||||
- **Components Tested (5 total):**
|
- **Components Tested (5 total):**
|
||||||
- `src/components/dashboard/decision-card.tsx` - 19 tests for rendering decision status, icon, reason, styling, color-coded backgrounds
|
- `src/components/dashboard/decision-card.tsx` - 19 tests for rendering decision status, icon, reason, styling, color-coded backgrounds
|
||||||
- `src/components/dashboard/data-panel.tsx` - 18 tests for biometrics display (BB, HRV, intensity), null handling, styling
|
- `src/components/dashboard/data-panel.tsx` - 18 tests for biometrics display (BB, HRV, intensity), null handling, styling
|
||||||
- `src/components/dashboard/nutrition-panel.tsx` - 12 tests for seeds, carbs, keto guidance display
|
- `src/components/dashboard/nutrition-panel.tsx` - 16 tests for seeds, carbs, keto guidance display, seed switch alert
|
||||||
- `src/components/dashboard/override-toggles.tsx` - 18 tests for toggle states, callbacks, styling
|
- `src/components/dashboard/override-toggles.tsx` - 18 tests for toggle states, callbacks, styling
|
||||||
- `src/components/calendar/day-cell.tsx` - 23 tests for phase coloring, today highlighting, click handling
|
- `src/components/calendar/day-cell.tsx` - 32 tests for phase coloring, today highlighting, click handling, period indicator
|
||||||
- **Test Files Created:**
|
- **Test Files Created:**
|
||||||
- `src/components/dashboard/decision-card.test.tsx` - 19 tests
|
- `src/components/dashboard/decision-card.test.tsx` - 19 tests
|
||||||
- `src/components/dashboard/data-panel.test.tsx` - 18 tests
|
- `src/components/dashboard/data-panel.test.tsx` - 18 tests
|
||||||
- `src/components/dashboard/nutrition-panel.test.tsx` - 12 tests
|
- `src/components/dashboard/nutrition-panel.test.tsx` - 16 tests
|
||||||
- `src/components/dashboard/override-toggles.test.tsx` - 18 tests
|
- `src/components/dashboard/override-toggles.test.tsx` - 18 tests
|
||||||
- `src/components/calendar/day-cell.test.tsx` - 23 tests
|
- `src/components/calendar/day-cell.test.tsx` - 32 tests
|
||||||
- **Total Tests Added:** 90 tests across 5 files
|
- **Total Tests Added:** 103 tests across 5 files
|
||||||
- **Why:** Component isolation ensures UI correctness and prevents regressions
|
- **Why:** Component isolation ensures UI correctness and prevents regressions
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -909,7 +909,7 @@ P4.* UX Polish ────────> After core functionality complete
|
|||||||
- [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5)
|
- [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5)
|
||||||
- [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5)
|
- [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] **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] **auth-middleware.ts** - Complete with 12 tests (`withAuth()` wrapper, structured logging, IP address logging)
|
||||||
- [x] **middleware.ts** - Complete with 12 tests (Next.js page protection)
|
- [x] **middleware.ts** - Complete with 12 tests (Next.js page protection)
|
||||||
- [x] **logger.ts** - Complete with 16 tests (JSON output, log levels, error serialization, child loggers) (P2.17)
|
- [x] **logger.ts** - Complete with 16 tests (JSON output, log levels, error serialization, child loggers) (P2.17)
|
||||||
- [x] **metrics.ts** - Complete with 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) (P2.16)
|
- [x] **metrics.ts** - Complete with 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) (P2.16)
|
||||||
@@ -920,7 +920,7 @@ P4.* UX Polish ────────> After core functionality complete
|
|||||||
- [x] **NutritionPanel** - Shows seeds, carbs, keto guidance
|
- [x] **NutritionPanel** - Shows seeds, carbs, keto guidance
|
||||||
- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms
|
- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms
|
||||||
- [x] **DayCell** - Phase-colored calendar day cell with click handler
|
- [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
|
- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend with emojis, 31 tests
|
||||||
- [x] **MiniCalendar** - Compact calendar widget with phase colors, navigation, legend, 23 tests (P2.14)
|
- [x] **MiniCalendar** - Compact calendar widget with phase colors, navigation, legend, 23 tests (P2.14)
|
||||||
|
|
||||||
### API Routes (21 complete)
|
### API Routes (21 complete)
|
||||||
@@ -929,7 +929,7 @@ P4.* UX Polish ────────> After core functionality complete
|
|||||||
- [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1)
|
- [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 with prediction tracking, 13 tests (P1.2, P4.5)
|
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog with prediction tracking, 13 tests (P1.2, P4.5)
|
||||||
- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3)
|
- [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)
|
- [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 24 tests (P1.4)
|
||||||
- [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5)
|
- [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5)
|
||||||
- [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5)
|
- [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5)
|
||||||
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
|
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
|
||||||
@@ -966,10 +966,10 @@ P4.* UX Polish ────────> After core functionality complete
|
|||||||
- [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback
|
- [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback
|
||||||
- [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation
|
- [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation
|
||||||
- [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling
|
- [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling
|
||||||
- [x] **P3.7: Error Handling Improvements** - Replaced console.error with structured pino logger across API routes, added key event logging (Period logged, Override toggled, Decision calculated, Auth failure), 3 new tests in auth-middleware.test.ts
|
- [x] **P3.7: Error Handling Improvements** - Replaced console.error with structured pino logger across API routes, added key event logging (Period logged, Override toggled, Decision calculated, Auth failure with IP address), 6 new tests in auth-middleware.test.ts
|
||||||
- [x] **P3.8: Loading States** - Complete with skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton), 29 tests in skeletons.test.tsx; loading.tsx files for all routes (dashboard, calendar, history, plan, settings); shimmer animations matching spec requirements
|
- [x] **P3.8: Loading States** - Complete with skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton), 29 tests in skeletons.test.tsx; loading.tsx files for all routes (dashboard, calendar, history, plan, settings); shimmer animations matching spec requirements
|
||||||
- [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry
|
- [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry
|
||||||
- [x] **P3.11: Missing Component Tests** - Complete with 90 tests across 5 component test files (DecisionCard: 19, DataPanel: 18, NutritionPanel: 12, OverrideToggles: 18, DayCell: 23)
|
- [x] **P3.11: Missing Component Tests** - Complete with 98 tests across 5 component test files (DecisionCard: 19, DataPanel: 18, NutritionPanel: 16, OverrideToggles: 18, DayCell: 27)
|
||||||
|
|
||||||
### P4: UX Polish and Accessibility
|
### P4: UX Polish and Accessibility
|
||||||
- [x] **P4.1: Dashboard Onboarding Banners** - Complete with OnboardingBanner component (16 tests), dashboard integration (5 new tests)
|
- [x] **P4.1: Dashboard Onboarding Banners** - Complete with OnboardingBanner component (16 tests), dashboard integration (5 new tests)
|
||||||
@@ -1007,6 +1007,10 @@ Additional spec compliance improvements implemented:
|
|||||||
| Seed switch alert in email | notifications.md | **FIXED** | Daily email now includes seed switch alert on day 15 |
|
| Seed switch alert in email | notifications.md | **FIXED** | Daily email now includes seed switch alert on day 15 |
|
||||||
| HRV status color-coding | dashboard.md | **FIXED** | Data panel now shows green/red/gray based on HRV status |
|
| HRV status color-coding | dashboard.md | **FIXED** | Data panel now shows green/red/gray based on HRV status |
|
||||||
| Intensity progress bar | dashboard.md | **FIXED** | Data panel now shows visual progress bar with color-coding |
|
| Intensity progress bar | dashboard.md | **FIXED** | Data panel now shows visual progress bar with color-coding |
|
||||||
|
| Seed switch alert on day 15 | nutrition.md | **FIXED** | NutritionPanel now displays seedSwitchAlert from API |
|
||||||
|
| Phase emojis in calendar legend | calendar.md | **FIXED** | MonthView now shows emojis per spec (🩸 Menstrual \| 🌱 Follicular \| 🌸 Ovulation \| 🌙 Early Luteal \| 🌑 Late Luteal) |
|
||||||
|
| Period indicator in day cells | calendar.md | **FIXED** | DayCell now shows 🩸 for days 1-3 |
|
||||||
|
| IP address in auth failure logs | observability.md | **FIXED** | Auth middleware now logs client IP from x-forwarded-for or x-real-ip headers |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -456,6 +456,40 @@ describe("GET /api/today", () => {
|
|||||||
);
|
);
|
||||||
expect(body.nutrition.carbRange).toBe("75-125g");
|
expect(body.nutrition.carbRange).toBe("75-125g");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns seed switch alert on day 15", async () => {
|
||||||
|
// Set to cycle day 15 - the seed switch day
|
||||||
|
currentMockUser = createMockUser({
|
||||||
|
lastPeriodDate: new Date("2024-12-27"), // 14 days ago = day 15
|
||||||
|
});
|
||||||
|
currentMockDailyLog = createMockDailyLog({
|
||||||
|
cycleDay: 15,
|
||||||
|
phase: "OVULATION",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.nutrition.seedSwitchAlert).toBe(
|
||||||
|
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null seed switch alert on other days", async () => {
|
||||||
|
currentMockUser = createMockUser({
|
||||||
|
lastPeriodDate: new Date("2025-01-01"), // cycle day 10
|
||||||
|
});
|
||||||
|
currentMockDailyLog = createMockDailyLog();
|
||||||
|
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.nutrition.seedSwitchAlert).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("biometrics data", () => {
|
describe("biometrics data", () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/lib/cycle";
|
} from "@/lib/cycle";
|
||||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { getNutritionGuidance } from "@/lib/nutrition";
|
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
|
||||||
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
||||||
|
|
||||||
// Default biometrics when no Garmin data is available
|
// Default biometrics when no Garmin data is available
|
||||||
@@ -107,8 +107,12 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
"Decision calculated",
|
"Decision calculated",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get nutrition guidance
|
// Get nutrition guidance with seed switch alert
|
||||||
const nutrition = getNutritionGuidance(cycleDay);
|
const baseNutrition = getNutritionGuidance(cycleDay);
|
||||||
|
const nutrition = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: getSeedSwitchAlert(cycleDay),
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
decision,
|
decision,
|
||||||
|
|||||||
@@ -238,4 +238,36 @@ describe("DayCell", () => {
|
|||||||
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
|
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("period indicator", () => {
|
||||||
|
it("shows period indicator dot on cycle day 1", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={1} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🩸")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows period indicator dot on cycle day 2", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={2} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🩸")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows period indicator dot on cycle day 3", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={3} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🩸")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show period indicator on cycle day 4", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={4} phase="FOLLICULAR" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show period indicator on cycle day 10", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={10} phase="FOLLICULAR" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function DayCell({
|
|||||||
}: DayCellProps) {
|
}: DayCellProps) {
|
||||||
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
|
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
|
||||||
|
|
||||||
|
const isPeriodDay = cycleDay >= 1 && cycleDay <= 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -61,7 +63,10 @@ export function DayCell({
|
|||||||
data-day={dataDay}
|
data-day={dataDay}
|
||||||
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
|
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{date.getDate()}</span>
|
<span className="text-sm font-medium">
|
||||||
|
{date.getDate()}
|
||||||
|
{isPeriodDay && <span className="ml-0.5">🩸</span>}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
|
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -218,6 +218,18 @@ describe("MonthView", () => {
|
|||||||
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("displays phase emojis per spec", () => {
|
||||||
|
render(<MonthView {...baseProps} />);
|
||||||
|
|
||||||
|
// Spec requires: 🩸 Menstrual | 🌱 Follicular | 🌸 Ovulation | 🌙 Early Luteal | 🌑 Late Luteal
|
||||||
|
// Look for complete legend items to avoid matching period indicator emojis
|
||||||
|
expect(screen.getByText(/🩸 Menstrual/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌱 Follicular/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌸 Ovulation/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌙 Early Luteal/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌑 Late Luteal/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cycle rollover", () => {
|
describe("cycle rollover", () => {
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ interface MonthViewProps {
|
|||||||
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
const PHASE_LEGEND = [
|
const PHASE_LEGEND = [
|
||||||
{ name: "Menstrual", color: "bg-blue-100" },
|
{ name: "Menstrual", color: "bg-blue-100", emoji: "🩸" },
|
||||||
{ name: "Follicular", color: "bg-green-100" },
|
{ name: "Follicular", color: "bg-green-100", emoji: "🌱" },
|
||||||
{ name: "Ovulation", color: "bg-purple-100" },
|
{ name: "Ovulation", color: "bg-purple-100", emoji: "🌸" },
|
||||||
{ name: "Early Luteal", color: "bg-yellow-100" },
|
{ name: "Early Luteal", color: "bg-yellow-100", emoji: "🌙" },
|
||||||
{ name: "Late Luteal", color: "bg-red-100" },
|
{ name: "Late Luteal", color: "bg-red-100", emoji: "🌑" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getDaysInMonth(year: number, month: number): number {
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
@@ -228,7 +228,9 @@ export function MonthView({
|
|||||||
{PHASE_LEGEND.map((phase) => (
|
{PHASE_LEGEND.map((phase) => (
|
||||||
<div key={phase.name} className="flex items-center gap-1">
|
<div key={phase.name} className="flex items-center gap-1">
|
||||||
<div className={`w-4 h-4 rounded ${phase.color}`} />
|
<div className={`w-4 h-4 rounded ${phase.color}`} />
|
||||||
<span className="text-xs text-gray-600">{phase.name}</span>
|
<span className="text-xs text-gray-600">
|
||||||
|
{phase.emoji} {phase.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,6 +116,52 @@ describe("NutritionPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("seed switch alert", () => {
|
||||||
|
it("displays seed switch alert when provided", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("🌱 SWITCH TODAY! Start Sesame + Sunflower"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not display alert section when seedSwitchAlert is null", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not display alert section when seedSwitchAlert is undefined", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders alert with prominent styling", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
const alert = screen.getByText(
|
||||||
|
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
);
|
||||||
|
expect(alert).toHaveClass("bg-amber-100", "dark:bg-amber-900");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("styling", () => {
|
describe("styling", () => {
|
||||||
it("renders within a bordered container", () => {
|
it("renders within a bordered container", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export function NutritionPanel({ nutrition }: NutritionPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
|
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
|
||||||
|
{nutrition.seedSwitchAlert && (
|
||||||
|
<div className="mb-3 p-2 rounded bg-amber-100 dark:bg-amber-900 text-sm font-medium">
|
||||||
|
{nutrition.seedSwitchAlert}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li>🌱 {nutrition.seeds}</li>
|
<li>🌱 {nutrition.seeds}</li>
|
||||||
<li>🍽️ Carbs: {nutrition.carbRange}</li>
|
<li>🍽️ Carbs: {nutrition.carbRange}</li>
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ describe("withAuth", () => {
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to create mock request with headers
|
||||||
|
const createMockRequest = (
|
||||||
|
headers: Record<string, string | null> = {},
|
||||||
|
): NextRequest =>
|
||||||
|
({
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) => headers[name] ?? null),
|
||||||
|
},
|
||||||
|
}) as unknown as NextRequest;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockCookies.mockResolvedValue(mockCookieStore);
|
mockCookies.mockResolvedValue(mockCookieStore);
|
||||||
@@ -91,7 +101,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = createMockRequest();
|
||||||
const response = await wrappedHandler(mockRequest);
|
const response = await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
@@ -109,7 +119,7 @@ describe("withAuth", () => {
|
|||||||
.mockResolvedValue(NextResponse.json({ data: "success" }));
|
.mockResolvedValue(NextResponse.json({ data: "success" }));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = createMockRequest();
|
||||||
const response = await wrappedHandler(mockRequest);
|
const response = await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -128,7 +138,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockCreatePocketBaseClient).toHaveBeenCalled();
|
expect(mockCreatePocketBaseClient).toHaveBeenCalled();
|
||||||
expect(mockCookies).toHaveBeenCalled();
|
expect(mockCookies).toHaveBeenCalled();
|
||||||
@@ -146,7 +156,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const response = await wrappedHandler({} as NextRequest);
|
const response = await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
@@ -159,7 +169,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = createMockRequest();
|
||||||
const mockParams = { id: "123" };
|
const mockParams = { id: "123" };
|
||||||
|
|
||||||
await wrappedHandler(mockRequest, { params: mockParams });
|
await wrappedHandler(mockRequest, { params: mockParams });
|
||||||
@@ -176,7 +186,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockRejectedValue(new Error("Handler error"));
|
const handler = vi.fn().mockRejectedValue(new Error("Handler error"));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const response = await wrappedHandler({} as NextRequest);
|
const response = await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
@@ -196,7 +206,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "not_authenticated" }),
|
expect.objectContaining({ reason: "not_authenticated" }),
|
||||||
@@ -204,6 +214,76 @@ describe("withAuth", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with IP address from x-forwarded-for header", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) =>
|
||||||
|
name === "x-forwarded-for" ? "192.168.1.100" : null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
|
||||||
|
await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "not_authenticated",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
}),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with IP address from x-real-ip header when x-forwarded-for not present", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) =>
|
||||||
|
name === "x-real-ip" ? "10.0.0.1" : null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
|
||||||
|
await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "not_authenticated",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
}),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with unknown IP when no IP headers present", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: {
|
||||||
|
get: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
|
||||||
|
await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ reason: "not_authenticated", ip: "unknown" }),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("logs auth failure when getCurrentUser returns null", async () => {
|
it("logs auth failure when getCurrentUser returns null", async () => {
|
||||||
mockIsAuthenticated.mockReturnValue(true);
|
mockIsAuthenticated.mockReturnValue(true);
|
||||||
mockGetCurrentUser.mockReturnValue(null);
|
mockGetCurrentUser.mockReturnValue(null);
|
||||||
@@ -211,7 +291,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "user_not_found" }),
|
expect.objectContaining({ reason: "user_not_found" }),
|
||||||
@@ -227,7 +307,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockRejectedValue(testError);
|
const handler = vi.fn().mockRejectedValue(testError);
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ export type AuthenticatedHandler<T = unknown> = (
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Extracts client IP address from request headers.
|
||||||
|
* Checks x-forwarded-for and x-real-ip headers, returns "unknown" if neither present.
|
||||||
|
*/
|
||||||
|
function getClientIp(request: NextRequest): string {
|
||||||
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||||
|
if (forwardedFor) {
|
||||||
|
// x-forwarded-for can contain multiple IPs; first one is the client
|
||||||
|
return forwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
const realIp = request.headers.get("x-real-ip");
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
export function withAuth<T = unknown>(
|
export function withAuth<T = unknown>(
|
||||||
handler: AuthenticatedHandler<T>,
|
handler: AuthenticatedHandler<T>,
|
||||||
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
|
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
|
||||||
@@ -57,16 +74,19 @@ export function withAuth<T = unknown>(
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
loadAuthFromCookies(pb, cookieStore);
|
loadAuthFromCookies(pb, cookieStore);
|
||||||
|
|
||||||
|
// Get client IP for logging
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
|
||||||
// Check if the user is authenticated
|
// Check if the user is authenticated
|
||||||
if (!isAuthenticated(pb)) {
|
if (!isAuthenticated(pb)) {
|
||||||
logger.warn({ reason: "not_authenticated" }, "Auth failure");
|
logger.warn({ reason: "not_authenticated", ip }, "Auth failure");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current user
|
// Get the current user
|
||||||
const user = getCurrentUser(pb);
|
const user = getCurrentUser(pb);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.warn({ reason: "user_not_found" }, "Auth failure");
|
logger.warn({ reason: "user_not_found", ip }, "Auth failure");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,4 +101,5 @@ export interface NutritionGuidance {
|
|||||||
seeds: string;
|
seeds: string;
|
||||||
carbRange: string;
|
carbRange: string;
|
||||||
ketoGuidance: string;
|
ketoGuidance: string;
|
||||||
|
seedSwitchAlert?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user