17 KiB
17 KiB
QA Plan: User Preferences API
Test Scenarios
Happy Path
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| HP-1 | GET returns stored preferences | GET /preferences/{user_id} with valid auth token matching user_id |
200 OK with {data: {user_id, preferences: {theme, language, notifications}, updated_at}, meta} |
AC-1 |
| HP-2 | PUT creates preferences on first call (upsert) | PUT /preferences/{user_id} with {"preferences": {"theme": "dark"}}, no prior preferences |
200 OK with full preferences (defaults merged: language=en, notifications defaults) |
AC-3, AC-5 |
| HP-3 | PUT merges with existing preferences (shallow merge) | PUT with {"preferences": {"theme": "light"}} when user already has {theme: "dark", language: "es"} |
200 OK with {theme: "light", language: "es", ...} — only theme changed |
AC-4, AC-5 |
| HP-4 | PUT replaces notifications object entirely when provided | PUT with {"preferences": {"notifications": {"email": false}}} when user has {email: true, push: true, digest: "weekly"} |
200 OK — notifications sub-fields not provided get defaults filled in before validation |
AC-4 |
| HP-5 | PUT returns full merged preference set | PUT with partial update | 200 OK response body contains ALL preference keys, not just the ones sent |
AC-5 |
| HP-6 | Admin can access another user's preferences (GET) | GET /preferences/{other_user_id} with admin role JWT |
200 OK with that user's preferences |
AC-9 |
| HP-7 | Admin can update another user's preferences (PUT) | PUT /preferences/{other_user_id} with admin role JWT and valid body |
200 OK with merged preferences |
AC-9 |
| HP-8 | GET with valid UUID format accepted | GET /preferences/550e8400-e29b-41d4-a716-446655440000 |
UUID parsed successfully, request proceeds to service layer | AC-10 |
| HP-9 | PUT with all valid preference values | PUT with {"preferences": {"theme": "system", "language": "fr", "notifications": {"email": true, "push": false, "digest": "daily"}}} |
200 OK — all values accepted and stored |
AC-13 |
| HP-10 | Consecutive PUTs accumulate preferences correctly | PUT {theme: "dark"}, then PUT {language: "es"} |
Final GET returns {theme: "dark", language: "es", ...defaults} — both updates persisted |
AC-4 |
Edge Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| EC-1 | GET for user with no preferences | GET /preferences/{user_id} where no PUT has ever been made |
404 Not Found with {error: {code: "NOT_FOUND", message: "preferences not found"}, meta} |
AC-2 |
| EC-2 | PUT with empty preferences object | PUT with {"preferences": {}} (no keys) |
200 OK — creates defaults if first call, or returns existing unchanged |
AC-3, AC-4 |
| EC-3 | PUT with all fields provided (no merge needed) | PUT with every preference key explicitly set | 200 OK — all fields overwritten to provided values |
AC-4 |
| EC-4 | PUT with only notifications sub-fields | PUT with {"preferences": {"notifications": {"digest": "never"}}} |
200 OK — other notification sub-fields get default values filled before merge |
AC-4 |
| EC-5 | Boundary: language code exactly 2 chars | PUT with {"preferences": {"language": "de"}} |
200 OK — valid ISO 639-1 code accepted |
AC-13 |
| EC-6 | Theme values at boundaries | PUT with each valid theme: light, dark, system separately |
All return 200 OK |
AC-13 |
| EC-7 | Digest values at boundaries | PUT with each valid digest: daily, weekly, never separately |
All return 200 OK |
AC-13 |
| EC-8 | PUT idempotency — same update twice | PUT {theme: "dark"} twice in a row |
Both return 200 OK with identical preferences (except updated_at may differ) |
AC-4, AC-5 |
| EC-9 | UUID in different valid formats | GET with uppercase UUID, lowercase UUID, mixed case | All treated equivalently as valid UUIDs | AC-10 |
| EC-10 | In-memory persistence across requests | PUT then GET in separate HTTP requests | GET returns what PUT stored | AC-11 |
Error Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| ER-1 | GET without authentication | GET /preferences/{user_id} with no auth token |
401 Unauthorized |
AC-8 |
| ER-2 | PUT without authentication | PUT /preferences/{user_id} with no auth token |
401 Unauthorized |
AC-8 |
| ER-3 | GET with expired/invalid JWT | GET with malformed or expired token | 401 Unauthorized |
AC-8 |
| ER-4 | GET for different user (non-admin) | GET /preferences/{other_user_id} with non-admin token |
403 Forbidden |
AC-9 |
| ER-5 | PUT for different user (non-admin) | PUT /preferences/{other_user_id} with non-admin token and valid body |
403 Forbidden |
AC-9 |
| ER-6 | GET with invalid UUID format | GET /preferences/not-a-uuid |
400 Bad Request — UUID validation failure |
AC-10 |
| ER-7 | PUT with invalid UUID format | PUT /preferences/12345 (not UUID) |
400 Bad Request — UUID validation failure |
AC-10 |
| ER-8 | PUT with missing preferences field | PUT with {} (empty body, no preferences key) |
400 Bad Request — preferences field required |
AC-6 |
| ER-9 | PUT with null preferences field | PUT with {"preferences": null} |
400 Bad Request — preferences must be a JSON object |
AC-6 |
| ER-10 | PUT with preferences as non-object | PUT with {"preferences": "string"} or {"preferences": 42} |
400 Bad Request — preferences must be a JSON object |
AC-6 |
| ER-11 | PUT with unknown top-level preference key | PUT with {"preferences": {"theme": "dark", "color": "blue"}} |
400 Bad Request — unknown key color rejected |
AC-7 |
| ER-12 | PUT with multiple unknown keys | PUT with {"preferences": {"foo": 1, "bar": 2}} |
400 Bad Request — unknown keys rejected |
AC-7 |
| ER-13 | PUT with invalid theme value | PUT with {"preferences": {"theme": "neon"}} |
400 Bad Request — theme must be one of: light, dark, system |
AC-13 |
| ER-14 | PUT with invalid language (too long) | PUT with {"preferences": {"language": "eng"}} (3 chars) |
400 Bad Request — language must match ^[a-z]{2}$ |
AC-13 |
| ER-15 | PUT with invalid language (uppercase) | PUT with {"preferences": {"language": "EN"}} |
400 Bad Request — language must be lowercase |
AC-13 |
| ER-16 | PUT with invalid language (numbers) | PUT with {"preferences": {"language": "1a"}} |
400 Bad Request — language must match ^[a-z]{2}$ |
AC-13 |
| ER-17 | PUT with invalid language (empty) | PUT with {"preferences": {"language": ""}} |
400 Bad Request — language must match ^[a-z]{2}$ |
AC-13 |
| ER-18 | PUT with invalid digest value | PUT with {"preferences": {"notifications": {"digest": "monthly"}}} |
400 Bad Request — digest must be one of: daily, weekly, never |
AC-13 |
| ER-19 | PUT with malformed JSON body | PUT with {broken json |
400 Bad Request — binding/parse error |
AC-6 |
| ER-20 | PUT with extra top-level fields outside preferences | PUT with {"preferences": {"theme": "dark"}, "extra": true} |
400 Bad Request — strict binding rejects unknown outer fields |
AC-6 |
Test Data Requirements
Fixtures
| Fixture | Description | Usage |
|---|---|---|
validUserID |
A valid UUID string, e.g. 550e8400-e29b-41d4-a716-446655440000 |
All authenticated test cases |
otherUserID |
A different valid UUID, e.g. 660e8400-e29b-41d4-a716-446655440000 |
Authorization cross-user tests |
defaultPreferences |
Preferences with all defaults: theme=system, language=en, email=true, push=true, digest=weekly | Baseline assertions |
customPreferences |
Preferences with non-default values: theme=dark, language=es, email=false, push=false, digest=daily | Merge and update tests |
Mocks
| Mock | Purpose |
|---|---|
mockPreferencesRepository |
In-test mock implementing port.PreferencesRepository for unit testing service and handler layers independently |
| Auth context | Simulated JWT auth context with user.ID and user.Roles for handler tests |
| Admin auth context | Simulated JWT with admin role for authorization override tests |
Test Data Setup Pattern
Following existing codebase conventions:
- Handler tests use
newTestHandler()helper that wires mock repo → real service → handler - Service tests use mock repo directly
- Domain tests are pure unit tests with no mocks
- All tests use
logging.Nop()for logger - Mock repos implement interface with compile-time assertion:
var _ port.PreferencesRepository = (*mockPreferencesRepository)(nil)
Integration Test Plan
Component Integration (within service boundary)
These tests verify the full stack within the preferences-api service works end-to-end.
| ID | Test | Components Exercised | Approach |
|---|---|---|---|
| INT-1 | Full GET flow: router → auth middleware → handler → service → memory adapter | All layers | HTTP test via chi router with auth context |
| INT-2 | Full PUT flow: router → auth middleware → handler → binding/validation → service → merge → adapter → response | All layers | HTTP test via chi router with auth context |
| INT-3 | PUT then GET round-trip | Handler + Service + Adapter | PUT preferences, then GET and verify returned data matches |
| INT-4 | Multiple PUTs with merge | Service + Domain merge logic + Adapter | Sequential PUTs with partial updates, verify cumulative merge |
| INT-5 | Auth middleware blocks unauthenticated requests | Router + Auth middleware | Request without JWT token to auth-protected routes |
| INT-6 | OpenAPI spec serves correctly | Router + Spec | GET /api/preferences-api/docs returns valid spec |
Cross-Layer Verification
| Layer Boundary | What to Verify |
|---|---|
| Handler → Service | Domain errors propagate unchanged and handler maps them to correct HTTP status codes |
| Service → Repository | ErrPreferencesNotFound is returned for missing users and handled correctly upstream |
| Domain validation → Handler error mapping | Each domain validation error (ErrInvalidTheme, ErrInvalidLanguage, ErrInvalidDigest) maps to 400 Bad Request |
| Auth middleware → Handler | Auth context is correctly populated and accessible via auth.GetUser(ctx) |
Unit Test Plan by Layer
Domain Layer Tests (internal/domain/)
| ID | Test | Focus |
|---|---|---|
| UT-D1 | NewDefaultPreferences returns correct defaults |
Factory function |
| UT-D2 | Validate() passes with all valid values |
Happy path |
| UT-D3 | Validate() fails for invalid theme |
Enum validation |
| UT-D4 | Validate() fails for invalid language (too long, uppercase, numbers, empty) |
Regex validation |
| UT-D5 | Validate() fails for invalid digest |
Enum validation |
| UT-D6 | MergeFrom() only overwrites non-nil fields |
Partial merge |
| UT-D7 | MergeFrom() replaces all fields when all provided |
Full merge |
| UT-D8 | MergeFrom() merges notification sub-fields individually |
Nested merge |
| UT-D9 | MergeFrom() with nil notifications leaves existing untouched |
No-op merge |
| UT-D10 | UserID.IsZero() returns true for empty, false for non-empty |
Type method |
Service Layer Tests (internal/service/)
| ID | Test | Focus |
|---|---|---|
| UT-S1 | Get() returns preferences for existing user |
Happy path |
| UT-S2 | Get() returns ErrPreferencesNotFound for missing user |
Not found |
| UT-S3 | Upsert() creates new preferences with defaults when user has none |
Create-on-first-PUT |
| UT-S4 | Upsert() merges update into existing preferences |
Merge logic |
| UT-S5 | Upsert() rejects invalid values (propagates domain validation errors) |
Validation passthrough |
| UT-S6 | Upsert() sets UpdatedAt timestamp |
Metadata |
Handler Layer Tests (internal/api/handlers/)
| ID | Test | Focus |
|---|---|---|
| UT-H1 | GET 200 — valid user, preferences exist | Happy path |
| UT-H2 | GET 404 — valid user, no preferences | Not found |
| UT-H3 | GET 403 — user_id doesn't match token, not admin | Authorization |
| UT-H4 | GET 400 — invalid UUID in path | Validation |
| UT-H5 | PUT 200 — create new preferences | Create path |
| UT-H6 | PUT 200 — merge existing preferences | Update path |
| UT-H7 | PUT 400 — missing preferences field |
Request validation |
| UT-H8 | PUT 400 — unknown top-level keys in preferences | Strict binding |
| UT-H9 | PUT 400 — invalid preference values | Domain validation |
| UT-H10 | PUT 403 — wrong user, not admin | Authorization |
| UT-H11 | Response envelope format matches {data, meta} |
Envelope structure |
Adapter Layer Tests (internal/adapter/memory/)
| ID | Test | Focus |
|---|---|---|
| UT-A1 | Get() returns stored preferences |
Read |
| UT-A2 | Get() returns ErrPreferencesNotFound for missing key |
Read miss |
| UT-A3 | Upsert() inserts new entry |
Write new |
| UT-A4 | Upsert() replaces existing entry |
Write existing |
| UT-A5 | Returned preferences are copies (mutation doesn't affect store) | Copy semantics |
| UT-A6 | Concurrent reads don't panic | Thread safety |
Performance Considerations
Load Expectations
- Read-heavy workload: ~100:1 read-to-write ratio
- Preferences are read on every page load / session initialization
- Writes happen only when user changes settings (rare)
Latency Budgets
| Operation | Target | Rationale |
|---|---|---|
| GET preferences | < 5ms (p99) | In-memory lookup, no I/O. Frontend blocks on this for initialization. |
| PUT preferences | < 10ms (p99) | In-memory merge + store, no I/O. User settings save should feel instant. |
Benchmarks to Run
| Benchmark | Description |
|---|---|
BenchmarkGet |
Measure GET handler throughput with in-memory adapter |
BenchmarkUpsert |
Measure PUT handler throughput including merge + validation |
BenchmarkConcurrentReads |
Verify RWMutex allows concurrent readers without contention |
BenchmarkConcurrentReadWrite |
Verify mixed read/write workload under RWMutex doesn't deadlock or degrade |
Stress Scenarios (future, out of scope for in-memory)
- Memory growth: many users' preferences in-memory (acceptable for dev, monitor in production)
- No pagination needed — single-user preference payload is ~200 bytes
Manual Verification Steps
| Step | Description | Expected Result |
|---|---|---|
| 1 | Start the service: go run ./cmd/server/main.go |
Service starts without error, logs listen address |
| 2 | Verify health: curl http://localhost:8001/api/preferences-api/health |
200 OK with health response |
| 3 | Verify OpenAPI docs render: open http://localhost:8001/api/preferences-api/docs in browser |
Scalar API docs page loads with preferences endpoints documented |
| 4 | PUT preferences (unauthenticated): curl -X PUT http://localhost:8001/api/preferences-api/preferences/{uuid} -d '{"preferences":{"theme":"dark"}}' |
401 Unauthorized (auth middleware blocks) |
| 5 | GET preferences (with auth): Use valid JWT and curl to retrieve preferences |
Returns 200 or 404 depending on prior state |
| 6 | PUT then GET round-trip (with auth): Create preferences, then retrieve them | GET response matches what was PUT |
| 7 | Verify example endpoints removed: curl http://localhost:8001/api/preferences-api/examples |
404 Not Found — old scaffold routes no longer exist |
| 8 | Verify build: cd services/preferences-api && go build ./... |
Compiles successfully |
| 9 | Verify tests: cd services/preferences-api && go test -v ./... |
All tests pass |
Acceptance Criteria Coverage Matrix
| AC # | Acceptance Criterion | Test IDs |
|---|---|---|
| AC-1 | GET returns 200 with stored preferences | HP-1, UT-H1, INT-1, INT-3 |
| AC-2 | GET returns 404 when no preferences exist | EC-1, UT-H2, UT-S2, UT-A2 |
| AC-3 | PUT creates preferences if none exist (upsert) | HP-2, UT-H5, UT-S3, INT-2 |
| AC-4 | PUT merges provided keys with existing (shallow merge) | HP-3, HP-4, HP-10, EC-2, EC-3, EC-4, UT-D6, UT-D7, UT-D8, UT-D9, UT-S4, UT-H6, INT-4 |
| AC-5 | PUT returns 200 with full merged preference set | HP-5, UT-H5, UT-H6 |
| AC-6 | PUT validates preferences field is present and is JSON object | ER-8, ER-9, ER-10, ER-19, ER-20, UT-H7 |
| AC-7 | PUT rejects unknown top-level preference keys with 400 | ER-11, ER-12, UT-H8 |
| AC-8 | Both endpoints require authentication | ER-1, ER-2, ER-3, INT-5 |
| AC-9 | Own-user access only, admin override | HP-6, HP-7, ER-4, ER-5, UT-H3, UT-H10 |
| AC-10 | user_id validated as UUID | HP-8, EC-9, ER-6, ER-7, UT-H4 |
| AC-11 | Preferences persisted in-memory via adapter pattern | EC-10, UT-A1, UT-A3, UT-A4, INT-3 |
| AC-12 | OpenAPI spec documents both endpoints | INT-6, Manual Step 3 |
| AC-13 | Domain model defines allowed keys and validation rules | HP-9, EC-5, EC-6, EC-7, ER-13, ER-14, ER-15, ER-16, ER-17, ER-18, UT-D1 through UT-D9 |
| AC-14 | Handler tests cover success, validation, auth, not-found | UT-H1 through UT-H11 |
| AC-15 | Service tests cover merge, create-on-first-PUT, authorization | UT-S1 through UT-S6 |
| AC-16 | All example scaffold code removed and replaced | Manual Steps 7, 8, 9 |