slack5-1770541397/.sdlc/features/user-preferences/qa-plan.md
rdev-worker f18c076325
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /create-qa-plan user-preferences
2026-02-08 09:17:32 +00:00

14 KiB

QA Plan: User Preferences API

Test Scenarios

Happy Path

ID Scenario Input Expected Output Derived From
HP-1 GET returns saved preferences GET /preferences/{user_id} with valid auth token for user usr_abc123 who has saved preferences {theme: "dark", language: "en", notifications: {email: true, push: true, sms: false}} 200 with {data: {user_id: "usr_abc123", preferences: {theme: "dark", language: "en", notifications: {...}}, updated_at: "<timestamp>"}, meta: {...}} AC-1
HP-2 GET returns defaults for new user GET /preferences/{user_id} with valid auth token for user with no saved preferences 200 with {data: {user_id, preferences: {theme: "system", language: "en", notifications: {email: true, push: true, sms: false}}, updated_at: "0001-01-01T00:00:00Z"}, meta: {...}} AC-2
HP-3 PUT creates preferences for new user PUT /preferences/{user_id} with {preferences: {theme: "dark", language: "fr", notifications: {email: false, push: true, sms: true}}} 200 with saved preferences in {data, meta} envelope AC-3
HP-4 PUT replaces existing preferences PUT /preferences/{user_id} for user who already has saved preferences, with new values 200 with fully replaced preferences (old values gone, new values present) AC-3, AC-9
HP-5 PUT with valid theme "light" PUT with {preferences: {theme: "light", ...}} 200, theme saved as "light" AC-4
HP-6 PUT with valid theme "dark" PUT with {preferences: {theme: "dark", ...}} 200, theme saved as "dark" AC-4
HP-7 PUT with valid theme "system" PUT with {preferences: {theme: "system", ...}} 200, theme saved as "system" AC-4
HP-8 PUT with language at max length (10 chars) PUT with {preferences: {language: "zh-Hant-TW", ...}} (exactly 10 chars) 200, language saved AC-4
HP-9 PUT then GET round-trip PUT preferences, then GET same user's preferences GET returns exactly what was PUT AC-1, AC-3
HP-10 PUT with unknown keys preserves them PUT with {preferences: {theme: "dark", language: "en", notifications: {...}, custom_key: "custom_value"}} 200, and subsequent GET returns the custom_key preserved AC-3 (extensibility requirement from spec)
HP-11 OpenAPI spec accessible GET /api/preferences-api/docs or spec endpoint Returns OpenAPI spec with both endpoints documented AC-10

Edge Cases

ID Scenario Input Expected Output Derived From
EC-1 PUT with empty theme (uses default) PUT with {preferences: {theme: "", language: "en", notifications: {...}}} 200 if empty theme is treated as valid (or 400 if validated as invalid) — domain Validate() allows empty theme per design AC-4
EC-2 PUT with language exactly at boundary PUT with {preferences: {language: "1234567890"}} (10 chars) 200, language saved AC-4
EC-3 PUT with language 11 chars (boundary violation) PUT with {preferences: {language: "12345678901"}} (11 chars) 400 Bad Request AC-4, AC-5
EC-4 PUT with multibyte language tag PUT with {preferences: {language: "日本語テスト6789"}} (10 runes, but more bytes) 200, validation uses rune count not byte count AC-4
EC-5 PUT with multibyte language exceeding 10 runes PUT with {preferences: {language: "日本語テスト67890"}} (11 runes) 400 Bad Request AC-4, AC-5
EC-6 GET then PUT then GET consistency GET defaults → PUT new values → GET again Second GET returns PUT values, not defaults AC-1, AC-2, AC-3
EC-7 PUT replaces all preferences (full replacement) First PUT with {theme: "dark", language: "fr", notifications: {email: false, push: false, sms: true}}, then PUT with only {theme: "light"} Second GET returns {theme: "light"} with other fields at Go zero-values (not merged with first PUT) — full replacement semantics AC-3
EC-8 PUT with all notification channels disabled PUT with {preferences: {notifications: {email: false, push: false, sms: false}}} 200, all notifications disabled AC-3
EC-9 PUT idempotency - same data twice PUT same preferences twice for same user Both return 200, second is a no-op upsert, data unchanged AC-9
EC-10 GET with user_id containing special characters GET /preferences/usr_abc-123.456 200 or handled gracefully (user_id is TEXT, no format constraint) AC-1
EC-11 PUT with empty preferences object PUT with {preferences: {}} 200 with default-like values (empty fields resolve to Go zero-values) AC-3

