15 KiB
15 KiB
QA Plan: User Preferences API
Test Scenarios
Happy Path
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| HP-1 | GET own preferences (existing) | GET /api/preferences-api/preferences/usr_123 with JWT for usr_123, preferences already saved |
200 with {data: {user_id, theme, language, notifications, updated_at}, meta: {...}} |
AC: GET returns preferences in {data, meta} envelope |
| HP-2 | GET own preferences (no saved prefs) | GET /api/preferences-api/preferences/usr_new with JWT for usr_new, no preferences stored |
200 with defaults: {theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}} |
AC: GET for no saved prefs returns 200 with defaults |
| HP-3 | PUT own preferences (create) | PUT /api/preferences-api/preferences/usr_123 with valid body, no prior prefs |
200 with saved preferences in {data, meta} envelope |
AC: PUT creates or replaces (upsert) |
| HP-4 | PUT own preferences (update) | PUT /api/preferences-api/preferences/usr_123 with valid body, prefs already exist |
200 with updated preferences, updated_at reflects new timestamp |
AC: PUT creates or replaces (upsert) |
| HP-5 | PUT with all valid theme values | Three requests with theme: "light", "dark", "system" |
All return 200 with matching theme | AC: theme must be one of light/dark/system |
| HP-6 | PUT with all valid language values | Five requests with language: "en", "fr", "es", "de", "ja" |
All return 200 with matching language | AC: language must be valid BCP-47 tag |
| HP-7 | PUT with all valid digest values | Three requests with digest: "none", "daily", "weekly" |
All return 200 with matching digest | AC: notifications.digest must be one of none/daily/weekly |
| HP-8 | PUT with boolean notification fields | notifications: {email: false, push: false, digest: "none"} |
200 with matching boolean values | AC: notifications.email/push must be boolean |
| HP-9 | Admin reads another user's preferences | GET /api/preferences-api/preferences/usr_456 with JWT for admin user usr_admin |
200 with usr_456's preferences | AC: admin read access (design: admin role can read any) |
| HP-10 | GET then PUT then GET roundtrip | PUT prefs, then GET same user | GET returns exactly what was PUT | AC: preferences persisted |
| HP-11 | Default values match spec | GET for user with no saved prefs | theme: "system", language: "en", notifications.email: true, notifications.push: true, notifications.digest: "weekly" |
AC: default preference values |
| HP-12 | Health endpoint still works | GET /api/preferences-api/health |
200 OK | Design: health endpoint preserved |
Edge Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| EC-1 | PUT replaces all fields (full replace, not merge) | First PUT with theme: "dark", second PUT with theme: "light" (all fields sent both times) |
Second GET returns theme: "light" |
AC: PUT fully replaces preferences |
| EC-2 | Concurrent updates to same user | Two simultaneous PUT requests for same user_id with different values | Both return 200; final state is one of the two (last-write-wins) | Design: upsert is atomic via ON CONFLICT |
| EC-3 | User ID with special characters | GET /api/preferences-api/preferences/usr_abc-123.456 |
200 (or appropriate handling) | Design: user_id is TEXT, matches JWT subject |
| EC-4 | Very long user ID | GET /api/preferences-api/preferences/{256-char-string} |
Handled gracefully (200 with defaults or appropriate error) | Design: user_id is TEXT PK |
| EC-5 | PUT immediately after service start (schema just created) | PUT to newly initialized service | 200, table created via EnsureSchema | Design: schema creation at startup |
| EC-6 | Multiple GETs for non-existent user | Repeated GET for user with no prefs | Always returns same defaults, 200 | AC: GET returns defaults for no prefs |
| EC-7 | Updated_at omitted for default preferences | GET for user with no saved preferences | updated_at is zero/omitted in response |
Design: updated_at omitted for defaults |
| EC-8 | Updated_at populated after PUT | PUT then GET | updated_at is a valid timestamp |
Design: updated_at set to NOW() on upsert |
Error Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| ER-1 | GET without authentication | GET /api/preferences-api/preferences/usr_123 with no JWT |
401 Unauthorized | AC: both endpoints require auth |
| ER-2 | PUT without authentication | PUT /api/preferences-api/preferences/usr_123 with no JWT |
401 Unauthorized | AC: both endpoints require auth |
| ER-3 | GET another user's preferences (non-admin) | GET /api/preferences-api/preferences/usr_456 with JWT for usr_123 (no admin role) |
403 Forbidden: "access denied: cannot access another user's preferences" | AC: user_id must match JWT UserID |
| ER-4 | PUT another user's preferences (non-admin) | PUT /api/preferences-api/preferences/usr_456 with JWT for usr_123 |
403 Forbidden | AC: user_id must match JWT UserID |
| ER-5 | PUT another user's preferences (admin) | PUT /api/preferences-api/preferences/usr_456 with JWT for admin user |
403 Forbidden (admin write not permitted) | Design: no admin write, PUT strictly self-access |
| ER-6 | PUT with invalid theme | {theme: "purple", ...} |
400 Bad Request: "theme must be one of: light, dark, system" | AC: theme validation |
| ER-7 | PUT with invalid language | {language: "zh", ...} |
400 Bad Request: "language must be one of: en, fr, es, de, ja" | AC: language validation |
| ER-8 | PUT with invalid digest | {notifications: {digest: "monthly"}} |
400 Bad Request: "notifications.digest must be one of: none, daily, weekly" | AC: digest validation |
| ER-9 | PUT with unknown preference keys | {theme: "dark", ..., "custom_key": "value"} |
400 Bad Request (strict binding rejects unknown fields) | AC: unknown keys return 400 |
| ER-10 | PUT with non-boolean email notification | {notifications: {email: "yes", ...}} |
400 Bad Request with per-field validation error | AC: notifications.email must be boolean |
| ER-11 | PUT with non-boolean push notification | {notifications: {push: 1, ...}} |
400 Bad Request with per-field validation error | AC: notifications.push must be boolean |
| ER-12 | PUT with empty body | Empty request body {} |
400 Bad Request (required fields missing) | AC: validation, Design: struct tag required |
| ER-13 | PUT with malformed JSON | {theme: dark} (unquoted) |
400 Bad Request | Design: app.BindAndValidateStrict |
| ER-14 | PUT with missing notifications object | {theme: "dark", language: "en"} |
400 Bad Request (notifications required) | Design: validate:"required" on notifications |
| ER-15 | PUT with missing theme field | {language: "en", notifications: {...}} |
400 Bad Request (theme required) | Design: validate:"required" on theme |
| ER-16 | PUT with missing language field | {theme: "dark", notifications: {...}} |
400 Bad Request (language required) | Design: validate:"required" on language |
| ER-17 | PUT with missing digest in notifications | {..., notifications: {email: true, push: true}} |
400 Bad Request (digest required) | Design: validate:"required" on digest |
| ER-18 | Expired JWT token | Any request with expired JWT | 401 Unauthorized | AC: auth.Middleware() |
| ER-19 | Invalid JWT signature | Any request with tampered JWT | 401 Unauthorized | AC: auth.Middleware() |
| ER-20 | PUT with multiple validation errors | {theme: "neon", language: "zz", notifications: {email: true, push: true, digest: "yearly"}} |
400 with per-field validation errors | AC: per-field validation errors |
Test Data Requirements
Fixtures
- Authenticated user JWT: Valid JWT for user
usr_test_123with no admin role - Admin user JWT: Valid JWT for user
usr_admin_001withadminrole - Second user JWT: Valid JWT for user
usr_test_456for cross-user tests - Expired JWT: JWT with past expiration for auth failure tests
- Invalid JWT: Malformed/tampered JWT for signature validation tests
Test Preferences Data
- Full valid preferences:
{theme: "dark", language: "fr", notifications: {email: false, push: true, digest: "daily"}} - Default preferences:
{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}} - All enum boundary values: Each valid value for theme (3), language (5), digest (3)
Database State
- Empty state: No rows in
user_preferencestable (for default behavior tests) - Seeded state: Pre-existing preferences for
usr_test_123(for GET/PUT existing tests) - In-memory adapter: Used for unit and handler tests (no real DB needed)
Integration Test Plan
Handler-to-Service-to-Repository Integration
Test the full request path using httptest.Server with the in-memory adapter:
| Test | Scope | What It Validates |
|---|---|---|
| Full GET flow | Handler → Service → InMemoryRepo | URL param extraction, auth check, default hydration, response envelope |
| Full PUT flow | Handler → Service → InMemoryRepo | Request binding, validation, domain validation, upsert, response envelope |
| PUT then GET roundtrip | Handler → Service → InMemoryRepo | Data persisted correctly through full write/read cycle |
| Auth middleware integration | Middleware → Handler | JWT extraction and rejection at middleware level before handler |
| Error propagation | Handler → Service → Domain | Domain validation errors bubble up as correct HTTP status codes |
Cross-Component Boundary Tests
| Boundary | Test Approach |
|---|---|
| Handler ↔ Service | Mock service interface to verify handler maps errors correctly |
| Service ↔ Repository | Use in-memory adapter to verify service applies defaults and validates |
| Domain ↔ Service | Verify service calls domain Validate() and handles returned errors |
| Request ↔ Handler | Verify strict JSON binding rejects unknown fields |
| Handler ↔ Response | Verify {data, meta} envelope structure matches spec |
PostgreSQL Adapter Tests (when DB available)
| Test | What It Validates |
|---|---|
| EnsureSchema idempotency | Running schema creation twice does not error |
| Upsert insert path | First write for a user creates a row |
| Upsert update path | Second write for same user updates the row |
| Get existing row | Reads back correct values from all columns |
| Get non-existent row | Returns nil, nil (not error) |
| Column type mapping | All Go types map correctly to/from PostgreSQL types |
Performance Considerations
Load Expectations
- Read frequency: High — preferences loaded on every page load per user session
- Write frequency: Low — preferences updated occasionally by explicit user action
- Data size: ~6 columns per row, single row per user — negligible
Latency Budgets
| Operation | Target | Rationale |
|---|---|---|
| GET (cache miss) | < 5ms | Single PK lookup on indexed TEXT column |
| GET (cache hit, future) | < 1ms | In-process LRU if implemented later |
| PUT | < 10ms | Single row upsert with index |
Benchmarks to Run
| Benchmark | Method |
|---|---|
| GET latency (p50/p95/p99) | go test -bench BenchmarkGetPreferences with in-memory adapter |
| PUT latency (p50/p95/p99) | go test -bench BenchmarkUpdatePreferences with in-memory adapter |
| Concurrent reads | 100 goroutines reading same user's prefs simultaneously |
| Concurrent writes | 10 goroutines writing same user's prefs simultaneously |
No caching needed for MVP
Single-row PK lookups are sub-millisecond on PostgreSQL. Buffer cache handles the working set naturally.
Manual Verification Steps
1. Service Startup
- Start service with
./scripts/dev.shor direct binary execution - Verify health endpoint responds:
curl http://localhost:8001/api/preferences-api/health - Verify
user_preferencestable created in PostgreSQL (check viapsql) - Verify OpenAPI docs accessible at the docs endpoint
2. Authentication Flow
- Obtain a valid JWT from the auth service
- Verify GET without JWT returns 401
- Verify PUT without JWT returns 401
3. Preference CRUD Flow
- GET preferences for authenticated user (should return defaults first time)
- PUT preferences with valid body (theme=dark, language=fr, etc.)
- GET preferences again (should return saved values, not defaults)
- PUT preferences with different values (verify overwrite)
- GET again to confirm overwrite
4. Authorization Checks
- As user A, try to GET user B's preferences → expect 403
- As user A, try to PUT user B's preferences → expect 403
- As admin, GET user B's preferences → expect 200
- As admin, try to PUT user B's preferences → expect 403
5. Validation Checks
- PUT with
theme: "invalid"→ expect 400 with descriptive error - PUT with
language: "xx"→ expect 400 - PUT with unknown key in JSON body → expect 400
- PUT with malformed JSON → expect 400
6. OpenAPI Documentation
- Verify Scalar docs page loads in browser
- Verify GET endpoint documented with schemas and examples
- Verify PUT endpoint documented with request body schema
- Verify error responses (400, 401, 403) documented
7. Example Scaffold Removal
- Verify no example endpoints respond (GET/POST/PUT/DELETE
/api/preferences-api/examples/*→ 404) - Verify no example-related code remains in the codebase
Acceptance Criteria Coverage Matrix
| Acceptance Criterion | Test Scenarios |
|---|---|
GET returns preferences in {data, meta} envelope |
HP-1, HP-2, HP-11 |
| PUT creates or fully replaces (upsert) | HP-3, HP-4, EC-1 |
| Both endpoints require authentication | ER-1, ER-2, ER-18, ER-19 |
| User can only access own preferences (JWT match) | ER-3, ER-4, ER-5 |
| Preferences stored as key-value pairs | HP-1, HP-10 |
| Theme validation (light/dark/system) | HP-5, ER-6 |
| Language validation (BCP-47 tags) | HP-6, ER-7 |
| notifications.email must be boolean | HP-8, ER-10 |
| notifications.push must be boolean | HP-8, ER-11 |
| notifications.digest validation | HP-7, ER-8 |
| GET returns 200 with defaults (not 404) | HP-2, HP-11, EC-6 |
| Default values correct | HP-11 |
| Unknown keys return 400 | ER-9 |
| Invalid values return 400 with per-field errors | ER-6, ER-7, ER-8, ER-10, ER-11, ER-20 |
| Persisted in PostgreSQL | HP-10, Integration tests |
| Hexagonal architecture (domain/service/port/adapter) | Code review, Integration tests |
| PreferenceRepository port interface | Code review |
| PreferenceService orchestrates logic | Integration tests |
| OpenAPI spec updated | HP-12, Manual step 6 |
| Unit tests cover domain, service, handler | Code review of test files |
| Integration tests with in-memory adapter | Integration test plan |
| Example scaffold removed | Manual step 7 |