14 KiB
14 KiB
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_preferencestable created via migration001_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 |