Error Cases

ID Scenario Input Expected Output Derived From
ER-1 PUT with invalid theme value PUT with {preferences: {theme: "blue"}} 400 Bad Request: "invalid theme: must be one of light, dark, system" AC-4, AC-5
ER-2 PUT with language exceeding max length PUT with {preferences: {language: "verylonglanguagetag"}} (> 10 chars) 400 Bad Request: "invalid language: must be at most 10 characters" AC-4, AC-5
ER-3 GET without authentication GET /preferences/{user_id} with no auth header 401 Unauthorized AC-6
ER-4 PUT without authentication PUT /preferences/{user_id} with no auth header 401 Unauthorized AC-6
ER-5 GET with expired JWT token GET with expired Bearer token 401 Unauthorized AC-6
ER-6 GET with invalid JWT token GET with malformed Bearer token 401 Unauthorized AC-6
ER-7 GET another user's preferences GET /preferences/other_user with auth token for usr_abc123 403 Forbidden: "access denied: can only access own preferences" AC-7
ER-8 PUT another user's preferences PUT /preferences/other_user with auth token for usr_abc123 403 Forbidden: "access denied: can only modify own preferences" AC-7
ER-9 PUT with malformed JSON body PUT with {invalid json 400 Bad Request AC-5
ER-10 PUT with missing preferences field PUT with {} (no preferences key) 400 Bad Request (validation: preferences is required) AC-5
ER-11 PUT with wrong content type PUT with Content-Type: text/plain and valid body 400 Bad Request AC-5
ER-12 PUT with null preferences PUT with {preferences: null} 400 Bad Request (validation: preferences is required) AC-5
ER-13 DELETE method not allowed DELETE /preferences/{user_id} 405 Method Not Allowed (not implemented per spec) Out of scope
ER-14 PATCH method not allowed PATCH /preferences/{user_id} 405 Method Not Allowed (not implemented per spec) Out of scope
ER-15 PUT with oversized payload PUT with a preferences JSON exceeding 64KB 400 Bad Request (payload size limit per design) AC-5, Design security

Test Data Requirements

Fixtures

  • Default preferences fixture: {theme: "system", language: "en", notifications: {email: true, push: true, sms: false}} — used to verify defaults in HP-2
  • Custom preferences fixture: {theme: "dark", language: "fr", notifications: {email: false, push: true, sms: true}} — used for PUT/GET round-trips
  • Preferences with unknown keys: {theme: "dark", language: "en", notifications: {...}, custom_key: "value"} — used for extensibility tests

Auth Tokens

  • Valid JWT for usr_abc123: Token with sub: "usr_abc123" claim, signed with test JWT secret
  • Valid JWT for usr_other: Token with sub: "usr_other" claim — used for cross-user authorization tests
  • Expired JWT: Token with past expiration time
  • Malformed JWT: Invalid token string (e.g., "not.a.valid.jwt")

Database Setup

  • Clean database: Migrations run, user_preferences table exists but is empty
  • Seeded database: One row for usr_abc123 with known preferences values

Mocks

  • Mock PreferencesRepository: In-memory implementation of port.PreferencesRepository with Get and Upsert for unit testing service and handler layers
  • Mock PreferencesService: For handler-level unit tests that isolate HTTP concern from business logic
  • No-op logger: logging.Nop() for all unit tests

Integration Test Plan

Cross-Layer Integration

ID Test Components Description
INT-1 Full PUT → GET round-trip Handler → Service → Repository (mock) Verify data flows correctly from HTTP request through all layers and back
INT-2 PUT upsert behavior Handler → Service → Repository (mock) First PUT creates, second PUT replaces — verify via two PUTs then GET
INT-3 Auth middleware + handler authorization auth.Middleware → Handler Verify JWT extraction → user matching → 403 on mismatch
INT-4 Default preferences on missing data Handler → Service → Repository (mock returns ErrNotFound) GET for non-existent user returns defaults, not 404
INT-5 Validation error propagation Handler → Service → Domain.Validate() Invalid theme → domain error → service → handler → 400 with message
INT-6 Request binding + domain validation app.BindAndValidate → Handler → Service Missing required fields rejected at binding; invalid values rejected at domain

Database Integration (requires PostgreSQL)

ID Test Description
DB-1 Migration runs cleanly CREATE TABLE IF NOT EXISTS user_preferences succeeds on empty database
DB-2 Migration is idempotent Running migration twice doesn't error
DB-3 JSONB round-trip Insert preferences as JSONB, read back, verify all fields match
DB-4 Upsert creates new row INSERT for new user_id creates row
DB-5 Upsert updates existing row INSERT ON CONFLICT for existing user_id updates preferences and updated_at
DB-6 Unknown keys survive JSONB round-trip Preferences with extra keys stored and retrieved correctly
DB-7 Get returns ErrPreferencesNotFound for missing user SELECT with non-existent user_id returns proper domain error

Route Integration

ID Test Description
RT-1 Routes use brace syntax Verify {user_id} parameter extraction works (not colon syntax)
RT-2 Health endpoint remains unprotected GET /api/preferences-api/health returns 200 without auth
RT-3 Preferences endpoints require auth GET and PUT under /api/preferences-api/preferences/ return 401 without auth (when AUTH_ENABLED=true)

Performance Considerations

Load Expectations

  • Read-heavy workload: Preferences fetched on every page load / session start
  • Write frequency: Low — users rarely change preferences
  • Expected latency: GET < 10ms (single PK lookup), PUT < 20ms (single upsert)

Benchmarks to Consider

  • GET throughput: Benchmark GetPreferences with database connection — should handle 1000+ req/s per instance
  • PUT throughput: Benchmark SetPreferences — should handle 500+ req/s per instance
  • Concurrent access: Verify no data corruption under concurrent GET/PUT for same user
  • Connection pool: Verify pool settings (25 open, 5 idle) are adequate under load

Size Limits

  • Payload size: Preferences JSON should be < 1KB typical; enforce 64KB max per design
  • JSONB storage: Monitor JSONB column size; no practical limit but track average

Manual Verification Steps

Startup Verification

  1. Start service with DATABASE_URL pointing to running PostgreSQL
  2. Verify health check at GET /api/preferences-api/health returns 200
  3. Verify migration created user_preferences table (check via psql)

API Manual Walkthrough

  1. GET without auth → verify 401 Unauthorized
  2. GET with valid auth, no prefs saved → verify 200 with default preferences
  3. PUT with valid auth and valid body → verify 200 with saved preferences
  4. GET same user → verify 200 with preferences matching the PUT
  5. PUT with invalid theme → verify 400 with descriptive error message
  6. GET as different user → verify 403 Forbidden

OpenAPI Spec Verification

  1. Access the API docs endpoint (Scalar UI)
  2. Verify both GET and PUT endpoints are documented
  3. Verify request/response schemas match implementation
  4. Verify auth requirements are documented
  5. Verify error responses (400, 401, 403) are documented

Database Verification

  1. After PUT, query user_preferences table directly — verify row exists with correct user_id, preferences JSONB, created_at, updated_at
  2. After second PUT, verify updated_at changed but created_at preserved
  3. Verify unknown keys in preferences JSONB are preserved in database

Acceptance Criteria Coverage Matrix

AC Description Test IDs
AC-1 GET returns preferences with 200 in {data, meta} envelope HP-1, HP-9
AC-2 GET returns defaults when no preferences saved HP-2, EC-6
AC-3 PUT creates or replaces preferences, returning 200 HP-3, HP-4, EC-7, EC-9
AC-4 PUT validates known keys (theme enum, language length) HP-5, HP-6, HP-7, HP-8, EC-1, EC-2, EC-3, EC-4, EC-5, ER-1, ER-2
AC-5 PUT with invalid input returns 400 with descriptive error ER-1, ER-2, ER-9, ER-10, ER-11, ER-12, ER-15
AC-6 Both endpoints require authentication ER-3, ER-4, ER-5, ER-6
AC-7 User can only access own preferences (403 otherwise) ER-7, ER-8
AC-8 Preferences persisted to PostgreSQL, survive restarts DB-3, DB-4, DB-5, DB-6
AC-9 Database uses upsert pattern HP-4, EC-9, DB-4, DB-5
AC-10 OpenAPI spec updated with both endpoints HP-11, RT-1
AC-11 Follows hexagonal architecture patterns INT-1 through INT-6
AC-12 Unit tests cover handler, service, domain layers All HP, EC, ER tests at respective layers
AC-13 URL parameters use brace syntax {user_id} RT-1