# QA Plan: User Preferences API

## Test Scenarios

### Happy Path

| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| HP-1 | GET returns stored preferences for existing user | `GET /api/preferences-api/preferences/{valid_uuid}` (user has saved prefs) | 200 with full preferences object in `{data, meta}` envelope | AC-1 |
| HP-2 | GET returns default preferences for unknown user | `GET /api/preferences-api/preferences/{valid_uuid}` (no saved prefs) | 200 with defaults: theme=system, language=en, notifications={email:true, push:true, digest:weekly} | AC-2 |
| HP-3 | PUT creates preferences for new user (upsert) | `PUT /api/preferences-api/preferences/{valid_uuid}` with `{"preferences":{"theme":"dark","language":"fr","notifications":{"email":false,"push":true,"digest":"daily"}}}` | 200 with full preference set persisted | AC-3 |
| HP-4 | PUT merges partial update - theme only | `PUT` with `{"preferences":{"theme":"light"}}` on user with existing prefs | 200 with theme changed, all other fields unchanged | AC-4 |
| HP-5 | PUT merges partial update - language only | `PUT` with `{"preferences":{"language":"es"}}` on user with existing prefs | 200 with language changed, all other fields unchanged | AC-4 |
| HP-6 | PUT merges partial update - single nested notification field | `PUT` with `{"preferences":{"notifications":{"push":false}}}` on user with existing prefs | 200 with push changed, email and digest unchanged (deep merge) | AC-4 |
| HP-7 | PUT merges partial update - multiple nested notification fields | `PUT` with `{"preferences":{"notifications":{"email":false,"digest":"daily"}}}` | 200 with email and digest changed, push unchanged | AC-4 |
| HP-8 | PUT with all valid theme values | `PUT` with theme=`"light"`, `"dark"`, `"system"` separately | 200 for each valid value | AC-5 |
| HP-9 | PUT with valid language values | `PUT` with language=`"en"`, `"fr"`, `"es"`, `"de"`, `"zh"` | 200 for each valid value | AC-6 |
| HP-10 | PUT with all valid digest values | `PUT` with digest=`"daily"`, `"weekly"`, `"never"` separately | 200 for each valid value | AC-7 |
| HP-11 | PUT with valid boolean notification fields | `PUT` with email=true/false, push=true/false | 200 for all boolean combinations | AC-8 |
| HP-12 | All GET responses use {data, meta} envelope | `GET /api/preferences-api/preferences/{valid_uuid}` | Response body has `data` and `meta` top-level keys; `meta` includes `request_id` and `timestamp` | AC-11 |
| HP-13 | All PUT responses use {data, meta} envelope | `PUT /api/preferences-api/preferences/{valid_uuid}` with valid body | Response body has `data` and `meta` top-level keys | AC-11 |
| HP-14 | GET then PUT then GET roundtrip | GET defaults, PUT partial update, GET returns merged result | All three requests succeed; final GET reflects PUT changes | AC-1, AC-3, AC-4 |
| HP-15 | PUT response contains full merged preferences | `PUT` with partial preferences on existing user | Response `data` contains complete preferences (merged), not just the submitted partial | AC-4 |

### Edge Cases

| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| EC-1 | PUT with empty preferences object | `PUT` with `{"preferences":{}}` | 200 with no changes to existing preferences (all fields omitted = no merge changes) | AC-4 |
| EC-2 | PUT with empty notifications object | `PUT` with `{"preferences":{"notifications":{}}}` | 200 with no notification field changes | AC-4 |
| EC-3 | PUT twice with different partial fields | First PUT: `{"preferences":{"theme":"dark"}}`, second PUT: `{"preferences":{"language":"fr"}}` | Both fields persisted after second PUT (theme=dark, language=fr) | AC-4 |
| EC-4 | PUT overwrites previous value of same field | PUT theme=dark, then PUT theme=light | Final theme is light, other fields unchanged | AC-4 |
| EC-5 | GET with lowercase UUID | `GET /preferences/{lowercase-uuid}` | 200 (UUID parsing is case-insensitive) | AC-1, AC-10 |
| EC-6 | GET with uppercase UUID | `GET /preferences/{UPPERCASE-UUID}` | 200 (UUID parsing accepts uppercase) | AC-1, AC-10 |
| EC-7 | PUT first user then GET second user | PUT prefs for user A, then GET for user B (no prefs) | User B gets defaults, not user A's prefs | AC-2 |
| EC-8 | Concurrent PUT requests for same user | Two simultaneous PUTs for same user_id | Both succeed (INSERT ON CONFLICT is atomic); last write wins | AC-3 |
| EC-9 | Default preferences have correct values | GET for unknown user | theme="system", language="en", notifications.email=true, notifications.push=true, notifications.digest="weekly" | AC-2 |
| EC-10 | Response updated_at reflects latest change | PUT, then GET | `updated_at` in GET response >= `updated_at` from PUT response | AC-1 |
| EC-11 | PUT on user with no existing prefs merges with defaults | PUT `{"preferences":{"theme":"dark"}}` for new user | Response shows theme=dark, language=en (default), notifications all defaults | AC-3, AC-4 |

### Error Cases

| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| ER-1 | PUT with invalid theme value | `{"preferences":{"theme":"midnight"}}` | 400 Bad Request with descriptive error about invalid theme | AC-5, AC-9 |
| ER-2 | PUT with empty theme string | `{"preferences":{"theme":""}}` | 400 Bad Request (empty is not in allowed values) | AC-5, AC-9 |
| ER-3 | PUT with numeric theme value | `{"preferences":{"theme":123}}` | 400 Bad Request (type mismatch) | AC-5, AC-9 |
| ER-4 | PUT with empty language | `{"preferences":{"language":""}}` | 400 Bad Request with descriptive error about invalid language | AC-6, AC-9 |
| ER-5 | PUT with invalid digest value | `{"preferences":{"notifications":{"digest":"monthly"}}}` | 400 Bad Request with descriptive error about invalid digest | AC-7, AC-9 |
| ER-6 | PUT with non-boolean email value | `{"preferences":{"notifications":{"email":"yes"}}}` | 400 Bad Request (type mismatch on boolean field) | AC-8, AC-9 |
| ER-7 | PUT with non-boolean push value | `{"preferences":{"notifications":{"push":1}}}` | 400 Bad Request (type mismatch on boolean field) | AC-8, AC-9 |
| ER-8 | GET with non-UUID user_id | `GET /preferences/not-a-uuid` | 400 Bad Request with "invalid user_id format" | AC-10 |
| ER-9 | PUT with non-UUID user_id | `PUT /preferences/not-a-uuid` with valid body | 400 Bad Request with "invalid user_id format" | AC-10 |
| ER-10 | GET with empty user_id | `GET /preferences/` | 404 (route not matched) or 400 | AC-10 |
| ER-11 | PUT with empty request body | `PUT /preferences/{valid_uuid}` with empty body | 400 Bad Request ("request body is required") | AC-9 |
| ER-12 | PUT with malformed JSON body | `PUT` with `{not json` | 400 Bad Request ("invalid request body") | AC-9 |
| ER-13 | PUT with missing preferences key | `PUT` with `{"theme":"dark"}` (no wrapper) | 400 Bad Request (validation: preferences is required) | AC-9 |
| ER-14 | PUT with null preferences value | `PUT` with `{"preferences":null}` | 400 Bad Request (validation: preferences is required) | AC-9 |
| ER-15 | GET with user_id containing SQL injection | `GET /preferences/'; DROP TABLE user_preferences; --` | 400 Bad Request (not a valid UUID), no SQL execution | AC-10 |
| ER-16 | PUT with oversized request body | `PUT` with very large JSON payload | 400 or 413 (framework body size limit) | AC-9 |
| ER-17 | PUT with invalid theme plus valid language | `{"preferences":{"theme":"bad","language":"en"}}` | 400 Bad Request for invalid theme (validation rejects entire request) | AC-5, AC-9 |

