14 KiB
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 withsub: "usr_abc123"claim, signed with test JWT secret - Valid JWT for
usr_other: Token withsub: "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_preferencestable exists but is empty - Seeded database: One row for
usr_abc123with known preferences values
Mocks
- Mock
PreferencesRepository: In-memory implementation ofport.PreferencesRepositorywithGetandUpsertfor 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
GetPreferenceswith 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
- Start service with
DATABASE_URLpointing to running PostgreSQL - Verify health check at
GET /api/preferences-api/healthreturns200 - Verify migration created
user_preferencestable (check viapsql)
API Manual Walkthrough
- GET without auth → verify 401 Unauthorized
- GET with valid auth, no prefs saved → verify 200 with default preferences
- PUT with valid auth and valid body → verify 200 with saved preferences
- GET same user → verify 200 with preferences matching the PUT
- PUT with invalid theme → verify 400 with descriptive error message
- GET as different user → verify 403 Forbidden
OpenAPI Spec Verification
- Access the API docs endpoint (Scalar UI)
- Verify both GET and PUT endpoints are documented
- Verify request/response schemas match implementation
- Verify auth requirements are documented
- Verify error responses (400, 401, 403) are documented
Database Verification
- After PUT, query
user_preferencestable directly — verify row exists with correctuser_id,preferencesJSONB,created_at,updated_at - After second PUT, verify
updated_atchanged butcreated_atpreserved - 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 |