slate-test-1770505673/.sdlc/features/user-preferences/qa-plan.md
rdev-worker 9e15946afd
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /create-qa-plan user-preferences
2026-02-07 23:37:49 +00:00

211 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-112, ER-117 |
| AC-14 | Service-layer tests with mock repository | SV-17 |
| AC-15 | Response times < 50ms at p99 for reads | BM-1, BM-2 |