From f18c076325bac251ccd8a1fce843d2ca9e9bea10 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 09:17:32 +0000 Subject: [PATCH] build: /create-qa-plan user-preferences --- .sdlc/features/user-preferences/manifest.yaml | 2 +- .sdlc/features/user-preferences/qa-plan.md | 172 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .sdlc/features/user-preferences/qa-plan.md diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 6664d3e..5806693 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -13,7 +13,7 @@ artifacts: status: draft path: design.md qa_plan: - status: pending + status: draft path: qa-plan.md qa_results: status: pending diff --git a/.sdlc/features/user-preferences/qa-plan.md b/.sdlc/features/user-preferences/qa-plan.md new file mode 100644 index 0000000..d9ad815 --- /dev/null +++ b/.sdlc/features/user-preferences/qa-plan.md @@ -0,0 +1,172 @@ +# 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: ""}, 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 |