16 KiB
16 KiB
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.PreferenceRepositoryinterface - 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
httptestwith 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.Pooldefaults (25 max open, 5 max idle) are sufficient
Manual Verification Steps
Pre-Implementation Checks
- Verify all 8 example scaffold files are deleted (T1 acceptance)
- Verify
health.goandconfig.goare untouched after scaffold removal - Verify service compiles after each task (
go build ./...)
Post-Implementation Smoke Tests
- Start service:
cd services/preferences-api && go run ./cmd/server/— verify it starts without errors - Health check:
curl http://localhost:8001/api/preferences-api/health— verify 200 OK - OpenAPI docs:
curl http://localhost:8001/api/preferences-api/docs— verify docs render with preference endpoints - PUT preferences (authenticated):
Verify: 200 OK, response contains all three preferences incurl -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"}'{data, meta}envelope - GET preferences (authenticated):
Verify: 200 OK, returns same preferences set in step 4curl http://localhost:8001/api/preferences-api/preferences/<user-id> \ -H "Authorization: Bearer <valid-jwt>" - Ownership violation: Use JWT for user-A, request user-B's preferences — verify 403
- Validation error: PUT
{"theme": "blue"}— verify 400 with descriptive message - Unknown key: PUT
{"font_size": "14"}— verify 400 with descriptive message
Test Suite Verification
cd services/preferences-api && go test -v ./...— all tests passcd services/preferences-api && go test -race ./...— no race conditionscd 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 |