# 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 controllable `Get` and `Upsert` behavior - **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_at` index not used by current queries but supports future cleanup/analytics ## Manual Verification Steps ### OpenAPI Documentation 1. Start the service locally (`./scripts/dev.sh` or direct `go run`) 2. Navigate to the Scalar docs UI (typically at `/api/preferences-api/docs`) 3. Verify both GET and PUT endpoints are documented 4. Verify request/response schemas match the spec 5. Verify parameter descriptions for `user_id` are present 6. Try executing sample requests from the docs UI ### Database Migration 1. Start with a clean database (no `preferences` table) 2. Start the service — migration should run automatically 3. Verify table exists: `\d preferences` in psql 4. Verify columns: `user_id UUID PK`, `preferences JSONB`, `created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ` 5. Verify index: `\di idx_preferences_updated_at` 6. Restart the service — migration should be idempotent (no errors) ### End-to-End Smoke Test 1. Start the service with a clean database 2. `GET /api/preferences-api/preferences/{uuid}` — expect `200` with empty preferences 3. `PUT /api/preferences-api/preferences/{uuid}` with `{preferences: {theme: "dark"}}` — expect `200` 4. `GET /api/preferences-api/preferences/{uuid}` — expect `200` with `{theme: "dark"}` 5. `PUT /api/preferences-api/preferences/{uuid}` with `{preferences: {language: "fr"}}` — expect `200` with merged `{theme: "dark", language: "fr"}` 6. `PUT /api/preferences-api/preferences/{uuid}` with `{preferences: {theme: "invalid"}}` — expect `400` with validation details 7. `GET /api/preferences-api/preferences/not-a-uuid` — expect `400` ### Health Check 1. `GET /api/preferences-api/health` — expect `200` (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 |