# QA Plan: User Preferences API ## Test Scenarios ### Happy Path | ID | Scenario | Input | Expected Output | Derived From | |----|----------|-------|-----------------|--------------| | HP-1 | Get preferences for user with saved preferences | `GET /api/preferences-api/preferences/{user_id}` with valid JWT matching user_id | 200 with `{data: {user_id, preferences: {theme: "dark", language: "en", notifications_enabled: true}, updated_at: "..."}, meta: {...}}` | AC-1 | | HP-2 | Get preferences for user with no saved preferences | `GET /api/preferences-api/preferences/{user_id}` for user with no row | 200 with `{data: {user_id, preferences: {}, updated_at: null}, meta: {...}}` | AC-2 | | HP-3 | Create preferences for new user (upsert - insert) | `PUT /api/preferences-api/preferences/{user_id}` with `{"preferences": {"theme": "dark"}}` | 200 with `{data: {user_id, preferences: {theme: "dark"}, updated_at: "..."}, meta: {...}}` | AC-3 | | HP-4 | Update existing preferences (upsert - update) | `PUT` with `{"preferences": {"theme": "light"}}` for user who already has theme: "dark" | 200 with preferences showing `theme: "light"`, other keys preserved | AC-3 | | HP-5 | Partial update preserves omitted keys | `PUT` with `{"preferences": {"theme": "dark"}}` for user who has `language: "en"` and `notifications_enabled: true` | 200 with all three keys present: theme changed, language and notifications_enabled unchanged | AC-4 | | HP-6 | Set theme to "light" | `PUT` with `{"preferences": {"theme": "light"}}` | 200 with `theme: "light"` | AC-5 | | HP-7 | Set theme to "dark" | `PUT` with `{"preferences": {"theme": "dark"}}` | 200 with `theme: "dark"` | AC-5 | | HP-8 | Set language to valid ISO 639-1 code | `PUT` with `{"preferences": {"language": "es"}}` | 200 with `language: "es"` | AC-5 | | HP-9 | Set notifications_enabled to true | `PUT` with `{"preferences": {"notifications_enabled": true}}` | 200 with `notifications_enabled: true` | AC-5 | | HP-10 | Set notifications_enabled to false | `PUT` with `{"preferences": {"notifications_enabled": false}}` | 200 with `notifications_enabled: false` | AC-5 | | HP-11 | Update all three preferences at once | `PUT` with `{"preferences": {"theme": "dark", "language": "fr", "notifications_enabled": false}}` | 200 with all three keys updated | AC-3, AC-5 | | HP-12 | Response envelope structure on GET | Valid GET request | Response has top-level `data` and `meta` keys, meta includes `request_id` and `timestamp` | AC-11 | | HP-13 | Response envelope structure on PUT | Valid PUT request | Response has top-level `data` and `meta` keys | AC-11 | ### Edge Cases | ID | Scenario | Input | Expected Output | Derived From | |----|----------|-------|-----------------|--------------| | EC-1 | Partial update with single key on user with all three set | `PUT` with `{"preferences": {"language": "de"}}` for user with theme, language, notifications_enabled | 200 with only language changed, theme and notifications_enabled preserved | AC-4 | | EC-2 | Update same key to its current value (no-op update) | `PUT` with `{"preferences": {"theme": "dark"}}` when already `theme: "dark"` | 200 with unchanged preferences, `updated_at` refreshed | AC-3, AC-4 | | EC-3 | Empty preferences object on PUT | `PUT` with `{"preferences": {}}` | 200 with existing preferences unchanged (no keys to update) OR 400 depending on validation rules | AC-4 | | EC-4 | Language code at boundary - two lowercase letters | `PUT` with `{"preferences": {"language": "zz"}}` | 200 accepted (matches `^[a-z]{2}$` even if not a real language) | AC-5 | | EC-5 | Multiple sequential partial updates accumulate correctly | PUT theme, then PUT language, then PUT notifications_enabled | GET returns all three keys | AC-4 | | EC-6 | User ID is a valid UUID but very specific format | `GET /api/preferences-api/preferences/00000000-0000-0000-0000-000000000000` | 200 with empty preferences (valid UUID, no row) | AC-2 | | EC-7 | Concurrent upserts for same user | Two simultaneous PUT requests with different keys | Both keys present after resolution (JSONB merge is atomic via ON CONFLICT) | AC-3, AC-4 | ### Error Cases | ID | Scenario | Input | Expected Output | Derived From | |----|----------|-------|-----------------|--------------| | ER-1 | Invalid theme value | `PUT` with `{"preferences": {"theme": "blue"}}` | 400 Bad Request with descriptive error message mentioning "theme" | AC-6 | | ER-2 | Invalid language - too long | `PUT` with `{"preferences": {"language": "eng"}}` | 400 Bad Request with descriptive error | AC-6 | | ER-3 | Invalid language - uppercase | `PUT` with `{"preferences": {"language": "EN"}}` | 400 Bad Request | AC-6 | | ER-4 | Invalid language - contains numbers | `PUT` with `{"preferences": {"language": "e1"}}` | 400 Bad Request | AC-6 | | ER-5 | Invalid language - single character | `PUT` with `{"preferences": {"language": "e"}}` | 400 Bad Request | AC-6 | | ER-6 | Invalid notifications_enabled - string instead of bool | `PUT` with `{"preferences": {"notifications_enabled": "yes"}}` | 400 Bad Request | AC-6 | | ER-7 | Invalid notifications_enabled - number instead of bool | `PUT` with `{"preferences": {"notifications_enabled": 1}}` | 400 Bad Request | AC-6 | | ER-8 | Unknown preference key | `PUT` with `{"preferences": {"font_size": 14}}` | 400 Bad Request with message about unknown key | AC-7 | | ER-9 | Mix of valid and unknown keys | `PUT` with `{"preferences": {"theme": "dark", "unknown_key": "value"}}` | 400 Bad Request (entire request rejected) | AC-7 | | ER-10 | Unauthenticated GET request | `GET` without Authorization header | 401 Unauthorized | AC-8 | | ER-11 | Unauthenticated PUT request | `PUT` without Authorization header | 401 Unauthorized | AC-8 | | ER-12 | Invalid JWT token on GET | `GET` with `Authorization: Bearer invalid-token` | 401 Unauthorized | AC-8 | | ER-13 | Invalid JWT token on PUT | `PUT` with `Authorization: Bearer invalid-token` | 401 Unauthorized | AC-8 | | ER-14 | User accessing another user's preferences (GET) | `GET /api/preferences-api/preferences/{other_user_id}` with JWT for different user | 403 Forbidden with "access denied" | AC-9 | | ER-15 | User updating another user's preferences (PUT) | `PUT /api/preferences-api/preferences/{other_user_id}` with JWT for different user | 403 Forbidden with "access denied" | AC-9 | | ER-16 | Invalid UUID format in path (GET) | `GET /api/preferences-api/preferences/not-a-uuid` | 400 Bad Request with "invalid user ID format" | AC-1, Design | | ER-17 | Invalid UUID format in path (PUT) | `PUT /api/preferences-api/preferences/not-a-uuid` | 400 Bad Request with "invalid user ID format" | AC-3, Design | | ER-18 | Missing preferences field in PUT body | `PUT` with `{}` (empty body) | 400 Bad Request (BindAndValidate rejects missing required field) | AC-3, Design | | ER-19 | Malformed JSON body on PUT | `PUT` with `{invalid json` | 400 Bad Request | Design | | ER-20 | Theme value is null | `PUT` with `{"preferences": {"theme": null}}` | 400 Bad Request | AC-6 | | ER-21 | Preference value is nested object | `PUT` with `{"preferences": {"theme": {"mode": "dark"}}}` | 400 Bad Request | AC-6 | | ER-22 | Preference value is array | `PUT` with `{"preferences": {"theme": ["dark"]}}` | 400 Bad Request | AC-6 | | ER-23 | Empty string for language | `PUT` with `{"preferences": {"language": ""}}` | 400 Bad Request | AC-6 | ## Domain Validation Unit Tests | ID | Scenario | Input | Expected Output | Derived From | |----|----------|-------|-----------------|--------------| | DV-1 | ValidatePreferences accepts valid theme | `{"theme": "dark"}` | nil (no error) | AC-13 | | DV-2 | ValidatePreferences accepts valid language | `{"language": "en"}` | nil | AC-13 | | DV-3 | ValidatePreferences accepts valid notifications_enabled | `{"notifications_enabled": true}` | nil | AC-13 | | DV-4 | ValidatePreferences accepts all three valid keys | `{"theme": "light", "language": "fr", "notifications_enabled": false}` | nil | AC-13 | | DV-5 | ValidatePreferenceKey rejects unknown key | `"font_size"` | ErrInvalidPreferenceKey | AC-13 | | DV-6 | ValidatePreferenceValue rejects invalid theme | `("theme", "blue")` | ErrInvalidPreferenceValue | AC-13 | | DV-7 | ValidatePreferenceValue rejects invalid language format | `("language", "ENG")` | ErrInvalidPreferenceValue | AC-13 | | DV-8 | ValidatePreferenceValue rejects non-boolean notifications | `("notifications_enabled", "yes")` | ErrInvalidPreferenceValue | AC-13 | ## Service Layer Unit Tests | ID | Scenario | Input | Expected Output | Derived From | |----|----------|-------|-----------------|--------------| | SV-1 | Get returns preferences for existing user | Mock repo returns UserPreferences | *UserPreferences with data | AC-15 | | SV-2 | Get returns empty preferences for new user | Mock repo returns nil | *UserPreferences with empty map | AC-15 | | SV-3 | Update with valid preferences calls repo Upsert | Valid prefs map | Upsert called, result returned | AC-15 | | SV-4 | Update with unknown key returns domain error | `{"unknown": "value"}` | ErrInvalidPreferenceKey (no repo call) | AC-15 | | SV-5 | Update with invalid value returns domain error | `{"theme": "blue"}` | ErrInvalidPreferenceValue (no repo call) | AC-15 | | SV-6 | Update propagates repository error | Valid prefs, repo returns error | Error propagated unchanged | AC-15 | ## Handler Integration Tests | ID | Scenario | Input | Expected Output | Derived From | |----|----------|-------|-----------------|--------------| | HI-1 | GET 200 with existing preferences | Authenticated request, mock repo returns data | 200, {data, meta} envelope | AC-16 | | HI-2 | GET 200 with empty preferences | Authenticated request, mock repo returns nil | 200, empty preferences in envelope | AC-16 | | HI-3 | GET 400 for invalid UUID | `/preferences/not-a-uuid` | 400 | AC-16 | | HI-4 | PUT 200 on success | Valid body, authenticated | 200, merged preferences | AC-16 | | HI-5 | PUT 400 for unknown key | `{"preferences": {"bad_key": "val"}}` | 400 | AC-16 | | HI-6 | PUT 400 for invalid value | `{"preferences": {"theme": "nope"}}` | 400 | AC-16 | | HI-7 | PUT 400 for missing preferences field | `{}` | 400 | AC-16 | | HI-8 | All responses use {data, meta} envelope | Any successful response | Top-level keys are `data` and `meta` | AC-16 | ## Test Data Requirements ### Fixtures - **Test user UUID**: A fixed UUID for the authenticated test user (e.g., `550e8400-e29b-41d4-a716-446655440000`) - **Other user UUID**: A different UUID for authorization tests (e.g., `660e8400-e29b-41d4-a716-446655440001`) - **Sample preferences**: `{"theme": "dark", "language": "en", "notifications_enabled": true}` ### Mocks - **Mock PreferencesRepository**: Implements `port.PreferencesRepository` with configurable return values and error injection for service-layer unit tests - **Mock PreferencesService**: For handler tests, a service mock that returns canned responses or errors - **Auth context**: Test helper to inject authenticated user into request context via `auth.MustGetUser()` compatible format - **No-op logger**: `logging.Nop()` for all test setups ### Database (for integration tests if added later) - PostgreSQL test database with migrations applied - Each test should run in a transaction that is rolled back after the test ## Integration Test Plan ### Component Interaction Points 1. **Handler → Service**: Handlers delegate to service methods; test with mock service to verify correct method calls and argument passing 2. **Service → Domain Validation**: Service calls `domain.ValidatePreferences` before repository; test that invalid input never reaches the repository mock 3. **Service → Repository**: Service calls `repo.Get` and `repo.Upsert`; test with mock repository to verify correct delegation 4. **Handler → Auth Middleware**: Auth middleware extracts JWT and sets user context; handler reads context to check ownership 5. **Handler → chi Router**: URL parameters extracted correctly with `{user_id}` brace syntax; test that routing dispatches correctly ### Cross-Layer Scenarios | Scenario | Layers Involved | What to Verify | |----------|----------------|----------------| | Valid GET end-to-end | Router → Middleware → Handler → Service → Repo | Correct user_id flows through all layers, response is properly enveloped | | Valid PUT end-to-end | Router → Middleware → Handler → BindAndValidate → Service → Domain → Repo | Input validated at domain layer, merged result returned through all layers | | Invalid key rejected | Handler → Service → Domain | Domain error maps to 400 at handler layer, repo never called | | Auth failure | Router → Middleware | 401 returned before handler is invoked | | Authorization failure | Handler (ownership check) | 403 returned before service is called | | DB error propagation | Repo → Service → Handler → app.Wrap | Raw error becomes 500, error is logged, no internal details leaked to client | ### Route Registration Verification - `GET /api/preferences-api/preferences/{user_id}` is registered and routable - `PUT /api/preferences-api/preferences/{user_id}` is registered and routable - Both routes require authentication (middleware applied) - Health endpoint (`GET /api/preferences-api/health`) remains functional and unauthenticated - URL parameters use `{user_id}` brace syntax (not `:user_id` colon syntax) ## Performance Considerations ### Load Expectations - **Read pattern**: Preferences fetched once per session start; expect ~1 GET per active user session - **Write pattern**: Preferences updated infrequently (settings changes); expect <<1 PUT per session - **Overall**: High read, very low write workload ### Latency Budget - **GET**: < 10ms at p99 (single primary key lookup on small table) - **PUT**: < 20ms at p99 (single upsert on primary key) ### Benchmarks to Run | Benchmark | Method | Target | |-----------|--------|--------| | Single GET by primary key | `go test -bench BenchmarkGet` | < 1ms per operation against test DB | | Single PUT upsert | `go test -bench BenchmarkUpsert` | < 2ms per operation against test DB | | JSONB merge correctness under concurrent writes | Parallel test with `t.Parallel()` | No data loss or corruption | ### Data Size - Each row ~200 bytes (UUID + small JSONB + timestamps) - Table fits entirely in PostgreSQL buffer cache even at millions of users - No additional indexes needed beyond the primary key ## Manual Verification Steps 1. **OpenAPI documentation renders correctly** - Start the service and navigate to the Scalar docs endpoint - Verify both GET and PUT endpoints appear with correct schemas - Verify request/response examples are accurate - Verify security requirements (Bearer auth) are documented 2. **Database migration runs cleanly** - Start the service against a fresh database - Verify `user_preferences` table is created with correct schema - Restart the service — verify migration is idempotent (no errors on re-run) 3. **Health endpoint still works** - `curl http://localhost:8001/api/preferences-api/health` returns 200 4. **End-to-end flow with real JWT** - Obtain a valid JWT token - `GET /api/preferences-api/preferences/{your_user_id}` → 200 with empty preferences - `PUT /api/preferences-api/preferences/{your_user_id}` with `{"preferences": {"theme": "dark"}}` → 200 - `GET /api/preferences-api/preferences/{your_user_id}` → 200 with `{"theme": "dark"}` - `PUT` with `{"preferences": {"language": "en"}}` → 200, verify theme is still "dark" - `GET` → 200, verify both theme and language are present 5. **Authorization boundary** - Use JWT for user A, try to GET/PUT preferences for user B → verify 403 6. **No Example scaffold remnants** - Verify no `/examples` routes respond - Verify no Example types in OpenAPI docs - `go build ./...` and `go test ./...` pass cleanly ## Acceptance Criteria Coverage Matrix | AC # | Description | Test IDs | |------|-------------|----------| | AC-1 | GET returns all preferences as key-value pairs | HP-1, HI-1 | | AC-2 | GET returns empty preferences (not 404) for new users | HP-2, EC-6, HI-2 | | AC-3 | PUT creates or updates (upsert semantics) | HP-3, HP-4, HP-11, HI-4 | | AC-4 | PUT supports partial updates, omitted keys preserved | HP-5, EC-1, EC-2, EC-5 | | AC-5 | Supported preference keys with valid values | HP-6, HP-7, HP-8, HP-9, HP-10, EC-4 | | AC-6 | Invalid values rejected with 400 and descriptive message | ER-1 through ER-7, ER-20 through ER-23 | | AC-7 | Unknown keys rejected with 400 | ER-8, ER-9, HI-5 | | AC-8 | Both endpoints require authentication (401) | ER-10, ER-11, ER-12, ER-13 | | AC-9 | Users can only access own preferences (403) | ER-14, ER-15 | | AC-10 | Persisted to PostgreSQL | Manual step 2, T3 acceptance criteria | | AC-11 | Standard {data, meta} envelope | HP-12, HP-13, HI-8 | | AC-12 | OpenAPI spec documented | Manual step 1 | | AC-13 | Domain layer validates independently of HTTP | DV-1 through DV-8 | | AC-14 | Hexagonal architecture followed | Manual step 6, code review | | AC-15 | Unit tests cover service layer | SV-1 through SV-6 | | AC-16 | Integration tests cover handler layer | HI-1 through HI-8 | ## Summary | Category | Count | |----------|-------| | Happy Path | 13 | | Edge Cases | 7 | | Error Cases | 23 | | Domain Validation Unit Tests | 8 | | Service Layer Unit Tests | 6 | | Handler Integration Tests | 8 | | Manual Verification Steps | 6 | | **Total Scenarios** | **71** | All 16 acceptance criteria have corresponding test coverage. No acceptance criterion is left untested.