slate-v3-1770514618/.sdlc/features/user-preferences/qa-plan.md
rdev-worker 3331b4e68f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /create-qa-plan user-preferences
2026-02-08 01:52:33 +00:00

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 Requestpreferences field required AC-6
ER-9 PUT with null preferences field PUT with {"preferences": null} 400 Bad Requestpreferences must be a JSON object AC-6
ER-10 PUT with preferences as non-object PUT with {"preferences": "string"} or {"preferences": 42} 400 Bad Requestpreferences 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