slack5-1770606136/.sdlc/features/user-preferences/qa-plan.md
rdev-worker 4ed372c740
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /create-qa-plan user-preferences
2026-02-09 03:20:13 +00:00

193 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-10HP-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-6IT-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-1HP-16, ER-1ER-19 |
| AC-14 | Service-layer tests cover business logic | IT-1IT-5, HP tests via service unit tests |
| AC-15 | Database migration creates preferences table | IT-10, DB-6, Manual: Migration |