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
2c87b1b618
commit
9e15946afd
@ -13,7 +13,7 @@ artifacts:
|
||||
status: draft
|
||||
path: design.md
|
||||
qa_plan:
|
||||
status: pending
|
||||
status: draft
|
||||
path: qa-plan.md
|
||||
qa_results:
|
||||
status: pending
|
||||
|
||||
210
.sdlc/features/user-preferences/qa-plan.md
Normal file
210
.sdlc/features/user-preferences/qa-plan.md
Normal file
@ -0,0 +1,210 @@
|
||||
# QA Plan: User Preferences API
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Happy Path
|
||||
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| HP-1 | GET preferences for user with preferences set | `GET /api/preferences-api/preferences/{user_id}` with valid JWT matching user_id; user has theme=dark, language=en, notifications_enabled=true stored | 200 OK, `{data: {theme: "dark", language: "en", notifications_enabled: "true"}, meta: {request_id, timestamp}}` | AC-1 |
|
||||
| HP-2 | GET preferences for user with no preferences | `GET /api/preferences-api/preferences/{user_id}` with valid JWT; user has no stored preferences | 200 OK, `{data: {}, meta: {...}}` (empty object, not 404) | AC-8 |
|
||||
| HP-3 | PUT create preferences for user (first time) | `PUT /api/preferences-api/preferences/{user_id}` with body `{"theme": "dark", "language": "fr"}` | 200 OK, `{data: {"theme": "dark", "language": "fr"}, meta: {...}}` | AC-2 |
|
||||
| HP-4 | PUT update existing preferences (full set) | `PUT` with body `{"theme": "light", "language": "es", "notifications_enabled": "false"}` when user already has preferences | 200 OK, data reflects all three updated values | AC-2, AC-6 |
|
||||
| HP-5 | PUT partial update preserves other keys | User has `{theme: "dark", language: "en", notifications_enabled: "true"}`; PUT `{"theme": "light"}` | 200 OK, `{data: {"theme": "light", "language": "en", "notifications_enabled": "true"}}` — language and notifications_enabled unchanged | AC-7 |
|
||||
| HP-6 | PUT idempotency — same request twice yields same result | Send identical `PUT {"theme": "dark"}` twice | Both return 200 OK with identical data; database state identical after both | AC-6 |
|
||||
| HP-7 | PUT single preference key — theme | `PUT {"theme": "system"}` | 200 OK, theme updated to "system" | AC-3 |
|
||||
| HP-8 | PUT single preference key — language | `PUT {"language": "es"}` | 200 OK, language updated to "es" | AC-3 |
|
||||
| HP-9 | PUT single preference key — notifications_enabled | `PUT {"notifications_enabled": "false"}` | 200 OK, notifications_enabled updated to "false" | AC-3 |
|
||||
| HP-10 | All valid theme values accepted | PUT with theme=light, theme=dark, theme=system (three separate requests) | All return 200 OK | AC-3 |
|
||||
| HP-11 | Language ISO 639-1 codes accepted | PUT with language=en, language=fr, language=de, language=ja | All return 200 OK | AC-3 |
|
||||
| HP-12 | GET after PUT returns consistent data | PUT `{"theme": "dark"}`, then GET | GET response includes `theme: "dark"` | AC-1, AC-2 |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| EC-1 | PUT with empty JSON body `{}` | `PUT` with body `{}` | 400 Bad Request — "request body is required" or equivalent (no keys to update) | AC-2 |
|
||||
| EC-2 | PUT with all three keys at once | `PUT {"theme": "dark", "language": "en", "notifications_enabled": "true"}` | 200 OK, all three stored | AC-2, AC-3 |
|
||||
| EC-3 | Rapid sequential PUTs (concurrency safety) | Two concurrent PUTs: `{"theme": "dark"}` and `{"theme": "light"}` | Both return 200; final state is deterministic (last write wins); no data corruption | AC-6 |
|
||||
| EC-4 | GET with user_id as valid UUID but no data | `GET /api/preferences-api/preferences/00000000-0000-0000-0000-000000000000` (valid UUID, no user data) | 200 OK with `{data: {}}` — never 404 | AC-8 |
|
||||
| EC-5 | Language boundary: two-character lowercase codes | `PUT {"language": "zz"}` (valid format, unusual code) | 200 OK — format-valid per regex `^[a-z]{2}$` | AC-3 |
|
||||
| EC-6 | PUT same value as currently stored | User has theme=dark; PUT `{"theme": "dark"}` | 200 OK, no change, idempotent | AC-6 |
|
||||
| EC-7 | Multiple PUTs building up preference set incrementally | PUT `{"theme": "dark"}`, then PUT `{"language": "en"}`, then PUT `{"notifications_enabled": "true"}` | After third PUT, GET returns all three keys | AC-7 |
|
||||
|
||||
### Error Cases
|
||||
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| ER-1 | GET with invalid UUID format | `GET /api/preferences-api/preferences/not-a-uuid` | 400 Bad Request, `"invalid user ID format"` | AC-1 (implicit UUID validation) |
|
||||
| ER-2 | PUT with invalid UUID format | `PUT /api/preferences-api/preferences/12345` with valid body | 400 Bad Request, `"invalid user ID format"` | AC-2 (implicit UUID validation) |
|
||||
| ER-3 | PUT with unknown preference key | `PUT {"color": "blue"}` | 400 Bad Request, descriptive error mentioning unknown key "color" | AC-4 |
|
||||
| ER-4 | PUT with multiple keys, one unknown | `PUT {"theme": "dark", "font_size": "14"}` | 400 Bad Request, error identifies "font_size" as unknown | AC-4 |
|
||||
| ER-5 | PUT with invalid theme value | `PUT {"theme": "blue"}` | 400 Bad Request, `"invalid value 'blue' for key 'theme': allowed values are [light, dark, system]"` | AC-5 |
|
||||
| ER-6 | PUT with invalid notifications_enabled value | `PUT {"notifications_enabled": "yes"}` | 400 Bad Request, descriptive error about invalid value | AC-5 |
|
||||
| ER-7 | PUT with invalid language value — too long | `PUT {"language": "english"}` | 400 Bad Request, invalid language format | AC-5 |
|
||||
| ER-8 | PUT with invalid language value — uppercase | `PUT {"language": "EN"}` | 400 Bad Request, invalid language format (regex: `^[a-z]{2}$`) | AC-5 |
|
||||
| ER-9 | PUT with invalid language value — digits | `PUT {"language": "12"}` | 400 Bad Request, invalid language format | AC-5 |
|
||||
| ER-10 | PUT with invalid language value — single char | `PUT {"language": "e"}` | 400 Bad Request, invalid language format | AC-5 |
|
||||
| ER-11 | GET without JWT (unauthenticated) | `GET /api/preferences-api/preferences/{user_id}` with no Authorization header | 401 Unauthorized | AC-9 |
|
||||
| ER-12 | PUT without JWT (unauthenticated) | `PUT /api/preferences-api/preferences/{user_id}` with no Authorization header | 401 Unauthorized | AC-9 |
|
||||
| ER-13 | GET with expired/invalid JWT | `GET` with `Authorization: Bearer invalid-token` | 401 Unauthorized | AC-9 |
|
||||
| ER-14 | GET for another user's preferences (ownership violation) | JWT subject = user-A, path user_id = user-B | 403 Forbidden, `"cannot access preferences for another user"` | AC-10 |
|
||||
| ER-15 | PUT for another user's preferences (ownership violation) | JWT subject = user-A, path user_id = user-B, valid body | 403 Forbidden, `"cannot access preferences for another user"` | AC-10 |
|
||||
| ER-16 | PUT with mix of valid and invalid values | `PUT {"theme": "dark", "language": "INVALID"}` | 400 Bad Request — validation fails before any persistence | AC-4, AC-5 |
|
||||
| ER-17 | PUT with non-JSON body | `PUT` with `Content-Type: application/json` but body is `not json` | 400 Bad Request | AC-2 |
|
||||
|
||||
### Domain Validation Unit Tests
|
||||
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| DV-1 | ValidateKey accepts all known keys | `ValidateKey("theme")`, `ValidateKey("language")`, `ValidateKey("notifications_enabled")` | nil (no error) for all three | AC-3 |
|
||||
| DV-2 | ValidateKey rejects unknown keys | `ValidateKey("color")`, `ValidateKey("")`, `ValidateKey("Theme")` | `ErrUnknownKey` for all | AC-4 |
|
||||
| DV-3 | ValidateValue for theme — valid values | `ValidateValue("theme", "light")`, `"dark"`, `"system"` | nil for all three | AC-3 |
|
||||
| DV-4 | ValidateValue for theme — invalid values | `ValidateValue("theme", "blue")`, `"DARK"`, `""` | `ErrInvalidValue` for all | AC-5 |
|
||||
| DV-5 | ValidateValue for language — valid ISO codes | `ValidateValue("language", "en")`, `"fr"`, `"de"`, `"ja"` | nil for all | AC-3 |
|
||||
| DV-6 | ValidateValue for language — invalid formats | `ValidateValue("language", "english")`, `"EN"`, `"e"`, `"123"`, `""` | `ErrInvalidValue` for all | AC-5 |
|
||||
| DV-7 | ValidateValue for notifications_enabled — valid | `ValidateValue("notifications_enabled", "true")`, `"false"` | nil for both | AC-3 |
|
||||
| DV-8 | ValidateValue for notifications_enabled — invalid | `ValidateValue("notifications_enabled", "yes")`, `"1"`, `"on"`, `""` | `ErrInvalidValue` for all | AC-5 |
|
||||
|
||||
### Service Layer Tests
|
||||
|
||||
| ID | Scenario | Input | Expected Output | Derived From |
|
||||
|----|----------|-------|-----------------|--------------|
|
||||
| SV-1 | Get delegates to repository and returns result | Mock repo returns `{"theme": "dark"}` | Service returns `{"theme": "dark"}`, nil | AC-1 |
|
||||
| SV-2 | Get returns empty map for user with no prefs | Mock repo returns `{}` | Service returns `{}`, nil | AC-8 |
|
||||
| SV-3 | Get propagates repository errors | Mock repo returns error | Service returns nil, error | AC-11 (DB dependency) |
|
||||
| SV-4 | Upsert validates all keys before persisting | Input: `{"theme": "dark", "unknown": "val"}` | Returns error wrapping `ErrUnknownKey`; repo.Upsert NOT called | AC-4 |
|
||||
| SV-5 | Upsert validates all values before persisting | Input: `{"theme": "blue"}` | Returns error wrapping `ErrInvalidValue`; repo.Upsert NOT called | AC-5 |
|
||||
| SV-6 | Upsert calls repo and returns full pref set | Input: `{"theme": "dark"}`; mock repo returns full set after upsert | Returns full set including unchanged keys | AC-2, AC-7 |
|
||||
| SV-7 | Upsert propagates repository errors | Mock repo Upsert returns error | Service returns error | AC-11 (DB dependency) |
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
### Test Users
|
||||
| ID | Purpose |
|
||||
|----|---------|
|
||||
| `550e8400-e29b-41d4-a716-446655440000` | Primary test user — owns preferences |
|
||||
| `660e8400-e29b-41d4-a716-446655440001` | Secondary test user — for ownership violation tests |
|
||||
|
||||
### Test Preference Sets
|
||||
| Set | Data | Used By |
|
||||
|-----|------|---------|
|
||||
| Full set | `{"theme": "dark", "language": "en", "notifications_enabled": "true"}` | HP-1, HP-4, HP-5 |
|
||||
| Partial set | `{"theme": "dark"}` | HP-3, HP-5, HP-6 |
|
||||
| Empty set | `{}` | HP-2, EC-4 |
|
||||
|
||||
### Mock Repository
|
||||
- Implements `port.PreferenceRepository` interface
|
||||
- Thread-safe with `sync.RWMutex`
|
||||
- In-memory `map[string]map[string]string` (userID -> key -> value)
|
||||
- Used by service tests and handler tests
|
||||
- Must return empty map (not nil) for unknown users
|
||||
|
||||
### Auth Context Setup
|
||||
- Use `auth.SetUser(ctx, &auth.User{ID: userID})` to inject authenticated user into request context
|
||||
- For unauthenticated tests: omit SetUser call
|
||||
- For ownership tests: set user with different ID than path param
|
||||
|
||||
## Integration Test Plan
|
||||
|
||||
### Component Boundary Tests
|
||||
|
||||
| ID | Boundary | Test Description |
|
||||
|----|----------|-----------------|
|
||||
| IT-1 | Handler → Service → Mock Repo | Full request/response cycle through handler with mock repository, verifying JSON envelope format, status codes, and error messages |
|
||||
| IT-2 | Auth Middleware → Handler | Verify auth middleware rejects unauthenticated requests before reaching handler (401 response) |
|
||||
| IT-3 | Handler → Auth Context → Ownership | Verify handler extracts JWT subject and compares with path user_id (403 on mismatch) |
|
||||
| IT-4 | Handler → app.Wrap error mapping | Verify that domain errors (ErrUnknownKey, ErrInvalidValue) are correctly mapped to HTTP status codes via app.Wrap |
|
||||
| IT-5 | Route registration → Handler dispatch | Verify GET and PUT routes are correctly registered and dispatch to the right handler methods |
|
||||
|
||||
### Database Integration Tests (if PostgreSQL available)
|
||||
|
||||
| ID | Test Description |
|
||||
|----|-----------------|
|
||||
| DB-1 | Migration creates `user_preferences` table with correct schema (composite PK, index) |
|
||||
| DB-2 | Adapter GetByUserID returns empty map for nonexistent user |
|
||||
| DB-3 | Adapter Upsert inserts new preferences and GetByUserID retrieves them |
|
||||
| DB-4 | Adapter Upsert updates existing preferences (ON CONFLICT behavior) |
|
||||
| DB-5 | Adapter Upsert is transactional — partial failure rolls back all changes |
|
||||
| DB-6 | Concurrent Upsert calls don't cause deadlocks or data corruption |
|
||||
|
||||
### End-to-End Smoke Tests (manual or scripted)
|
||||
|
||||
| ID | Test Description |
|
||||
|----|-----------------|
|
||||
| E2E-1 | Start service, run migration, PUT preferences, GET preferences — full round trip |
|
||||
| E2E-2 | Verify health endpoint still works after preference code replaces example code |
|
||||
| E2E-3 | Verify OpenAPI docs endpoint renders and documents both preference endpoints |
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Latency Budget
|
||||
- **Target**: p99 < 50ms for `GET /preferences/{user_id}` (per AC-15)
|
||||
- **Expected**: < 5ms — single indexed SELECT on composite PK
|
||||
- **Measurement**: Use `httptest` with timing assertions or benchmark tests
|
||||
|
||||
### Benchmark Tests
|
||||
|
||||
| ID | Benchmark | Target |
|
||||
|----|-----------|--------|
|
||||
| BM-1 | `BenchmarkGetPreferences` — service.Get with mock repo | Baseline for handler overhead |
|
||||
| BM-2 | `BenchmarkUpsertPreferences` — service.Upsert with validation + mock repo | Validate validation overhead is minimal |
|
||||
|
||||
### Load Considerations
|
||||
- Table growth: 3 rows per user (one per preference key), indexed by user_id
|
||||
- No caching needed — direct PK lookups are efficient
|
||||
- Connection pool: `pkg/database.Pool` defaults (25 max open, 5 max idle) are sufficient
|
||||
|
||||
## Manual Verification Steps
|
||||
|
||||
### Pre-Implementation Checks
|
||||
1. Verify all 8 example scaffold files are deleted (T1 acceptance)
|
||||
2. Verify `health.go` and `config.go` are untouched after scaffold removal
|
||||
3. Verify service compiles after each task (`go build ./...`)
|
||||
|
||||
### Post-Implementation Smoke Tests
|
||||
1. **Start service**: `cd services/preferences-api && go run ./cmd/server/` — verify it starts without errors
|
||||
2. **Health check**: `curl http://localhost:8001/api/preferences-api/health` — verify 200 OK
|
||||
3. **OpenAPI docs**: `curl http://localhost:8001/api/preferences-api/docs` — verify docs render with preference endpoints
|
||||
4. **PUT preferences** (authenticated):
|
||||
```bash
|
||||
curl -X PUT http://localhost:8001/api/preferences-api/preferences/<user-id> \
|
||||
-H "Authorization: Bearer <valid-jwt>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"theme": "dark", "language": "en", "notifications_enabled": "true"}'
|
||||
```
|
||||
Verify: 200 OK, response contains all three preferences in `{data, meta}` envelope
|
||||
5. **GET preferences** (authenticated):
|
||||
```bash
|
||||
curl http://localhost:8001/api/preferences-api/preferences/<user-id> \
|
||||
-H "Authorization: Bearer <valid-jwt>"
|
||||
```
|
||||
Verify: 200 OK, returns same preferences set in step 4
|
||||
6. **Ownership violation**: Use JWT for user-A, request user-B's preferences — verify 403
|
||||
7. **Validation error**: PUT `{"theme": "blue"}` — verify 400 with descriptive message
|
||||
8. **Unknown key**: PUT `{"font_size": "14"}` — verify 400 with descriptive message
|
||||
|
||||
### Test Suite Verification
|
||||
- `cd services/preferences-api && go test -v ./...` — all tests pass
|
||||
- `cd services/preferences-api && go test -race ./...` — no race conditions
|
||||
- `cd services/preferences-api && go vet ./...` — no static analysis issues
|
||||
|
||||
## Acceptance Criteria Coverage Matrix
|
||||
|
||||
| AC# | Description | Test IDs |
|
||||
|-----|-------------|----------|
|
||||
| AC-1 | GET returns preferences in {data, meta} envelope | HP-1, HP-2, HP-12, IT-1 |
|
||||
| AC-2 | PUT creates/updates with upsert semantics | HP-3, HP-4, EC-2, SV-6, IT-1 |
|
||||
| AC-3 | Supported keys: theme, language, notifications_enabled | HP-7, HP-8, HP-9, HP-10, HP-11, DV-1, DV-3, DV-5, DV-7 |
|
||||
| AC-4 | Unknown keys rejected with 400 | ER-3, ER-4, DV-2, SV-4 |
|
||||
| AC-5 | Invalid values rejected with 400 | ER-5, ER-6, ER-7, ER-8, ER-9, ER-10, ER-16, DV-4, DV-6, DV-8, SV-5 |
|
||||
| AC-6 | PUT is idempotent | HP-6, EC-6 |
|
||||
| AC-7 | PUT supports partial updates | HP-5, EC-7, SV-6 |
|
||||
| AC-8 | GET with no preferences returns 200 with {} | HP-2, EC-4, SV-2 |
|
||||
| AC-9 | Both endpoints require JWT auth | ER-11, ER-12, ER-13, IT-2 |
|
||||
| AC-10 | Ownership check — user_id must match JWT subject | ER-14, ER-15, IT-3 |
|
||||
| AC-11 | Preferences persisted in PostgreSQL with migration | DB-1, DB-2, DB-3, DB-4, DB-5 |
|
||||
| AC-12 | OpenAPI spec documents both endpoints | E2E-3 |
|
||||
| AC-13 | Handler tests cover success, validation, not-found, auth | HP-1–12, ER-1–17 |
|
||||
| AC-14 | Service-layer tests with mock repository | SV-1–7 |
|
||||
| AC-15 | Response times < 50ms at p99 for reads | BM-1, BM-2 |
|
||||
Loading…
Reference in New Issue
Block a user