diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index c261900..8df094b 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -18,7 +18,7 @@ artifacts: approved_by: user approved_at: 2026-02-09T03:15:39.312285548Z 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..3094f6c --- /dev/null +++ b/.sdlc/features/user-preferences/qa-plan.md @@ -0,0 +1,192 @@ +# 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 |