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 18:26:55 +00:00
parent 493f96a9fd
commit 08a3685359
2 changed files with 204 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,203 @@
# QA Plan: User Preferences API
## Test Scenarios
### Happy Path
| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| HP-1 | GET own preferences (existing) | `GET /api/preferences-api/preferences/usr_123` with JWT for `usr_123`, preferences already saved | 200 with `{data: {user_id, theme, language, notifications, updated_at}, meta: {...}}` | AC: GET returns preferences in `{data, meta}` envelope |
| HP-2 | GET own preferences (no saved prefs) | `GET /api/preferences-api/preferences/usr_new` with JWT for `usr_new`, no preferences stored | 200 with defaults: `{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}}` | AC: GET for no saved prefs returns 200 with defaults |
| HP-3 | PUT own preferences (create) | `PUT /api/preferences-api/preferences/usr_123` with valid body, no prior prefs | 200 with saved preferences in `{data, meta}` envelope | AC: PUT creates or replaces (upsert) |
| HP-4 | PUT own preferences (update) | `PUT /api/preferences-api/preferences/usr_123` with valid body, prefs already exist | 200 with updated preferences, `updated_at` reflects new timestamp | AC: PUT creates or replaces (upsert) |
| HP-5 | PUT with all valid theme values | Three requests with `theme: "light"`, `"dark"`, `"system"` | All return 200 with matching theme | AC: theme must be one of light/dark/system |
| HP-6 | PUT with all valid language values | Five requests with `language: "en"`, `"fr"`, `"es"`, `"de"`, `"ja"` | All return 200 with matching language | AC: language must be valid BCP-47 tag |
| HP-7 | PUT with all valid digest values | Three requests with `digest: "none"`, `"daily"`, `"weekly"` | All return 200 with matching digest | AC: notifications.digest must be one of none/daily/weekly |
| HP-8 | PUT with boolean notification fields | `notifications: {email: false, push: false, digest: "none"}` | 200 with matching boolean values | AC: notifications.email/push must be boolean |
| HP-9 | Admin reads another user's preferences | `GET /api/preferences-api/preferences/usr_456` with JWT for admin user `usr_admin` | 200 with usr_456's preferences | AC: admin read access (design: admin role can read any) |
| HP-10 | GET then PUT then GET roundtrip | PUT prefs, then GET same user | GET returns exactly what was PUT | AC: preferences persisted |
| HP-11 | Default values match spec | GET for user with no saved prefs | `theme: "system"`, `language: "en"`, `notifications.email: true`, `notifications.push: true`, `notifications.digest: "weekly"` | AC: default preference values |
| HP-12 | Health endpoint still works | `GET /api/preferences-api/health` | 200 OK | Design: health endpoint preserved |
### Edge Cases
| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| EC-1 | PUT replaces all fields (full replace, not merge) | First PUT with `theme: "dark"`, second PUT with `theme: "light"` (all fields sent both times) | Second GET returns `theme: "light"` | AC: PUT fully replaces preferences |
| EC-2 | Concurrent updates to same user | Two simultaneous PUT requests for same user_id with different values | Both return 200; final state is one of the two (last-write-wins) | Design: upsert is atomic via ON CONFLICT |
| EC-3 | User ID with special characters | `GET /api/preferences-api/preferences/usr_abc-123.456` | 200 (or appropriate handling) | Design: user_id is TEXT, matches JWT subject |
| EC-4 | Very long user ID | `GET /api/preferences-api/preferences/{256-char-string}` | Handled gracefully (200 with defaults or appropriate error) | Design: user_id is TEXT PK |
| EC-5 | PUT immediately after service start (schema just created) | PUT to newly initialized service | 200, table created via EnsureSchema | Design: schema creation at startup |
| EC-6 | Multiple GETs for non-existent user | Repeated GET for user with no prefs | Always returns same defaults, 200 | AC: GET returns defaults for no prefs |
| EC-7 | Updated_at omitted for default preferences | GET for user with no saved preferences | `updated_at` is zero/omitted in response | Design: updated_at omitted for defaults |
| EC-8 | Updated_at populated after PUT | PUT then GET | `updated_at` is a valid timestamp | Design: updated_at set to NOW() on upsert |
### Error Cases
| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| ER-1 | GET without authentication | `GET /api/preferences-api/preferences/usr_123` with no JWT | 401 Unauthorized | AC: both endpoints require auth |
| ER-2 | PUT without authentication | `PUT /api/preferences-api/preferences/usr_123` with no JWT | 401 Unauthorized | AC: both endpoints require auth |
| ER-3 | GET another user's preferences (non-admin) | `GET /api/preferences-api/preferences/usr_456` with JWT for `usr_123` (no admin role) | 403 Forbidden: "access denied: cannot access another user's preferences" | AC: user_id must match JWT UserID |
| ER-4 | PUT another user's preferences (non-admin) | `PUT /api/preferences-api/preferences/usr_456` with JWT for `usr_123` | 403 Forbidden | AC: user_id must match JWT UserID |
| ER-5 | PUT another user's preferences (admin) | `PUT /api/preferences-api/preferences/usr_456` with JWT for admin user | 403 Forbidden (admin write not permitted) | Design: no admin write, PUT strictly self-access |
| ER-6 | PUT with invalid theme | `{theme: "purple", ...}` | 400 Bad Request: "theme must be one of: light, dark, system" | AC: theme validation |
| ER-7 | PUT with invalid language | `{language: "zh", ...}` | 400 Bad Request: "language must be one of: en, fr, es, de, ja" | AC: language validation |
| ER-8 | PUT with invalid digest | `{notifications: {digest: "monthly"}}` | 400 Bad Request: "notifications.digest must be one of: none, daily, weekly" | AC: digest validation |
| ER-9 | PUT with unknown preference keys | `{theme: "dark", ..., "custom_key": "value"}` | 400 Bad Request (strict binding rejects unknown fields) | AC: unknown keys return 400 |
| ER-10 | PUT with non-boolean email notification | `{notifications: {email: "yes", ...}}` | 400 Bad Request with per-field validation error | AC: notifications.email must be boolean |
| ER-11 | PUT with non-boolean push notification | `{notifications: {push: 1, ...}}` | 400 Bad Request with per-field validation error | AC: notifications.push must be boolean |
| ER-12 | PUT with empty body | Empty request body `{}` | 400 Bad Request (required fields missing) | AC: validation, Design: struct tag `required` |
| ER-13 | PUT with malformed JSON | `{theme: dark}` (unquoted) | 400 Bad Request | Design: app.BindAndValidateStrict |
| ER-14 | PUT with missing notifications object | `{theme: "dark", language: "en"}` | 400 Bad Request (notifications required) | Design: `validate:"required"` on notifications |
| ER-15 | PUT with missing theme field | `{language: "en", notifications: {...}}` | 400 Bad Request (theme required) | Design: `validate:"required"` on theme |
| ER-16 | PUT with missing language field | `{theme: "dark", notifications: {...}}` | 400 Bad Request (language required) | Design: `validate:"required"` on language |
| ER-17 | PUT with missing digest in notifications | `{..., notifications: {email: true, push: true}}` | 400 Bad Request (digest required) | Design: `validate:"required"` on digest |
| ER-18 | Expired JWT token | Any request with expired JWT | 401 Unauthorized | AC: auth.Middleware() |
| ER-19 | Invalid JWT signature | Any request with tampered JWT | 401 Unauthorized | AC: auth.Middleware() |
| ER-20 | PUT with multiple validation errors | `{theme: "neon", language: "zz", notifications: {email: true, push: true, digest: "yearly"}}` | 400 with per-field validation errors | AC: per-field validation errors |
## Test Data Requirements
### Fixtures
- **Authenticated user JWT**: Valid JWT for user `usr_test_123` with no admin role
- **Admin user JWT**: Valid JWT for user `usr_admin_001` with `admin` role
- **Second user JWT**: Valid JWT for user `usr_test_456` for cross-user tests
- **Expired JWT**: JWT with past expiration for auth failure tests
- **Invalid JWT**: Malformed/tampered JWT for signature validation tests
### Test Preferences Data
- **Full valid preferences**: `{theme: "dark", language: "fr", notifications: {email: false, push: true, digest: "daily"}}`
- **Default preferences**: `{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}}`
- **All enum boundary values**: Each valid value for theme (3), language (5), digest (3)
### Database State
- **Empty state**: No rows in `user_preferences` table (for default behavior tests)
- **Seeded state**: Pre-existing preferences for `usr_test_123` (for GET/PUT existing tests)
- **In-memory adapter**: Used for unit and handler tests (no real DB needed)
## Integration Test Plan
### Handler-to-Service-to-Repository Integration
Test the full request path using `httptest.Server` with the in-memory adapter:
| Test | Scope | What It Validates |
|------|-------|-------------------|
| Full GET flow | Handler → Service → InMemoryRepo | URL param extraction, auth check, default hydration, response envelope |
| Full PUT flow | Handler → Service → InMemoryRepo | Request binding, validation, domain validation, upsert, response envelope |
| PUT then GET roundtrip | Handler → Service → InMemoryRepo | Data persisted correctly through full write/read cycle |
| Auth middleware integration | Middleware → Handler | JWT extraction and rejection at middleware level before handler |
| Error propagation | Handler → Service → Domain | Domain validation errors bubble up as correct HTTP status codes |
### Cross-Component Boundary Tests
| Boundary | Test Approach |
|----------|---------------|
| Handler ↔ Service | Mock service interface to verify handler maps errors correctly |
| Service ↔ Repository | Use in-memory adapter to verify service applies defaults and validates |
| Domain ↔ Service | Verify service calls domain `Validate()` and handles returned errors |
| Request ↔ Handler | Verify strict JSON binding rejects unknown fields |
| Handler ↔ Response | Verify `{data, meta}` envelope structure matches spec |
### PostgreSQL Adapter Tests (when DB available)
| Test | What It Validates |
|------|-------------------|
| EnsureSchema idempotency | Running schema creation twice does not error |
| Upsert insert path | First write for a user creates a row |
| Upsert update path | Second write for same user updates the row |
| Get existing row | Reads back correct values from all columns |
| Get non-existent row | Returns `nil, nil` (not error) |
| Column type mapping | All Go types map correctly to/from PostgreSQL types |
## Performance Considerations
### Load Expectations
- **Read frequency**: High — preferences loaded on every page load per user session
- **Write frequency**: Low — preferences updated occasionally by explicit user action
- **Data size**: ~6 columns per row, single row per user — negligible
### Latency Budgets
| Operation | Target | Rationale |
|-----------|--------|-----------|
| GET (cache miss) | < 5ms | Single PK lookup on indexed TEXT column |
| GET (cache hit, future) | < 1ms | In-process LRU if implemented later |
| PUT | < 10ms | Single row upsert with index |
### Benchmarks to Run
| Benchmark | Method |
|-----------|--------|
| GET latency (p50/p95/p99) | `go test -bench BenchmarkGetPreferences` with in-memory adapter |
| PUT latency (p50/p95/p99) | `go test -bench BenchmarkUpdatePreferences` with in-memory adapter |
| Concurrent reads | 100 goroutines reading same user's prefs simultaneously |
| Concurrent writes | 10 goroutines writing same user's prefs simultaneously |
### No caching needed for MVP
Single-row PK lookups are sub-millisecond on PostgreSQL. Buffer cache handles the working set naturally.
## Manual Verification Steps
### 1. Service Startup
- [ ] Start service with `./scripts/dev.sh` or direct binary execution
- [ ] Verify health endpoint responds: `curl http://localhost:8001/api/preferences-api/health`
- [ ] Verify `user_preferences` table created in PostgreSQL (check via `psql`)
- [ ] Verify OpenAPI docs accessible at the docs endpoint
### 2. Authentication Flow
- [ ] Obtain a valid JWT from the auth service
- [ ] Verify GET without JWT returns 401
- [ ] Verify PUT without JWT returns 401
### 3. Preference CRUD Flow
- [ ] GET preferences for authenticated user (should return defaults first time)
- [ ] PUT preferences with valid body (theme=dark, language=fr, etc.)
- [ ] GET preferences again (should return saved values, not defaults)
- [ ] PUT preferences with different values (verify overwrite)
- [ ] GET again to confirm overwrite
### 4. Authorization Checks
- [ ] As user A, try to GET user B's preferences → expect 403
- [ ] As user A, try to PUT user B's preferences → expect 403
- [ ] As admin, GET user B's preferences → expect 200
- [ ] As admin, try to PUT user B's preferences → expect 403
### 5. Validation Checks
- [ ] PUT with `theme: "invalid"` → expect 400 with descriptive error
- [ ] PUT with `language: "xx"` → expect 400
- [ ] PUT with unknown key in JSON body → expect 400
- [ ] PUT with malformed JSON → expect 400
### 6. OpenAPI Documentation
- [ ] Verify Scalar docs page loads in browser
- [ ] Verify GET endpoint documented with schemas and examples
- [ ] Verify PUT endpoint documented with request body schema
- [ ] Verify error responses (400, 401, 403) documented
### 7. Example Scaffold Removal
- [ ] Verify no example endpoints respond (GET/POST/PUT/DELETE `/api/preferences-api/examples/*` → 404)
- [ ] Verify no example-related code remains in the codebase
## Acceptance Criteria Coverage Matrix
| Acceptance Criterion | Test Scenarios |
|---------------------|----------------|
| GET returns preferences in `{data, meta}` envelope | HP-1, HP-2, HP-11 |
| PUT creates or fully replaces (upsert) | HP-3, HP-4, EC-1 |
| Both endpoints require authentication | ER-1, ER-2, ER-18, ER-19 |
| User can only access own preferences (JWT match) | ER-3, ER-4, ER-5 |
| Preferences stored as key-value pairs | HP-1, HP-10 |
| Theme validation (light/dark/system) | HP-5, ER-6 |
| Language validation (BCP-47 tags) | HP-6, ER-7 |
| notifications.email must be boolean | HP-8, ER-10 |
| notifications.push must be boolean | HP-8, ER-11 |
| notifications.digest validation | HP-7, ER-8 |
| GET returns 200 with defaults (not 404) | HP-2, HP-11, EC-6 |
| Default values correct | HP-11 |
| Unknown keys return 400 | ER-9 |
| Invalid values return 400 with per-field errors | ER-6, ER-7, ER-8, ER-10, ER-11, ER-20 |
| Persisted in PostgreSQL | HP-10, Integration tests |
| Hexagonal architecture (domain/service/port/adapter) | Code review, Integration tests |
| PreferenceRepository port interface | Code review |
| PreferenceService orchestrates logic | Integration tests |
| OpenAPI spec updated | HP-12, Manual step 6 |
| Unit tests cover domain, service, handler | Code review of test files |
| Integration tests with in-memory adapter | Integration test plan |
| Example scaffold removed | Manual step 7 |