7.3 KiB
Code Review: User Preferences API
Summary
Overall Assessment: PASS
The implementation is well-structured, follows the hexagonal architecture pattern correctly, and satisfies the spec requirements. Build succeeds, all 16 tests pass, and go vet reports no issues. No example scaffold files remain. The code follows project conventions (handler pattern, error wrapping, response envelope, auth middleware, brace URL params). Security is sound with proper authorization checks, parameterized SQL, strict JSON binding, and auth middleware. A few minor improvements are recommended but nothing blocks shipping.
Findings
Blockers
None.
Warnings
-
services/preferences-api/internal/service/preferences.go:55Re-fetch after upsert may return nil with in-memory adapter -- TheUpdatePreferencesmethod callss.repo.Get(ctx, userID)afterUpsertto return the saved result. With the in-memory adapter, the returnedUpdatedAtwill be the Go-set value (line 42), not a DB-generated timestamp. With PostgreSQL, theNOW()value from the DB is returned. This inconsistency is minor (tests use in-memory, production uses PostgreSQL) but could cause confusion if the in-memory adapter is ever used for integration testing that checks timestamps. Consider having Upsert return the saved entity directly to avoid the extra round-trip. -
services/preferences-api/internal/domain/preferences.goNo domain-level unit tests -- The spec states "Unit tests cover domain validation, service logic, and handler request/response mapping." TheValidate()andDefaultPreferences()functions have no direct unit tests. They are indirectly tested via service tests, but dedicated domain tests would catch regressions if the domain layer changes independently. -
services/preferences-api/internal/api/handlers/preferences_test.go:73-75Weak assertion for updated_at on defaults -- The test for default preferences checksupdated_atbut the assertion is effectively a no-op (the comment says "may be present but empty for defaults" and the condition does nothing). This should assert thatupdated_atis either absent or an empty string for default preferences, matching the spec behavior. -
services/preferences-api/internal/api/handlers/preferences_test.goNo PUT-then-GET roundtrip test -- The QA plan specifies a roundtrip test (HP-10) verifying that data persisted via PUT can be retrieved via GET. No such test exists in the handler test suite. This is an important integration-level assertion.
Suggestions
-
services/preferences-api/internal/api/handlers/preferences_test.goMissing handler-level tests for invalid language and invalid digest -- Handler tests only cover invalid theme (TestPreference_Update_InvalidThemeValue). Invalid language and invalid digest are tested at the service layer but not at the handler HTTP level. Adding these would verify the full error mapping path end-to-end. -
services/preferences-api/internal/api/routes.go:36Auth middleware is conditional onAUTH_ENABLED-- The spec says "Both endpoints require authentication via the existingauth.Middleware()" unconditionally. The implementation makes auth optional via an environment variable. This is likely intentional for local development, but should be documented to avoid security concerns in production deployments. -
services/preferences-api/internal/api/handlers/preferences.go:34-37Request DTO has redundant validation with domain layer -- The struct tags onUpdatePreferencesRequest(oneof=light dark system, etc.) duplicate the same validation rules indomain.Validate(). The domain validation will never fire in the handler path since struct tag validation catches invalid values first. This is defensive but means the domainValidate()is effectively untested via the handler path. Consider keeping validation in one canonical location. -
services/preferences-api/cmd/server/main.goNodb.Ping()to verify database connectivity at startup -- Thesql.Opencall only validates arguments, it doesn't actually connect. Addingdb.PingContext(ctx)after opening would catch connection issues at startup rather than on the first request.
Spec Alignment
The implementation matches the spec well:
| Requirement | Status |
|---|---|
| GET /api/preferences-api/preferences/{user_id} | Implemented correctly |
| PUT /api/preferences-api/preferences/{user_id} | Implemented correctly |
| Auth required on both endpoints | Implemented (conditionally via AUTH_ENABLED) |
| Self-access enforcement (JWT match) | Implemented correctly |
| Admin read access | Implemented correctly |
| Admin write denied | Implemented correctly |
| Theme/language/digest validation | Implemented at both struct-tag and domain levels |
| GET returns defaults (200, not 404) | Implemented correctly |
| Default values match spec | Correct: theme=system, language=en, email=true, push=true, digest=weekly |
| Unknown keys return 400 | Implemented via app.BindAndValidateStrict |
| Invalid values return 400 | Implemented via struct tags and domain validation |
| PostgreSQL persistence | Implemented with parameterized queries |
| Hexagonal architecture | Correctly follows domain -> service -> port -> adapter |
| OpenAPI spec updated | Complete with schemas, security, and error responses |
| Example scaffold removed | Fully removed, no remnants |
Minor gap: The spec says "PUT with invalid preference values returns 400 Bad Request with per-field validation errors." The struct tag validation does produce per-field details via httperror.WithDetails, which satisfies this. However, domain-level validation errors (from Validate()) produce a single error message rather than per-field details. Since struct tags catch these first, this is not a practical issue.
Test Coverage Assessment
| Acceptance Criterion | Has Test? | Test Name |
|---|---|---|
| GET returns preferences in envelope | Yes | TestPreference_Get_SelfAccess |
| PUT creates/replaces (upsert) | Yes | TestPreference_Update_SelfAccess |
| Auth required | Partial | Auth is bypassed in tests (set via context); no 401 test |
| JWT user match enforced | Yes | TestPreference_Get_Forbidden, TestPreference_Update_Forbidden |
| Admin read allowed | Yes | TestPreference_Get_AdminAccess |
| Admin write denied | Yes | TestPreference_Update_AdminForbidden |
| Theme validation | Yes | TestPreference_Update_InvalidThemeValue (handler), TestPreferenceService_UpdatePreferences_InvalidTheme (service) |
| Language validation | Partial | Service test only, no handler test |
| Digest validation | Partial | Service test only, no handler test |
| GET returns defaults (not 404) | Yes | TestPreference_Get_SelfAccess, TestPreferenceService_GetPreferences_Defaults |
| Default values correct | Yes | TestPreferenceService_GetPreferences_Defaults |
| Unknown keys return 400 | Yes | TestPreference_Update_UnknownFields |
| Missing fields return 400 | Yes | TestPreference_Update_InvalidBody |
| PUT-then-GET roundtrip | No | Missing |
| Domain validation standalone | No | No domain-level tests |
| GET existing (non-default) prefs | Yes | TestPreference_Get_ExistingPreferences |
16 total tests, all passing. Coverage is good for the critical paths. The main gaps are the roundtrip test and domain-level unit tests, which are classified as warnings above.