17 KiB
17 KiB
QA Plan: User Preferences API
Test Scenarios
Happy Path
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| HP-1 | Get preferences for user with saved preferences | GET /api/preferences-api/preferences/{user_id} with valid JWT matching user_id |
200 with {data: {user_id, preferences: {theme: "dark", language: "en", notifications_enabled: true}, updated_at: "..."}, meta: {...}} |
AC-1 |
| HP-2 | Get preferences for user with no saved preferences | GET /api/preferences-api/preferences/{user_id} for user with no row |
200 with {data: {user_id, preferences: {}, updated_at: null}, meta: {...}} |
AC-2 |
| HP-3 | Create preferences for new user (upsert - insert) | PUT /api/preferences-api/preferences/{user_id} with {"preferences": {"theme": "dark"}} |
200 with {data: {user_id, preferences: {theme: "dark"}, updated_at: "..."}, meta: {...}} |
AC-3 |
| HP-4 | Update existing preferences (upsert - update) | PUT with {"preferences": {"theme": "light"}} for user who already has theme: "dark" |
200 with preferences showing theme: "light", other keys preserved |
AC-3 |
| HP-5 | Partial update preserves omitted keys | PUT with {"preferences": {"theme": "dark"}} for user who has language: "en" and notifications_enabled: true |
200 with all three keys present: theme changed, language and notifications_enabled unchanged | AC-4 |
| HP-6 | Set theme to "light" | PUT with {"preferences": {"theme": "light"}} |
200 with theme: "light" |
AC-5 |
| HP-7 | Set theme to "dark" | PUT with {"preferences": {"theme": "dark"}} |
200 with theme: "dark" |
AC-5 |
| HP-8 | Set language to valid ISO 639-1 code | PUT with {"preferences": {"language": "es"}} |
200 with language: "es" |
AC-5 |
| HP-9 | Set notifications_enabled to true | PUT with {"preferences": {"notifications_enabled": true}} |
200 with notifications_enabled: true |
AC-5 |
| HP-10 | Set notifications_enabled to false | PUT with {"preferences": {"notifications_enabled": false}} |
200 with notifications_enabled: false |
AC-5 |
| HP-11 | Update all three preferences at once | PUT with {"preferences": {"theme": "dark", "language": "fr", "notifications_enabled": false}} |
200 with all three keys updated | AC-3, AC-5 |
| HP-12 | Response envelope structure on GET | Valid GET request | Response has top-level data and meta keys, meta includes request_id and timestamp |
AC-11 |
| HP-13 | Response envelope structure on PUT | Valid PUT request | Response has top-level data and meta keys |
AC-11 |
Edge Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| EC-1 | Partial update with single key on user with all three set | PUT with {"preferences": {"language": "de"}} for user with theme, language, notifications_enabled |
200 with only language changed, theme and notifications_enabled preserved | AC-4 |
| EC-2 | Update same key to its current value (no-op update) | PUT with {"preferences": {"theme": "dark"}} when already theme: "dark" |
200 with unchanged preferences, updated_at refreshed |
AC-3, AC-4 |
| EC-3 | Empty preferences object on PUT | PUT with {"preferences": {}} |
200 with existing preferences unchanged (no keys to update) OR 400 depending on validation rules | AC-4 |
| EC-4 | Language code at boundary - two lowercase letters | PUT with {"preferences": {"language": "zz"}} |
200 accepted (matches ^[a-z]{2}$ even if not a real language) |
AC-5 |
| EC-5 | Multiple sequential partial updates accumulate correctly | PUT theme, then PUT language, then PUT notifications_enabled | GET returns all three keys | AC-4 |
| EC-6 | User ID is a valid UUID but very specific format | GET /api/preferences-api/preferences/00000000-0000-0000-0000-000000000000 |
200 with empty preferences (valid UUID, no row) | AC-2 |
| EC-7 | Concurrent upserts for same user | Two simultaneous PUT requests with different keys | Both keys present after resolution (JSONB merge is atomic via ON CONFLICT) | AC-3, AC-4 |
Error Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| ER-1 | Invalid theme value | PUT with {"preferences": {"theme": "blue"}} |
400 Bad Request with descriptive error message mentioning "theme" | AC-6 |
| ER-2 | Invalid language - too long | PUT with {"preferences": {"language": "eng"}} |
400 Bad Request with descriptive error | AC-6 |
| ER-3 | Invalid language - uppercase | PUT with {"preferences": {"language": "EN"}} |
400 Bad Request | AC-6 |
| ER-4 | Invalid language - contains numbers | PUT with {"preferences": {"language": "e1"}} |
400 Bad Request | AC-6 |
| ER-5 | Invalid language - single character | PUT with {"preferences": {"language": "e"}} |
400 Bad Request | AC-6 |
| ER-6 | Invalid notifications_enabled - string instead of bool | PUT with {"preferences": {"notifications_enabled": "yes"}} |
400 Bad Request | AC-6 |
| ER-7 | Invalid notifications_enabled - number instead of bool | PUT with {"preferences": {"notifications_enabled": 1}} |
400 Bad Request | AC-6 |
| ER-8 | Unknown preference key | PUT with {"preferences": {"font_size": 14}} |
400 Bad Request with message about unknown key | AC-7 |
| ER-9 | Mix of valid and unknown keys | PUT with {"preferences": {"theme": "dark", "unknown_key": "value"}} |
400 Bad Request (entire request rejected) | AC-7 |
| ER-10 | Unauthenticated GET request | GET without Authorization header |
401 Unauthorized | AC-8 |
| ER-11 | Unauthenticated PUT request | PUT without Authorization header |
401 Unauthorized | AC-8 |
| ER-12 | Invalid JWT token on GET | GET with Authorization: Bearer invalid-token |
401 Unauthorized | AC-8 |
| ER-13 | Invalid JWT token on PUT | PUT with Authorization: Bearer invalid-token |
401 Unauthorized | AC-8 |
| ER-14 | User accessing another user's preferences (GET) | GET /api/preferences-api/preferences/{other_user_id} with JWT for different user |
403 Forbidden with "access denied" | AC-9 |
| ER-15 | User updating another user's preferences (PUT) | PUT /api/preferences-api/preferences/{other_user_id} with JWT for different user |
403 Forbidden with "access denied" | AC-9 |
| ER-16 | Invalid UUID format in path (GET) | GET /api/preferences-api/preferences/not-a-uuid |
400 Bad Request with "invalid user ID format" | AC-1, Design |
| ER-17 | Invalid UUID format in path (PUT) | PUT /api/preferences-api/preferences/not-a-uuid |
400 Bad Request with "invalid user ID format" | AC-3, Design |
| ER-18 | Missing preferences field in PUT body | PUT with {} (empty body) |
400 Bad Request (BindAndValidate rejects missing required field) | AC-3, Design |
| ER-19 | Malformed JSON body on PUT | PUT with {invalid json |
400 Bad Request | Design |
| ER-20 | Theme value is null | PUT with {"preferences": {"theme": null}} |
400 Bad Request | AC-6 |
| ER-21 | Preference value is nested object | PUT with {"preferences": {"theme": {"mode": "dark"}}} |
400 Bad Request | AC-6 |
| ER-22 | Preference value is array | PUT with {"preferences": {"theme": ["dark"]}} |
400 Bad Request | AC-6 |
| ER-23 | Empty string for language | PUT with {"preferences": {"language": ""}} |
400 Bad Request | AC-6 |
Domain Validation Unit Tests
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| DV-1 | ValidatePreferences accepts valid theme | {"theme": "dark"} |
nil (no error) | AC-13 |
| DV-2 | ValidatePreferences accepts valid language | {"language": "en"} |
nil | AC-13 |
| DV-3 | ValidatePreferences accepts valid notifications_enabled | {"notifications_enabled": true} |
nil | AC-13 |
| DV-4 | ValidatePreferences accepts all three valid keys | {"theme": "light", "language": "fr", "notifications_enabled": false} |
nil | AC-13 |
| DV-5 | ValidatePreferenceKey rejects unknown key | "font_size" |
ErrInvalidPreferenceKey | AC-13 |
| DV-6 | ValidatePreferenceValue rejects invalid theme | ("theme", "blue") |
ErrInvalidPreferenceValue | AC-13 |
| DV-7 | ValidatePreferenceValue rejects invalid language format | ("language", "ENG") |
ErrInvalidPreferenceValue | AC-13 |
| DV-8 | ValidatePreferenceValue rejects non-boolean notifications | ("notifications_enabled", "yes") |
ErrInvalidPreferenceValue | AC-13 |
Service Layer Unit Tests
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| SV-1 | Get returns preferences for existing user | Mock repo returns UserPreferences | *UserPreferences with data | AC-15 |
| SV-2 | Get returns empty preferences for new user | Mock repo returns nil | *UserPreferences with empty map | AC-15 |
| SV-3 | Update with valid preferences calls repo Upsert | Valid prefs map | Upsert called, result returned | AC-15 |
| SV-4 | Update with unknown key returns domain error | {"unknown": "value"} |
ErrInvalidPreferenceKey (no repo call) | AC-15 |
| SV-5 | Update with invalid value returns domain error | {"theme": "blue"} |
ErrInvalidPreferenceValue (no repo call) | AC-15 |
| SV-6 | Update propagates repository error | Valid prefs, repo returns error | Error propagated unchanged | AC-15 |
Handler Integration Tests
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| HI-1 | GET 200 with existing preferences | Authenticated request, mock repo returns data | 200, {data, meta} envelope | AC-16 |
| HI-2 | GET 200 with empty preferences | Authenticated request, mock repo returns nil | 200, empty preferences in envelope | AC-16 |
| HI-3 | GET 400 for invalid UUID | /preferences/not-a-uuid |
400 | AC-16 |
| HI-4 | PUT 200 on success | Valid body, authenticated | 200, merged preferences | AC-16 |
| HI-5 | PUT 400 for unknown key | {"preferences": {"bad_key": "val"}} |
400 | AC-16 |
| HI-6 | PUT 400 for invalid value | {"preferences": {"theme": "nope"}} |
400 | AC-16 |
| HI-7 | PUT 400 for missing preferences field | {} |
400 | AC-16 |
| HI-8 | All responses use {data, meta} envelope | Any successful response | Top-level keys are data and meta |
AC-16 |
Test Data Requirements
Fixtures
- Test user UUID: A fixed UUID for the authenticated test user (e.g.,
550e8400-e29b-41d4-a716-446655440000) - Other user UUID: A different UUID for authorization tests (e.g.,
660e8400-e29b-41d4-a716-446655440001) - Sample preferences:
{"theme": "dark", "language": "en", "notifications_enabled": true}
Mocks
- Mock PreferencesRepository: Implements
port.PreferencesRepositorywith configurable return values and error injection for service-layer unit tests - Mock PreferencesService: For handler tests, a service mock that returns canned responses or errors
- Auth context: Test helper to inject authenticated user into request context via
auth.MustGetUser()compatible format - No-op logger:
logging.Nop()for all test setups
Database (for integration tests if added later)
- PostgreSQL test database with migrations applied
- Each test should run in a transaction that is rolled back after the test
Integration Test Plan
Component Interaction Points
- Handler → Service: Handlers delegate to service methods; test with mock service to verify correct method calls and argument passing
- Service → Domain Validation: Service calls
domain.ValidatePreferencesbefore repository; test that invalid input never reaches the repository mock - Service → Repository: Service calls
repo.Getandrepo.Upsert; test with mock repository to verify correct delegation - Handler → Auth Middleware: Auth middleware extracts JWT and sets user context; handler reads context to check ownership
- Handler → chi Router: URL parameters extracted correctly with
{user_id}brace syntax; test that routing dispatches correctly
Cross-Layer Scenarios
| Scenario | Layers Involved | What to Verify |
|---|---|---|
| Valid GET end-to-end | Router → Middleware → Handler → Service → Repo | Correct user_id flows through all layers, response is properly enveloped |
| Valid PUT end-to-end | Router → Middleware → Handler → BindAndValidate → Service → Domain → Repo | Input validated at domain layer, merged result returned through all layers |
| Invalid key rejected | Handler → Service → Domain | Domain error maps to 400 at handler layer, repo never called |
| Auth failure | Router → Middleware | 401 returned before handler is invoked |
| Authorization failure | Handler (ownership check) | 403 returned before service is called |
| DB error propagation | Repo → Service → Handler → app.Wrap | Raw error becomes 500, error is logged, no internal details leaked to client |
Route Registration Verification
GET /api/preferences-api/preferences/{user_id}is registered and routablePUT /api/preferences-api/preferences/{user_id}is registered and routable- Both routes require authentication (middleware applied)
- Health endpoint (
GET /api/preferences-api/health) remains functional and unauthenticated - URL parameters use
{user_id}brace syntax (not:user_idcolon syntax)
Performance Considerations
Load Expectations
- Read pattern: Preferences fetched once per session start; expect ~1 GET per active user session
- Write pattern: Preferences updated infrequently (settings changes); expect <<1 PUT per session
- Overall: High read, very low write workload
Latency Budget
- GET: < 10ms at p99 (single primary key lookup on small table)
- PUT: < 20ms at p99 (single upsert on primary key)
Benchmarks to Run
| Benchmark | Method | Target |
|---|---|---|
| Single GET by primary key | go test -bench BenchmarkGet |
< 1ms per operation against test DB |
| Single PUT upsert | go test -bench BenchmarkUpsert |
< 2ms per operation against test DB |
| JSONB merge correctness under concurrent writes | Parallel test with t.Parallel() |
No data loss or corruption |
Data Size
- Each row ~200 bytes (UUID + small JSONB + timestamps)
- Table fits entirely in PostgreSQL buffer cache even at millions of users
- No additional indexes needed beyond the primary key
Manual Verification Steps
-
OpenAPI documentation renders correctly
- Start the service and navigate to the Scalar docs endpoint
- Verify both GET and PUT endpoints appear with correct schemas
- Verify request/response examples are accurate
- Verify security requirements (Bearer auth) are documented
-
Database migration runs cleanly
- Start the service against a fresh database
- Verify
user_preferencestable is created with correct schema - Restart the service — verify migration is idempotent (no errors on re-run)
-
Health endpoint still works
curl http://localhost:8001/api/preferences-api/healthreturns 200
-
End-to-end flow with real JWT
- Obtain a valid JWT token
GET /api/preferences-api/preferences/{your_user_id}→ 200 with empty preferencesPUT /api/preferences-api/preferences/{your_user_id}with{"preferences": {"theme": "dark"}}→ 200GET /api/preferences-api/preferences/{your_user_id}→ 200 with{"theme": "dark"}PUTwith{"preferences": {"language": "en"}}→ 200, verify theme is still "dark"GET→ 200, verify both theme and language are present
-
Authorization boundary
- Use JWT for user A, try to GET/PUT preferences for user B → verify 403
-
No Example scaffold remnants
- Verify no
/examplesroutes respond - Verify no Example types in OpenAPI docs
go build ./...andgo test ./...pass cleanly
- Verify no
Acceptance Criteria Coverage Matrix
| AC # | Description | Test IDs |
|---|---|---|
| AC-1 | GET returns all preferences as key-value pairs | HP-1, HI-1 |
| AC-2 | GET returns empty preferences (not 404) for new users | HP-2, EC-6, HI-2 |
| AC-3 | PUT creates or updates (upsert semantics) | HP-3, HP-4, HP-11, HI-4 |
| AC-4 | PUT supports partial updates, omitted keys preserved | HP-5, EC-1, EC-2, EC-5 |
| AC-5 | Supported preference keys with valid values | HP-6, HP-7, HP-8, HP-9, HP-10, EC-4 |
| AC-6 | Invalid values rejected with 400 and descriptive message | ER-1 through ER-7, ER-20 through ER-23 |
| AC-7 | Unknown keys rejected with 400 | ER-8, ER-9, HI-5 |
| AC-8 | Both endpoints require authentication (401) | ER-10, ER-11, ER-12, ER-13 |
| AC-9 | Users can only access own preferences (403) | ER-14, ER-15 |
| AC-10 | Persisted to PostgreSQL | Manual step 2, T3 acceptance criteria |
| AC-11 | Standard {data, meta} envelope | HP-12, HP-13, HI-8 |
| AC-12 | OpenAPI spec documented | Manual step 1 |
| AC-13 | Domain layer validates independently of HTTP | DV-1 through DV-8 |
| AC-14 | Hexagonal architecture followed | Manual step 6, code review |
| AC-15 | Unit tests cover service layer | SV-1 through SV-6 |
| AC-16 | Integration tests cover handler layer | HI-1 through HI-8 |
Summary
| Category | Count |
|---|---|
| Happy Path | 13 |
| Edge Cases | 7 |
| Error Cases | 23 |
| Domain Validation Unit Tests | 8 |
| Service Layer Unit Tests | 6 |
| Handler Integration Tests | 8 |
| Manual Verification Steps | 6 |
| Total Scenarios | 71 |
All 16 acceptance criteria have corresponding test coverage. No acceptance criterion is left untested.