diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index ba966b3..923348d 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..14bebe3 --- /dev/null +++ b/.sdlc/features/user-preferences/qa-plan.md @@ -0,0 +1,229 @@ +# QA Plan: User Preferences API + +## Test Scenarios + +### Happy Path + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| HP-1 | GET returns stored preferences | GET `/preferences/{user_id}` with valid auth token matching user_id | `200 OK` with `{data: {user_id, preferences: {theme, language, notifications}, updated_at}, meta}` | AC-1 | +| HP-2 | PUT creates preferences on first call (upsert) | PUT `/preferences/{user_id}` with `{"preferences": {"theme": "dark"}}`, no prior preferences | `200 OK` with full preferences (defaults merged: language=en, notifications defaults) | AC-3, AC-5 | +| HP-3 | PUT merges with existing preferences (shallow merge) | PUT with `{"preferences": {"theme": "light"}}` when user already has `{theme: "dark", language: "es"}` | `200 OK` with `{theme: "light", language: "es", ...}` — only theme changed | AC-4, AC-5 | +| HP-4 | PUT replaces notifications object entirely when provided | PUT with `{"preferences": {"notifications": {"email": false}}}` when user has `{email: true, push: true, digest: "weekly"}` | `200 OK` — notifications sub-fields not provided get defaults filled in before validation | AC-4 | +| HP-5 | PUT returns full merged preference set | PUT with partial update | `200 OK` response body contains ALL preference keys, not just the ones sent | AC-5 | +| HP-6 | Admin can access another user's preferences (GET) | GET `/preferences/{other_user_id}` with admin role JWT | `200 OK` with that user's preferences | AC-9 | +| HP-7 | Admin can update another user's preferences (PUT) | PUT `/preferences/{other_user_id}` with admin role JWT and valid body | `200 OK` with merged preferences | AC-9 | +| HP-8 | GET with valid UUID format accepted | GET `/preferences/550e8400-e29b-41d4-a716-446655440000` | UUID parsed successfully, request proceeds to service layer | AC-10 | +| HP-9 | PUT with all valid preference values | PUT with `{"preferences": {"theme": "system", "language": "fr", "notifications": {"email": true, "push": false, "digest": "daily"}}}` | `200 OK` — all values accepted and stored | AC-13 | +| HP-10 | Consecutive PUTs accumulate preferences correctly | PUT `{theme: "dark"}`, then PUT `{language: "es"}` | Final GET returns `{theme: "dark", language: "es", ...defaults}` — both updates persisted | AC-4 | + +### Edge Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| EC-1 | GET for user with no preferences | GET `/preferences/{user_id}` where no PUT has ever been made | `404 Not Found` with `{error: {code: "NOT_FOUND", message: "preferences not found"}, meta}` | AC-2 | +| EC-2 | PUT with empty preferences object | PUT with `{"preferences": {}}` (no keys) | `200 OK` — creates defaults if first call, or returns existing unchanged | AC-3, AC-4 | +| EC-3 | PUT with all fields provided (no merge needed) | PUT with every preference key explicitly set | `200 OK` — all fields overwritten to provided values | AC-4 | +| EC-4 | PUT with only notifications sub-fields | PUT with `{"preferences": {"notifications": {"digest": "never"}}}` | `200 OK` — other notification sub-fields get default values filled before merge | AC-4 | +| EC-5 | Boundary: language code exactly 2 chars | PUT with `{"preferences": {"language": "de"}}` | `200 OK` — valid ISO 639-1 code accepted | AC-13 | +| EC-6 | Theme values at boundaries | PUT with each valid theme: `light`, `dark`, `system` separately | All return `200 OK` | AC-13 | +| EC-7 | Digest values at boundaries | PUT with each valid digest: `daily`, `weekly`, `never` separately | All return `200 OK` | AC-13 | +| EC-8 | PUT idempotency — same update twice | PUT `{theme: "dark"}` twice in a row | Both return `200 OK` with identical preferences (except updated_at may differ) | AC-4, AC-5 | +| EC-9 | UUID in different valid formats | GET with uppercase UUID, lowercase UUID, mixed case | All treated equivalently as valid UUIDs | AC-10 | +| EC-10 | In-memory persistence across requests | PUT then GET in separate HTTP requests | GET returns what PUT stored | AC-11 | + +### Error Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| ER-1 | GET without authentication | GET `/preferences/{user_id}` with no auth token | `401 Unauthorized` | AC-8 | +| ER-2 | PUT without authentication | PUT `/preferences/{user_id}` with no auth token | `401 Unauthorized` | AC-8 | +| ER-3 | GET with expired/invalid JWT | GET with malformed or expired token | `401 Unauthorized` | AC-8 | +| ER-4 | GET for different user (non-admin) | GET `/preferences/{other_user_id}` with non-admin token | `403 Forbidden` | AC-9 | +| ER-5 | PUT for different user (non-admin) | PUT `/preferences/{other_user_id}` with non-admin token and valid body | `403 Forbidden` | AC-9 | +| ER-6 | GET with invalid UUID format | GET `/preferences/not-a-uuid` | `400 Bad Request` — UUID validation failure | AC-10 | +| ER-7 | PUT with invalid UUID format | PUT `/preferences/12345` (not UUID) | `400 Bad Request` — UUID validation failure | AC-10 | +| ER-8 | PUT with missing preferences field | PUT with `{}` (empty body, no `preferences` key) | `400 Bad Request` — `preferences` field required | AC-6 | +| ER-9 | PUT with null preferences field | PUT with `{"preferences": null}` | `400 Bad Request` — `preferences` must be a JSON object | AC-6 | +| ER-10 | PUT with preferences as non-object | PUT with `{"preferences": "string"}` or `{"preferences": 42}` | `400 Bad Request` — `preferences` must be a JSON object | AC-6 | +| ER-11 | PUT with unknown top-level preference key | PUT with `{"preferences": {"theme": "dark", "color": "blue"}}` | `400 Bad Request` — unknown key `color` rejected | AC-7 | +| ER-12 | PUT with multiple unknown keys | PUT with `{"preferences": {"foo": 1, "bar": 2}}` | `400 Bad Request` — unknown keys rejected | AC-7 | +| ER-13 | PUT with invalid theme value | PUT with `{"preferences": {"theme": "neon"}}` | `400 Bad Request` — theme must be one of: light, dark, system | AC-13 | +| ER-14 | PUT with invalid language (too long) | PUT with `{"preferences": {"language": "eng"}}` (3 chars) | `400 Bad Request` — language must match `^[a-z]{2}$` | AC-13 | +| ER-15 | PUT with invalid language (uppercase) | PUT with `{"preferences": {"language": "EN"}}` | `400 Bad Request` — language must be lowercase | AC-13 | +| ER-16 | PUT with invalid language (numbers) | PUT with `{"preferences": {"language": "1a"}}` | `400 Bad Request` — language must match `^[a-z]{2}$` | AC-13 | +| ER-17 | PUT with invalid language (empty) | PUT with `{"preferences": {"language": ""}}` | `400 Bad Request` — language must match `^[a-z]{2}$` | AC-13 | +| ER-18 | PUT with invalid digest value | PUT with `{"preferences": {"notifications": {"digest": "monthly"}}}` | `400 Bad Request` — digest must be one of: daily, weekly, never | AC-13 | +| ER-19 | PUT with malformed JSON body | PUT with `{broken json` | `400 Bad Request` — binding/parse error | AC-6 | +| ER-20 | PUT with extra top-level fields outside preferences | PUT with `{"preferences": {"theme": "dark"}, "extra": true}` | `400 Bad Request` — strict binding rejects unknown outer fields | AC-6 | + +## Test Data Requirements + +### Fixtures + +| Fixture | Description | Usage | +|---------|-------------|-------| +| `validUserID` | A valid UUID string, e.g. `550e8400-e29b-41d4-a716-446655440000` | All authenticated test cases | +| `otherUserID` | A different valid UUID, e.g. `660e8400-e29b-41d4-a716-446655440000` | Authorization cross-user tests | +| `defaultPreferences` | Preferences with all defaults: theme=system, language=en, email=true, push=true, digest=weekly | Baseline assertions | +| `customPreferences` | Preferences with non-default values: theme=dark, language=es, email=false, push=false, digest=daily | Merge and update tests | + +### Mocks + +| Mock | Purpose | +|------|---------| +| `mockPreferencesRepository` | In-test mock implementing `port.PreferencesRepository` for unit testing service and handler layers independently | +| Auth context | Simulated JWT auth context with `user.ID` and `user.Roles` for handler tests | +| Admin auth context | Simulated JWT with admin role for authorization override tests | + +### Test Data Setup Pattern + +Following existing codebase conventions: +- Handler tests use `newTestHandler()` helper that wires mock repo → real service → handler +- Service tests use mock repo directly +- Domain tests are pure unit tests with no mocks +- All tests use `logging.Nop()` for logger +- Mock repos implement interface with compile-time assertion: `var _ port.PreferencesRepository = (*mockPreferencesRepository)(nil)` + +## Integration Test Plan + +### Component Integration (within service boundary) + +These tests verify the full stack within the `preferences-api` service works end-to-end. + +| ID | Test | Components Exercised | Approach | +|----|------|---------------------|----------| +| INT-1 | Full GET flow: router → auth middleware → handler → service → memory adapter | All layers | HTTP test via chi router with auth context | +| INT-2 | Full PUT flow: router → auth middleware → handler → binding/validation → service → merge → adapter → response | All layers | HTTP test via chi router with auth context | +| INT-3 | PUT then GET round-trip | Handler + Service + Adapter | PUT preferences, then GET and verify returned data matches | +| INT-4 | Multiple PUTs with merge | Service + Domain merge logic + Adapter | Sequential PUTs with partial updates, verify cumulative merge | +| INT-5 | Auth middleware blocks unauthenticated requests | Router + Auth middleware | Request without JWT token to auth-protected routes | +| INT-6 | OpenAPI spec serves correctly | Router + Spec | GET `/api/preferences-api/docs` returns valid spec | + +### Cross-Layer Verification + +| Layer Boundary | What to Verify | +|---------------|---------------| +| Handler → Service | Domain errors propagate unchanged and handler maps them to correct HTTP status codes | +| Service → Repository | `ErrPreferencesNotFound` is returned for missing users and handled correctly upstream | +| Domain validation → Handler error mapping | Each domain validation error (`ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrInvalidDigest`) maps to `400 Bad Request` | +| Auth middleware → Handler | Auth context is correctly populated and accessible via `auth.GetUser(ctx)` | + +## Unit Test Plan by Layer + +### Domain Layer Tests (`internal/domain/`) + +| ID | Test | Focus | +|----|------|-------| +| UT-D1 | `NewDefaultPreferences` returns correct defaults | Factory function | +| UT-D2 | `Validate()` passes with all valid values | Happy path | +| UT-D3 | `Validate()` fails for invalid theme | Enum validation | +| UT-D4 | `Validate()` fails for invalid language (too long, uppercase, numbers, empty) | Regex validation | +| UT-D5 | `Validate()` fails for invalid digest | Enum validation | +| UT-D6 | `MergeFrom()` only overwrites non-nil fields | Partial merge | +| UT-D7 | `MergeFrom()` replaces all fields when all provided | Full merge | +| UT-D8 | `MergeFrom()` merges notification sub-fields individually | Nested merge | +| UT-D9 | `MergeFrom()` with nil notifications leaves existing untouched | No-op merge | +| UT-D10 | `UserID.IsZero()` returns true for empty, false for non-empty | Type method | + +### Service Layer Tests (`internal/service/`) + +| ID | Test | Focus | +|----|------|-------| +| UT-S1 | `Get()` returns preferences for existing user | Happy path | +| UT-S2 | `Get()` returns `ErrPreferencesNotFound` for missing user | Not found | +| UT-S3 | `Upsert()` creates new preferences with defaults when user has none | Create-on-first-PUT | +| UT-S4 | `Upsert()` merges update into existing preferences | Merge logic | +| UT-S5 | `Upsert()` rejects invalid values (propagates domain validation errors) | Validation passthrough | +| UT-S6 | `Upsert()` sets `UpdatedAt` timestamp | Metadata | + +### Handler Layer Tests (`internal/api/handlers/`) + +| ID | Test | Focus | +|----|------|-------| +| UT-H1 | GET 200 — valid user, preferences exist | Happy path | +| UT-H2 | GET 404 — valid user, no preferences | Not found | +| UT-H3 | GET 403 — user_id doesn't match token, not admin | Authorization | +| UT-H4 | GET 400 — invalid UUID in path | Validation | +| UT-H5 | PUT 200 — create new preferences | Create path | +| UT-H6 | PUT 200 — merge existing preferences | Update path | +| UT-H7 | PUT 400 — missing `preferences` field | Request validation | +| UT-H8 | PUT 400 — unknown top-level keys in preferences | Strict binding | +| UT-H9 | PUT 400 — invalid preference values | Domain validation | +| UT-H10 | PUT 403 — wrong user, not admin | Authorization | +| UT-H11 | Response envelope format matches `{data, meta}` | Envelope structure | + +### Adapter Layer Tests (`internal/adapter/memory/`) + +| ID | Test | Focus | +|----|------|-------| +| UT-A1 | `Get()` returns stored preferences | Read | +| UT-A2 | `Get()` returns `ErrPreferencesNotFound` for missing key | Read miss | +| UT-A3 | `Upsert()` inserts new entry | Write new | +| UT-A4 | `Upsert()` replaces existing entry | Write existing | +| UT-A5 | Returned preferences are copies (mutation doesn't affect store) | Copy semantics | +| UT-A6 | Concurrent reads don't panic | Thread safety | + +## Performance Considerations + +### Load Expectations + +- Read-heavy workload: ~100:1 read-to-write ratio +- Preferences are read on every page load / session initialization +- Writes happen only when user changes settings (rare) + +### Latency Budgets + +| Operation | Target | Rationale | +|-----------|--------|-----------| +| GET preferences | < 5ms (p99) | In-memory lookup, no I/O. Frontend blocks on this for initialization. | +| PUT preferences | < 10ms (p99) | In-memory merge + store, no I/O. User settings save should feel instant. | + +### Benchmarks to Run + +| Benchmark | Description | +|-----------|-------------| +| `BenchmarkGet` | Measure GET handler throughput with in-memory adapter | +| `BenchmarkUpsert` | Measure PUT handler throughput including merge + validation | +| `BenchmarkConcurrentReads` | Verify RWMutex allows concurrent readers without contention | +| `BenchmarkConcurrentReadWrite` | Verify mixed read/write workload under RWMutex doesn't deadlock or degrade | + +### Stress Scenarios (future, out of scope for in-memory) + +- Memory growth: many users' preferences in-memory (acceptable for dev, monitor in production) +- No pagination needed — single-user preference payload is ~200 bytes + +## Manual Verification Steps + +| Step | Description | Expected Result | +|------|-------------|-----------------| +| 1 | Start the service: `go run ./cmd/server/main.go` | Service starts without error, logs listen address | +| 2 | Verify health: `curl http://localhost:8001/api/preferences-api/health` | `200 OK` with health response | +| 3 | Verify OpenAPI docs render: open `http://localhost:8001/api/preferences-api/docs` in browser | Scalar API docs page loads with preferences endpoints documented | +| 4 | PUT preferences (unauthenticated): `curl -X PUT http://localhost:8001/api/preferences-api/preferences/{uuid} -d '{"preferences":{"theme":"dark"}}'` | `401 Unauthorized` (auth middleware blocks) | +| 5 | GET preferences (with auth): Use valid JWT and `curl` to retrieve preferences | Returns `200` or `404` depending on prior state | +| 6 | PUT then GET round-trip (with auth): Create preferences, then retrieve them | GET response matches what was PUT | +| 7 | Verify example endpoints removed: `curl http://localhost:8001/api/preferences-api/examples` | `404 Not Found` — old scaffold routes no longer exist | +| 8 | Verify build: `cd services/preferences-api && go build ./...` | Compiles successfully | +| 9 | Verify tests: `cd services/preferences-api && go test -v ./...` | All tests pass | + +## Acceptance Criteria Coverage Matrix + +| AC # | Acceptance Criterion | Test IDs | +|------|---------------------|----------| +| AC-1 | GET returns 200 with stored preferences | HP-1, UT-H1, INT-1, INT-3 | +| AC-2 | GET returns 404 when no preferences exist | EC-1, UT-H2, UT-S2, UT-A2 | +| AC-3 | PUT creates preferences if none exist (upsert) | HP-2, UT-H5, UT-S3, INT-2 | +| AC-4 | PUT merges provided keys with existing (shallow merge) | HP-3, HP-4, HP-10, EC-2, EC-3, EC-4, UT-D6, UT-D7, UT-D8, UT-D9, UT-S4, UT-H6, INT-4 | +| AC-5 | PUT returns 200 with full merged preference set | HP-5, UT-H5, UT-H6 | +| AC-6 | PUT validates preferences field is present and is JSON object | ER-8, ER-9, ER-10, ER-19, ER-20, UT-H7 | +| AC-7 | PUT rejects unknown top-level preference keys with 400 | ER-11, ER-12, UT-H8 | +| AC-8 | Both endpoints require authentication | ER-1, ER-2, ER-3, INT-5 | +| AC-9 | Own-user access only, admin override | HP-6, HP-7, ER-4, ER-5, UT-H3, UT-H10 | +| AC-10 | user_id validated as UUID | HP-8, EC-9, ER-6, ER-7, UT-H4 | +| AC-11 | Preferences persisted in-memory via adapter pattern | EC-10, UT-A1, UT-A3, UT-A4, INT-3 | +| AC-12 | OpenAPI spec documents both endpoints | INT-6, Manual Step 3 | +| AC-13 | Domain model defines allowed keys and validation rules | HP-9, EC-5, EC-6, EC-7, ER-13, ER-14, ER-15, ER-16, ER-17, ER-18, UT-D1 through UT-D9 | +| AC-14 | Handler tests cover success, validation, auth, not-found | UT-H1 through UT-H11 | +| AC-15 | Service tests cover merge, create-on-first-PUT, authorization | UT-S1 through UT-S6 | +| AC-16 | All example scaffold code removed and replaced | Manual Steps 7, 8, 9 |