# QA Plan: User Preferences API

## Test Scenarios

### Happy Path

| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| HP-1 | GET returns saved preferences | GET `/preferences/{user_id}` with valid auth token for user `usr_abc123` who has saved preferences `{theme: "dark", language: "en", notifications: {email: true, push: true, sms: false}}` | 200 with `{data: {user_id: "usr_abc123", preferences: {theme: "dark", language: "en", notifications: {...}}, updated_at: "<timestamp>"}, meta: {...}}` | AC-1 |
| HP-2 | GET returns defaults for new user | GET `/preferences/{user_id}` with valid auth token for user with no saved preferences | 200 with `{data: {user_id, preferences: {theme: "system", language: "en", notifications: {email: true, push: true, sms: false}}, updated_at: "0001-01-01T00:00:00Z"}, meta: {...}}` | AC-2 |
| HP-3 | PUT creates preferences for new user | PUT `/preferences/{user_id}` with `{preferences: {theme: "dark", language: "fr", notifications: {email: false, push: true, sms: true}}}` | 200 with saved preferences in `{data, meta}` envelope | AC-3 |
| HP-4 | PUT replaces existing preferences | PUT `/preferences/{user_id}` for user who already has saved preferences, with new values | 200 with fully replaced preferences (old values gone, new values present) | AC-3, AC-9 |
| HP-5 | PUT with valid theme "light" | PUT with `{preferences: {theme: "light", ...}}` | 200, theme saved as "light" | AC-4 |
| HP-6 | PUT with valid theme "dark" | PUT with `{preferences: {theme: "dark", ...}}` | 200, theme saved as "dark" | AC-4 |
| HP-7 | PUT with valid theme "system" | PUT with `{preferences: {theme: "system", ...}}` | 200, theme saved as "system" | AC-4 |
| HP-8 | PUT with language at max length (10 chars) | PUT with `{preferences: {language: "zh-Hant-TW", ...}}` (exactly 10 chars) | 200, language saved | AC-4 |
| HP-9 | PUT then GET round-trip | PUT preferences, then GET same user's preferences | GET returns exactly what was PUT | AC-1, AC-3 |
| HP-10 | PUT with unknown keys preserves them | PUT with `{preferences: {theme: "dark", language: "en", notifications: {...}, custom_key: "custom_value"}}` | 200, and subsequent GET returns the `custom_key` preserved | AC-3 (extensibility requirement from spec) |
| HP-11 | OpenAPI spec accessible | GET `/api/preferences-api/docs` or spec endpoint | Returns OpenAPI spec with both endpoints documented | AC-10 |

### Edge Cases

| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| EC-1 | PUT with empty theme (uses default) | PUT with `{preferences: {theme: "", language: "en", notifications: {...}}}` | 200 if empty theme is treated as valid (or 400 if validated as invalid) — domain `Validate()` allows empty theme per design | AC-4 |
| EC-2 | PUT with language exactly at boundary | PUT with `{preferences: {language: "1234567890"}}` (10 chars) | 200, language saved | AC-4 |
| EC-3 | PUT with language 11 chars (boundary violation) | PUT with `{preferences: {language: "12345678901"}}` (11 chars) | 400 Bad Request | AC-4, AC-5 |
| EC-4 | PUT with multibyte language tag | PUT with `{preferences: {language: "日本語テスト6789"}}` (10 runes, but more bytes) | 200, validation uses rune count not byte count | AC-4 |
| EC-5 | PUT with multibyte language exceeding 10 runes | PUT with `{preferences: {language: "日本語テスト67890"}}` (11 runes) | 400 Bad Request | AC-4, AC-5 |
| EC-6 | GET then PUT then GET consistency | GET defaults → PUT new values → GET again | Second GET returns PUT values, not defaults | AC-1, AC-2, AC-3 |
| EC-7 | PUT replaces all preferences (full replacement) | First PUT with `{theme: "dark", language: "fr", notifications: {email: false, push: false, sms: true}}`, then PUT with only `{theme: "light"}` | Second GET returns `{theme: "light"}` with other fields at Go zero-values (not merged with first PUT) — full replacement semantics | AC-3 |
| EC-8 | PUT with all notification channels disabled | PUT with `{preferences: {notifications: {email: false, push: false, sms: false}}}` | 200, all notifications disabled | AC-3 |
| EC-9 | PUT idempotency - same data twice | PUT same preferences twice for same user | Both return 200, second is a no-op upsert, data unchanged | AC-9 |
| EC-10 | GET with user_id containing special characters | GET `/preferences/usr_abc-123.456` | 200 or handled gracefully (user_id is TEXT, no format constraint) | AC-1 |
| EC-11 | PUT with empty preferences object | PUT with `{preferences: {}}` | 200 with default-like values (empty fields resolve to Go zero-values) | AC-3 |

### Error Cases

