slack5-1770544098/.sdlc/features/user-preferences/qa-plan.md
rdev-worker c812bbfd13
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /create-qa-plan user-preferences
2026-02-08 10:04:30 +00:00

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.PreferencesRepository with 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

  1. Handler → Service: Handlers delegate to service methods; test with mock service to verify correct method calls and argument passing
  2. Service → Domain Validation: Service calls domain.ValidatePreferences before repository; test that invalid input never reaches the repository mock
  3. Service → Repository: Service calls repo.Get and repo.Upsert; test with mock repository to verify correct delegation
  4. Handler → Auth Middleware: Auth middleware extracts JWT and sets user context; handler reads context to check ownership
  5. 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 routable
  • PUT /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_id colon 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

  1. 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
  2. Database migration runs cleanly

    • Start the service against a fresh database
    • Verify user_preferences table is created with correct schema
    • Restart the service — verify migration is idempotent (no errors on re-run)
  3. Health endpoint still works

    • curl http://localhost:8001/api/preferences-api/health returns 200
  4. End-to-end flow with real JWT

    • Obtain a valid JWT token
    • GET /api/preferences-api/preferences/{your_user_id} → 200 with empty preferences
    • PUT /api/preferences-api/preferences/{your_user_id} with {"preferences": {"theme": "dark"}} → 200
    • GET /api/preferences-api/preferences/{your_user_id} → 200 with {"theme": "dark"}
    • PUT with {"preferences": {"language": "en"}} → 200, verify theme is still "dark"
    • GET → 200, verify both theme and language are present
  5. Authorization boundary

    • Use JWT for user A, try to GET/PUT preferences for user B → verify 403
  6. No Example scaffold remnants

    • Verify no /examples routes respond
    • Verify no Example types in OpenAPI docs
    • go build ./... and go test ./... pass cleanly

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.