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 stored values | GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000 (user has theme=dark, language=fr stored) |
200 with {"data": {"user_id": "550e...", "preferences": {"theme": "dark", "language": "fr", "notifications_enabled": true}}, "meta": {...}} |
AC-1, AC-9 |
| HP-2 | GET preferences for new user returns defaults | GET /api/preferences-api/preferences/660e8400-e29b-41d4-a716-446655440000 (no stored prefs) |
200 with {"data": {"user_id": "660e...", "preferences": {"theme": "system", "language": "en", "notifications_enabled": true}}, "meta": {...}} |
AC-2, AC-6 |
| HP-3 | PUT creates preferences for new user | PUT /api/preferences-api/preferences/550e8400-... with {"preferences": {"theme": "dark"}} |
200 with full merged preferences (theme=dark, others at defaults) |
AC-3 |
| HP-4 | PUT updates existing preferences | PUT /api/preferences-api/preferences/550e8400-... with {"preferences": {"language": "fr"}} (user already has theme=dark) |
200 with merged result: theme=dark, language=fr, notifications_enabled=true |
AC-3 |
| HP-5 | PUT is idempotent | Call PUT with {"preferences": {"theme": "dark"}} twice for the same user |
Both return 200 with identical response body | AC-4 |
| HP-6 | PUT multiple keys at once | PUT with {"preferences": {"theme": "light", "language": "de", "notifications_enabled": false}} |
200 with all three keys updated | AC-3, AC-6 |
| HP-7 | PUT with boolean notifications_enabled=true | PUT with {"preferences": {"notifications_enabled": true}} |
200 with notifications_enabled: true in response (boolean, not string) |
AC-6 |
| HP-8 | PUT with boolean notifications_enabled=false | PUT with {"preferences": {"notifications_enabled": false}} |
200 with notifications_enabled: false in response |
AC-6 |
| HP-9 | GET returns typed values (boolean for notifications) | GET after storing notifications_enabled=true |
Response has "notifications_enabled": true (boolean), not "true" (string) |
AC-6, AC-9 |
| HP-10 | Valid theme values accepted | PUT with theme = "light", "dark", or "system" separately |
All return 200 | AC-6 |
| HP-11 | Valid BCP 47 language tags accepted | PUT with language = "en", "fr", "zh-Hans", "en-US" |
All return 200 | AC-6 |
| HP-12 | OpenAPI docs endpoint accessible | GET /api/preferences-api/docs or configured docs path |
Returns OpenAPI/Scalar documentation page | AC-10 |
| HP-13 | Health endpoint still works | GET /api/preferences-api/health |
200 OK | AC-13 |
Edge Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| EC-1 | PUT with empty preferences object | PUT with {"preferences": {}} |
200 with current/default preferences unchanged | AC-3 |
| EC-2 | PUT partial update preserves unmentioned keys | User has theme=dark. PUT with {"preferences": {"language": "de"}} |
200 with theme=dark preserved, language=de updated, notifications_enabled=true (default) |
AC-3 |
| EC-3 | GET for different user_ids returns independent data | Set theme=dark for user A, theme=light for user B |
GET user A returns dark, GET user B returns light |
AC-1, AC-5 |
| EC-4 | PUT immediately followed by GET returns consistent data | PUT to set theme=dark, then GET |
GET returns theme=dark |
AC-3, AC-4, AC-5 |
| EC-5 | Language tag with subtags | PUT with language = "zh-Hans-CN" |
200 accepted (valid BCP 47) | AC-6 |
| EC-6 | Boundary language tag - minimum length | PUT with language = "en" |
200 accepted | AC-6 |
| EC-7 | Boundary language tag - 3-letter code | PUT with language = "deu" |
200 accepted (3-letter ISO 639) | AC-6 |
| EC-8 | user_id as different valid UUID formats | GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000 |
200 (standard UUID with hyphens accepted) | AC-8 |
| EC-9 | Default values match specification | GET for a brand new user |
Exactly: theme=system, language=en, notifications_enabled=true |
AC-2, AC-6 |
| EC-10 | PUT same value as default | PUT with {"preferences": {"theme": "system"}} |
200 stored successfully, subsequent GET returns system |
AC-3 |
Error Cases
| ID | Scenario | Input | Expected Output | Derived From |
|---|---|---|---|---|
| ER-1 | PUT with unknown preference key | PUT with {"preferences": {"font_size": "14"}} |
400 Bad Request with message mentioning font_size |
AC-7 |
| ER-2 | PUT with mix of valid and unknown keys | PUT with {"preferences": {"theme": "dark", "font_size": "14"}} |
400 Bad Request (reject entire request, not partial) | AC-7 |
| ER-3 | GET with invalid user_id (not UUID) | GET /api/preferences-api/preferences/not-a-uuid |
400 Bad Request | AC-8 |
| ER-4 | PUT with invalid user_id (not UUID) | PUT /api/preferences-api/preferences/abc123 with valid body |
400 Bad Request | AC-8 |
| ER-5 | GET with empty user_id | GET /api/preferences-api/preferences/ |
404 (route not matched) or 400 | AC-8 |
| ER-6 | PUT with invalid theme value | PUT with {"preferences": {"theme": "midnight"}} |
400 Bad Request | AC-6 |
| ER-7 | PUT with invalid language tag | PUT with {"preferences": {"language": "123"}} |
400 Bad Request | AC-6 |
| ER-8 | PUT with invalid notifications_enabled value | PUT with {"preferences": {"notifications_enabled": "maybe"}} |
400 Bad Request | AC-6 |
| ER-9 | PUT with malformed JSON body | PUT with {invalid json |
400 Bad Request | AC-3 |
| ER-10 | PUT with wrong content type | PUT with Content-Type: text/plain |
400 Bad Request | AC-3 |
| ER-11 | PUT with null preferences | PUT with {"preferences": null} |
400 Bad Request | AC-3 |
| ER-12 | PUT with missing preferences field | PUT with {} |
400 Bad Request or 200 with no changes (implementation-dependent) | AC-3 |
| ER-13 | PUT with wrong value type for theme | PUT with {"preferences": {"theme": 123}} |
400 Bad Request (type mismatch) | AC-6 |
| ER-14 | PUT with wrong value type for notifications_enabled | PUT with {"preferences": {"notifications_enabled": "true"}} (string instead of bool) |
400 Bad Request or handled gracefully | AC-6 |
| ER-15 | PUT with empty string theme | PUT with {"preferences": {"theme": ""}} |
400 Bad Request | AC-6 |
| ER-16 | PUT with empty string language | PUT with {"preferences": {"language": ""}} |
400 Bad Request | AC-6 |
| ER-17 | user_id with SQL injection attempt | GET /api/preferences-api/preferences/'; DROP TABLE user_preferences; -- |
400 Bad Request (UUID validation rejects) | AC-8 |
| ER-18 | PUT with extremely long value | PUT with {"preferences": {"language": " + 10000 char string + "}} |
400 Bad Request (fails BCP 47 validation) | AC-6 |
Test Data Requirements
Fixtures
| Fixture | Description | Usage |
|---|---|---|
valid-uuid-1 |
550e8400-e29b-41d4-a716-446655440000 |
Primary test user for CRUD operations |
valid-uuid-2 |
660e8400-e29b-41d4-a716-446655440001 |
Secondary user for isolation tests |
no-prefs-uuid |
770e8400-e29b-41d4-a716-446655440002 |
User with no stored preferences (defaults only) |
invalid-uuid |
not-a-uuid |
For UUID validation error tests |
Mock Repository (Unit Tests)
- Service tests: Mock
PreferenceRepositoryinterface with configurable return values- Returns empty slice for users with no stored preferences
- Returns partial preference rows for partial-stored tests
- Returns all preference rows for fully-stored tests
- Returns error for database failure simulation
Database State (Integration Tests)
- Clean
user_preferencestable before each test - Seed data: insert known rows for
valid-uuid-1withtheme=dark,language=fr - Leave
no-prefs-uuidwith no rows (test default behavior)
Integration Test Plan
Component Integration Tests
| ID | Test | Components Under Test | Verification |
|---|---|---|---|
| IT-1 | GET flows through handler → service → repo → DB | All layers | Response matches expected defaults for new user |
| IT-2 | PUT flows through handler → service → repo → DB and persists | All layers | Subsequent GET returns updated values |
| IT-3 | PUT upsert updates existing rows without duplication | Service → adapter → DB | SELECT count shows no duplicate (user_id, key) rows |
| IT-4 | Migration creates table with correct schema | Migration runner → DB | Table exists, columns have correct types, PK and index present |
| IT-5 | Database connection failure handled gracefully | Handler → service → adapter | 500 Internal Server Error (not panic, not leak connection details) |
| IT-6 | Multiple concurrent PUT requests for same user | Handler → service → adapter → DB | No deadlocks, final state is consistent, ON CONFLICT handles races |
Cross-Boundary Tests
| ID | Test | Boundary | Verification |
|---|---|---|---|
| CB-1 | Domain validation errors map to HTTP 400 | Domain → Service → Handler | ErrUnknownPreferenceKey → 400, ErrInvalidPreferenceValue → 400, ErrInvalidUserID → 400 |
| CB-2 | Boolean serialization round-trip | DB (TEXT) → Domain → API (JSON) | "true" in DB → true (boolean) in JSON response |
| CB-3 | OpenAPI spec matches actual API behavior | Spec → Handler | All documented endpoints respond as specified |
| CB-4 | Auth middleware integration (when enabled) | Auth middleware → Handler | Unauthenticated requests rejected when AUTH_ENABLED=true |
Unit Test Plan
Domain Layer Tests (internal/domain/)
| ID | Test | Input | Expected |
|---|---|---|---|
| UT-D1 | DefaultPreferences returns all keys | None | Map with theme=system, language=en, notifications_enabled=true |
| UT-D2 | ValidateKey accepts known keys | "theme", "language", "notifications_enabled" |
nil error |
| UT-D3 | ValidateKey rejects unknown keys | "font_size", "", "THEME" |
ErrUnknownPreferenceKey |
| UT-D4 | ValidateValue for theme - valid | "light", "dark", "system" |
nil error |
| UT-D5 | ValidateValue for theme - invalid | "midnight", "", "123" |
ErrInvalidPreferenceValue |
| UT-D6 | ValidateValue for language - valid BCP 47 | "en", "fr", "zh-Hans", "en-US" |
nil error |
| UT-D7 | ValidateValue for language - invalid | "123", "", "a", "toolongstring" |
ErrInvalidPreferenceValue |
| UT-D8 | ValidateValue for notifications_enabled - valid | "true", "false" |
nil error |
| UT-D9 | ValidateValue for notifications_enabled - invalid | "yes", "1", "" |
ErrInvalidPreferenceValue |
| UT-D10 | MergeWithDefaults fills missing keys | {theme: "dark"} |
{theme: "dark", language: "en", notifications_enabled: "true"} |
| UT-D11 | MergeWithDefaults preserves all stored | All 3 keys provided | All stored values preserved |
| UT-D12 | MergeWithDefaults with empty input | {} |
All defaults |
| UT-D13 | SerializeForResponse converts booleans | {notifications_enabled: "true"} |
{notifications_enabled: true} (bool) |
| UT-D14 | SerializeForResponse preserves strings | {theme: "dark"} |
{theme: "dark"} (string) |
| UT-D15 | ValidateUserID accepts valid UUID | "550e8400-e29b-41d4-a716-446655440000" |
nil error |
| UT-D16 | ValidateUserID rejects non-UUID | "not-a-uuid", "", "123" |
ErrInvalidUserID |
Service Layer Tests (internal/service/)
| ID | Test | Setup | Input | Expected |
|---|---|---|---|---|
| UT-S1 | GetPreferences - no stored prefs | Mock returns empty slice | Valid UUID | Result with all defaults |
| UT-S2 | GetPreferences - partial stored | Mock returns [{theme, "dark"}] |
Valid UUID | theme=dark, others defaulted |
| UT-S3 | GetPreferences - all stored | Mock returns all 3 rows | Valid UUID | All stored values in result |
| UT-S4 | GetPreferences - invalid user_id | N/A | "bad-id" |
ErrInvalidUserID |
| UT-S5 | GetPreferences - repo error | Mock returns error | Valid UUID | Error propagated |
| UT-S6 | UpdatePreferences - single key | Mock Upsert succeeds | {theme: "dark"} |
Upsert called once, returns merged result |
| UT-S7 | UpdatePreferences - multiple keys | Mock Upsert succeeds | {theme: "dark", language: "fr"} |
Upsert called twice |
| UT-S8 | UpdatePreferences - unknown key | N/A | {font_size: "14"} |
ErrUnknownPreferenceKey |
| UT-S9 | UpdatePreferences - invalid value | N/A | {theme: "midnight"} |
ErrInvalidPreferenceValue |
| UT-S10 | UpdatePreferences - invalid user_id | N/A | user_id="bad" |
ErrInvalidUserID |
| UT-S11 | UpdatePreferences - boolean handling | Mock Upsert succeeds | {notifications_enabled: false} |
Upsert called with "false" string |
| UT-S12 | UpdatePreferences - repo error on upsert | Mock Upsert returns error | Valid input | Error propagated |
Handler Layer Tests (internal/api/handlers/)
| ID | Test | Method/Path | Request | Expected Status | Expected Body |
|---|---|---|---|---|---|
| UT-H1 | GET success with defaults | GET /preferences/{uuid} |
None | 200 | {data: {user_id, preferences: defaults}, meta} |
| UT-H2 | GET success with stored values | GET /preferences/{uuid} |
None (mock returns data) | 200 | {data: {user_id, preferences: stored+defaults}, meta} |
| UT-H3 | GET invalid user_id | GET /preferences/bad-id |
None | 400 | Error body |
| UT-H4 | PUT success | PUT /preferences/{uuid} |
{"preferences":{"theme":"dark"}} |
200 | Full merged prefs |
| UT-H5 | PUT unknown key | PUT /preferences/{uuid} |
{"preferences":{"font_size":"14"}} |
400 | Error mentioning unknown key |
| UT-H6 | PUT invalid value | PUT /preferences/{uuid} |
{"preferences":{"theme":"bad"}} |
400 | Error mentioning invalid value |
| UT-H7 | PUT invalid user_id | PUT /preferences/bad-id |
Valid body | 400 | Error body |
| UT-H8 | PUT malformed JSON | PUT /preferences/{uuid} |
{broken json |
400 | Error body |
| UT-H9 | Response envelope shape | GET /preferences/{uuid} |
None | 200 | Has top-level data and meta keys |
Performance Considerations
Latency Expectations
| Operation | Expected P50 | Expected P99 | Notes |
|---|---|---|---|
| GET preferences | < 5ms | < 20ms | Single indexed SELECT by user_id |
| PUT single key | < 10ms | < 50ms | Single INSERT ON CONFLICT |
| PUT all 3 keys | < 15ms | < 75ms | 3 sequential INSERT ON CONFLICT queries |
Load Expectations
- Expected load: Low frequency (once per page load, once per settings save)
- No caching required at current scale
- Connection pool defaults (25 max, 5 idle) are sufficient
Benchmarks to Run
| ID | Benchmark | Target |
|---|---|---|
| PF-1 | GET preferences with populated data | Baseline latency |
| PF-2 | PUT single preference key | Baseline latency |
| PF-3 | Concurrent GET requests (10 goroutines) | No deadlocks, stable latency |
| PF-4 | Concurrent PUT requests for same user (10 goroutines) | No deadlocks, data consistent after all complete |
Manual Verification Steps
-
Database Migration: After deployment, verify
user_preferencestable exists with correct schema:\d user_preferencesConfirm columns, types, primary key, and index match the migration.
-
Default Values: Call
GET /api/preferences-api/preferences/{new-uuid}for a UUID with no data. Verify response contains all three defaults:theme=system,language=en,notifications_enabled=true. -
Round-Trip Persistence: PUT a preference, restart the service, GET the same user. Verify data survived the restart (stored in PostgreSQL, not in-memory).
-
OpenAPI Spec Accuracy: Open the Scalar docs page and verify both endpoints are documented with correct schemas and example responses.
-
Error Messages: Send a PUT with an unknown key (e.g.,
font_size) and verify the 400 response includes the offending key name in the error message. -
Example Scaffold Removal: Verify
GET /api/preferences-api/examplesreturns 404 (old endpoints removed). -
Health Check: Verify
GET /api/preferences-api/healthstill returns 200 OK after all changes.
Acceptance Criteria Coverage Matrix
| AC# | Acceptance Criterion | Test Scenarios |
|---|---|---|
| AC-1 | GET returns all preferences for user | HP-1, HP-9, EC-3 |
| AC-2 | GET for user with no prefs returns defaults | HP-2, EC-9 |
| AC-3 | PUT creates or updates preferences | HP-3, HP-4, HP-6, EC-1, EC-2, EC-10 |
| AC-4 | PUT is idempotent | HP-5 |
| AC-5 | Preferences stored in PostgreSQL | HP-1, EC-3, EC-4, IT-2, IT-3 |
| AC-6 | Supported keys with validation | HP-7, HP-8, HP-9, HP-10, HP-11, EC-5, EC-6, EC-7, EC-9, ER-6, ER-7, ER-8, ER-13, ER-14, ER-15, ER-16 |
| AC-7 | Unknown keys rejected with 400 | ER-1, ER-2 |
| AC-8 | user_id validated as UUID | ER-3, ER-4, ER-5, ER-17, EC-8 |
| AC-9 | All responses use {data, meta} envelope | HP-1, HP-2, UT-H9 |
| AC-10 | OpenAPI spec updated | HP-12, CB-3 |
| AC-11 | Migration creates user_preferences table | IT-4 |
| AC-12 | Example endpoints removed | Manual step 6 |
| AC-13 | Hexagonal architecture followed | All unit tests (mock-based layers) |
| AC-14 | Unit tests cover service logic | UT-S1 through UT-S12 |
| AC-15 | Handler tests cover HTTP layer | UT-H1 through UT-H9 |