| ID | Scenario | Input | Expected Output | Derived From |
|----|----------|-------|-----------------|--------------|
| ER-1 | PUT with invalid theme value | PUT with `{preferences: {theme: "blue"}}` | 400 Bad Request: `"invalid theme: must be one of light, dark, system"` | AC-4, AC-5 |
| ER-2 | PUT with language exceeding max length | PUT with `{preferences: {language: "verylonglanguagetag"}}` (> 10 chars) | 400 Bad Request: `"invalid language: must be at most 10 characters"` | AC-4, AC-5 |
| ER-3 | GET without authentication | GET `/preferences/{user_id}` with no auth header | 401 Unauthorized | AC-6 |
| ER-4 | PUT without authentication | PUT `/preferences/{user_id}` with no auth header | 401 Unauthorized | AC-6 |
| ER-5 | GET with expired JWT token | GET with expired Bearer token | 401 Unauthorized | AC-6 |
| ER-6 | GET with invalid JWT token | GET with malformed Bearer token | 401 Unauthorized | AC-6 |
| ER-7 | GET another user's preferences | GET `/preferences/other_user` with auth token for `usr_abc123` | 403 Forbidden: `"access denied: can only access own preferences"` | AC-7 |
| ER-8 | PUT another user's preferences | PUT `/preferences/other_user` with auth token for `usr_abc123` | 403 Forbidden: `"access denied: can only modify own preferences"` | AC-7 |
| ER-9 | PUT with malformed JSON body | PUT with `{invalid json` | 400 Bad Request | AC-5 |
| ER-10 | PUT with missing preferences field | PUT with `{}` (no `preferences` key) | 400 Bad Request (validation: `preferences` is required) | AC-5 |
| ER-11 | PUT with wrong content type | PUT with `Content-Type: text/plain` and valid body | 400 Bad Request | AC-5 |
| ER-12 | PUT with null preferences | PUT with `{preferences: null}` | 400 Bad Request (validation: `preferences` is required) | AC-5 |
| ER-13 | DELETE method not allowed | DELETE `/preferences/{user_id}` | 405 Method Not Allowed (not implemented per spec) | Out of scope |
| ER-14 | PATCH method not allowed | PATCH `/preferences/{user_id}` | 405 Method Not Allowed (not implemented per spec) | Out of scope |
| ER-15 | PUT with oversized payload | PUT with a preferences JSON exceeding 64KB | 400 Bad Request (payload size limit per design) | AC-5, Design security |

## Test Data Requirements

### Fixtures
- **Default preferences fixture**: `{theme: "system", language: "en", notifications: {email: true, push: true, sms: false}}` — used to verify defaults in HP-2
- **Custom preferences fixture**: `{theme: "dark", language: "fr", notifications: {email: false, push: true, sms: true}}` — used for PUT/GET round-trips
- **Preferences with unknown keys**: `{theme: "dark", language: "en", notifications: {...}, custom_key: "value"}` — used for extensibility tests

### Auth Tokens
- **Valid JWT for `usr_abc123`**: Token with `sub: "usr_abc123"` claim, signed with test JWT secret
- **Valid JWT for `usr_other`**: Token with `sub: "usr_other"` claim — used for cross-user authorization tests
- **Expired JWT**: Token with past expiration time
- **Malformed JWT**: Invalid token string (e.g., `"not.a.valid.jwt"`)

### Database Setup
- **Clean database**: Migrations run, `user_preferences` table exists but is empty
- **Seeded database**: One row for `usr_abc123` with known preferences values

### Mocks
- **Mock `PreferencesRepository`**: In-memory implementation of `port.PreferencesRepository` with `Get` and `Upsert` for unit testing service and handler layers
- **Mock `PreferencesService`**: For handler-level unit tests that isolate HTTP concern from business logic
- **No-op logger**: `logging.Nop()` for all unit tests

## Integration Test Plan

### Cross-Layer Integration

| ID | Test | Components | Description |
|----|------|------------|-------------|
| INT-1 | Full PUT → GET round-trip | Handler → Service → Repository (mock) | Verify data flows correctly from HTTP request through all layers and back |
| INT-2 | PUT upsert behavior | Handler → Service → Repository (mock) | First PUT creates, second PUT replaces — verify via two PUTs then GET |
| INT-3 | Auth middleware + handler authorization | auth.Middleware → Handler | Verify JWT extraction → user matching → 403 on mismatch |
| INT-4 | Default preferences on missing data | Handler → Service → Repository (mock returns ErrNotFound) | GET for non-existent user returns defaults, not 404 |
| INT-5 | Validation error propagation | Handler → Service → Domain.Validate() | Invalid theme → domain error → service → handler → 400 with message |
| INT-6 | Request binding + domain validation | app.BindAndValidate → Handler → Service | Missing required fields rejected at binding; invalid values rejected at domain |

### Database Integration (requires PostgreSQL)

