16 KiB
16 KiB
QA Plan: User Preferences API
Test Scenarios
Happy Path
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| HP-1 | GET returns stored preferences for a user | GET /api/preferences-api/preferences/{valid_uuid} where user has preferences {theme: "dark", language: "en", notifications_enabled: true} |
200 with {data: {user_id, preferences: {theme: "dark", language: "en", notifications_enabled: true}, updated_at}, meta} |
AC-1 |
| HP-2 | GET returns empty preferences for user with no stored preferences | GET /api/preferences-api/preferences/{valid_uuid} where user has no row |
200 with {data: {user_id, preferences: {}, updated_at}, meta} |
AC-2 |
| HP-3 | PUT creates preferences for new user (upsert - insert path) | PUT /api/preferences-api/preferences/{valid_uuid} with body {preferences: {theme: "light"}} where no row exists |
200 with {data: {user_id, preferences: {theme: "light"}, updated_at}, meta} |
AC-4, AC-5, AC-6 |
| HP-4 | PUT updates preferences for existing user (upsert - update path) | PUT /api/preferences-api/preferences/{valid_uuid} with body {preferences: {theme: "dark"}} where user already has {theme: "light", language: "en"} |
200 with merged preferences {theme: "dark", language: "en"} |
AC-4, AC-6 |
| HP-5 | PUT with all known preference keys valid | PUT with {preferences: {theme: "system", language: "ja", notifications_enabled: false}} |
200 with all preferences stored correctly |
AC-7 |
| HP-6 | PUT with unknown preference keys accepted | PUT with {preferences: {custom_key: "custom_value", another: 42}} |
200 with unknown keys stored as-is |
AC-9 |
| HP-7 | PUT with mix of known and unknown keys | PUT with {preferences: {theme: "dark", custom_flag: true}} |
200 with both keys stored |
AC-7, AC-9 |
| HP-8 | Preferences survive service restart | PUT preferences, restart service, GET same user | GET returns previously stored preferences | AC-10 |
| HP-9 | All responses follow envelope pattern | Any successful GET or PUT | Response has {data, meta} structure with meta containing request_id and timestamp |
AC-11 |
| HP-10 | PUT validates theme "light" accepted | PUT with {preferences: {theme: "light"}} |
200 success |
AC-7 |
| HP-11 | PUT validates theme "dark" accepted | PUT with {preferences: {theme: "dark"}} |
200 success |
AC-7 |
| HP-12 | PUT validates theme "system" accepted | PUT with {preferences: {theme: "system"}} |
200 success |
AC-7 |
| HP-13 | PUT validates various BCP-47 language tags | PUT with {preferences: {language: "en"}}, then "fr", "es", "de", "ja" |
200 for each |
AC-7 |
| HP-14 | PUT validates notifications_enabled true | PUT with {preferences: {notifications_enabled: true}} |
200 success |
AC-7 |
| HP-15 | PUT validates notifications_enabled false | PUT with {preferences: {notifications_enabled: false}} |
200 success |
AC-7 |
| HP-16 | PUT is idempotent | Same PUT request sent twice | Both return 200 with identical preferences; second call does not create duplicate |
AC-4 |
Edge Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| EC-1 | GET with UUID that has never had preferences | GET /api/preferences-api/preferences/{new_uuid} |
200 with empty preferences {} (not 404) |
AC-2 |
| EC-2 | PUT with empty preferences object | PUT with {preferences: {}} |
200 success, no preferences changed (or empty stored) |
AC-5 |
| EC-3 | PUT preserves existing keys not in request | User has {theme: "dark", language: "en"}, PUT with {preferences: {theme: "light"}} |
Stored preferences: {theme: "light", language: "en"} - language preserved |
AC-4 (merge behavior from design) |
| EC-4 | PUT with unknown key containing complex JSON value | PUT with {preferences: {custom: {nested: {deeply: true}}}} |
200 with nested value stored as-is |
AC-9 |
| EC-5 | PUT with unknown key containing array value | PUT with {preferences: {tags: ["a", "b", "c"]}} |
200 with array stored |
AC-9 |
| EC-6 | PUT with unknown key containing null value | PUT with {preferences: {optional_field: null}} |
200 with null stored |
AC-9 |
| EC-7 | GET with lowercase UUID | GET with {550e8400-e29b-41d4-a716-446655440000} |
200 success |
AC-1 |
| EC-8 | GET with uppercase UUID | GET with {550E8400-E29B-41D4-A716-446655440000} |
200 success (UUIDs are case-insensitive) |
AC-1 |
| EC-9 | PUT with large number of preference keys | PUT with 100 different key-value pairs |
200 success, all keys stored |
AC-9 |
| EC-10 | PUT with unicode string values for unknown keys | PUT with {preferences: {greeting: "こんにちは"}} |
200 with unicode preserved |
AC-9 |
| EC-11 | PUT with numeric values for unknown keys | PUT with {preferences: {max_items: 50, ratio: 3.14}} |
200 with numeric types preserved |
AC-9 |
| EC-12 | Sequential PUTs accumulate preferences | PUT {theme: "dark"}, then PUT {language: "fr"} |
GET returns {theme: "dark", language: "fr"} |
AC-4, merge behavior |
| EC-13 | PUT with boolean-like string for notifications_enabled | PUT with {preferences: {notifications_enabled: "true"}} (string, not bool) |
400 validation error - must be boolean |
AC-7 |
Error Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| ER-1 | GET with invalid UUID format | GET /api/preferences-api/preferences/not-a-uuid |
400 Bad Request |
AC-3 |
| ER-2 | GET with empty string as user_id | GET /api/preferences-api/preferences/ |
404 (route not matched) or 400 |
AC-3 |
| ER-3 | PUT with invalid UUID format | PUT /api/preferences-api/preferences/abc123 |
400 Bad Request |
AC-3 |
| ER-4 | PUT with invalid theme value | PUT with {preferences: {theme: "midnight"}} |
400 with details: {theme: "must be one of: light, dark, system"} |
AC-7, AC-8 |
| ER-5 | PUT with invalid language tag | PUT with {preferences: {language: "not-a-language-!!"}} |
400 with details: {language: "must be a valid BCP-47 language tag"} |
AC-7, AC-8 |
| ER-6 | PUT with invalid notifications_enabled type | PUT with {preferences: {notifications_enabled: "yes"}} |
400 with details: {notifications_enabled: "must be a boolean"} |
AC-7, AC-8 |
| ER-7 | PUT with multiple validation errors | PUT with {preferences: {theme: "neon", language: "???", notifications_enabled: 42}} |
400 with details containing all three field errors |
AC-8 |
| ER-8 | PUT with missing request body | PUT with empty body |
400 Bad Request |
AC-5 |
| ER-9 | PUT with missing preferences field | PUT with {} (empty JSON object) |
400 Bad Request |
AC-5 |
| ER-10 | PUT with malformed JSON body | PUT with {not json |
400 Bad Request |
AC-5 |
| ER-11 | PUT with preferences as non-object type | PUT with {preferences: "string"} |
400 Bad Request |
AC-5 |
| ER-12 | PUT with preferences as array | PUT with {preferences: [1, 2, 3]} |
400 Bad Request |
AC-5 |
| ER-13 | PUT with integer theme value | PUT with {preferences: {theme: 123}} |
400 with details for theme |
AC-7, AC-8 |
| ER-14 | PUT with null theme value | PUT with {preferences: {theme: null}} |
400 with details for theme |
AC-7, AC-8 |
| ER-15 | Database unavailable on GET | Database connection dropped | 500 Internal Server Error |
Design: Error Handling |
| ER-16 | Database unavailable on PUT | Database connection dropped | 500 Internal Server Error |
Design: Error Handling |
| ER-17 | GET non-existent route | GET /api/preferences-api/nonexistent |
404 Not Found |
Standard routing |
| ER-18 | POST to preferences endpoint (wrong method) | POST /api/preferences-api/preferences/{uuid} |
405 Method Not Allowed |
Standard routing |
| ER-19 | DELETE to preferences endpoint (wrong method) | DELETE /api/preferences-api/preferences/{uuid} |
405 Method Not Allowed |
Standard routing |
Test Data Requirements
Fixtures
- Valid UUIDs: Generate at least 3 unique UUIDs for test isolation (e.g.,
550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001,770e8400-e29b-41d4-a716-446655440002) - Pre-seeded preferences row: One user with
{theme: "dark", language: "en", notifications_enabled: true}for GET and merge tests - Empty database state: Tests must handle both seeded and unseeded user_ids
Mocks
- Mock
PreferenceRepository: For handler and service unit tests, providing controllableGetandUpsertbehavior - Mock returning nil: Simulates user with no preferences
- Mock returning error: Simulates database failures (connection errors, query errors)
- Mock tracking calls: Verifies correct arguments passed to repository methods
Test Data Values
| Key | Valid Values | Invalid Values |
|---|---|---|
theme |
"light", "dark", "system" |
"midnight", 123, null, "", true |
language |
"en", "fr", "es", "de", "ja", "zh-Hans" |
"not-a-language-!!", 123, null, "" |
notifications_enabled |
true, false |
"true", "false", 1, 0, "yes", null |
Integration Test Plan
Component Boundary Tests
| ID | Test | Components | What to Verify |
|---|---|---|---|
| IT-1 | Handler → Service → Repository (GET, no preferences) | Handler, Service, Mock Repo | Full request/response cycle; empty preferences returned for unknown user |
| IT-2 | Handler → Service → Repository (GET, with preferences) | Handler, Service, Mock Repo | Full request/response cycle; stored preferences returned |
| IT-3 | Handler → Service → Repository (PUT, create) | Handler, Service, Mock Repo | Request binding, validation, upsert delegation, response mapping |
| IT-4 | Handler → Service → Repository (PUT, update with merge) | Handler, Service, Mock Repo | Merge logic: existing keys preserved, new keys added, changed keys updated |
| IT-5 | Handler → Service (PUT, validation failure) | Handler, Service | Validation errors mapped to structured HTTP error response with per-field details |
| IT-6 | Service → PostgreSQL Adapter (GET) | Service, Postgres Adapter, PostgreSQL | Real database round-trip for GET (requires running PostgreSQL) |
| IT-7 | Service → PostgreSQL Adapter (Upsert) | Service, Postgres Adapter, PostgreSQL | Real database round-trip for INSERT and UPDATE paths |
| IT-8 | Full stack: HTTP → Handler → Service → Postgres (GET) | All layers | End-to-end GET via HTTP with real database |
| IT-9 | Full stack: HTTP → Handler → Service → Postgres (PUT then GET) | All layers | End-to-end PUT followed by GET, verifying persistence |
| IT-10 | Migration creates correct schema | Migration, PostgreSQL | Table structure, column types, index existence |
Database Integration Tests (require PostgreSQL)
| ID | Test | Verification |
|---|---|---|
| DB-1 | Adapter Get returns nil for non-existent user | Get returns nil, nil (not error) |
| DB-2 | Adapter Upsert inserts new row | Row exists after Upsert, Get returns it |
| DB-3 | Adapter Upsert updates existing row | updated_at changes, preferences updated |
| DB-4 | Adapter Upsert is atomic (ON CONFLICT) | Concurrent Upserts don't produce duplicate rows |
| DB-5 | JSONB marshaling round-trip | Complex nested JSON preserved through write→read cycle |
| DB-6 | Migration is idempotent | Running migration twice doesn't error |
Performance Considerations
Latency Budgets
| Operation | Target P50 | Target P99 | Rationale |
|---|---|---|---|
| GET preferences | < 5ms | < 20ms | Single primary key lookup, no joins |
| PUT preferences (new user) | < 10ms | < 50ms | Single INSERT with conflict check |
| PUT preferences (existing user) | < 10ms | < 50ms | Single UPDATE on primary key |
Load Expectations
- Read:Write ratio: ~10:1 (preferences read on every page load, written on settings changes)
- Expected QPS: Low to moderate; preferences are per-user, not global
- Connection pool: Default 25 open / 5 idle connections should be sufficient
Benchmarks to Run
| Benchmark | What to Measure |
|---|---|
| BenchmarkServiceValidation | Time to validate known preference keys (no I/O) |
| BenchmarkJSONBMarshal | Time to marshal/unmarshal preference maps to JSONB |
| BenchmarkGetEndpoint | End-to-end GET latency with real database |
| BenchmarkPutEndpoint | End-to-end PUT latency with real database |
Scalability Notes
- Primary key index ensures O(1) lookups regardless of table size
- No table scans in either GET or PUT queries
- JSONB stored in decomposed binary format; efficient for full-object reads/writes
updated_atindex not used by current queries but supports future cleanup/analytics
Manual Verification Steps
OpenAPI Documentation
- Start the service locally (
./scripts/dev.shor directgo run) - Navigate to the Scalar docs UI (typically at
/api/preferences-api/docs) - Verify both GET and PUT endpoints are documented
- Verify request/response schemas match the spec
- Verify parameter descriptions for
user_idare present - Try executing sample requests from the docs UI
Database Migration
- Start with a clean database (no
preferencestable) - Start the service — migration should run automatically
- Verify table exists:
\d preferencesin psql - Verify columns:
user_id UUID PK,preferences JSONB,created_at TIMESTAMPTZ,updated_at TIMESTAMPTZ - Verify index:
\di idx_preferences_updated_at - Restart the service — migration should be idempotent (no errors)
End-to-End Smoke Test
- Start the service with a clean database
GET /api/preferences-api/preferences/{uuid}— expect200with empty preferencesPUT /api/preferences-api/preferences/{uuid}with{preferences: {theme: "dark"}}— expect200GET /api/preferences-api/preferences/{uuid}— expect200with{theme: "dark"}PUT /api/preferences-api/preferences/{uuid}with{preferences: {language: "fr"}}— expect200with merged{theme: "dark", language: "fr"}PUT /api/preferences-api/preferences/{uuid}with{preferences: {theme: "invalid"}}— expect400with validation detailsGET /api/preferences-api/preferences/not-a-uuid— expect400
Health Check
GET /api/preferences-api/health— expect200(verify health endpoint still works after route changes)
Acceptance Criteria Coverage Matrix
| AC | Description | Test IDs |
|---|---|---|
| AC-1 | GET returns all preferences as key-value pairs | HP-1, HP-7, HP-8 |
| AC-2 | GET returns 200 with empty preferences for no stored prefs | HP-2, EC-1 |
| AC-3 | GET returns 404 (400) for invalid UUID format | ER-1, ER-2, ER-3 |
| AC-4 | PUT creates or updates (upsert) | HP-3, HP-4, HP-16, EC-3, EC-12 |
| AC-5 | PUT accepts JSON body with preferences object | HP-3, HP-5, EC-2, ER-8, ER-9, ER-10, ER-11, ER-12 |
| AC-6 | PUT returns 200 with updated preferences | HP-3, HP-4, HP-5 |
| AC-7 | PUT validates known keys (theme, language, notifications_enabled) | HP-5, HP-10–HP-15, EC-13, ER-4, ER-5, ER-6, ER-13, ER-14 |
| AC-8 | PUT returns 400 with details on validation failure | ER-4, ER-5, ER-6, ER-7 |
| AC-9 | Unknown keys accepted and stored | HP-6, HP-7, EC-4, EC-5, EC-6, EC-9, EC-10, EC-11 |
| AC-10 | Preferences persisted in PostgreSQL | HP-8, IT-6–IT-9, DB-2, DB-3 |
| AC-11 | All responses follow {data, meta} envelope | HP-9 (verified across all HP tests) |
| AC-12 | OpenAPI spec documents both endpoints | Manual: OpenAPI verification |
| AC-13 | Handler tests cover success and error cases | HP-1–HP-16, ER-1–ER-19 |
| AC-14 | Service-layer tests cover business logic | IT-1–IT-5, HP tests via service unit tests |
| AC-15 | Database migration creates preferences table | IT-10, DB-6, Manual: Migration |