diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index c1d0d0f..e57ff87 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -18,7 +18,7 @@ artifacts: approved_by: user approved_at: 2026-02-09T02:23:50.511364172Z qa_plan: - status: pending + status: draft path: qa-plan.md qa_results: status: pending diff --git a/.sdlc/features/user-preferences/qa-plan.md b/.sdlc/features/user-preferences/qa-plan.md new file mode 100644 index 0000000..715f796 --- /dev/null +++ b/.sdlc/features/user-preferences/qa-plan.md @@ -0,0 +1,167 @@ +# QA Plan: User Preferences API + +## Test Scenarios + +### Happy Path + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| HP-1 | GET returns stored preferences for existing user | `GET /api/preferences-api/preferences/{valid_uuid}` (user has saved prefs) | 200 with full preferences object in `{data, meta}` envelope | AC-1 | +| HP-2 | GET returns default preferences for unknown user | `GET /api/preferences-api/preferences/{valid_uuid}` (no saved prefs) | 200 with defaults: theme=system, language=en, notifications={email:true, push:true, digest:weekly} | AC-2 | +| HP-3 | PUT creates preferences for new user (upsert) | `PUT /api/preferences-api/preferences/{valid_uuid}` with `{"preferences":{"theme":"dark","language":"fr","notifications":{"email":false,"push":true,"digest":"daily"}}}` | 200 with full preference set persisted | AC-3 | +| HP-4 | PUT merges partial update - theme only | `PUT` with `{"preferences":{"theme":"light"}}` on user with existing prefs | 200 with theme changed, all other fields unchanged | AC-4 | +| HP-5 | PUT merges partial update - language only | `PUT` with `{"preferences":{"language":"es"}}` on user with existing prefs | 200 with language changed, all other fields unchanged | AC-4 | +| HP-6 | PUT merges partial update - single nested notification field | `PUT` with `{"preferences":{"notifications":{"push":false}}}` on user with existing prefs | 200 with push changed, email and digest unchanged (deep merge) | AC-4 | +| HP-7 | PUT merges partial update - multiple nested notification fields | `PUT` with `{"preferences":{"notifications":{"email":false,"digest":"daily"}}}` | 200 with email and digest changed, push unchanged | AC-4 | +| HP-8 | PUT with all valid theme values | `PUT` with theme=`"light"`, `"dark"`, `"system"` separately | 200 for each valid value | AC-5 | +| HP-9 | PUT with valid language values | `PUT` with language=`"en"`, `"fr"`, `"es"`, `"de"`, `"zh"` | 200 for each valid value | AC-6 | +| HP-10 | PUT with all valid digest values | `PUT` with digest=`"daily"`, `"weekly"`, `"never"` separately | 200 for each valid value | AC-7 | +| HP-11 | PUT with valid boolean notification fields | `PUT` with email=true/false, push=true/false | 200 for all boolean combinations | AC-8 | +| HP-12 | All GET responses use {data, meta} envelope | `GET /api/preferences-api/preferences/{valid_uuid}` | Response body has `data` and `meta` top-level keys; `meta` includes `request_id` and `timestamp` | AC-11 | +| HP-13 | All PUT responses use {data, meta} envelope | `PUT /api/preferences-api/preferences/{valid_uuid}` with valid body | Response body has `data` and `meta` top-level keys | AC-11 | +| HP-14 | GET then PUT then GET roundtrip | GET defaults, PUT partial update, GET returns merged result | All three requests succeed; final GET reflects PUT changes | AC-1, AC-3, AC-4 | +| HP-15 | PUT response contains full merged preferences | `PUT` with partial preferences on existing user | Response `data` contains complete preferences (merged), not just the submitted partial | AC-4 | + +### Edge Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| EC-1 | PUT with empty preferences object | `PUT` with `{"preferences":{}}` | 200 with no changes to existing preferences (all fields omitted = no merge changes) | AC-4 | +| EC-2 | PUT with empty notifications object | `PUT` with `{"preferences":{"notifications":{}}}` | 200 with no notification field changes | AC-4 | +| EC-3 | PUT twice with different partial fields | First PUT: `{"preferences":{"theme":"dark"}}`, second PUT: `{"preferences":{"language":"fr"}}` | Both fields persisted after second PUT (theme=dark, language=fr) | AC-4 | +| EC-4 | PUT overwrites previous value of same field | PUT theme=dark, then PUT theme=light | Final theme is light, other fields unchanged | AC-4 | +| EC-5 | GET with lowercase UUID | `GET /preferences/{lowercase-uuid}` | 200 (UUID parsing is case-insensitive) | AC-1, AC-10 | +| EC-6 | GET with uppercase UUID | `GET /preferences/{UPPERCASE-UUID}` | 200 (UUID parsing accepts uppercase) | AC-1, AC-10 | +| EC-7 | PUT first user then GET second user | PUT prefs for user A, then GET for user B (no prefs) | User B gets defaults, not user A's prefs | AC-2 | +| EC-8 | Concurrent PUT requests for same user | Two simultaneous PUTs for same user_id | Both succeed (INSERT ON CONFLICT is atomic); last write wins | AC-3 | +| EC-9 | Default preferences have correct values | GET for unknown user | theme="system", language="en", notifications.email=true, notifications.push=true, notifications.digest="weekly" | AC-2 | +| EC-10 | Response updated_at reflects latest change | PUT, then GET | `updated_at` in GET response >= `updated_at` from PUT response | AC-1 | +| EC-11 | PUT on user with no existing prefs merges with defaults | PUT `{"preferences":{"theme":"dark"}}` for new user | Response shows theme=dark, language=en (default), notifications all defaults | AC-3, AC-4 | + +### Error Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| ER-1 | PUT with invalid theme value | `{"preferences":{"theme":"midnight"}}` | 400 Bad Request with descriptive error about invalid theme | AC-5, AC-9 | +| ER-2 | PUT with empty theme string | `{"preferences":{"theme":""}}` | 400 Bad Request (empty is not in allowed values) | AC-5, AC-9 | +| ER-3 | PUT with numeric theme value | `{"preferences":{"theme":123}}` | 400 Bad Request (type mismatch) | AC-5, AC-9 | +| ER-4 | PUT with empty language | `{"preferences":{"language":""}}` | 400 Bad Request with descriptive error about invalid language | AC-6, AC-9 | +| ER-5 | PUT with invalid digest value | `{"preferences":{"notifications":{"digest":"monthly"}}}` | 400 Bad Request with descriptive error about invalid digest | AC-7, AC-9 | +| ER-6 | PUT with non-boolean email value | `{"preferences":{"notifications":{"email":"yes"}}}` | 400 Bad Request (type mismatch on boolean field) | AC-8, AC-9 | +| ER-7 | PUT with non-boolean push value | `{"preferences":{"notifications":{"push":1}}}` | 400 Bad Request (type mismatch on boolean field) | AC-8, AC-9 | +| ER-8 | GET with non-UUID user_id | `GET /preferences/not-a-uuid` | 400 Bad Request with "invalid user_id format" | AC-10 | +| ER-9 | PUT with non-UUID user_id | `PUT /preferences/not-a-uuid` with valid body | 400 Bad Request with "invalid user_id format" | AC-10 | +| ER-10 | GET with empty user_id | `GET /preferences/` | 404 (route not matched) or 400 | AC-10 | +| ER-11 | PUT with empty request body | `PUT /preferences/{valid_uuid}` with empty body | 400 Bad Request ("request body is required") | AC-9 | +| ER-12 | PUT with malformed JSON body | `PUT` with `{not json` | 400 Bad Request ("invalid request body") | AC-9 | +| ER-13 | PUT with missing preferences key | `PUT` with `{"theme":"dark"}` (no wrapper) | 400 Bad Request (validation: preferences is required) | AC-9 | +| ER-14 | PUT with null preferences value | `PUT` with `{"preferences":null}` | 400 Bad Request (validation: preferences is required) | AC-9 | +| ER-15 | GET with user_id containing SQL injection | `GET /preferences/'; DROP TABLE user_preferences; --` | 400 Bad Request (not a valid UUID), no SQL execution | AC-10 | +| ER-16 | PUT with oversized request body | `PUT` with very large JSON payload | 400 or 413 (framework body size limit) | AC-9 | +| ER-17 | PUT with invalid theme plus valid language | `{"preferences":{"theme":"bad","language":"en"}}` | 400 Bad Request for invalid theme (validation rejects entire request) | AC-5, AC-9 | + +## Test Data Requirements + +### Fixtures + +| Fixture | Description | +|---------|-------------| +| `validUUID` | A well-formed UUID: `550e8400-e29b-41d4-a716-446655440000` | +| `validUUID2` | A second UUID for multi-user tests: `660e8400-e29b-41d4-a716-446655440001` | +| `defaultPreferences` | `{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}}` | +| `fullPreferences` | `{theme: "dark", language: "fr", notifications: {email: false, push: true, digest: "daily"}}` | +| `partialThemeOnly` | `{preferences: {theme: "light"}}` | +| `partialNotificationOnly` | `{preferences: {notifications: {push: false}}}` | + +### Mocks + +| Mock | Purpose | Used By | +|------|---------|---------| +| `mockPreferencesRepository` | In-memory implementation of `PreferencesRepository` port | Service unit tests, handler unit tests | +| Mock must support: `Get(ctx, userID)` returning `nil, nil` for unknown users | Enables testing default fallback behavior | Service tests | +| Mock must support: `Upsert(ctx, prefs)` storing preferences by user_id | Enables testing persistence roundtrip | Service tests | + +### Test Database (Integration) + +- PostgreSQL instance with `user_preferences` table created via migration `001_create_user_preferences.sql` +- Each integration test should use a clean table state (truncate between tests or use unique UUIDs) + +## Integration Test Plan + +### Cross-Layer Integration (Handler → Service → Repository) + +| ID | Scenario | Components | Verification | +|----|----------|------------|--------------| +| IT-1 | Full GET flow with mock repo | Handler → Service → Mock Repo | HTTP 200, correct envelope, default preferences for unknown user | +| IT-2 | Full PUT flow with mock repo | Handler → Service → Mock Repo | HTTP 200, preferences persisted in mock, response contains merged result | +| IT-3 | PUT then GET roundtrip with mock repo | Handler → Service → Mock Repo | PUT creates, GET returns what was PUT | +| IT-4 | Deep merge across PUT calls | Handler → Service → Mock Repo | First PUT sets theme, second PUT sets language, GET returns both | +| IT-5 | Validation error propagation | Handler → Service → Domain validation | Domain error surfaces as HTTP 400 with descriptive message | +| IT-6 | UUID validation at handler layer | Handler (chi URL param extraction) | Invalid UUID returns 400 before reaching service layer | + +### Database Integration (requires PostgreSQL) + +| ID | Scenario | Components | Verification | +|----|----------|------------|--------------| +| DB-1 | Migration creates table | Migration runner → PostgreSQL | Table `user_preferences` exists with correct columns | +| DB-2 | Adapter Get for non-existent user | PostgreSQL adapter → DB | Returns `nil, nil` (not error) | +| DB-3 | Adapter Upsert creates new row | PostgreSQL adapter → DB | Row inserted; SELECT confirms data | +| DB-4 | Adapter Upsert updates existing row | PostgreSQL adapter → DB | Row updated; `updated_at` changed | +| DB-5 | JSONB serialization roundtrip | Adapter → DB → Adapter | Write preferences, read back, all fields match | +| DB-6 | Concurrent upsert doesn't error | Two goroutines upsert same user | Both succeed (ON CONFLICT handles it) | + +### OpenAPI Spec Verification + +| ID | Scenario | Verification | +|----|----------|--------------| +| OA-1 | OpenAPI spec exports | `--export-openapi` produces valid JSON | +| OA-2 | GET endpoint documented | Spec contains GET `/api/preferences-api/preferences/{user_id}` with response schema | +| OA-3 | PUT endpoint documented | Spec contains PUT with request body schema and response schema | +| OA-4 | Schemas match domain types | `UserPreferences`, `UpdatePreferencesRequest`, `PreferencesResponse` schemas defined | + +## Performance Considerations + +| Aspect | Expectation | Test Method | +|--------|-------------|-------------| +| GET latency | < 10ms for single PK lookup (excluding network) | Benchmark test: `BenchmarkGetPreferences` with seeded data | +| PUT latency | < 20ms for upsert (excluding network) | Benchmark test: `BenchmarkUpdatePreferences` | +| No N+1 queries | GET and PUT each execute exactly 1 SQL statement | Count queries in integration test (or review adapter code) | +| JSONB size | Preferences JSON < 500 bytes for standard fields | Assert serialized size in unit test | +| Concurrent writes | No deadlocks or errors under concurrent PUT | Run 10 goroutines doing PUT for same user, assert no errors | + +## Manual Verification Steps + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | Start service with `./scripts/dev.sh` or equivalent | Service starts, connects to PostgreSQL, runs migration | +| 2 | `curl GET /api/preferences-api/preferences/{new-uuid}` | Returns 200 with default preferences | +| 3 | `curl PUT /api/preferences-api/preferences/{uuid}` with `{"preferences":{"theme":"dark"}}` | Returns 200 with theme=dark, other fields defaulted | +| 4 | `curl GET /api/preferences-api/preferences/{same-uuid}` | Returns 200 with theme=dark persisted | +| 5 | `curl PUT /api/preferences-api/preferences/{same-uuid}` with `{"preferences":{"language":"fr"}}` | Returns 200 with theme=dark (retained), language=fr (updated) | +| 6 | `curl GET /api/preferences-api/preferences/not-a-uuid` | Returns 400 Bad Request | +| 7 | `curl PUT /api/preferences-api/preferences/{uuid}` with `{"preferences":{"theme":"invalid"}}` | Returns 400 with descriptive error message | +| 8 | `curl GET /health` | Returns 200 (health endpoint still works) | +| 9 | Visit OpenAPI docs page (Scalar UI) | Both endpoints documented with schemas | +| 10 | Restart service, repeat GET for same UUID | Returns persisted preferences (survived restart = AC-13) | + +## Acceptance Criteria Coverage Matrix + +| AC | Description | Test IDs | +|----|-------------|----------| +| AC-1 | GET returns stored preferences | HP-1, HP-14, EC-10 | +| AC-2 | GET unknown user returns defaults | HP-2, EC-7, EC-9, EC-11 | +| AC-3 | PUT creates preferences (upsert) | HP-3, EC-11, IT-2, IT-3 | +| AC-4 | PUT merges partial update (deep merge) | HP-4, HP-5, HP-6, HP-7, HP-15, EC-1, EC-2, EC-3, EC-4, IT-4 | +| AC-5 | PUT validates theme | HP-8, ER-1, ER-2, ER-3, ER-17 | +| AC-6 | PUT validates language non-empty | HP-9, ER-4 | +| AC-7 | PUT validates digest | HP-10, ER-5 | +| AC-8 | PUT validates notification booleans | HP-11, ER-6, ER-7 | +| AC-9 | Invalid values return 400 with details | ER-1 through ER-17 | +| AC-10 | Invalid user_id returns 400 | ER-8, ER-9, ER-10, ER-15, IT-6 | +| AC-11 | Standard {data, meta} envelope | HP-12, HP-13 | +| AC-12 | OpenAPI spec documents endpoints | OA-1, OA-2, OA-3, OA-4 | +| AC-13 | Persisted in PostgreSQL | DB-1 through DB-5, Manual step 10 | +| AC-14 | Migration creates table | DB-1 | +| AC-15 | Hexagonal architecture | IT-1 through IT-5 (layer isolation verified by mock injection) | +| AC-16 | Service and handler unit tests | All HP, EC, ER tests implemented as unit tests | +| AC-17 | Example scaffolding removed | Manual code review; no example.go files remain |