## Test Data Requirements

### Fixtures

| Fixture | Description |
|---------|-------------|
| `validUUID` | A well-formed UUID: `550e8400-e29b-41d4-a716-446655440000` |
| `validUUID2` | A second UUID for multi-user tests: `660e8400-e29b-41d4-a716-446655440001` |
| `defaultPreferences` | `{theme: "system", language: "en", notifications: {email: true, push: true, digest: "weekly"}}` |
| `fullPreferences` | `{theme: "dark", language: "fr", notifications: {email: false, push: true, digest: "daily"}}` |
| `partialThemeOnly` | `{preferences: {theme: "light"}}` |
| `partialNotificationOnly` | `{preferences: {notifications: {push: false}}}` |

### Mocks

| Mock | Purpose | Used By |
|------|---------|---------|
| `mockPreferencesRepository` | In-memory implementation of `PreferencesRepository` port | Service unit tests, handler unit tests |
| Mock must support: `Get(ctx, userID)` returning `nil, nil` for unknown users | Enables testing default fallback behavior | Service tests |
| Mock must support: `Upsert(ctx, prefs)` storing preferences by user_id | Enables testing persistence roundtrip | Service tests |

### Test Database (Integration)

- PostgreSQL instance with `user_preferences` table created via migration `001_create_user_preferences.sql`
- Each integration test should use a clean table state (truncate between tests or use unique UUIDs)

## Integration Test Plan

### Cross-Layer Integration (Handler → Service → Repository)

| ID | Scenario | Components | Verification |
|----|----------|------------|--------------|
| IT-1 | Full GET flow with mock repo | Handler → Service → Mock Repo | HTTP 200, correct envelope, default preferences for unknown user |
| IT-2 | Full PUT flow with mock repo | Handler → Service → Mock Repo | HTTP 200, preferences persisted in mock, response contains merged result |
| IT-3 | PUT then GET roundtrip with mock repo | Handler → Service → Mock Repo | PUT creates, GET returns what was PUT |
| IT-4 | Deep merge across PUT calls | Handler → Service → Mock Repo | First PUT sets theme, second PUT sets language, GET returns both |
| IT-5 | Validation error propagation | Handler → Service → Domain validation | Domain error surfaces as HTTP 400 with descriptive message |
| IT-6 | UUID validation at handler layer | Handler (chi URL param extraction) | Invalid UUID returns 400 before reaching service layer |

### Database Integration (requires PostgreSQL)

| ID | Scenario | Components | Verification |
|----|----------|------------|--------------|
| DB-1 | Migration creates table | Migration runner → PostgreSQL | Table `user_preferences` exists with correct columns |
| DB-2 | Adapter Get for non-existent user | PostgreSQL adapter → DB | Returns `nil, nil` (not error) |
| DB-3 | Adapter Upsert creates new row | PostgreSQL adapter → DB | Row inserted; SELECT confirms data |
| DB-4 | Adapter Upsert updates existing row | PostgreSQL adapter → DB | Row updated; `updated_at` changed |
| DB-5 | JSONB serialization roundtrip | Adapter → DB → Adapter | Write preferences, read back, all fields match |
| DB-6 | Concurrent upsert doesn't error | Two goroutines upsert same user | Both succeed (ON CONFLICT handles it) |

### OpenAPI Spec Verification

| ID | Scenario | Verification |
|----|----------|--------------|
| OA-1 | OpenAPI spec exports | `--export-openapi` produces valid JSON |
| OA-2 | GET endpoint documented | Spec contains GET `/api/preferences-api/preferences/{user_id}` with response schema |
| OA-3 | PUT endpoint documented | Spec contains PUT with request body schema and response schema |
| OA-4 | Schemas match domain types | `UserPreferences`, `UpdatePreferencesRequest`, `PreferencesResponse` schemas defined |

