From 6a8d55c0b9b41550d20af682a3b46486f09baa6d Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sun, 11 Jan 2026 07:49:56 +0000 Subject: [PATCH] Document spec gaps: auth, phase scaling, observability, testing Address 21 previously undefined behaviors across specs: - Authentication: Replace email/password with OIDC (Pocket-ID) - Cycle tracking: Add fixed-luteal phase scaling formula with examples - Calendar: Document period logging behavior (preserve predictions) - Garmin: Clarify connection is required (no phase-only mode) - Dashboard: Add UI states, dark mode, onboarding, accessibility - Notifications: Document timezone batching approach - New specs: observability.md (health, metrics, logging) - New specs: testing.md (unit + integration strategy) - Main spec: Add backup/recovery, known limitations, API updates Co-Authored-By: Claude Opus 4.5 --- spec.md | 68 ++++++++++-- specs/authentication.md | 62 ++++++++--- specs/calendar.md | 27 +++++ specs/cycle-tracking.md | 39 +++++-- specs/dashboard.md | 62 +++++++++++ specs/garmin-integration.md | 2 + specs/notifications.md | 23 +++- specs/observability.md | 138 ++++++++++++++++++++++++ specs/testing.md | 204 ++++++++++++++++++++++++++++++++++++ 9 files changed, 596 insertions(+), 29 deletions(-) create mode 100644 specs/observability.md create mode 100644 specs/testing.md diff --git a/spec.md b/spec.md index 44ddcff..2f8bd90 100644 --- a/spec.md +++ b/spec.md @@ -425,14 +425,18 @@ interface PeriodLog { } ``` +### Data Retention + +All logs (DailyLog, PeriodLog) are retained indefinitely. No automatic deletion or archival. SQLite handles large datasets efficiently for single-user scenarios. + --- ## API Routes ``` -# Auth (PocketBase handles user auth) -POST /api/auth/login -POST /api/auth/register +# Auth (PocketBase handles via OIDC) +GET /api/auth/login - Initiate OIDC flow +GET /api/auth/callback - OIDC callback handler POST /api/auth/logout # Garmin Token Management @@ -450,7 +454,8 @@ GET /api/cycle/current - Current phase info # Daily GET /api/today - Today's decision + all data -GET /api/history - Historical logs +GET /api/history - Historical logs (cursor-based pagination) + Query params: ?cursor=&limit=20 # Calendar GET /api/calendar/:userId/:token.ics - ICS feed (public, token-protected) @@ -460,6 +465,10 @@ POST /api/calendar/regenerate-token - Generate new calendar token POST /api/overrides - Set active overrides DELETE /api/overrides/:type - Remove override +# Observability +GET /api/health - Health check for monitoring +GET /metrics - Prometheus metrics endpoint + # Cron (internal, protected) POST /api/cron/garmin-sync - Fetch Garmin data (6 AM) POST /api/cron/notifications - Send emails (7 AM) @@ -773,7 +782,7 @@ EOF |----------|----------| | Garmin API unavailable | Use last known values, note in email | | Garmin tokens expired | Email user with re-auth instructions | -| No Garmin data yet | Use phase-only decision | +| Garmin not connected | Block app usage, show onboarding prompt | | User hasn't set period date | Prompt in dashboard, block email until set | | Email delivery failure | Log, retry once | | Invalid ICS request | Return 404 | @@ -785,11 +794,51 @@ EOF - Garmin tokens encrypted at rest (AES-256) - ICS feed URL contains random token (not guessable) - Cron endpoints protected by secret header -- PocketBase handles user auth +- PocketBase handles user auth via OIDC - HTTPS enforced via Traefik --- +## Backup & Recovery + +PocketBase stores all data in a single SQLite database file. + +**Backup Procedure:** +```bash +# Stop PocketBase or use SQLite backup API +cp /path/to/pb_data/data.db /path/to/backups/data-$(date +%Y%m%d).db +``` + +**What to backup:** +- `pb_data/data.db` - Main database (users, logs, settings) +- `pb_data/storage/` - Any uploaded files (if applicable) + +**Recovery:** +```bash +# Stop PocketBase +cp /path/to/backups/data-YYYYMMDD.db /path/to/pb_data/data.db +# Restart PocketBase +``` + +Backups are manual. Set up your own cron job or backup solution as needed. + +--- + +## Known Limitations + +The following are **out of scope** for MVP: + +| Limitation | Notes | +|------------|-------| +| Phase-only mode | Garmin connection required; no fallback without biometrics | +| Pregnancy/menopause | Cycle tracking assumes regular menstrual cycles | +| Hormonal birth control | May disrupt natural cycle phases | +| API versioning | Single version; breaking changes via deprecation | +| Formal API documentation | Endpoints documented in spec only | +| E2E tests | Unit + integration tests only (authorized skip) | + +--- + ## File Structure ``` @@ -821,9 +870,12 @@ phaseflow/ │ │ │ ├── [userId]/[token].ics/route.ts │ │ │ └── regenerate-token/route.ts │ │ ├── overrides/route.ts +│ │ ├── health/route.ts │ │ └── cron/ │ │ ├── garmin-sync/route.ts │ │ └── notifications/route.ts +│ │ └── metrics/ +│ │ └── route.ts # Prometheus metrics │ ├── components/ │ │ ├── dashboard/ │ │ │ ├── decision-card.tsx @@ -843,7 +895,9 @@ phaseflow/ │ │ ├── nutrition.ts │ │ ├── email.ts │ │ ├── ics.ts # ICS feed generation -│ │ └── encryption.ts +│ │ ├── encryption.ts +│ │ ├── logger.ts # Structured JSON logging +│ │ └── metrics.ts # Prometheus metrics │ └── types/ │ └── index.ts ├── flake.nix diff --git a/specs/authentication.md b/specs/authentication.md index 7a86e0e..7665ef8 100644 --- a/specs/authentication.md +++ b/specs/authentication.md @@ -2,11 +2,11 @@ ## Job to Be Done -When I access PhaseFlow, I want to securely log in with my email, so that my personal health data remains private. +When I access PhaseFlow, I want to securely log in with my identity provider, so that my personal health data remains private. ## Auth Provider -Using PocketBase for authentication and data storage. +Using PocketBase for authentication and data storage, with OIDC (Pocket-ID) as the primary identity provider. **Connection:** - `POCKETBASE_URL` environment variable @@ -14,12 +14,30 @@ Using PocketBase for authentication and data storage. ## Login Flow -### Email/Password Authentication +### OIDC Authentication (Pocket-ID) -1. User enters email and password on `/login` -2. App calls PocketBase `authWithPassword` -3. On success, PocketBase sets auth cookie -4. User redirected to dashboard +1. User clicks "Sign In" on `/login` +2. App redirects to Pocket-ID authorization endpoint +3. User authenticates with Pocket-ID (handles MFA if configured) +4. Pocket-ID redirects back with authorization code +5. PocketBase exchanges code for tokens +6. User redirected to dashboard + +### OIDC Configuration + +Configure in PocketBase Admin UI: **Settings → Auth providers → OpenID Connect** + +**Required Settings:** +- `Client ID` - From Pocket-ID application +- `Client Secret` - From Pocket-ID application +- `Issuer URL` - Your Pocket-ID instance URL (e.g., `https://id.yourdomain.com`) + +**Environment Variables:** +```env +POCKETBASE_OIDC_CLIENT_ID=phaseflow +POCKETBASE_OIDC_CLIENT_SECRET=xxx +POCKETBASE_OIDC_ISSUER_URL=https://id.yourdomain.com +``` ### Session Management @@ -32,11 +50,8 @@ Using PocketBase for authentication and data storage. ### `/login` **Elements:** -- Email input -- Password input -- "Sign In" button +- "Sign In with Pocket-ID" button - Error message display -- Link to password reset (future) **Behavior:** - Redirect to `/` on successful login @@ -130,18 +145,37 @@ User profile management: ## Success Criteria -1. Login completes in under 2 seconds +1. OIDC login flow completes in under 5 seconds (including redirect) 2. Session persists across browser refreshes 3. Unauthorized access redirects to login 4. User data isolated by authentication ## Acceptance Tests -- [ ] Valid credentials authenticate successfully -- [ ] Invalid credentials show error message +- [ ] OIDC redirect initiates correctly +- [ ] Successful OIDC callback creates/updates user - [ ] Session persists after page refresh - [ ] Protected routes redirect when not authenticated - [ ] GET `/api/user` returns current user data - [ ] PATCH `/api/user` updates user record - [ ] Logout clears session completely - [ ] Auth cookie is HttpOnly and Secure + +## Future Enhancements + +### Open Registration + +When open registration is enabled: +- Add `/signup` page with OIDC provider selection +- New users created automatically on first OIDC login +- Admin approval workflow (optional) + +### Additional OAuth2 Providers + +PocketBase supports multiple OAuth2 providers. Future options: +- Google +- GitHub +- Apple +- Other OIDC-compliant providers + +Each provider configured separately in PocketBase Admin UI. diff --git a/specs/calendar.md b/specs/calendar.md index e38bddd..77c20b6 100644 --- a/specs/calendar.md +++ b/specs/calendar.md @@ -55,6 +55,31 @@ Generates new `calendarToken`, invalidating previous subscriptions. } ``` +### Period Logging Behavior + +When a user logs a new period start date, the ICS feed updates as follows: + +1. **Current cycle**: Phase events are corrected retroactively to reflect actual dates +2. **Original predictions preserved**: Previous predictions for the current cycle are kept as separate events with "(Predicted)" suffix, allowing users to compare predicted vs. actual +3. **Older cycles**: Events from previous cycles remain unchanged +4. **Future cycles**: Regenerated based on new period date and cycle length + +**Example after logging period on Day 28 of a predicted 31-day cycle:** +```ics +BEGIN:VEVENT +SUMMARY:🩸 Menstrual (Days 1-3) +DTSTART;VALUE=DATE:20240128 +DESCRIPTION:Actual period start +END:VEVENT +BEGIN:VEVENT +SUMMARY:🩸 Menstrual (Predicted) +DTSTART;VALUE=DATE:20240131 +DESCRIPTION:Original prediction - period arrived 3 days early +END:VEVENT +``` + +This creates a built-in accuracy feedback loop for understanding cycle patterns. + ## In-App Calendar ### Month View (`/calendar`) @@ -107,6 +132,8 @@ Below calendar: - [ ] ICS contains events for next 90 days - [ ] Invalid token returns 401 - [ ] Regenerate token creates new unique token +- [ ] Period log updates current cycle events retroactively +- [ ] Period log preserves original predictions as "(Predicted)" events - [ ] Month view renders all days in month - [ ] Day cells show correct phase colors - [ ] Today is visually highlighted diff --git a/specs/cycle-tracking.md b/specs/cycle-tracking.md index ac3ab2e..632dbe4 100644 --- a/specs/cycle-tracking.md +++ b/specs/cycle-tracking.md @@ -19,15 +19,29 @@ Default cycle length: 31 days (configurable per user). ### Cycle Phases -Based on a 31-day cycle: +Phase boundaries scale based on cycle length using a **fixed luteal, variable follicular** approach. The luteal phase (14 days) is biologically consistent; the follicular phase expands or contracts with cycle length. + +**Phase Calculation Formula:** + +Given `cycleLength` (user-configurable, default 31): | Phase | Days | Weekly Limit | Training Type | |-------|------|--------------|---------------| | MENSTRUAL | 1-3 | 30 min | Gentle rebounding only | -| FOLLICULAR | 4-14 | 120 min | Strength + rebounding | -| OVULATION | 15-16 | 80 min | Peak performance | -| EARLY_LUTEAL | 17-24 | 100 min | Moderate training | -| LATE_LUTEAL | 25-31 | 50 min | Gentle rebounding ONLY | +| FOLLICULAR | 4 to (cycleLength - 16) | 120 min | Strength + rebounding | +| OVULATION | (cycleLength - 15) to (cycleLength - 14) | 80 min | Peak performance | +| EARLY_LUTEAL | (cycleLength - 13) to (cycleLength - 7) | 100 min | Moderate training | +| LATE_LUTEAL | (cycleLength - 6) to cycleLength | 50 min | Gentle rebounding ONLY | + +**Examples by Cycle Length:** + +| Phase | 28-day | 31-day | 35-day | +|-------|--------|--------|--------| +| MENSTRUAL | 1-3 | 1-3 | 1-3 | +| FOLLICULAR | 4-12 | 4-15 | 4-19 | +| OVULATION | 13-14 | 16-17 | 20-21 | +| EARLY_LUTEAL | 15-21 | 18-24 | 22-28 | +| LATE_LUTEAL | 22-28 | 25-31 | 29-35 | ## API Endpoints @@ -101,7 +115,18 @@ interface PeriodLog { Users with irregular cycles can adjust: - Default: 31 days - Range: 21-45 days -- Affects phase day boundaries proportionally +- Affects phase day boundaries per formula above + +## Known Limitations + +The following scenarios are **out of scope** for MVP: + +- **Pregnancy** - Cycle tracking pauses; app not designed for pregnancy +- **Menopause** - Irregular/absent cycles not supported +- **Hormonal birth control** - May suppress or alter natural cycle phases +- **Anovulatory cycles** - App assumes ovulation occurs; no detection for skipped ovulation + +Users in these situations should consult healthcare providers for personalized guidance. ## Success Criteria @@ -115,6 +140,8 @@ Users with irregular cycles can adjust: - [ ] `getCycleDay` returns 1 on period start date - [ ] `getCycleDay` handles cycle rollover correctly - [ ] `getPhase` returns correct phase for each day range +- [ ] `getPhase` scales correctly for 28-day cycle +- [ ] `getPhase` scales correctly for 35-day cycle - [ ] POST `/api/cycle/period` updates user record - [ ] GET `/api/cycle/current` returns accurate phase info - [ ] Days beyond cycle length default to LATE_LUTEAL diff --git a/specs/dashboard.md b/specs/dashboard.md index ced0e88..7d2e4bf 100644 --- a/specs/dashboard.md +++ b/specs/dashboard.md @@ -77,12 +77,68 @@ Visual cycle overview for the current month. - Period days marked distinctly - Quick navigation to previous/next month +## UI States + +### Loading States + +Use skeleton loaders during data fetching: +- Decision card: Shimmer placeholder for status and reason +- Data panel: Skeleton rows for each metric +- Mini calendar: Placeholder grid + +### Error Handling + +Toast notifications for errors with actionable feedback: +- Network errors: "Unable to fetch data. Retry?" +- Garmin sync failed: "Garmin data unavailable. Using last known values." +- Save errors: "Failed to save. Try again." + +Toast position: Bottom-right, auto-dismiss after 5 seconds (errors persist until dismissed). + +## Dark Mode + +Auto-detect system preference via CSS `prefers-color-scheme`: +- No manual toggle +- Respect OS-level dark mode setting +- Ensure all phase colors maintain contrast in both modes + +## Onboarding + +First-time users see inline prompts/banners for missing setup: + +| Missing State | Banner Message | Action | +|---------------|----------------|--------| +| Garmin not connected | "Connect your Garmin to get started" | Link to /settings/garmin | +| Last period not set | "Set your last period date for accurate tracking" | Opens date picker | +| Notification time not set | "Set your preferred notification time" | Link to /settings | + +Banners appear at top of dashboard, dismissable once action completed. + +## Responsive Design + +Mobile-responsive layout: +- Single-column layout on mobile (<768px) +- All critical metrics visible without scrolling +- Touch-friendly toggle buttons (min 44x44px tap targets) +- Collapsible sections for secondary content + +## Accessibility + +Basic accessibility requirements: +- Semantic HTML (proper heading hierarchy, landmarks) +- Keyboard navigation for all interactive elements +- Focus indicators visible +- Color contrast minimum 4.5:1 for text +- Phase colors supplemented with icons/text (not color-only) + ## Success Criteria 1. User can determine training status within 3 seconds of opening app 2. All biometric data visible without scrolling on mobile 3. Override toggles accessible but not accidentally activated 4. Phase colors consistent across all components +5. Dashboard usable with keyboard only +6. Loading states display within 100ms of navigation ## Acceptance Tests @@ -92,3 +148,9 @@ Visual cycle overview for the current month. - [ ] Nutrition panel shows correct seeds for cycle day - [ ] Override toggles update user record immediately - [ ] Mini calendar renders current month with phase colors +- [ ] Skeleton loaders appear during data fetch +- [ ] Toast notifications display on error +- [ ] Dark mode activates based on system preference +- [ ] Onboarding banner shows when Garmin not connected +- [ ] Dashboard renders correctly on mobile viewport +- [ ] All interactive elements keyboard-accessible diff --git a/specs/garmin-integration.md b/specs/garmin-integration.md index 4e89475..36449bf 100644 --- a/specs/garmin-integration.md +++ b/specs/garmin-integration.md @@ -1,5 +1,7 @@ # Garmin Integration Specification +> **Required**: Garmin connection is mandatory. PhaseFlow cannot function without biometric data from Garmin. The app's core value proposition is combining cycle phases with real physiological data—phase-only mode is not supported. + ## Job to Be Done When I connect my Garmin account, I want the app to automatically sync my biometrics daily, so that training decisions are based on real physiological data. diff --git a/specs/notifications.md b/specs/notifications.md index 003749f..1dd3a9b 100644 --- a/specs/notifications.md +++ b/specs/notifications.md @@ -81,9 +81,28 @@ Protected by `CRON_SECRET` header. ### Timezone Handling -Users store preferred timezone (e.g., `America/New_York`). +Users are batched into hourly timezone windows for efficient scheduling. -Cron runs every hour. Check if current hour in user's timezone matches their `notificationTime`. +**Implementation:** +1. Cron job runs 24 times daily (once per hour, on the hour) +2. Each run queries users whose `notificationTime` hour matches the current UTC hour adjusted for their timezone +3. Users are processed in batches within their timezone window + +**Example:** +- User A: timezone `America/New_York`, notificationTime `07:00` +- User B: timezone `America/Los_Angeles`, notificationTime `07:00` +- User C: timezone `Europe/London`, notificationTime `07:00` + +When cron runs at 12:00 UTC: +- User A receives notification (12:00 UTC = 07:00 EST) +- User B skipped (12:00 UTC = 04:00 PST) +- User C skipped (12:00 UTC = 12:00 GMT) + +**Query Logic:** +```sql +SELECT * FROM users +WHERE EXTRACT(HOUR FROM NOW() AT TIME ZONE timezone) = CAST(SUBSTRING(notificationTime, 1, 2) AS INT) +``` ## Rate Limiting diff --git a/specs/observability.md b/specs/observability.md new file mode 100644 index 0000000..d0f5f34 --- /dev/null +++ b/specs/observability.md @@ -0,0 +1,138 @@ +# Observability Specification + +## Job to Be Done + +When the app is running in production, I want visibility into its health and behavior, so that I can detect and diagnose issues quickly. + +## Health Check + +### GET `/api/health` + +Returns application health status for monitoring and load balancer checks. + +**Response (200 OK):** +```json +{ + "status": "ok", + "timestamp": "2024-01-10T12:00:00Z", + "version": "1.0.0" +} +``` + +**Response (503 Service Unavailable):** +```json +{ + "status": "unhealthy", + "timestamp": "2024-01-10T12:00:00Z", + "error": "PocketBase connection failed" +} +``` + +**Checks Performed:** +- PocketBase connectivity +- Basic app startup complete + +**Usage:** +- Nomad health checks +- Uptime monitoring (e.g., Uptime Kuma) +- Load balancer health probes + +## Prometheus Metrics + +### GET `/metrics` + +Returns Prometheus-format metrics for scraping. + +**Standard Node.js Metrics:** +- `nodejs_heap_size_total_bytes` +- `nodejs_heap_size_used_bytes` +- `nodejs_eventloop_lag_seconds` +- `http_request_duration_seconds` (histogram) +- `http_requests_total` (counter) + +**Custom Application Metrics:** + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `phaseflow_garmin_sync_total` | counter | `status` (success/failure) | Garmin sync attempts | +| `phaseflow_garmin_sync_duration_seconds` | histogram | - | Garmin sync duration | +| `phaseflow_email_sent_total` | counter | `type` (daily/warning) | Emails sent | +| `phaseflow_decision_engine_calls_total` | counter | `decision` (REST/GENTLE/...) | Decision engine invocations | +| `phaseflow_active_users` | gauge | - | Users with activity in last 24h | + +**Implementation:** +Use `prom-client` npm package for metrics collection. + +## Structured Logging + +### Format + +JSON-structured logs for all significant events: + +```json +{ + "timestamp": "2024-01-10T12:00:00.000Z", + "level": "info", + "message": "Garmin sync completed", + "userId": "user123", + "duration_ms": 1250, + "metrics": { + "bodyBattery": 95, + "hrvStatus": "Balanced" + } +} +``` + +### Log Levels + +| Level | Usage | +|-------|-------| +| `error` | Failures requiring attention (sync failures, email errors) | +| `warn` | Degraded behavior (using cached data, retries) | +| `info` | Normal operations (sync complete, email sent, decision made) | + +### Key Events to Log + +| Event | Level | Fields | +|-------|-------|--------| +| Auth success | info | userId | +| Auth failure | warn | reason, ip | +| Garmin sync start | info | userId | +| Garmin sync complete | info | userId, duration_ms, metrics | +| Garmin sync failure | error | userId, error, attempt | +| Email sent | info | userId, type, recipient | +| Email failed | error | userId, type, error | +| Decision calculated | info | userId, decision, reason | +| Period logged | info | userId, date | +| Override toggled | info | userId, override, enabled | + +### Implementation + +Use structured logger (e.g., `pino`) configured for JSON output: + +```typescript +import pino from 'pino'; + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + formatters: { + level: (label) => ({ level: label }), + }, +}); +``` + +## Success Criteria + +1. Health endpoint responds in under 100ms +2. Metrics endpoint scrapable by Prometheus +3. All key events logged with consistent structure +4. Logs parseable by log aggregators (Loki, ELK, etc.) + +## Acceptance Tests + +- [ ] GET `/api/health` returns 200 when healthy +- [ ] GET `/api/health` returns 503 when PocketBase unreachable +- [ ] GET `/metrics` returns valid Prometheus format +- [ ] Custom metrics increment on relevant events +- [ ] Logs output valid JSON to stdout +- [ ] Error logs include stack traces diff --git a/specs/testing.md b/specs/testing.md new file mode 100644 index 0000000..7bb9aee --- /dev/null +++ b/specs/testing.md @@ -0,0 +1,204 @@ +# Testing Specification + +## Job to Be Done + +When I make changes to the codebase, I want automated tests to catch regressions, so that I can deploy with confidence. + +## Testing Strategy + +PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are not required for MVP (authorized skip). + +### Test Types + +| Type | Scope | Tools | Location | +|------|-------|-------|----------| +| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` | +| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` | + +## Framework + +**Vitest** - Fast, Vite-native test runner with TypeScript support. + +**Configuration (`vitest.config.ts`):** +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + }, + }, +}); +``` + +## Unit Tests + +Test pure functions in isolation without external dependencies. + +### Priority Targets + +| Module | Functions | Priority | +|--------|-----------|----------| +| `src/lib/cycle.ts` | `getCycleDay`, `getPhase`, `getPhaseConfig` | High | +| `src/lib/decision-engine.ts` | `getTrainingDecision` | High | +| `src/lib/nutrition.ts` | `getNutritionGuidance`, `getSeeds`, `getMacros` | Medium | +| `src/lib/ics.ts` | `generatePhaseEvents`, `generateCalendarFeed` | Medium | +| `src/lib/encryption.ts` | `encrypt`, `decrypt` | Medium | + +### Example Test + +```typescript +// src/lib/cycle.test.ts +import { describe, it, expect } from 'vitest'; +import { getCycleDay, getPhase } from './cycle'; + +describe('getCycleDay', () => { + it('returns 1 on period start date', () => { + const lastPeriod = new Date('2024-01-01'); + const today = new Date('2024-01-01'); + expect(getCycleDay(lastPeriod, 31, today)).toBe(1); + }); + + it('handles cycle rollover', () => { + const lastPeriod = new Date('2024-01-01'); + const today = new Date('2024-02-01'); // Day 32 + expect(getCycleDay(lastPeriod, 31, today)).toBe(1); + }); +}); + +describe('getPhase', () => { + it('returns MENSTRUAL for days 1-3', () => { + expect(getPhase(1, 31)).toBe('MENSTRUAL'); + expect(getPhase(3, 31)).toBe('MENSTRUAL'); + }); + + it('scales correctly for 28-day cycle', () => { + // LATE_LUTEAL should be days 22-28 for 28-day cycle + expect(getPhase(22, 28)).toBe('LATE_LUTEAL'); + expect(getPhase(21, 28)).toBe('EARLY_LUTEAL'); + }); +}); +``` + +## Integration Tests + +Test API routes and PocketBase interactions. + +### Setup + +Use a test PocketBase instance or PocketBase's testing utilities. + +```typescript +// src/test/setup.ts +import PocketBase from 'pocketbase'; + +export const testPb = new PocketBase(process.env.TEST_POCKETBASE_URL); + +beforeAll(async () => { + // Setup test data +}); + +afterAll(async () => { + // Cleanup +}); +``` + +### Priority Targets + +| Route | Tests | +|-------|-------| +| `GET /api/today` | Returns decision with valid auth | +| `GET /api/cycle/current` | Returns correct phase info | +| `POST /api/cycle/period` | Updates user record | +| `GET /api/user` | Returns authenticated user | +| `PATCH /api/user` | Updates user fields | +| `GET /api/health` | Returns health status | + +### Example Test + +```typescript +// src/app/api/today/route.test.ts +import { describe, it, expect } from 'vitest'; +import { GET } from './route'; + +describe('GET /api/today', () => { + it('returns 401 without auth', async () => { + const request = new Request('http://localhost/api/today'); + const response = await GET(request); + expect(response.status).toBe(401); + }); + + it('returns decision with valid auth', async () => { + // Setup authenticated request + const request = new Request('http://localhost/api/today', { + headers: { Cookie: 'pb_auth=...' }, + }); + const response = await GET(request); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toHaveProperty('decision'); + expect(data).toHaveProperty('cycleDay'); + }); +}); +``` + +## File Naming + +Tests colocated with source files: + +``` +src/ + lib/ + cycle.ts + cycle.test.ts + decision-engine.ts + decision-engine.test.ts + app/ + api/ + today/ + route.ts + route.test.ts +``` + +## Running Tests + +```bash +# Run all tests +npm test + +# Run with coverage +npm run test:coverage + +# Run in watch mode +npm run test:watch + +# Run specific file +npm test -- src/lib/cycle.test.ts +``` + +## Coverage Expectations + +No strict coverage thresholds for MVP, but aim for: +- 80%+ coverage on `src/lib/` (core logic) +- Key API routes tested for auth and happy path + +## Success Criteria + +1. All tests pass in CI before merge +2. Core decision engine logic has comprehensive tests +3. Phase scaling tested for multiple cycle lengths +4. API auth tested for protected routes + +## Acceptance Tests + +- [ ] `npm test` runs without errors +- [ ] Unit tests cover decision engine logic +- [ ] Unit tests cover cycle phase calculations +- [ ] Integration tests verify API authentication +- [ ] Tests run in CI pipeline