# 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 `PreferenceRepository` interface 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_preferences` table before each test
- Seed data: insert known rows for `valid-uuid-1` with `theme=dark`, `language=fr`
- Leave `no-prefs-uuid` with 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

1. **Database Migration**: After deployment, verify `user_preferences` table exists with correct schema:
   ```sql
   \d user_preferences
   ```
   Confirm columns, types, primary key, and index match the migration.

2. **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`.

3. **Round-Trip Persistence**: PUT a preference, restart the service, GET the same user. Verify data survived the restart (stored in PostgreSQL, not in-memory).

4. **OpenAPI Spec Accuracy**: Open the Scalar docs page and verify both endpoints are documented with correct schemas and example responses.

5. **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.

6. **Example Scaffold Removal**: Verify `GET /api/preferences-api/examples` returns 404 (old endpoints removed).

7. **Health Check**: Verify `GET /api/preferences-api/health` still 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 |