## Performance Considerations

| Aspect | Expectation | Test Method |
|--------|-------------|-------------|
| GET latency | < 10ms for single PK lookup (excluding network) | Benchmark test: `BenchmarkGetPreferences` with seeded data |
| PUT latency | < 20ms for upsert (excluding network) | Benchmark test: `BenchmarkUpdatePreferences` |
| No N+1 queries | GET and PUT each execute exactly 1 SQL statement | Count queries in integration test (or review adapter code) |
| JSONB size | Preferences JSON < 500 bytes for standard fields | Assert serialized size in unit test |
| Concurrent writes | No deadlocks or errors under concurrent PUT | Run 10 goroutines doing PUT for same user, assert no errors |

## Manual Verification Steps

| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Start service with `./scripts/dev.sh` or equivalent | Service starts, connects to PostgreSQL, runs migration |
| 2 | `curl GET /api/preferences-api/preferences/{new-uuid}` | Returns 200 with default preferences |
| 3 | `curl PUT /api/preferences-api/preferences/{uuid}` with `{"preferences":{"theme":"dark"}}` | Returns 200 with theme=dark, other fields defaulted |
| 4 | `curl GET /api/preferences-api/preferences/{same-uuid}` | Returns 200 with theme=dark persisted |
| 5 | `curl PUT /api/preferences-api/preferences/{same-uuid}` with `{"preferences":{"language":"fr"}}` | Returns 200 with theme=dark (retained), language=fr (updated) |
| 6 | `curl GET /api/preferences-api/preferences/not-a-uuid` | Returns 400 Bad Request |
| 7 | `curl PUT /api/preferences-api/preferences/{uuid}` with `{"preferences":{"theme":"invalid"}}` | Returns 400 with descriptive error message |
| 8 | `curl GET /health` | Returns 200 (health endpoint still works) |
| 9 | Visit OpenAPI docs page (Scalar UI) | Both endpoints documented with schemas |
| 10 | Restart service, repeat GET for same UUID | Returns persisted preferences (survived restart = AC-13) |

## Acceptance Criteria Coverage Matrix

| AC | Description | Test IDs |
|----|-------------|----------|
| AC-1 | GET returns stored preferences | HP-1, HP-14, EC-10 |
| AC-2 | GET unknown user returns defaults | HP-2, EC-7, EC-9, EC-11 |
| AC-3 | PUT creates preferences (upsert) | HP-3, EC-11, IT-2, IT-3 |
| AC-4 | PUT merges partial update (deep merge) | HP-4, HP-5, HP-6, HP-7, HP-15, EC-1, EC-2, EC-3, EC-4, IT-4 |
| AC-5 | PUT validates theme | HP-8, ER-1, ER-2, ER-3, ER-17 |
| AC-6 | PUT validates language non-empty | HP-9, ER-4 |
| AC-7 | PUT validates digest | HP-10, ER-5 |
| AC-8 | PUT validates notification booleans | HP-11, ER-6, ER-7 |
| AC-9 | Invalid values return 400 with details | ER-1 through ER-17 |
| AC-10 | Invalid user_id returns 400 | ER-8, ER-9, ER-10, ER-15, IT-6 |
| AC-11 | Standard {data, meta} envelope | HP-12, HP-13 |
| AC-12 | OpenAPI spec documents endpoints | OA-1, OA-2, OA-3, OA-4 |
| AC-13 | Persisted in PostgreSQL | DB-1 through DB-5, Manual step 10 |
| AC-14 | Migration creates table | DB-1 |
| AC-15 | Hexagonal architecture | IT-1 through IT-5 (layer isolation verified by mock injection) |
| AC-16 | Service and handler unit tests | All HP, EC, ER tests implemented as unit tests |
| AC-17 | Example scaffolding removed | Manual code review; no example.go files remain |
