build: /create-qa-plan user-preferences
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
e0b6dc03eb
commit
c812bbfd13
@ -13,7 +13,7 @@ artifacts:
|
||||
status: draft
|
||||
path: design.md
|
||||
qa_plan:
|
||||
status: pending
|
||||
status: draft
|
||||
path: qa-plan.md
|
||||
qa_results:
|
||||
status: pending
|
||||
|
||||
232
.sdlc/features/user-preferences/qa-plan.md
Normal file
232
.sdlc/features/user-preferences/qa-plan.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user