build: /create-qa-plan user-preferences
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
rdev-worker 2026-02-08 10:04:30 +00:00
parent e0b6dc03eb
commit c812bbfd13
2 changed files with 233 additions and 1 deletions

View File

@ -13,7 +13,7 @@ artifacts:
status: draft
path: design.md
qa_plan:
status: pending
status: draft
path: qa-plan.md
qa_results:
status: pending

View File

@ -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.