From c812bbfd13e4eec4640aeff8f8be7a8ef118c7b5 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 10:04:30 +0000 Subject: [PATCH] build: /create-qa-plan user-preferences --- .sdlc/features/user-preferences/manifest.yaml | 2 +- .sdlc/features/user-preferences/qa-plan.md | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 .sdlc/features/user-preferences/qa-plan.md diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index c9a3f92..10a5406 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..60e1616 --- /dev/null +++ b/.sdlc/features/user-preferences/qa-plan.md @@ -0,0 +1,232 @@ +# 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.