diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 566ed51..5885860 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -13,7 +13,7 @@ artifacts: status: draft path: design.md qa_plan: - status: pending + status: draft path: qa-plan.md qa_results: status: pending diff --git a/.sdlc/features/user-preferences/qa-plan.md b/.sdlc/features/user-preferences/qa-plan.md new file mode 100644 index 0000000..a4e5faa --- /dev/null +++ b/.sdlc/features/user-preferences/qa-plan.md @@ -0,0 +1,203 @@ +# QA Plan: User Preferences API + +## Test Scenarios + +### Happy Path + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| HP-1 | GET own preferences (existing) | `GET /api/preferences-api/preferences/usr_123` with JWT for `usr_123`, preferences already saved | 200 with `{data: {user_id, theme, language, notifications, updated_at}, meta: {...}}` | AC: GET returns preferences in `{data, meta}` envelope | +| HP-2 | GET own preferences (no saved prefs) | `GET /api/preferences-api/preferences/usr_new` with JWT for `usr_new`, no preferences stored | 200 with defaults: `{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}}` | AC: GET for no saved prefs returns 200 with defaults | +| HP-3 | PUT own preferences (create) | `PUT /api/preferences-api/preferences/usr_123` with valid body, no prior prefs | 200 with saved preferences in `{data, meta}` envelope | AC: PUT creates or replaces (upsert) | +| HP-4 | PUT own preferences (update) | `PUT /api/preferences-api/preferences/usr_123` with valid body, prefs already exist | 200 with updated preferences, `updated_at` reflects new timestamp | AC: PUT creates or replaces (upsert) | +| HP-5 | PUT with all valid theme values | Three requests with `theme: "light"`, `"dark"`, `"system"` | All return 200 with matching theme | AC: theme must be one of light/dark/system | +| HP-6 | PUT with all valid language values | Five requests with `language: "en"`, `"fr"`, `"es"`, `"de"`, `"ja"` | All return 200 with matching language | AC: language must be valid BCP-47 tag | +| HP-7 | PUT with all valid digest values | Three requests with `digest: "none"`, `"daily"`, `"weekly"` | All return 200 with matching digest | AC: notifications.digest must be one of none/daily/weekly | +| HP-8 | PUT with boolean notification fields | `notifications: {email: false, push: false, digest: "none"}` | 200 with matching boolean values | AC: notifications.email/push must be boolean | +| HP-9 | Admin reads another user's preferences | `GET /api/preferences-api/preferences/usr_456` with JWT for admin user `usr_admin` | 200 with usr_456's preferences | AC: admin read access (design: admin role can read any) | +| HP-10 | GET then PUT then GET roundtrip | PUT prefs, then GET same user | GET returns exactly what was PUT | AC: preferences persisted | +| HP-11 | Default values match spec | GET for user with no saved prefs | `theme: "system"`, `language: "en"`, `notifications.email: true`, `notifications.push: true`, `notifications.digest: "weekly"` | AC: default preference values | +| HP-12 | Health endpoint still works | `GET /api/preferences-api/health` | 200 OK | Design: health endpoint preserved | + +### Edge Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| EC-1 | PUT replaces all fields (full replace, not merge) | First PUT with `theme: "dark"`, second PUT with `theme: "light"` (all fields sent both times) | Second GET returns `theme: "light"` | AC: PUT fully replaces preferences | +| EC-2 | Concurrent updates to same user | Two simultaneous PUT requests for same user_id with different values | Both return 200; final state is one of the two (last-write-wins) | Design: upsert is atomic via ON CONFLICT | +| EC-3 | User ID with special characters | `GET /api/preferences-api/preferences/usr_abc-123.456` | 200 (or appropriate handling) | Design: user_id is TEXT, matches JWT subject | +| EC-4 | Very long user ID | `GET /api/preferences-api/preferences/{256-char-string}` | Handled gracefully (200 with defaults or appropriate error) | Design: user_id is TEXT PK | +| EC-5 | PUT immediately after service start (schema just created) | PUT to newly initialized service | 200, table created via EnsureSchema | Design: schema creation at startup | +| EC-6 | Multiple GETs for non-existent user | Repeated GET for user with no prefs | Always returns same defaults, 200 | AC: GET returns defaults for no prefs | +| EC-7 | Updated_at omitted for default preferences | GET for user with no saved preferences | `updated_at` is zero/omitted in response | Design: updated_at omitted for defaults | +| EC-8 | Updated_at populated after PUT | PUT then GET | `updated_at` is a valid timestamp | Design: updated_at set to NOW() on upsert | + +### Error Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| ER-1 | GET without authentication | `GET /api/preferences-api/preferences/usr_123` with no JWT | 401 Unauthorized | AC: both endpoints require auth | +| ER-2 | PUT without authentication | `PUT /api/preferences-api/preferences/usr_123` with no JWT | 401 Unauthorized | AC: both endpoints require auth | +| ER-3 | GET another user's preferences (non-admin) | `GET /api/preferences-api/preferences/usr_456` with JWT for `usr_123` (no admin role) | 403 Forbidden: "access denied: cannot access another user's preferences" | AC: user_id must match JWT UserID | +| ER-4 | PUT another user's preferences (non-admin) | `PUT /api/preferences-api/preferences/usr_456` with JWT for `usr_123` | 403 Forbidden | AC: user_id must match JWT UserID | +| ER-5 | PUT another user's preferences (admin) | `PUT /api/preferences-api/preferences/usr_456` with JWT for admin user | 403 Forbidden (admin write not permitted) | Design: no admin write, PUT strictly self-access | +| ER-6 | PUT with invalid theme | `{theme: "purple", ...}` | 400 Bad Request: "theme must be one of: light, dark, system" | AC: theme validation | +| ER-7 | PUT with invalid language | `{language: "zh", ...}` | 400 Bad Request: "language must be one of: en, fr, es, de, ja" | AC: language validation | +| ER-8 | PUT with invalid digest | `{notifications: {digest: "monthly"}}` | 400 Bad Request: "notifications.digest must be one of: none, daily, weekly" | AC: digest validation | +| ER-9 | PUT with unknown preference keys | `{theme: "dark", ..., "custom_key": "value"}` | 400 Bad Request (strict binding rejects unknown fields) | AC: unknown keys return 400 | +| ER-10 | PUT with non-boolean email notification | `{notifications: {email: "yes", ...}}` | 400 Bad Request with per-field validation error | AC: notifications.email must be boolean | +| ER-11 | PUT with non-boolean push notification | `{notifications: {push: 1, ...}}` | 400 Bad Request with per-field validation error | AC: notifications.push must be boolean | +| ER-12 | PUT with empty body | Empty request body `{}` | 400 Bad Request (required fields missing) | AC: validation, Design: struct tag `required` | +| ER-13 | PUT with malformed JSON | `{theme: dark}` (unquoted) | 400 Bad Request | Design: app.BindAndValidateStrict | +| ER-14 | PUT with missing notifications object | `{theme: "dark", language: "en"}` | 400 Bad Request (notifications required) | Design: `validate:"required"` on notifications | +| ER-15 | PUT with missing theme field | `{language: "en", notifications: {...}}` | 400 Bad Request (theme required) | Design: `validate:"required"` on theme | +| ER-16 | PUT with missing language field | `{theme: "dark", notifications: {...}}` | 400 Bad Request (language required) | Design: `validate:"required"` on language | +| ER-17 | PUT with missing digest in notifications | `{..., notifications: {email: true, push: true}}` | 400 Bad Request (digest required) | Design: `validate:"required"` on digest | +| ER-18 | Expired JWT token | Any request with expired JWT | 401 Unauthorized | AC: auth.Middleware() | +| ER-19 | Invalid JWT signature | Any request with tampered JWT | 401 Unauthorized | AC: auth.Middleware() | +| ER-20 | PUT with multiple validation errors | `{theme: "neon", language: "zz", notifications: {email: true, push: true, digest: "yearly"}}` | 400 with per-field validation errors | AC: per-field validation errors | + +## Test Data Requirements + +### Fixtures +- **Authenticated user JWT**: Valid JWT for user `usr_test_123` with no admin role +- **Admin user JWT**: Valid JWT for user `usr_admin_001` with `admin` role +- **Second user JWT**: Valid JWT for user `usr_test_456` for cross-user tests +- **Expired JWT**: JWT with past expiration for auth failure tests +- **Invalid JWT**: Malformed/tampered JWT for signature validation tests + +### Test Preferences Data +- **Full valid preferences**: `{theme: "dark", language: "fr", notifications: {email: false, push: true, digest: "daily"}}` +- **Default preferences**: `{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}}` +- **All enum boundary values**: Each valid value for theme (3), language (5), digest (3) + +### Database State +- **Empty state**: No rows in `user_preferences` table (for default behavior tests) +- **Seeded state**: Pre-existing preferences for `usr_test_123` (for GET/PUT existing tests) +- **In-memory adapter**: Used for unit and handler tests (no real DB needed) + +## Integration Test Plan + +### Handler-to-Service-to-Repository Integration +Test the full request path using `httptest.Server` with the in-memory adapter: + +| Test | Scope | What It Validates | +|------|-------|-------------------| +| Full GET flow | Handler → Service → InMemoryRepo | URL param extraction, auth check, default hydration, response envelope | +| Full PUT flow | Handler → Service → InMemoryRepo | Request binding, validation, domain validation, upsert, response envelope | +| PUT then GET roundtrip | Handler → Service → InMemoryRepo | Data persisted correctly through full write/read cycle | +| Auth middleware integration | Middleware → Handler | JWT extraction and rejection at middleware level before handler | +| Error propagation | Handler → Service → Domain | Domain validation errors bubble up as correct HTTP status codes | + +### Cross-Component Boundary Tests +| Boundary | Test Approach | +|----------|---------------| +| Handler ↔ Service | Mock service interface to verify handler maps errors correctly | +| Service ↔ Repository | Use in-memory adapter to verify service applies defaults and validates | +| Domain ↔ Service | Verify service calls domain `Validate()` and handles returned errors | +| Request ↔ Handler | Verify strict JSON binding rejects unknown fields | +| Handler ↔ Response | Verify `{data, meta}` envelope structure matches spec | + +### PostgreSQL Adapter Tests (when DB available) +| Test | What It Validates | +|------|-------------------| +| EnsureSchema idempotency | Running schema creation twice does not error | +| Upsert insert path | First write for a user creates a row | +| Upsert update path | Second write for same user updates the row | +| Get existing row | Reads back correct values from all columns | +| Get non-existent row | Returns `nil, nil` (not error) | +| Column type mapping | All Go types map correctly to/from PostgreSQL types | + +## Performance Considerations + +### Load Expectations +- **Read frequency**: High — preferences loaded on every page load per user session +- **Write frequency**: Low — preferences updated occasionally by explicit user action +- **Data size**: ~6 columns per row, single row per user — negligible + +### Latency Budgets +| Operation | Target | Rationale | +|-----------|--------|-----------| +| GET (cache miss) | < 5ms | Single PK lookup on indexed TEXT column | +| GET (cache hit, future) | < 1ms | In-process LRU if implemented later | +| PUT | < 10ms | Single row upsert with index | + +### Benchmarks to Run +| Benchmark | Method | +|-----------|--------| +| GET latency (p50/p95/p99) | `go test -bench BenchmarkGetPreferences` with in-memory adapter | +| PUT latency (p50/p95/p99) | `go test -bench BenchmarkUpdatePreferences` with in-memory adapter | +| Concurrent reads | 100 goroutines reading same user's prefs simultaneously | +| Concurrent writes | 10 goroutines writing same user's prefs simultaneously | + +### No caching needed for MVP +Single-row PK lookups are sub-millisecond on PostgreSQL. Buffer cache handles the working set naturally. + +## Manual Verification Steps + +### 1. Service Startup +- [ ] Start service with `./scripts/dev.sh` or direct binary execution +- [ ] Verify health endpoint responds: `curl http://localhost:8001/api/preferences-api/health` +- [ ] Verify `user_preferences` table created in PostgreSQL (check via `psql`) +- [ ] Verify OpenAPI docs accessible at the docs endpoint + +### 2. Authentication Flow +- [ ] Obtain a valid JWT from the auth service +- [ ] Verify GET without JWT returns 401 +- [ ] Verify PUT without JWT returns 401 + +### 3. Preference CRUD Flow +- [ ] GET preferences for authenticated user (should return defaults first time) +- [ ] PUT preferences with valid body (theme=dark, language=fr, etc.) +- [ ] GET preferences again (should return saved values, not defaults) +- [ ] PUT preferences with different values (verify overwrite) +- [ ] GET again to confirm overwrite + +### 4. Authorization Checks +- [ ] As user A, try to GET user B's preferences → expect 403 +- [ ] As user A, try to PUT user B's preferences → expect 403 +- [ ] As admin, GET user B's preferences → expect 200 +- [ ] As admin, try to PUT user B's preferences → expect 403 + +### 5. Validation Checks +- [ ] PUT with `theme: "invalid"` → expect 400 with descriptive error +- [ ] PUT with `language: "xx"` → expect 400 +- [ ] PUT with unknown key in JSON body → expect 400 +- [ ] PUT with malformed JSON → expect 400 + +### 6. OpenAPI Documentation +- [ ] Verify Scalar docs page loads in browser +- [ ] Verify GET endpoint documented with schemas and examples +- [ ] Verify PUT endpoint documented with request body schema +- [ ] Verify error responses (400, 401, 403) documented + +### 7. Example Scaffold Removal +- [ ] Verify no example endpoints respond (GET/POST/PUT/DELETE `/api/preferences-api/examples/*` → 404) +- [ ] Verify no example-related code remains in the codebase + +## Acceptance Criteria Coverage Matrix + +| Acceptance Criterion | Test Scenarios | +|---------------------|----------------| +| GET returns preferences in `{data, meta}` envelope | HP-1, HP-2, HP-11 | +| PUT creates or fully replaces (upsert) | HP-3, HP-4, EC-1 | +| Both endpoints require authentication | ER-1, ER-2, ER-18, ER-19 | +| User can only access own preferences (JWT match) | ER-3, ER-4, ER-5 | +| Preferences stored as key-value pairs | HP-1, HP-10 | +| Theme validation (light/dark/system) | HP-5, ER-6 | +| Language validation (BCP-47 tags) | HP-6, ER-7 | +| notifications.email must be boolean | HP-8, ER-10 | +| notifications.push must be boolean | HP-8, ER-11 | +| notifications.digest validation | HP-7, ER-8 | +| GET returns 200 with defaults (not 404) | HP-2, HP-11, EC-6 | +| Default values correct | HP-11 | +| Unknown keys return 400 | ER-9 | +| Invalid values return 400 with per-field errors | ER-6, ER-7, ER-8, ER-10, ER-11, ER-20 | +| Persisted in PostgreSQL | HP-10, Integration tests | +| Hexagonal architecture (domain/service/port/adapter) | Code review, Integration tests | +| PreferenceRepository port interface | Code review | +| PreferenceService orchestrates logic | Integration tests | +| OpenAPI spec updated | HP-12, Manual step 6 | +| Unit tests cover domain, service, handler | Code review of test files | +| Integration tests with in-memory adapter | Integration test plan | +| Example scaffold removed | Manual step 7 |