# 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 |