slack5-1770574304/.sdlc/features/user-preferences/qa-plan.md
rdev-worker 08a3685359
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /create-qa-plan user-preferences
2026-02-08 18:26:55 +00:00

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_123 with no admin role
  • Admin user JWT: Valid JWT for user usr_admin_001 with admin role
  • Second user JWT: Valid JWT for user usr_test_456 for 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_preferences table (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.sh or direct binary execution
  • Verify health endpoint responds: curl http://localhost:8001/api/preferences-api/health
  • Verify user_preferences table created in PostgreSQL (check via psql)
  • 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