| ID | Test | Description |
|----|------|-------------|
| DB-1 | Migration runs cleanly | `CREATE TABLE IF NOT EXISTS user_preferences` succeeds on empty database |
| DB-2 | Migration is idempotent | Running migration twice doesn't error |
| DB-3 | JSONB round-trip | Insert preferences as JSONB, read back, verify all fields match |
| DB-4 | Upsert creates new row | INSERT for new `user_id` creates row |
| DB-5 | Upsert updates existing row | INSERT ON CONFLICT for existing `user_id` updates `preferences` and `updated_at` |
| DB-6 | Unknown keys survive JSONB round-trip | Preferences with extra keys stored and retrieved correctly |
| DB-7 | Get returns ErrPreferencesNotFound for missing user | SELECT with non-existent `user_id` returns proper domain error |

### Route Integration

| ID | Test | Description |
|----|------|-------------|
| RT-1 | Routes use brace syntax | Verify `{user_id}` parameter extraction works (not colon syntax) |
| RT-2 | Health endpoint remains unprotected | GET `/api/preferences-api/health` returns 200 without auth |
| RT-3 | Preferences endpoints require auth | GET and PUT under `/api/preferences-api/preferences/` return 401 without auth (when `AUTH_ENABLED=true`) |

## Performance Considerations

### Load Expectations
- **Read-heavy workload**: Preferences fetched on every page load / session start
- **Write frequency**: Low — users rarely change preferences
- **Expected latency**: GET < 10ms (single PK lookup), PUT < 20ms (single upsert)

### Benchmarks to Consider
- **GET throughput**: Benchmark `GetPreferences` with database connection — should handle 1000+ req/s per instance
- **PUT throughput**: Benchmark `SetPreferences` — should handle 500+ req/s per instance
- **Concurrent access**: Verify no data corruption under concurrent GET/PUT for same user
- **Connection pool**: Verify pool settings (25 open, 5 idle) are adequate under load

### Size Limits
- **Payload size**: Preferences JSON should be < 1KB typical; enforce 64KB max per design
- **JSONB storage**: Monitor JSONB column size; no practical limit but track average

## Manual Verification Steps

### Startup Verification
1. Start service with `DATABASE_URL` pointing to running PostgreSQL
2. Verify health check at `GET /api/preferences-api/health` returns `200`
3. Verify migration created `user_preferences` table (check via `psql`)

### API Manual Walkthrough
1. **GET without auth** → verify 401 Unauthorized
2. **GET with valid auth, no prefs saved** → verify 200 with default preferences
3. **PUT with valid auth and valid body** → verify 200 with saved preferences
4. **GET same user** → verify 200 with preferences matching the PUT
5. **PUT with invalid theme** → verify 400 with descriptive error message
6. **GET as different user** → verify 403 Forbidden

### OpenAPI Spec Verification
1. Access the API docs endpoint (Scalar UI)
2. Verify both GET and PUT endpoints are documented
3. Verify request/response schemas match implementation
4. Verify auth requirements are documented
5. Verify error responses (400, 401, 403) are documented

### Database Verification
1. After PUT, query `user_preferences` table directly — verify row exists with correct `user_id`, `preferences` JSONB, `created_at`, `updated_at`
2. After second PUT, verify `updated_at` changed but `created_at` preserved
3. Verify unknown keys in preferences JSONB are preserved in database

## Acceptance Criteria Coverage Matrix

| AC | Description | Test IDs |
|----|-------------|----------|
| AC-1 | GET returns preferences with 200 in `{data, meta}` envelope | HP-1, HP-9 |
| AC-2 | GET returns defaults when no preferences saved | HP-2, EC-6 |
| AC-3 | PUT creates or replaces preferences, returning 200 | HP-3, HP-4, EC-7, EC-9 |
| AC-4 | PUT validates known keys (theme enum, language length) | HP-5, HP-6, HP-7, HP-8, EC-1, EC-2, EC-3, EC-4, EC-5, ER-1, ER-2 |
| AC-5 | PUT with invalid input returns 400 with descriptive error | ER-1, ER-2, ER-9, ER-10, ER-11, ER-12, ER-15 |
| AC-6 | Both endpoints require authentication | ER-3, ER-4, ER-5, ER-6 |
| AC-7 | User can only access own preferences (403 otherwise) | ER-7, ER-8 |
| AC-8 | Preferences persisted to PostgreSQL, survive restarts | DB-3, DB-4, DB-5, DB-6 |
| AC-9 | Database uses upsert pattern | HP-4, EC-9, DB-4, DB-5 |
| AC-10 | OpenAPI spec updated with both endpoints | HP-11, RT-1 |
| AC-11 | Follows hexagonal architecture patterns | INT-1 through INT-6 |
| AC-12 | Unit tests cover handler, service, domain layers | All HP, EC, ER tests at respective layers |
| AC-13 | URL parameters use brace syntax `{user_id}` | RT-1 |
