# 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 |
