diff --git a/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx b/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx index 865640d8..0a556823 100644 --- a/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx +++ b/frontend/apps/finance/src/features/settings/__tests__/ExportDataSection.test.tsx @@ -318,7 +318,6 @@ describe("ExportDataSection", () => { body: { code: "RATE_LIMITED", message: `Export limit reached. You can request another export after ${formatDateOnly(retryAfter)}.`, - retryAfter, }, }, ]), @@ -587,7 +586,6 @@ describe("ExportDataSection", () => { body: { code: "RATE_LIMITED", message: `Export limit reached. Next available: ${retryAfter}`, - retryAfter, }, }, ]), diff --git a/frontend/apps/finance/src/features/settings/types.ts b/frontend/apps/finance/src/features/settings/types.ts index 020e506f..db206aef 100644 --- a/frontend/apps/finance/src/features/settings/types.ts +++ b/frontend/apps/finance/src/features/settings/types.ts @@ -26,13 +26,6 @@ export interface ExportListResponse { hasMore: boolean; } -/** Error response from 429 rate-limited requests. */ -export interface ExportRateLimitedResponse { - code: string; - message: string; - retryAfter: string; -} - /** Export lifecycle phases: exactly one is active at any time. */ export type ExportStatus = 'idle' | 'loading' | 'creating' | 'polling' | 'error'; diff --git a/services/access/access.go b/services/access/access.go index 2b76d9d9..8a7b51d2 100644 --- a/services/access/access.go +++ b/services/access/access.go @@ -5,19 +5,19 @@ // from the Registry) can import it without a build dependency on each other. // // The package owns three things: -// - the Access classification applied to every route (access.go), +// - the access Level applied to every gateway-facing route (access.go), // - the Registry of concrete routes and their access levels (registry.go), // - a gin-priority path resolver used by the gateway middleware (resolver.go). package access -// Access is the classification applied to every gateway-facing route. It +// Level is the access classification applied to every gateway-facing route. It // determines whether a request needs a token and, if so, which role may // proceed. -type Access int +type Level int const ( // Public routes are reachable with no token. - Public Access = iota + Public Level = iota // Authenticated routes require any valid token, regardless of role. Authenticated // Personal routes require a valid token acting as a regular user (role == "user"). @@ -32,7 +32,7 @@ const ( ) // String returns the level name, used for readable logs and test diagnostics. -func (a Access) String() string { +func (a Level) String() string { switch a { case Public: return "Public" diff --git a/services/access/proxy.go b/services/access/proxy.go index e4aa5daf..f09a890e 100644 --- a/services/access/proxy.go +++ b/services/access/proxy.go @@ -12,7 +12,7 @@ type ProxyPrefix struct { Service string } -// ProxyPrefixes is the ordered inventory of downstream prefixes the gateway +// proxyPrefixes is the ordered inventory of downstream prefixes the gateway // proxies and the service each targets. It makes "what prefixes and services // exist" part of the single source of truth: the gateway derives its proxy // wiring from this list (see services/gateway/internal/router), and a @@ -20,13 +20,25 @@ type ProxyPrefix struct { // under a proxied prefix and every proxied prefix has at least one classified // route. // +// It is set once at init and unexported so no caller can reassign it; the +// gateway reads a copy via Prefixes(). +// // Two prefixes map to the auth service: /api/auth (its own routes) and // /api/admin (operator routes such as GET /api/admin/users, still served by // auth even though their Access is Admin). -var ProxyPrefixes = []ProxyPrefix{ +var proxyPrefixes = []ProxyPrefix{ {Prefix: "/api/auth", Service: "auth"}, {Prefix: "/api/admin", Service: "auth"}, {Prefix: "/api/expenses", Service: "expense"}, {Prefix: "/api/finance", Service: "finance"}, {Prefix: "/api/datarights", Service: "datarights"}, } + +// Prefixes returns a copy of the proxy-prefix inventory. The copy is why the +// gateway can derive its routing wiring (router.New) from this list without +// being able to mutate the shared, init-time-fixed backing array. +func Prefixes() []ProxyPrefix { + out := make([]ProxyPrefix, len(proxyPrefixes)) + copy(out, proxyPrefixes) + return out +} diff --git a/services/access/proxy_test.go b/services/access/proxy_test.go index d70715e3..e45aa92e 100644 --- a/services/access/proxy_test.go +++ b/services/access/proxy_test.go @@ -16,7 +16,7 @@ func underPrefix(path, prefix string) bool { // coveringPrefix returns the single ProxyPrefix a path sits under (prefixes are // disjoint on segment boundaries, so at most one matches). func coveringPrefix(path string) (ProxyPrefix, bool) { - for _, p := range ProxyPrefixes { + for _, p := range proxyPrefixes { if underPrefix(path, p.Prefix) { return p, true } @@ -30,7 +30,7 @@ func coveringPrefix(path string) (ProxyPrefix, bool) { // same service that serves the route (else the gateway would forward it to the // wrong downstream). func TestProxyPrefixes_EveryRegistryRouteIsReachable(t *testing.T) { - for _, r := range Registry { + for _, r := range registry { prefix, ok := coveringPrefix(r.Path) if !ok { t.Errorf("route %s (%s %s) is classified but sits under no ProxyPrefix; the gateway would never proxy it (add a ProxyPrefix for its prefix)", r.ID, r.Method, r.Path) @@ -47,9 +47,9 @@ func TestProxyPrefixes_EveryRegistryRouteIsReachable(t *testing.T) { // prefix with no Registry route is proxied-but-unclassified, so under // deny-by-default every request to it would 403 (a silently dead prefix). func TestProxyPrefixes_EveryPrefixHasARoute(t *testing.T) { - for _, p := range ProxyPrefixes { + for _, p := range proxyPrefixes { found := false - for _, r := range Registry { + for _, r := range registry { if underPrefix(r.Path, p.Prefix) { found = true break diff --git a/services/access/registry.go b/services/access/registry.go index b19cb6c0..bae45512 100644 --- a/services/access/registry.go +++ b/services/access/registry.go @@ -20,20 +20,20 @@ type Route struct { // "/api/finance/tags/:id" or "/api/expenses/:id/history". Path string // Access is the classification enforced by the gateway middleware. - Access Access + Access Level } -// Registry is the single, exhaustive list of every gateway-facing downstream -// route. It is the sole source of truth: downstream services register their -// routes by iterating RoutesFor, and the gateway classifies each request by -// resolving against these patterns (see resolver.go). +// registry is the single, exhaustive list of every gateway-facing downstream +// route. It is the sole source of truth, set once at init and unexported so no +// caller can reassign it: downstream services read it via RoutesFor and the +// gateway classifies each request via Resolve (see resolver.go). // // Gateway-native endpoints (/health, /metrics) are intentionally absent: no // downstream service serves them, so the gateway classifies them itself. // // Path strings must match gin's registered patterns byte-for-byte (verified by // each service's registration coverage test against engine.Routes()). -var Registry = []Route{ +var registry = []Route{ // --- auth service --- {ID: "auth.register", Service: "auth", Method: http.MethodPost, Path: "/api/auth/register", Access: Public}, {ID: "auth.login", Service: "auth", Method: http.MethodPost, Path: "/api/auth/login", Access: Public}, @@ -89,23 +89,10 @@ var Registry = []Route{ // a route missing from the Registry can never be served. func RoutesFor(service string) []Route { var routes []Route - for _, r := range Registry { + for _, r := range registry { if r.Service == service { routes = append(routes, r) } } return routes } - -// Classifies reports whether method+path is matched by an explicit Registry -// entry, as opposed to falling through to the fail-safe Deny default in -// Resolve. Services use it as a defense-in-depth check that every route they -// register is classified by the Registry. -func Classifies(method, path string) bool { - for _, r := range Registry { - if r.Method == method && matchPattern(r.Path, path) { - return true - } - } - return false -} diff --git a/services/access/registry_test.go b/services/access/registry_test.go index c8cfa308..60e46202 100644 --- a/services/access/registry_test.go +++ b/services/access/registry_test.go @@ -5,8 +5,8 @@ import "testing" // TestRegistry_IDsAreUnique guards the bind-by-ID contract: services look up // handlers by Route.ID, so a duplicate ID would silently shadow a handler. func TestRegistry_IDsAreUnique(t *testing.T) { - seen := make(map[string]Route, len(Registry)) - for _, r := range Registry { + seen := make(map[string]Route, len(registry)) + for _, r := range registry { if prev, dup := seen[r.ID]; dup { t.Errorf("duplicate route ID %q: %s %s and %s %s", r.ID, prev.Method, prev.Path, r.Method, r.Path) } @@ -18,24 +18,13 @@ func TestRegistry_IDsAreUnique(t *testing.T) { // directly from the Registry (no second hand-list): every entry's own // method+path must resolve to the Access it declares. func TestRegistry_EveryEntryResolvesToItsAccess(t *testing.T) { - for _, r := range Registry { + for _, r := range registry { if got := Resolve(r.Method, r.Path); got != r.Access { t.Errorf("Resolve(%q, %q) = %s, want %s (route %s)", r.Method, r.Path, got, r.Access, r.ID) } } } -// TestRegistry_EveryEntryIsClassified confirms Classifies agrees with the -// Registry for every real route: each entry's path is matched by an explicit -// entry rather than the fail-safe default. -func TestRegistry_EveryEntryIsClassified(t *testing.T) { - for _, r := range Registry { - if !Classifies(r.Method, r.Path) { - t.Errorf("Classifies(%q, %q) = false, want true (route %s)", r.Method, r.Path, r.ID) - } - } -} - // TestRoutesFor_PartitionsRegistry proves RoutesFor slices the Registry cleanly: // the four known services together account for every entry with no gaps or // duplicates, and each returned route actually belongs to the requested service. @@ -53,8 +42,8 @@ func TestRoutesFor_PartitionsRegistry(t *testing.T) { total += len(routes) } - if total != len(Registry) { - t.Errorf("RoutesFor over %v covered %d routes, but Registry has %d; a service is misnamed or missing", services, total, len(Registry)) + if total != len(registry) { + t.Errorf("RoutesFor over %v covered %d routes, but Registry has %d; a service is misnamed or missing", services, total, len(registry)) } } @@ -65,31 +54,3 @@ func TestRoutesFor_UnknownServiceIsEmpty(t *testing.T) { t.Errorf("RoutesFor(%q) = %d routes, want 0", "nope", len(routes)) } } - -// TestClassifies_UnknownRoutesAreUnclassified is the failure-mode guardrail: a -// route with no Registry entry, or a real path under the wrong method, is not -// classified, so Resolve returns the fail-safe Deny default (403 at the -// gateway). -func TestClassifies_UnknownRoutesAreUnclassified(t *testing.T) { - cases := []struct { - name string - method string - path string - }{ - {"unknown service", "GET", "/api/newservice/records"}, - {"wrong method on a real route", "GET", "/api/auth/login"}, - {"sibling substring of a real route", "POST", "/api/datarights/exports-admin"}, - {"bare group with no exact route", "GET", "/api/finance"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if Classifies(tc.method, tc.path) { - t.Errorf("Classifies(%q, %q) = true, want false", tc.method, tc.path) - } - if got := Resolve(tc.method, tc.path); got != Deny { - t.Errorf("Resolve(%q, %q) = %s, want Deny (fail-safe default)", tc.method, tc.path, got) - } - }) - } -} diff --git a/services/access/resolver.go b/services/access/resolver.go index b46fcea3..7e273d3e 100644 --- a/services/access/resolver.go +++ b/services/access/resolver.go @@ -19,10 +19,10 @@ import "strings" // borrow "/api/datarights/exports"'s level: static segments must match // byte-for-byte. This makes the leading-substring bug that segment-boundary // prefix matching guarded against (commit ca37e4c) impossible by construction. -func Resolve(method, path string) Access { +func Resolve(method, path string) Level { var best *Route - for i := range Registry { - entry := &Registry[i] + for i := range registry { + entry := ®istry[i] if entry.Method != method || !matchPattern(entry.Path, path) { continue } diff --git a/services/access/resolver_test.go b/services/access/resolver_test.go index 0c07308f..a7ba5c0d 100644 --- a/services/access/resolver_test.go +++ b/services/access/resolver_test.go @@ -10,7 +10,7 @@ func TestResolve_WorkedExamples(t *testing.T) { name string method string path string - want Access + want Level }{ {"login is public", "POST", "/api/auth/login", Public}, {"register is public", "POST", "/api/auth/register", Public}, @@ -65,6 +65,7 @@ func TestResolve_FallsBackToDeny(t *testing.T) { {"bare admin group", "GET", "/api/admin"}, {"bare datarights group", "GET", "/api/datarights"}, {"unknown api path", "GET", "/api/unknown"}, + {"unknown service deep path", "GET", "/api/newservice/records"}, {"extra trailing segment", "GET", "/api/finance/tags/abc/extra"}, } diff --git a/services/apierr/codes.go b/services/apierr/codes.go new file mode 100644 index 00000000..80341d12 --- /dev/null +++ b/services/apierr/codes.go @@ -0,0 +1,16 @@ +// Package apierr single-sources the GoFin API error contract: the typed +// service error, the shared error-code constants, and the HTTP wire-response +// mapping. It is the one place the {code, message, fields?} contract lives so +// all four API services emit a byte-identical error shape. +package apierr + +// Shared, cross-service error codes (domain truth). Service-specific codes +// (e.g. PERIOD_LOCKED, DUPLICATE_TAG) stay as local constants in each service; +// they are still valid Code strings and do not belong here. +const ( + CodeUnauthorized = "UNAUTHORIZED" + CodeNotFound = "NOT_FOUND" + CodeValidation = "VALIDATION_ERROR" + CodeInternal = "INTERNAL_SERVER_ERROR" + CodeForbidden = "FORBIDDEN" +) diff --git a/services/apierr/error.go b/services/apierr/error.go new file mode 100644 index 00000000..47f5cfda --- /dev/null +++ b/services/apierr/error.go @@ -0,0 +1,40 @@ +package apierr + +import "net/http" + +// Error is the single typed service error. It carries everything the wire +// contract can express, including Fields (the field-level validation detail +// that most services drop today). +type Error struct { + Code string // maps to the "code" wire field + Message string // maps to "message" + Status int // HTTP status to write + Fields map[string]string // optional field-level validation detail +} + +func (e *Error) Error() string { return e.Message } + +// Unauthorized builds a 401 UNAUTHORIZED error. +func Unauthorized(msg string) *Error { + return &Error{Code: CodeUnauthorized, Message: msg, Status: http.StatusUnauthorized} +} + +// NotFound builds a 404 NOT_FOUND error. +func NotFound(msg string) *Error { + return &Error{Code: CodeNotFound, Message: msg, Status: http.StatusNotFound} +} + +// Validation builds a 400 VALIDATION_ERROR error carrying optional field detail. +func Validation(msg string, fields map[string]string) *Error { + return &Error{Code: CodeValidation, Message: msg, Status: http.StatusBadRequest, Fields: fields} +} + +// Conflict builds a 409 error with a caller-supplied code (for DUPLICATE_* style codes). +func Conflict(code, msg string) *Error { + return &Error{Code: code, Message: msg, Status: http.StatusConflict} +} + +// Internal builds a 500 INTERNAL_SERVER_ERROR error. +func Internal(msg string) *Error { + return &Error{Code: CodeInternal, Message: msg, Status: http.StatusInternalServerError} +} diff --git a/services/apierr/error_test.go b/services/apierr/error_test.go new file mode 100644 index 00000000..d655ab51 --- /dev/null +++ b/services/apierr/error_test.go @@ -0,0 +1,61 @@ +package apierr_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/apierr" +) + +func TestConstructors_SetCodeAndStatus(t *testing.T) { + cases := []struct { + name string + err *apierr.Error + wantCode string + wantStatus int + wantMessage string + }{ + {"unauthorized", apierr.Unauthorized("no auth"), apierr.CodeUnauthorized, http.StatusUnauthorized, "no auth"}, + {"not found", apierr.NotFound("gone"), apierr.CodeNotFound, http.StatusNotFound, "gone"}, + {"validation", apierr.Validation("bad", nil), apierr.CodeValidation, http.StatusBadRequest, "bad"}, + {"conflict", apierr.Conflict("DUPLICATE_TAG", "dup"), "DUPLICATE_TAG", http.StatusConflict, "dup"}, + {"internal", apierr.Internal("boom"), apierr.CodeInternal, http.StatusInternalServerError, "boom"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.NotNil(t, tc.err) + assert.Equal(t, tc.wantCode, tc.err.Code) + assert.Equal(t, tc.wantStatus, tc.err.Status) + assert.Equal(t, tc.wantMessage, tc.err.Message) + }) + } +} + +func TestValidation_CarriesFields(t *testing.T) { + fields := map[string]string{"amount": "required"} + + err := apierr.Validation("validation failed", fields) + + assert.Equal(t, fields, err.Fields) +} + +func TestError_MessageIsErrorString(t *testing.T) { + err := apierr.NotFound("period not found") + + assert.Equal(t, "period not found", err.Error()) +} + +func TestError_UnwrapsThroughErrorsAs(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", apierr.Conflict("DUPLICATE_TAG", "dup")) + + var target *apierr.Error + require.True(t, errors.As(wrapped, &target)) + assert.Equal(t, "DUPLICATE_TAG", target.Code) + assert.Equal(t, http.StatusConflict, target.Status) +} diff --git a/services/apierr/go.mod b/services/apierr/go.mod new file mode 100644 index 00000000..ba687bb9 --- /dev/null +++ b/services/apierr/go.mod @@ -0,0 +1,44 @@ +module github.com/ItsThompson/gofin/services/apierr + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/services/apierr/go.sum b/services/apierr/go.sum new file mode 100644 index 00000000..01a6a0ef --- /dev/null +++ b/services/apierr/go.sum @@ -0,0 +1,91 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/apierr/respond.go b/services/apierr/respond.go new file mode 100644 index 00000000..4e13ce34 --- /dev/null +++ b/services/apierr/respond.go @@ -0,0 +1,40 @@ +package apierr + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// APIError is the JSON wire shape for every error response: {code, message, +// fields?}. It is byte-identical to what the services emit today; only the +// exported name adopts the APIError initialism (US-HARDEN-04). JSON tags are +// unchanged. +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Fields map[string]string `json:"fields,omitempty"` +} + +// Respond maps any error to a gin JSON response following {code, message, +// fields?}. It classifies via errors.As so a %w-wrapped *Error is still mapped +// to its own status/code. Unknown errors, and any *Error with an unset +// (non-positive) Status, fall through to 500 CodeInternal so Respond always +// writes a coherent status/code pairing. +func Respond(c *gin.Context, err error) { + var apiErr *Error + if errors.As(err, &apiErr) && apiErr.Status > 0 { + c.JSON(apiErr.Status, APIError{ + Code: apiErr.Code, + Message: apiErr.Message, + Fields: apiErr.Fields, + }) + return + } + + c.JSON(http.StatusInternalServerError, APIError{ + Code: CodeInternal, + Message: "An unexpected error occurred", + }) +} diff --git a/services/apierr/respond_test.go b/services/apierr/respond_test.go new file mode 100644 index 00000000..afd4d5d9 --- /dev/null +++ b/services/apierr/respond_test.go @@ -0,0 +1,128 @@ +package apierr_test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/apierr" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// newTestContext returns a gin context wired to a fresh recorder so Respond +// can be exercised without a full router. +func newTestContext() (*gin.Context, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + return c, w +} + +// decodeBody unmarshals the recorded response into a generic map so tests can +// assert exact wire keys, including the omitempty behavior of "fields". +func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body +} + +func TestRespond_CodeToStatusMapping(t *testing.T) { + cases := []struct { + name string + err *apierr.Error + wantStatus int + wantCode string + }{ + {"unauthorized", apierr.Unauthorized("nope"), http.StatusUnauthorized, apierr.CodeUnauthorized}, + {"not found", apierr.NotFound("missing"), http.StatusNotFound, apierr.CodeNotFound}, + {"validation", apierr.Validation("bad", nil), http.StatusBadRequest, apierr.CodeValidation}, + {"conflict", apierr.Conflict("DUPLICATE_TAG", "dup"), http.StatusConflict, "DUPLICATE_TAG"}, + {"internal", apierr.Internal("boom"), http.StatusInternalServerError, apierr.CodeInternal}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, w := newTestContext() + + apierr.Respond(c, tc.err) + + require.Equal(t, tc.wantStatus, w.Code) + body := decodeBody(t, w) + assert.Equal(t, tc.wantCode, body["code"]) + assert.Equal(t, tc.err.Message, body["message"]) + }) + } +} + +func TestRespond_CopiesFieldsOntoWire(t *testing.T) { + c, w := newTestContext() + fields := map[string]string{"amount": "required", "date": "invalid"} + + apierr.Respond(c, apierr.Validation("validation failed", fields)) + + require.Equal(t, http.StatusBadRequest, w.Code) + body := decodeBody(t, w) + assert.Equal(t, apierr.CodeValidation, body["code"]) + + gotFields, ok := body["fields"].(map[string]any) + require.True(t, ok, "fields must be present on the wire response") + assert.Equal(t, "required", gotFields["amount"]) + assert.Equal(t, "invalid", gotFields["date"]) +} + +func TestRespond_OmitsFieldsWhenAbsent(t *testing.T) { + c, w := newTestContext() + + apierr.Respond(c, apierr.NotFound("missing")) + + body := decodeBody(t, w) + _, present := body["fields"] + assert.False(t, present, "fields must be omitted when nil (omitempty)") +} + +func TestRespond_WrappedErrorClassifiedByErrorsAs(t *testing.T) { + c, w := newTestContext() + wrapped := fmt.Errorf("repository call failed: %w", apierr.NotFound("period not found")) + + apierr.Respond(c, wrapped) + + require.Equal(t, http.StatusNotFound, w.Code) + body := decodeBody(t, w) + assert.Equal(t, apierr.CodeNotFound, body["code"]) + assert.Equal(t, "period not found", body["message"]) +} + +func TestRespond_UnknownErrorYields500Internal(t *testing.T) { + c, w := newTestContext() + + apierr.Respond(c, errors.New("some unexpected failure")) + + require.Equal(t, http.StatusInternalServerError, w.Code) + body := decodeBody(t, w) + assert.Equal(t, apierr.CodeInternal, body["code"]) + assert.Equal(t, "An unexpected error occurred", body["message"]) +} + +func TestRespond_ZeroStatusErrorYields500Internal(t *testing.T) { + c, w := newTestContext() + // A hand-built *Error with an unset Status (0) must not yield WriteHeader(0); + // Respond falls back to a coherent 500 INTERNAL_SERVER_ERROR. + malformed := &apierr.Error{Code: "SOMETHING", Message: "no status set"} + + apierr.Respond(c, malformed) + + require.Equal(t, http.StatusInternalServerError, w.Code) + body := decodeBody(t, w) + assert.Equal(t, apierr.CodeInternal, body["code"]) + assert.Equal(t, "An unexpected error occurred", body["message"]) +} diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index f0bcf8a4..1d0cf736 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -12,6 +12,10 @@ COPY access/go.mod access/go.sum* ./access/ COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ +COPY serverkit/go.mod serverkit/go.sum* ./serverkit/ +COPY apierr/go.mod apierr/go.sum* ./apierr/ +COPY httpx/go.mod httpx/go.sum* ./httpx/ +COPY pgutil/go.mod pgutil/go.sum* ./pgutil/ RUN --mount=type=cache,target=/go/pkg/mod \ cd auth && GOWORK=off go mod download @@ -21,6 +25,10 @@ COPY access/ ./access/ COPY dbmigrate/ ./dbmigrate/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ +COPY serverkit/ ./serverkit/ +COPY apierr/ ./apierr/ +COPY httpx/ ./httpx/ +COPY pgutil/ ./pgutil/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/auth/cmd/main.go b/services/auth/cmd/main.go index 5f0c0cbe..041ae8fe 100644 --- a/services/auth/cmd/main.go +++ b/services/auth/cmd/main.go @@ -11,9 +11,7 @@ import ( "syscall" "time" - "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" - "google.golang.org/grpc" "github.com/ItsThompson/gofin/services/auth/db/migrations" "github.com/ItsThompson/gofin/services/auth/internal/config" @@ -21,16 +19,16 @@ import ( "github.com/ItsThompson/gofin/services/auth/internal/handler" "github.com/ItsThompson/gofin/services/auth/internal/repository" "github.com/ItsThompson/gofin/services/auth/internal/service" - "github.com/ItsThompson/gofin/services/dbmigrate" "github.com/ItsThompson/gofin/services/healthcheck" - "github.com/ItsThompson/gofin/services/metrics" + "github.com/ItsThompson/gofin/services/serverkit" + pb "github.com/ItsThompson/gofin/services/auth/proto/authpb" ) func main() { // Support subcommands: "--healthcheck" checks the health endpoint and exits. if healthcheck.ShouldRun(os.Args) { - os.Exit(healthcheck.Run("8081")) + os.Exit(healthcheck.Run(config.RESTPort())) } // Support subcommands: "seed-admin" runs the admin seeder and exits. @@ -59,51 +57,33 @@ func run() error { } // Set up structured logging - logLevel := slog.LevelInfo - switch cfg.LogLevel { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - } - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) - logger = logger.With(slog.String("service", "auth")) + logger := serverkit.NewLogger(cfg.LogLevel, "auth") slog.SetDefault(logger) - // Run database migrations (embedded in binary via go:embed) - if err := dbmigrate.RunWithFS(cfg.DBUrl, migrations.FS, "."); err != nil { - return fmt.Errorf("running migrations: %w", err) - } - - // Connect to PostgreSQL - pool, err := pgxpool.New(ctx, cfg.DBUrl) + // Run migrations, connect to PostgreSQL, and ping (caller owns pool.Close). + pool, err := serverkit.ConnectPostgres(ctx, cfg.DBUrl, migrations.FS) if err != nil { - return fmt.Errorf("connecting to database: %w", err) + return err } defer pool.Close() - - if err := pool.Ping(ctx); err != nil { - return fmt.Errorf("pinging database: %w", err) - } logger.Info("connected to PostgreSQL") // Build dependency graph queries := db.New(pool) repo := repository.NewPostgresUserRepository(queries) blacklistRepo := repository.NewPostgresBlacklistRepository(queries) - jwtSvc := service.NewJWTService(cfg.JWTSecret) + jwtSvc := service.NewJWTService(cfg.JWTSecret, + service.WithAccessTTL(cfg.JWTAccessTTL), + service.WithRefreshTTL(cfg.JWTRefreshTTL), + ) pwdSvc := service.NewPasswordService(cfg.BcryptCost) authSvc := service.NewAuthService(repo, blacklistRepo, jwtSvc, pwdSvc, logger) // Start background workers - authSvc.StartPeriodicCleanup(ctx, 5*time.Minute, 30*time.Second) + authSvc.StartPeriodicCleanup(ctx, cfg.CleanupInterval, cfg.CleanupTimeout) - // Start gRPC server - grpcServer := grpc.NewServer( - grpc.UnaryInterceptor(metrics.UnaryServerInterceptor()), - ) + // Build the gRPC server + grpcServer := serverkit.NewGRPCServer() grpcHandler := handler.NewGRPCHandler(authSvc, logger) pb.RegisterAuthServiceServer(grpcServer, grpcHandler) @@ -112,30 +92,9 @@ func run() error { return fmt.Errorf("listening on gRPC port %s: %w", cfg.GRPCPort, err) } - go func() { - logger.Info("gRPC server starting", - slog.String("port", cfg.GRPCPort), - ) - if err := grpcServer.Serve(grpcLis); err != nil { - logger.Error("gRPC server failed", slog.String("error", err.Error())) - } - }() - - // Start REST server - if cfg.IsProduction() { - gin.SetMode(gin.ReleaseMode) - } - router := gin.New() - router.Use(gin.Recovery()) - router.Use(metrics.HTTPMetrics()) - - metrics.Register(router) - - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - - restHandler := handler.NewRESTHandler(authSvc, logger, cfg.IsProduction(), cfg.CookieDomain) + // Build the REST server + router := serverkit.NewRouter("auth", cfg.IsProduction()) + restHandler := handler.NewRESTHandler(authSvc, logger, cfg.IsProduction(), cfg.CookieDomain, cfg.JWTAccessTTL, cfg.JWTRefreshTTL) restHandler.RegisterRoutes(router) httpServer := &http.Server{ @@ -143,34 +102,14 @@ func run() error { Handler: router, } - go func() { - logger.Info("REST server starting", - slog.String("port", cfg.RESTPort), - ) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("REST server failed", slog.String("error", err.Error())) - } - }() - logger.Info("auth service ready", slog.String("rest_port", cfg.RESTPort), slog.String("grpc_port", cfg.GRPCPort), ) - // Wait for shutdown signal - <-ctx.Done() - logger.Info("shutting down auth service") - - // Graceful shutdown: give in-flight requests up to 10 seconds - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - grpcServer.GracefulStop() - if err := httpServer.Shutdown(shutdownCtx); err != nil { - logger.Error("REST server shutdown error", slog.String("error", err.Error())) - } - - return nil + // Serve blocks until ctx is cancelled or a server fails to bind; a fatal + // serve error propagates so run() exits non-zero (fixes the C5 zombie). + return serverkit.Serve(ctx, httpServer, grpcServer, grpcLis) } // runSeedAdmin creates an admin user from environment variables. @@ -184,8 +123,7 @@ func runSeedAdmin() error { return fmt.Errorf("loading config: %w", err) } - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - logger = logger.With(slog.String("service", "auth"), slog.String("command", "seed-admin")) + logger := serverkit.NewLogger(cfg.LogLevel, "auth").With(slog.String("command", "seed-admin")) // Read admin credentials from env adminUsername := os.Getenv("ADMIN_USERNAME") diff --git a/services/auth/go.mod b/services/auth/go.mod index 1f0fc884..267e3117 100644 --- a/services/auth/go.mod +++ b/services/auth/go.mod @@ -4,9 +4,12 @@ go 1.26 require ( github.com/ItsThompson/gofin/services/access v0.0.0 - github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 + github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 + github.com/ItsThompson/gofin/services/httpx v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 + github.com/ItsThompson/gofin/services/pgutil v0.0.0 + github.com/ItsThompson/gofin/services/serverkit v0.0.0 github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 @@ -25,7 +28,16 @@ replace github.com/ItsThompson/gofin/services/metrics => ../metrics replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate +replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit + +replace github.com/ItsThompson/gofin/services/apierr => ../apierr + +replace github.com/ItsThompson/gofin/services/httpx => ../httpx + +replace github.com/ItsThompson/gofin/services/pgutil => ../pgutil + require ( + github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect diff --git a/services/auth/internal/config/config.go b/services/auth/internal/config/config.go index a235ced0..c4920ac1 100644 --- a/services/auth/internal/config/config.go +++ b/services/auth/internal/config/config.go @@ -4,18 +4,48 @@ import ( "fmt" "os" "strconv" + "time" + + "github.com/ItsThompson/gofin/services/auth/internal/service" +) + +// DefaultRESTPort is the REST listener port used when REST_PORT is unset. It is +// also the port the --healthcheck probe targets (US-PLATFORM-05), so both the +// server and its probe share one source of truth. +const DefaultRESTPort = "8081" + +const defaultGRPCPort = "9081" + +// Cleanup cadence defaults for the blacklist sweep background worker. +const ( + defaultCleanupInterval = 5 * time.Minute + defaultCleanupTimeout = 30 * time.Second ) // Config holds all configuration for the auth service, loaded from environment variables. type Config struct { - DBUrl string - JWTSecret string - BcryptCost int - LogLevel string - Environment string - RESTPort string - GRPCPort string - CookieDomain string + DBUrl string + JWTSecret string + BcryptCost int + LogLevel string + Environment string + RESTPort string + GRPCPort string + CookieDomain string + JWTAccessTTL time.Duration + JWTRefreshTTL time.Duration + CleanupInterval time.Duration + CleanupTimeout time.Duration +} + +// RESTPort returns the configured REST port from REST_PORT, or DefaultRESTPort. +// It is exported so the --healthcheck probe, which runs before the full config +// Load, targets the same port the server listens on (US-PLATFORM-05). +func RESTPort() string { + if p := os.Getenv("REST_PORT"); p != "" { + return p + } + return DefaultRESTPort } // Load reads configuration from environment variables and returns a Config. @@ -53,28 +83,62 @@ func Load() (*Config, error) { environment = "development" } - restPort := os.Getenv("REST_PORT") - if restPort == "" { - restPort = "8081" - } - grpcPort := os.Getenv("GRPC_PORT") if grpcPort == "" { - grpcPort = "9081" + grpcPort = defaultGRPCPort + } + + jwtAccessTTL, err := durationEnv("JWT_ACCESS_TTL", service.DefaultAccessTokenTTL) + if err != nil { + return nil, err + } + + jwtRefreshTTL, err := durationEnv("JWT_REFRESH_TTL", service.DefaultRefreshTokenTTL) + if err != nil { + return nil, err + } + + cleanupInterval, err := durationEnv("CLEANUP_INTERVAL", defaultCleanupInterval) + if err != nil { + return nil, err + } + + cleanupTimeout, err := durationEnv("CLEANUP_TIMEOUT", defaultCleanupTimeout) + if err != nil { + return nil, err } return &Config{ - DBUrl: dbURL, - JWTSecret: jwtSecret, - BcryptCost: bcryptCost, - LogLevel: logLevel, - Environment: environment, - RESTPort: restPort, - GRPCPort: grpcPort, - CookieDomain: os.Getenv("COOKIE_DOMAIN"), + DBUrl: dbURL, + JWTSecret: jwtSecret, + BcryptCost: bcryptCost, + LogLevel: logLevel, + Environment: environment, + RESTPort: RESTPort(), + GRPCPort: grpcPort, + CookieDomain: os.Getenv("COOKIE_DOMAIN"), + JWTAccessTTL: jwtAccessTTL, + JWTRefreshTTL: jwtRefreshTTL, + CleanupInterval: cleanupInterval, + CleanupTimeout: cleanupTimeout, }, nil } +// durationEnv reads a time.Duration from the named env var, falling back to def +// when unset. A malformed value is a load-time error rather than a silent +// fallback so misconfiguration surfaces at boot. +func durationEnv(key string, def time.Duration) (time.Duration, error) { + val := os.Getenv(key) + if val == "" { + return def, nil + } + d, err := time.ParseDuration(val) + if err != nil { + return 0, fmt.Errorf("%s must be a valid duration: %w", key, err) + } + return d, nil +} + // IsProduction returns true if the environment is not "development". func (c *Config) IsProduction() bool { return c.Environment != "development" diff --git a/services/auth/internal/config/config_test.go b/services/auth/internal/config/config_test.go index 5b902b20..3be6dc58 100644 --- a/services/auth/internal/config/config_test.go +++ b/services/auth/internal/config/config_test.go @@ -2,9 +2,12 @@ package config import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/auth/internal/service" ) func TestLoad_RequiredVars(t *testing.T) { @@ -145,16 +148,94 @@ func TestIsProduction(t *testing.T) { assert.True(t, (&Config{Environment: "staging"}).IsProduction()) } -// clearEnv unsets all config env vars so tests are isolated. +func TestLoad_DurationDefaults(t *testing.T) { + clearEnv(t) + setEnv(t, map[string]string{ + "AUTH_DB_URL": "postgres://localhost/test", + "JWT_SECRET": "secret", + }) + + cfg, err := Load() + require.NoError(t, err) + + // Defaults are single-sourced from the JWTService's documented lifetimes. + assert.Equal(t, service.DefaultAccessTokenTTL, cfg.JWTAccessTTL) + assert.Equal(t, service.DefaultRefreshTokenTTL, cfg.JWTRefreshTTL) + assert.Equal(t, 15*time.Minute, cfg.JWTAccessTTL) + assert.Equal(t, 7*24*time.Hour, cfg.JWTRefreshTTL) + assert.Equal(t, 5*time.Minute, cfg.CleanupInterval) + assert.Equal(t, 30*time.Second, cfg.CleanupTimeout) +} + +func TestLoad_DurationOverrides(t *testing.T) { + clearEnv(t) + setEnv(t, map[string]string{ + "AUTH_DB_URL": "postgres://localhost/test", + "JWT_SECRET": "secret", + "JWT_ACCESS_TTL": "30m", + "JWT_REFRESH_TTL": "48h", + "CLEANUP_INTERVAL": "1m", + "CLEANUP_TIMEOUT": "10s", + }) + + cfg, err := Load() + require.NoError(t, err) + + assert.Equal(t, 30*time.Minute, cfg.JWTAccessTTL) + assert.Equal(t, 48*time.Hour, cfg.JWTRefreshTTL) + assert.Equal(t, time.Minute, cfg.CleanupInterval) + assert.Equal(t, 10*time.Second, cfg.CleanupTimeout) +} + +func TestLoad_InvalidDuration(t *testing.T) { + tests := []struct { + name string + key string + }{ + {name: "access ttl", key: "JWT_ACCESS_TTL"}, + {name: "refresh ttl", key: "JWT_REFRESH_TTL"}, + {name: "cleanup interval", key: "CLEANUP_INTERVAL"}, + {name: "cleanup timeout", key: "CLEANUP_TIMEOUT"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t) + setEnv(t, map[string]string{ + "AUTH_DB_URL": "postgres://localhost/test", + "JWT_SECRET": "secret", + tt.key: "not-a-duration", + }) + + _, err := Load() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.key) + assert.Contains(t, err.Error(), "must be a valid duration") + }) + } +} + +func TestRESTPort(t *testing.T) { + clearEnv(t) + assert.Equal(t, DefaultRESTPort, RESTPort()) + + t.Setenv("REST_PORT", "9090") + assert.Equal(t, "9090", RESTPort()) +} + +// clearEnv resets all config env vars to empty for test isolation. Load treats +// an empty value as unset (each read guards on ""), so the "missing var" cases +// behave as if the var were absent, while t.Setenv still restores the original +// on cleanup. func clearEnv(t *testing.T) { t.Helper() for _, key := range []string{ "AUTH_DB_URL", "JWT_SECRET", "BCRYPT_COST", "LOG_LEVEL", "ENVIRONMENT", "REST_PORT", "GRPC_PORT", + "COOKIE_DOMAIN", "JWT_ACCESS_TTL", "JWT_REFRESH_TTL", + "CLEANUP_INTERVAL", "CLEANUP_TIMEOUT", } { t.Setenv(key, "") - // t.Setenv restores original value on cleanup, but we need - // the var to be truly unset for "missing" tests } } diff --git a/services/auth/internal/handler/grpc.go b/services/auth/internal/handler/grpc.go index 6ff040c5..81285ab6 100644 --- a/services/auth/internal/handler/grpc.go +++ b/services/auth/internal/handler/grpc.go @@ -2,18 +2,22 @@ package handler import ( "context" + "errors" "log/slog" + "net/http" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/internal/service" pb "github.com/ItsThompson/gofin/services/auth/proto/authpb" ) -// GRPCHandler implements the AuthService gRPC server. -// Register, Login, and ValidateToken are implemented. -// All other RPCs return Unimplemented (stubs for later tickets). +// GRPCHandler implements the AuthService gRPC server. Register and Login +// deliberately return codes.Unimplemented directing callers to the REST +// endpoints. RPCs without an explicit method are served by the embedded +// UnimplementedAuthServiceServer. type GRPCHandler struct { pb.UnimplementedAuthServiceServer authService *service.AuthService @@ -28,6 +32,14 @@ func NewGRPCHandler(authService *service.AuthService, logger *slog.Logger) *GRPC } } +// isMissingUser reports whether err is (or wraps) the service's "user not +// found" signal, which GetUserByID surfaces as a 401 *apierr.Error. errors.As +// unwraps %w chains, so a wrapped typed error still classifies correctly (C7). +func isMissingUser(err error) bool { + var apiErr *apierr.Error + return errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized +} + func (h *GRPCHandler) ValidateToken(ctx context.Context, req *pb.ValidateTokenRequest) (*pb.ValidateTokenResponse, error) { result, err := h.authService.ValidateToken(ctx, req.GetAccessToken()) if err != nil { @@ -56,26 +68,6 @@ func (h *GRPCHandler) Login(ctx context.Context, req *pb.LoginRequest) (*pb.Auth return nil, status.Error(codes.Unimplemented, "use REST endpoint POST /api/auth/login") } -func (h *GRPCHandler) RefreshToken(ctx context.Context, req *pb.RefreshTokenRequest) (*pb.AuthResponse, error) { - return nil, status.Error(codes.Unimplemented, "RefreshToken not yet implemented") -} - -func (h *GRPCHandler) Logout(ctx context.Context, req *pb.LogoutRequest) (*pb.LogoutResponse, error) { - return nil, status.Error(codes.Unimplemented, "Logout not yet implemented") -} - -func (h *GRPCHandler) AssumeIdentity(ctx context.Context, req *pb.AssumeIdentityRequest) (*pb.AuthResponse, error) { - return nil, status.Error(codes.Unimplemented, "AssumeIdentity not yet implemented") -} - -func (h *GRPCHandler) RestoreIdentity(ctx context.Context, req *pb.RestoreIdentityRequest) (*pb.AuthResponse, error) { - return nil, status.Error(codes.Unimplemented, "RestoreIdentity not yet implemented") -} - -func (h *GRPCHandler) ListUsers(ctx context.Context, req *pb.ListUsersRequest) (*pb.ListUsersResponse, error) { - return nil, status.Error(codes.Unimplemented, "ListUsers not yet implemented") -} - func (h *GRPCHandler) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.UserResponse, error) { userID := req.GetUserId() if userID == "" { @@ -84,7 +76,7 @@ func (h *GRPCHandler) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb. user, err := h.authService.GetUserByID(ctx, userID) if err != nil { - if authErr, ok := err.(*service.AuthError); ok && authErr.Status == 401 { + if isMissingUser(err) { return nil, status.Error(codes.NotFound, "user not found") } h.logger.Error("failed to get user", @@ -106,14 +98,6 @@ func (h *GRPCHandler) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb. }, nil } -func (h *GRPCHandler) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.UserResponse, error) { - return nil, status.Error(codes.Unimplemented, "UpdateUser not yet implemented") -} - -func (h *GRPCHandler) ChangePassword(ctx context.Context, req *pb.ChangePasswordRequest) (*pb.ChangePasswordResponse, error) { - return nil, status.Error(codes.Unimplemented, "ChangePassword not yet implemented") -} - func (h *GRPCHandler) VerifyPassword(ctx context.Context, req *pb.VerifyPasswordRequest) (*pb.VerifyPasswordResponse, error) { userID := req.GetUserId() if userID == "" { @@ -126,7 +110,7 @@ func (h *GRPCHandler) VerifyPassword(ctx context.Context, req *pb.VerifyPassword user, err := h.authService.GetUserByID(ctx, userID) if err != nil { - if authErr, ok := err.(*service.AuthError); ok && authErr.Status == 401 { + if isMissingUser(err) { return nil, status.Error(codes.NotFound, "user not found") } h.logger.Error("failed to look up user for password verification", diff --git a/services/auth/internal/handler/grpc_test.go b/services/auth/internal/handler/grpc_test.go index b329473f..849d518d 100644 --- a/services/auth/internal/handler/grpc_test.go +++ b/services/auth/internal/handler/grpc_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "errors" "fmt" "io" "log/slog" @@ -14,6 +15,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/internal/model" "github.com/ItsThompson/gofin/services/auth/internal/service" pb "github.com/ItsThompson/gofin/services/auth/proto/authpb" @@ -39,6 +41,25 @@ func newTestGRPCHandlerWithBlacklist() (*GRPCHandler, *mockUserRepository, *mock return NewGRPCHandler(authSvc, logger), repo, blacklistRepo } +// TestIsMissingUser_ClassifiesWrappedTypedError locks in the C7 gRPC change: +// the handlers classify the service's "user not found" signal with errors.As, +// so a %w-wrapped *apierr.Error (401) is still recognized and mapped to +// codes.NotFound, while unrelated errors and non-401 apierr.Errors are not. +func TestIsMissingUser_ClassifiesWrappedTypedError(t *testing.T) { + // Bare 401 (what GetUserByID returns for a missing user). + assert.True(t, isMissingUser(apierr.Unauthorized("User not found"))) + + // %w-wrapped 401 still classifies via errors.As. + wrapped := fmt.Errorf("looking up user: %w", apierr.Unauthorized("User not found")) + assert.True(t, isMissingUser(wrapped)) + + // A 404 apierr.Error is a different case, not "missing user" for these RPCs. + assert.False(t, isMissingUser(apierr.NotFound("Target user not found"))) + + // A plain error is not classified. + assert.False(t, isMissingUser(errors.New("db connection lost"))) +} + func TestGRPCValidateToken_Success(t *testing.T) { handler, jwtSvc, repo := newTestGRPCHandler() @@ -72,40 +93,6 @@ func TestGRPCValidateToken_Invalid(t *testing.T) { assert.Equal(t, codes.Unauthenticated, st.Code()) } -func TestGRPCStubs_ReturnUnimplemented(t *testing.T) { - handler, _, _ := newTestGRPCHandler() - ctx := context.Background() - - _, err := handler.RefreshToken(ctx, &pb.RefreshTokenRequest{}) - assertUnimplemented(t, err) - - _, err = handler.Logout(ctx, &pb.LogoutRequest{}) - assertUnimplemented(t, err) - - _, err = handler.AssumeIdentity(ctx, &pb.AssumeIdentityRequest{}) - assertUnimplemented(t, err) - - _, err = handler.RestoreIdentity(ctx, &pb.RestoreIdentityRequest{}) - assertUnimplemented(t, err) - - _, err = handler.ListUsers(ctx, &pb.ListUsersRequest{}) - assertUnimplemented(t, err) - - _, err = handler.UpdateUser(ctx, &pb.UpdateUserRequest{}) - assertUnimplemented(t, err) - - _, err = handler.ChangePassword(ctx, &pb.ChangePasswordRequest{}) - assertUnimplemented(t, err) -} - -func assertUnimplemented(t *testing.T, err error) { - t.Helper() - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unimplemented, st.Code()) -} - func TestGRPCGetUser_Success(t *testing.T) { handler, _, repo := newTestGRPCHandler() diff --git a/services/auth/internal/handler/refresh_integration_test.go b/services/auth/internal/handler/refresh_integration_test.go index c9802fd3..46dc2bd3 100644 --- a/services/auth/internal/handler/refresh_integration_test.go +++ b/services/auth/internal/handler/refresh_integration_test.go @@ -120,7 +120,7 @@ func TestConcurrentRefresh_Integration(t *testing.T) { authSvc := service.NewAuthService(userRepo, blacklistRepo, jwtSvc, pwdSvc, logger) gin.SetMode(gin.TestMode) - h := handler.NewRESTHandler(authSvc, logger, false, "") + h := handler.NewRESTHandler(authSvc, logger, false, "", service.DefaultAccessTokenTTL, service.DefaultRefreshTokenTTL) router := gin.New() h.RegisterRoutes(router) diff --git a/services/auth/internal/handler/registration_test.go b/services/auth/internal/handler/registration_test.go index ee02f4ea..d410a490 100644 --- a/services/auth/internal/handler/registration_test.go +++ b/services/auth/internal/handler/registration_test.go @@ -22,7 +22,7 @@ func TestRegisterRoutes_MatchesRegistry(t *testing.T) { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) engine := gin.New() - NewRESTHandler(nil, logger, false, "").RegisterRoutes(engine) + NewRESTHandler(nil, logger, false, "", 0, 0).RegisterRoutes(engine) registered := make([]access.RegisteredRoute, 0) for _, r := range engine.Routes() { diff --git a/services/auth/internal/handler/rest.go b/services/auth/internal/handler/rest.go index caeafbeb..9642175e 100644 --- a/services/auth/internal/handler/rest.go +++ b/services/auth/internal/handler/rest.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" "time" @@ -8,26 +9,34 @@ import ( "github.com/gin-gonic/gin" "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/internal/model" "github.com/ItsThompson/gofin/services/auth/internal/service" + "github.com/ItsThompson/gofin/services/httpx" "github.com/ItsThompson/gofin/services/metrics" ) // RESTHandler handles HTTP requests for the auth service. type RESTHandler struct { - authService *service.AuthService - logger *slog.Logger - cookieSecure bool - cookieDomain string + authService *service.AuthService + logger *slog.Logger + cookieSecure bool + cookieDomain string + accessTokenTTL time.Duration + refreshTokenTTL time.Duration } -// NewRESTHandler creates a new RESTHandler. -func NewRESTHandler(authService *service.AuthService, logger *slog.Logger, cookieSecure bool, cookieDomain string) *RESTHandler { +// NewRESTHandler creates a new RESTHandler. accessTokenTTL and refreshTokenTTL +// set the auth-cookie max-ages; they are sourced from the same config as the +// JWT TTLs so the cookie lifetime always tracks the token lifetime. +func NewRESTHandler(authService *service.AuthService, logger *slog.Logger, cookieSecure bool, cookieDomain string, accessTokenTTL, refreshTokenTTL time.Duration) *RESTHandler { return &RESTHandler{ - authService: authService, - logger: logger, - cookieSecure: cookieSecure, - cookieDomain: cookieDomain, + authService: authService, + logger: logger, + cookieSecure: cookieSecure, + cookieDomain: cookieDomain, + accessTokenTTL: accessTokenTTL, + refreshTokenTTL: refreshTokenTTL, } } @@ -65,11 +74,7 @@ func (h *RESTHandler) Register(c *gin.Context) { start := time.Now() var req model.RegisterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } @@ -97,11 +102,7 @@ func (h *RESTHandler) Login(c *gin.Context) { start := time.Now() var req model.LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } @@ -127,12 +128,8 @@ func (h *RESTHandler) Login(c *gin.Context) { // Me handles GET /api/auth/me. // Returns the current user based on the X-User-ID header set by the gateway. func (h *RESTHandler) Me(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } @@ -150,21 +147,13 @@ func (h *RESTHandler) Me(c *gin.Context) { // CompleteOnboarding handles POST /api/auth/onboarding-complete. // Marks the user's onboarding as done and updates currency on the auth record. func (h *RESTHandler) CompleteOnboarding(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.CompleteOnboardingRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } @@ -179,29 +168,30 @@ func (h *RESTHandler) CompleteOnboarding(c *gin.Context) { }) } -// setAuthCookies sets the access and refresh token httpOnly cookies. +// setAuthCookies sets the access and refresh token httpOnly cookies. Each +// cookie's max-age tracks the corresponding JWT TTL (sourced from config), so +// the cookie lifetime never drifts from the token lifetime. func (h *RESTHandler) setAuthCookies(c *gin.Context, tokens *model.TokenPair) { c.SetSameSite(http.SameSiteStrictMode) - // Access token: 15-minute expiry, root path so the Grafana auth proxy - // (on a separate port) can also receive the cookie. + // Access token cookie: root path so the Grafana auth proxy (on a separate + // port) can also receive the cookie. c.SetCookie( - "gofin_access", // name - tokens.AccessToken, // value - int(15*time.Minute/time.Second), // maxAge in seconds - "/", // path - h.cookieDomain, // domain - h.cookieSecure, // secure - true, // httpOnly + "gofin_access", // name + tokens.AccessToken, // value + int(h.accessTokenTTL/time.Second), // maxAge in seconds + "/", // path + h.cookieDomain, // domain + h.cookieSecure, // secure + true, // httpOnly ) - // Refresh token: 7-day expiry, scoped to /api/auth - // Path is /api/auth (not /api/auth/refresh) so both the refresh and - // logout endpoints can read the cookie. + // Refresh token cookie: scoped to /api/auth (not /api/auth/refresh) so both + // the refresh and logout endpoints can read the cookie. c.SetCookie( "gofin_refresh", tokens.RefreshToken, - int(7*24*time.Hour/time.Second), + int(h.refreshTokenTTL/time.Second), "/api/auth", h.cookieDomain, h.cookieSecure, @@ -209,23 +199,18 @@ func (h *RESTHandler) setAuthCookies(c *gin.Context, tokens *model.TokenPair) { ) } -// handleError maps service errors to HTTP responses following the ApiError contract. +// handleError logs unexpected (non-apierr) errors before delegating to +// apierr.Respond, which owns the {code, message, fields?} wire mapping. +// apierr.Respond takes no logger, so logging here preserves the 500 +// observability the service relies on. func (h *RESTHandler) handleError(c *gin.Context, err error) { - if authErr, ok := err.(*service.AuthError); ok { - c.JSON(authErr.Status, model.ApiError{ - Code: authErr.Code, - Message: authErr.Message, - }) - return + var apiErr *apierr.Error + if !errors.As(err, &apiErr) { + h.logger.Error("unexpected error", + slog.String("error", err.Error()), + ) } - - h.logger.Error("unexpected error", - slog.String("error", err.Error()), - ) - c.JSON(http.StatusInternalServerError, model.ApiError{ - Code: model.ErrInternalServerError, - Message: "An unexpected error occurred", - }) + apierr.Respond(c, err) } // clearAuthCookies removes both auth cookies by setting MaxAge to -1 @@ -244,10 +229,7 @@ func (h *RESTHandler) Refresh(c *gin.Context) { cookie, err := c.Request.Cookie("gofin_refresh") if err != nil || cookie.Value == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "No refresh token provided", - }) + apierr.Respond(c, apierr.Unauthorized("No refresh token provided")) return } @@ -329,21 +311,13 @@ func (h *RESTHandler) ListUsers(c *gin.Context) { func (h *RESTHandler) AssumeIdentity(c *gin.Context) { start := time.Now() - adminUserID := c.GetHeader("X-User-ID") - if adminUserID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + adminUserID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.AssumeIdentityRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } @@ -375,10 +349,7 @@ func (h *RESTHandler) RestoreIdentity(c *gin.Context) { assumedBy := c.GetHeader("X-Assumed-By") if assumedBy == "" { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "No assumed identity to restore", - }) + apierr.Respond(c, apierr.Validation("No assumed identity to restore", nil)) return } @@ -406,21 +377,13 @@ func (h *RESTHandler) RestoreIdentity(c *gin.Context) { func (h *RESTHandler) UpdateProfile(c *gin.Context) { start := time.Now() - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.UpdateProfileRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } @@ -447,21 +410,13 @@ func (h *RESTHandler) UpdateProfile(c *gin.Context) { func (h *RESTHandler) ChangePassword(c *gin.Context) { start := time.Now() - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.ChangePasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } diff --git a/services/auth/internal/handler/rest_test.go b/services/auth/internal/handler/rest_test.go index 6553b302..fdd0aee8 100644 --- a/services/auth/internal/handler/rest_test.go +++ b/services/auth/internal/handler/rest_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/internal/model" "github.com/ItsThompson/gofin/services/auth/internal/repository" "github.com/ItsThompson/gofin/services/auth/internal/service" @@ -142,7 +143,8 @@ func setupTestRouterWithBlacklist(repo *mockUserRepository, blacklistRepo *mockB pwdSvc := service.NewPasswordService(4) authSvc := service.NewAuthService(repo, blacklistRepo, jwtSvc, pwdSvc, logger) - handler := NewRESTHandler(authSvc, logger, false, "") + // TTLs match the JWTService defaults so cookie max-ages stay 900 / 604800. + handler := NewRESTHandler(authSvc, logger, false, "", service.DefaultAccessTokenTTL, service.DefaultRefreshTokenTTL) r := gin.New() handler.RegisterRoutes(r) return r @@ -221,7 +223,7 @@ func TestRegisterHandler_WeakPassword(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrWeakPassword, errResp.Code) } @@ -243,7 +245,7 @@ func TestRegisterHandler_DuplicateEmail(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrDuplicateEmail, errResp.Code) } @@ -259,9 +261,11 @@ func TestRegisterHandler_InvalidJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) + // Malformed JSON carries no per-field detail, so fields is omitted. + assert.Nil(t, errResp.Fields) } func TestRegisterHandler_MissingFields(t *testing.T) { @@ -276,9 +280,32 @@ func TestRegisterHandler_MissingFields(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) +} + +// TestRegisterHandler_ValidationFields_C6 asserts that a validator-detected +// field error now surfaces the offending field in the response `fields` map +// (the wire slot auth dropped before the apierr/httpx migration: C6). +func TestRegisterHandler_ValidationFields_C6(t *testing.T) { + repo := new(mockUserRepository) + r := setupTestRouter(repo) + + // Valid username + password but a malformed email fails the `email` rule. + w := doJSON(r, "POST", "/api/auth/register", map[string]string{ + "username": "testuser", + "email": "not-an-email", + "password": "ValidPass1", + }) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var errResp apierr.APIError + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Equal(t, apierr.CodeValidation, errResp.Code) + require.NotEmpty(t, errResp.Fields, "validation error should carry field detail") + assert.Contains(t, errResp.Fields, "Email") } // --- Login Handler Tests --- @@ -301,7 +328,7 @@ func TestRegisterHandler_DuplicateEmailFromConstraint(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrDuplicateEmail, errResp.Code) } @@ -323,7 +350,7 @@ func TestRegisterHandler_DuplicateUsernameFromConstraint(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrDuplicateUsername, errResp.Code) } @@ -386,7 +413,7 @@ func TestLoginHandler_InvalidCredentials(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrInvalidCredentials, errResp.Code) assert.Equal(t, "Invalid email or password", errResp.Message) @@ -437,11 +464,11 @@ func TestRefreshHandler_Success(t *testing.T) { blacklistRepo.On("ConsumeToken", mock.Anything, refreshClaims.ID, "user-123", mock.AnythingOfType("time.Time")).Return(true, nil) repo.On("GetUserByID", mock.Anything, "user-123").Return(&model.User{ - ID: "user-123", - Username: "testuser", - Email: "test@example.com", - Role: "user", - Currency: "USD", + ID: "user-123", + Username: "testuser", + Email: "test@example.com", + Role: "user", + Currency: "USD", CreatedAt: time.Now(), }, nil) @@ -478,9 +505,9 @@ func TestRefreshHandler_NoCookie(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrUnauthorized, errResp.Code) + assert.Equal(t, apierr.CodeUnauthorized, errResp.Code) } func TestRefreshHandler_BlacklistedToken(t *testing.T) { @@ -503,9 +530,9 @@ func TestRefreshHandler_BlacklistedToken(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrUnauthorized, errResp.Code) + assert.Equal(t, apierr.CodeUnauthorized, errResp.Code) } // --- Logout Handler Tests --- @@ -612,9 +639,9 @@ func TestCompleteOnboardingHandler_MissingUserID(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrUnauthorized, errResp.Code) + assert.Equal(t, apierr.CodeUnauthorized, errResp.Code) } func TestCompleteOnboardingHandler_InvalidBody(t *testing.T) { @@ -625,9 +652,9 @@ func TestCompleteOnboardingHandler_InvalidBody(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) } // --- ListUsers Handler Tests --- @@ -760,7 +787,7 @@ func TestRestoreIdentityHandler_NoAssumedBy(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) } diff --git a/services/auth/internal/model/errors.go b/services/auth/internal/model/errors.go index 28d5c18b..36cebd19 100644 --- a/services/auth/internal/model/errors.go +++ b/services/auth/internal/model/errors.go @@ -1,22 +1,12 @@ package model -// ApiError follows the error contract from 10-nonfunctional.md: -// { code, message, fields? } -type ApiError struct { - Code string `json:"code"` - Message string `json:"message"` - Fields map[string]string `json:"fields,omitempty"` -} - -// Common error codes +// Auth-specific error codes. The cross-service codes (UNAUTHORIZED, NOT_FOUND, +// VALIDATION_ERROR, INTERNAL_SERVER_ERROR, FORBIDDEN) live in the shared apierr +// package; only codes unique to the auth domain remain here. They are still +// valid apierr.Error Code strings. const ( - ErrDuplicateEmail = "DUPLICATE_EMAIL" - ErrDuplicateUsername = "DUPLICATE_USERNAME" - ErrInvalidCredentials = "INVALID_CREDENTIALS" - ErrWeakPassword = "WEAK_PASSWORD" - ErrValidationError = "VALIDATION_ERROR" - ErrUnauthorized = "UNAUTHORIZED" - ErrForbidden = "FORBIDDEN" - ErrNotFound = "NOT_FOUND" - ErrInternalServerError = "INTERNAL_SERVER_ERROR" + ErrDuplicateEmail = "DUPLICATE_EMAIL" + ErrDuplicateUsername = "DUPLICATE_USERNAME" + ErrInvalidCredentials = "INVALID_CREDENTIALS" + ErrWeakPassword = "WEAK_PASSWORD" ) diff --git a/services/auth/internal/model/user.go b/services/auth/internal/model/user.go index 49f7eef3..721aa114 100644 --- a/services/auth/internal/model/user.go +++ b/services/auth/internal/model/user.go @@ -2,6 +2,12 @@ package model import "time" +// Role values stored on the user record and carried in JWT claims. +const ( + RoleUser = "user" + RoleAdmin = "admin" +) + // User represents a user in the auth domain. type User struct { ID string `json:"id"` diff --git a/services/auth/internal/repository/blacklist_postgres.go b/services/auth/internal/repository/blacklist_postgres.go index ee2a99c7..0e63390d 100644 --- a/services/auth/internal/repository/blacklist_postgres.go +++ b/services/auth/internal/repository/blacklist_postgres.go @@ -2,13 +2,12 @@ package repository import ( "context" - "errors" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/ItsThompson/gofin/services/auth/internal/db" + "github.com/ItsThompson/gofin/services/pgutil" ) // PostgresBlacklistRepository implements BlacklistRepository using sqlc-generated queries. @@ -22,14 +21,14 @@ func NewPostgresBlacklistRepository(queries *db.Queries) *PostgresBlacklistRepos } func (r *PostgresBlacklistRepository) ConsumeToken(ctx context.Context, jti, userID string, expiresAt time.Time) (bool, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return false, err } // ConsumeRefreshToken returns the jti if INSERT succeeded, // or pgx.ErrNoRows if ON CONFLICT triggered (already consumed). - _, err := r.queries.ConsumeRefreshToken(ctx, db.ConsumeRefreshTokenParams{ + _, err = r.queries.ConsumeRefreshToken(ctx, db.ConsumeRefreshTokenParams{ Jti: jti, UserID: uid, ExpiresAt: pgtype.Timestamptz{ @@ -38,7 +37,7 @@ func (r *PostgresBlacklistRepository) ConsumeToken(ctx context.Context, jti, use }, }) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return false, nil // Already consumed: not an error, just a signal } return false, err // Actual DB error @@ -47,8 +46,8 @@ func (r *PostgresBlacklistRepository) ConsumeToken(ctx context.Context, jti, use } func (r *PostgresBlacklistRepository) BlacklistToken(ctx context.Context, jti, userID string, expiresAt time.Time) error { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return err } @@ -67,8 +66,8 @@ func (r *PostgresBlacklistRepository) CleanupExpired(ctx context.Context) error } func (r *PostgresBlacklistRepository) DeleteByUserID(ctx context.Context, userID string) error { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return err } return r.queries.DeleteRefreshTokenBlacklist(ctx, uid) diff --git a/services/auth/internal/repository/postgres.go b/services/auth/internal/repository/postgres.go index 944c8055..7d166346 100644 --- a/services/auth/internal/repository/postgres.go +++ b/services/auth/internal/repository/postgres.go @@ -2,16 +2,13 @@ package repository import ( "context" - "errors" - "fmt" "time" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" "github.com/ItsThompson/gofin/services/auth/internal/db" "github.com/ItsThompson/gofin/services/auth/internal/model" + "github.com/ItsThompson/gofin/services/pgutil" ) // PostgresUserRepository implements UserRepository using sqlc-generated queries. @@ -33,9 +30,8 @@ func (r *PostgresUserRepository) CreateUser(ctx context.Context, username, email Currency: currency, }) if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { - return nil, &DuplicateError{Constraint: pgErr.ConstraintName} + if constraint, ok := pgutil.IsUniqueViolation(err); ok { + return nil, &DuplicateError{Constraint: constraint} } return nil, err } @@ -45,7 +41,7 @@ func (r *PostgresUserRepository) CreateUser(ctx context.Context, username, email func (r *PostgresUserRepository) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { dbUser, err := r.queries.GetUserByEmail(ctx, email) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -54,14 +50,14 @@ func (r *PostgresUserRepository) GetUserByEmail(ctx context.Context, email strin } func (r *PostgresUserRepository) GetUserByID(ctx context.Context, id string) (*model.User, error) { - uid := pgtype.UUID{} - if err := uid.Scan(id); err != nil { + uid, err := pgutil.ParseUUID(id) + if err != nil { return nil, err } dbUser, err := r.queries.GetUserByID(ctx, uid) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -72,7 +68,7 @@ func (r *PostgresUserRepository) GetUserByID(ctx context.Context, id string) (*m func (r *PostgresUserRepository) GetUserByUsername(ctx context.Context, username string) (*model.User, error) { dbUser, err := r.queries.GetUserByUsername(ctx, username) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -84,8 +80,7 @@ func (r *PostgresUserRepository) GetUserByUsername(ctx context.Context, username func dbUserToModel(u db.AuthUser) *model.User { id := "" if u.ID.Valid { - idBytes := u.ID.Bytes - id = formatUUID(idBytes) + id = uuid.UUID(u.ID.Bytes).String() } return &model.User{ @@ -101,15 +96,9 @@ func dbUserToModel(u db.AuthUser) *model.User { } } -// formatUUID formats a [16]byte as a UUID string. -func formatUUID(b [16]byte) string { - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) -} - func (r *PostgresUserRepository) CompleteOnboarding(ctx context.Context, userID string, currency string) (*model.User, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return nil, err } @@ -118,7 +107,7 @@ func (r *PostgresUserRepository) CompleteOnboarding(ctx context.Context, userID ID: uid, }) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -136,7 +125,7 @@ func (r *PostgresUserRepository) ListAllUsers(ctx context.Context) ([]*model.Use for _, row := range rows { id := "" if row.ID.Valid { - id = formatUUID(row.ID.Bytes) + id = uuid.UUID(row.ID.Bytes).String() } users = append(users, &model.User{ ID: id, @@ -150,8 +139,8 @@ func (r *PostgresUserRepository) ListAllUsers(ctx context.Context) ([]*model.Use } func (r *PostgresUserRepository) UpdateUser(ctx context.Context, userID, username, email, currency string) (*model.User, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return nil, err } @@ -162,11 +151,10 @@ func (r *PostgresUserRepository) UpdateUser(ctx context.Context, userID, usernam ID: uid, }) if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { - return nil, &DuplicateError{Constraint: pgErr.ConstraintName} + if constraint, ok := pgutil.IsUniqueViolation(err); ok { + return nil, &DuplicateError{Constraint: constraint} } - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -175,8 +163,8 @@ func (r *PostgresUserRepository) UpdateUser(ctx context.Context, userID, usernam } func (r *PostgresUserRepository) UpdatePassword(ctx context.Context, userID, passwordHash string) error { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return err } @@ -187,8 +175,8 @@ func (r *PostgresUserRepository) UpdatePassword(ctx context.Context, userID, pas } func (r *PostgresUserRepository) RevokeAllUserTokens(ctx context.Context, userID string) error { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return err } @@ -196,14 +184,14 @@ func (r *PostgresUserRepository) RevokeAllUserTokens(ctx context.Context, userID } func (r *PostgresUserRepository) GetTokensRevokedAt(ctx context.Context, userID string) (*time.Time, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return nil, err } ts, err := r.queries.GetTokensRevokedAt(ctx, uid) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -216,8 +204,8 @@ func (r *PostgresUserRepository) GetTokensRevokedAt(ctx context.Context, userID } func (r *PostgresUserRepository) DeleteUser(ctx context.Context, userID string) error { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { + uid, err := pgutil.ParseUUID(userID) + if err != nil { return err } diff --git a/services/auth/internal/service/auth.go b/services/auth/internal/service/auth.go index adcce00f..91533310 100644 --- a/services/auth/internal/service/auth.go +++ b/services/auth/internal/service/auth.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "log/slog" + "net/http" "strings" "time" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/internal/model" "github.com/ItsThompson/gofin/services/auth/internal/repository" ) @@ -44,10 +46,10 @@ func (s *AuthService) Register(ctx context.Context, req *model.RegisterRequest) // Validate password strength if err := ValidatePasswordStrength(req.Password); err != nil { - return nil, nil, &AuthError{ + return nil, nil, &apierr.Error{ Code: model.ErrWeakPassword, Message: err.Error(), - Status: 400, + Status: http.StatusBadRequest, } } @@ -61,11 +63,7 @@ func (s *AuthService) Register(ctx context.Context, req *model.RegisterRequest) return nil, nil, fmt.Errorf("checking email uniqueness: %w", err) } if existingByEmail != nil { - return nil, nil, &AuthError{ - Code: model.ErrDuplicateEmail, - Message: "An account with this email already exists", - Status: 409, - } + return nil, nil, apierr.Conflict(model.ErrDuplicateEmail, "An account with this email already exists") } // Check for duplicate username @@ -74,11 +72,7 @@ func (s *AuthService) Register(ctx context.Context, req *model.RegisterRequest) return nil, nil, fmt.Errorf("checking username uniqueness: %w", err) } if existingByUsername != nil { - return nil, nil, &AuthError{ - Code: model.ErrDuplicateUsername, - Message: "This username is already taken", - Status: 409, - } + return nil, nil, apierr.Conflict(model.ErrDuplicateUsername, "This username is already taken") } // Hash password @@ -88,22 +82,14 @@ func (s *AuthService) Register(ctx context.Context, req *model.RegisterRequest) } // Create user - user, err := s.repo.CreateUser(ctx, username, email, hash, "user", "USD") + user, err := s.repo.CreateUser(ctx, username, email, hash, model.RoleUser, "USD") if err != nil { var dupErr *repository.DuplicateError if errors.As(err, &dupErr) { if strings.Contains(dupErr.Constraint, "email") { - return nil, nil, &AuthError{ - Code: model.ErrDuplicateEmail, - Message: "An account with this email already exists", - Status: 409, - } - } - return nil, nil, &AuthError{ - Code: model.ErrDuplicateUsername, - Message: "This username is already taken", - Status: 409, + return nil, nil, apierr.Conflict(model.ErrDuplicateEmail, "An account with this email already exists") } + return nil, nil, apierr.Conflict(model.ErrDuplicateUsername, "This username is already taken") } return nil, nil, fmt.Errorf("creating user: %w", err) } @@ -139,19 +125,19 @@ func (s *AuthService) Login(ctx context.Context, req *model.LoginRequest) (*mode // Generic error for both "user not found" and "wrong password" (no field hints) if user == nil { - return nil, nil, &AuthError{ + return nil, nil, &apierr.Error{ Code: model.ErrInvalidCredentials, Message: "Invalid email or password", - Status: 401, + Status: http.StatusUnauthorized, } } // Verify password if !s.password.CheckPassword(req.Password, user.PasswordHash) { - return nil, nil, &AuthError{ + return nil, nil, &apierr.Error{ Code: model.ErrInvalidCredentials, Message: "Invalid email or password", - Status: 401, + Status: http.StatusUnauthorized, } } @@ -177,11 +163,7 @@ func (s *AuthService) Login(ctx context.Context, req *model.LoginRequest) (*mode func (s *AuthService) ValidateToken(ctx context.Context, tokenString string) (*model.ValidateTokenResult, error) { claims, err := s.jwt.ValidateAccessToken(tokenString) if err != nil { - return nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "Please log in again", - Status: 401, - } + return nil, apierr.Unauthorized("Please log in again") } // Check if user's tokens have been revoked (e.g., after password change) @@ -192,11 +174,7 @@ func (s *AuthService) ValidateToken(ctx context.Context, tokenString string) (*m slog.String("error", err.Error()), ) // Fail open: if we can't check revocation, still reject to be safe - return nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "Unable to validate token", - Status: 401, - } + return nil, apierr.Unauthorized("Unable to validate token") } if revokedAt != nil && claims.IssuedAt != nil { @@ -209,11 +187,7 @@ func (s *AuthService) ValidateToken(ctx context.Context, tokenString string) (*m // before the microsecond-precise revocation timestamp. revokedAtTruncated := revokedAt.Truncate(time.Second) if tokenIssuedAt.Before(revokedAtTruncated) { - return nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "Token has been revoked. Please log in again.", - Status: 401, - } + return nil, apierr.Unauthorized("Token has been revoked. Please log in again.") } } @@ -232,37 +206,18 @@ func (s *AuthService) GetUserByID(ctx context.Context, userID string) (*model.Us return nil, fmt.Errorf("looking up user: %w", err) } if user == nil { - return nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "User not found", - Status: 401, - } + return nil, apierr.Unauthorized("User not found") } return user, nil } -// AuthError is a typed error that carries an HTTP status code and error code. -type AuthError struct { - Code string - Message string - Status int -} - -func (e *AuthError) Error() string { - return e.Message -} - // RefreshToken validates a refresh token, atomically consumes it (blacklists // to prevent reuse), and generates a new access + refresh token pair. func (s *AuthService) RefreshToken(ctx context.Context, refreshTokenString string) (*model.User, *model.TokenPair, error) { // Validate the refresh token JWT claims, err := s.jwt.ValidateRefreshToken(refreshTokenString) if err != nil { - return nil, nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "Invalid or expired refresh token", - Status: 401, - } + return nil, nil, apierr.Unauthorized("Invalid or expired refresh token") } // Atomically consume the token (blacklist + uniqueness check in one query) @@ -277,11 +232,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshTokenString strin slog.String("jti", claims.ID), slog.String("user_id", claims.Subject), ) - return nil, nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "Refresh token has been revoked", - Status: 401, - } + return nil, nil, apierr.Unauthorized("Refresh token has been revoked") } // Look up the user @@ -290,11 +241,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshTokenString strin return nil, nil, fmt.Errorf("looking up user for refresh: %w", err) } if user == nil { - return nil, nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "User not found", - Status: 401, - } + return nil, nil, apierr.Unauthorized("User not found") } // Generate new token pair @@ -350,11 +297,7 @@ func (s *AuthService) CompleteOnboarding(ctx context.Context, userID string, cur return nil, fmt.Errorf("completing onboarding: %w", err) } if user == nil { - return nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "User not found", - Status: 401, - } + return nil, apierr.Unauthorized("User not found") } s.logger.Info("onboarding completed", @@ -379,11 +322,7 @@ func (s *AuthService) UpdateProfile(ctx context.Context, userID string, req *mod return nil, fmt.Errorf("checking email uniqueness: %w", err) } if existingByEmail != nil && existingByEmail.ID != userID { - return nil, &AuthError{ - Code: model.ErrDuplicateEmail, - Message: "An account with this email already exists", - Status: 409, - } + return nil, apierr.Conflict(model.ErrDuplicateEmail, "An account with this email already exists") } // Check for duplicate username (exclude current user) @@ -392,11 +331,7 @@ func (s *AuthService) UpdateProfile(ctx context.Context, userID string, req *mod return nil, fmt.Errorf("checking username uniqueness: %w", err) } if existingByUsername != nil && existingByUsername.ID != userID { - return nil, &AuthError{ - Code: model.ErrDuplicateUsername, - Message: "This username is already taken", - Status: 409, - } + return nil, apierr.Conflict(model.ErrDuplicateUsername, "This username is already taken") } user, err := s.repo.UpdateUser(ctx, userID, username, email, currency) @@ -404,26 +339,17 @@ func (s *AuthService) UpdateProfile(ctx context.Context, userID string, req *mod var dupErr *repository.DuplicateError if errors.As(err, &dupErr) { if strings.Contains(dupErr.Constraint, "email") { - return nil, &AuthError{ - Code: model.ErrDuplicateEmail, - Message: "An account with this email already exists", - Status: 409, - } - } - return nil, &AuthError{ - Code: model.ErrDuplicateUsername, - Message: "This username is already taken", - Status: 409, + return nil, apierr.Conflict(model.ErrDuplicateEmail, "An account with this email already exists") } + return nil, apierr.Conflict(model.ErrDuplicateUsername, "This username is already taken") } return nil, fmt.Errorf("updating user: %w", err) } if user == nil { - return nil, &AuthError{ - Code: model.ErrNotFound, - Message: "User not found", - Status: 404, - } + // C8: an own-record-missing lookup returns 401 to match GetUserByID/ + // RefreshToken/CompleteOnboarding/ChangePassword (the four-method + // majority), not the 404 used for admin lookups of another user. + return nil, apierr.Unauthorized("User not found") } s.logger.Info("profile updated", @@ -454,11 +380,7 @@ func (s *AuthService) ListUsers(ctx context.Context) ([]*model.User, error) { // admin role before calling this method. func (s *AuthService) AssumeIdentity(ctx context.Context, adminUserID, targetUserID string) (*model.User, *model.TokenPair, error) { if adminUserID == targetUserID { - return nil, nil, &AuthError{ - Code: model.ErrValidationError, - Message: "Cannot assume your own identity", - Status: 400, - } + return nil, nil, apierr.Validation("Cannot assume your own identity", nil) } targetUser, err := s.repo.GetUserByID(ctx, targetUserID) @@ -466,11 +388,7 @@ func (s *AuthService) AssumeIdentity(ctx context.Context, adminUserID, targetUse return nil, nil, fmt.Errorf("looking up target user: %w", err) } if targetUser == nil { - return nil, nil, &AuthError{ - Code: model.ErrNotFound, - Message: "Target user not found", - Status: 404, - } + return nil, nil, apierr.NotFound("Target user not found") } accessToken, refreshToken, err := s.jwt.GenerateTokenPairWithAssumedBy( @@ -496,11 +414,7 @@ func (s *AuthService) AssumeIdentity(ctx context.Context, adminUserID, targetUse // looks up the original admin user, and generates fresh tokens for that admin. func (s *AuthService) RestoreIdentity(ctx context.Context, assumedByUserID string) (*model.User, *model.TokenPair, error) { if assumedByUserID == "" { - return nil, nil, &AuthError{ - Code: model.ErrValidationError, - Message: "No assumed identity to restore", - Status: 400, - } + return nil, nil, apierr.Validation("No assumed identity to restore", nil) } adminUser, err := s.repo.GetUserByID(ctx, assumedByUserID) @@ -508,18 +422,14 @@ func (s *AuthService) RestoreIdentity(ctx context.Context, assumedByUserID strin return nil, nil, fmt.Errorf("looking up admin user: %w", err) } if adminUser == nil { - return nil, nil, &AuthError{ - Code: model.ErrNotFound, - Message: "Admin user not found", - Status: 404, - } + return nil, nil, apierr.NotFound("Admin user not found") } - if adminUser.Role != "admin" { - return nil, nil, &AuthError{ - Code: model.ErrForbidden, + if adminUser.Role != model.RoleAdmin { + return nil, nil, &apierr.Error{ + Code: apierr.CodeForbidden, Message: "Assumed-by user is not an admin", - Status: 403, + Status: http.StatusForbidden, } } @@ -561,7 +471,7 @@ func (s *AuthService) SeedAdmin(ctx context.Context, username, email, password s return fmt.Errorf("hashing admin password: %w", err) } - user, err := s.repo.CreateUser(ctx, username, email, hash, "admin", "USD") + user, err := s.repo.CreateUser(ctx, username, email, hash, model.RoleAdmin, "USD") if err != nil { return fmt.Errorf("creating admin user: %w", err) } @@ -591,28 +501,24 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID string, req *mo return nil, nil, fmt.Errorf("looking up user: %w", err) } if user == nil { - return nil, nil, &AuthError{ - Code: model.ErrUnauthorized, - Message: "User not found", - Status: 401, - } + return nil, nil, apierr.Unauthorized("User not found") } // Verify current password if !s.password.CheckPassword(req.CurrentPassword, user.PasswordHash) { - return nil, nil, &AuthError{ + return nil, nil, &apierr.Error{ Code: model.ErrInvalidCredentials, Message: "Current password is incorrect", - Status: 401, + Status: http.StatusUnauthorized, } } // Validate new password strength if err := ValidatePasswordStrength(req.NewPassword); err != nil { - return nil, nil, &AuthError{ + return nil, nil, &apierr.Error{ Code: model.ErrWeakPassword, Message: err.Error(), - Status: 400, + Status: http.StatusBadRequest, } } diff --git a/services/auth/internal/service/auth_test.go b/services/auth/internal/service/auth_test.go index 4e879058..9766a815 100644 --- a/services/auth/internal/service/auth_test.go +++ b/services/auth/internal/service/auth_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log/slog" + "net/http" "strings" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/internal/model" "github.com/ItsThompson/gofin/services/auth/internal/repository" ) @@ -185,10 +187,10 @@ func TestRegister_WeakPassword(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrWeakPassword, authErr.Code) - assert.Equal(t, 400, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrWeakPassword, apiErr.Code) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) } func TestRegister_PasswordTooLong(t *testing.T) { @@ -205,11 +207,11 @@ func TestRegister_PasswordTooLong(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrWeakPassword, authErr.Code) - assert.Equal(t, 400, authErr.Status) - assert.Contains(t, authErr.Message, "must not exceed 72 characters") + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrWeakPassword, apiErr.Code) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Contains(t, apiErr.Message, "must not exceed 72 characters") } func TestRegister_DuplicateEmail(t *testing.T) { @@ -229,10 +231,10 @@ func TestRegister_DuplicateEmail(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrDuplicateEmail, authErr.Code) - assert.Equal(t, 409, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrDuplicateEmail, apiErr.Code) + assert.Equal(t, http.StatusConflict, apiErr.Status) } func TestRegister_DuplicateUsername(t *testing.T) { @@ -253,10 +255,10 @@ func TestRegister_DuplicateUsername(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrDuplicateUsername, authErr.Code) - assert.Equal(t, 409, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrDuplicateUsername, apiErr.Code) + assert.Equal(t, http.StatusConflict, apiErr.Status) } func TestRegister_EmailNormalization(t *testing.T) { @@ -304,10 +306,10 @@ func TestRegister_DuplicateEmailFromConstraint(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrDuplicateEmail, authErr.Code) - assert.Equal(t, 409, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrDuplicateEmail, apiErr.Code) + assert.Equal(t, http.StatusConflict, apiErr.Status) } func TestRegister_DuplicateUsernameFromConstraint(t *testing.T) { @@ -327,10 +329,10 @@ func TestRegister_DuplicateUsernameFromConstraint(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrDuplicateUsername, authErr.Code) - assert.Equal(t, 409, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrDuplicateUsername, apiErr.Code) + assert.Equal(t, http.StatusConflict, apiErr.Status) } // --- Login Tests --- @@ -379,12 +381,12 @@ func TestLogin_WrongPassword(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrInvalidCredentials, authErr.Code) - assert.Equal(t, 401, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrInvalidCredentials, apiErr.Code) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) // Must not hint at which field is wrong - assert.Equal(t, "Invalid email or password", authErr.Message) + assert.Equal(t, "Invalid email or password", apiErr.Message) } func TestLogin_UserNotFound(t *testing.T) { @@ -400,11 +402,11 @@ func TestLogin_UserNotFound(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrInvalidCredentials, authErr.Code) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrInvalidCredentials, apiErr.Code) // Same message as wrong password: no hint about whether user exists - assert.Equal(t, "Invalid email or password", authErr.Message) + assert.Equal(t, "Invalid email or password", apiErr.Message) } // --- ValidateToken Tests --- @@ -435,9 +437,9 @@ func TestValidateToken_Invalid(t *testing.T) { _, err := svc.ValidateToken(ctx, "garbage-token") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrUnauthorized, authErr.Code) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeUnauthorized, apiErr.Code) } func TestValidateToken_RevokedToken(t *testing.T) { @@ -455,10 +457,10 @@ func TestValidateToken_RevokedToken(t *testing.T) { _, err = svc.ValidateToken(ctx, access) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrUnauthorized, authErr.Code) - assert.Contains(t, authErr.Message, "revoked") + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeUnauthorized, apiErr.Code) + assert.Contains(t, apiErr.Message, "revoked") } func TestValidateToken_SameSecondGracePeriod(t *testing.T) { @@ -543,10 +545,10 @@ func TestRefreshToken_BlacklistedToken(t *testing.T) { _, _, err = svc.RefreshToken(ctx, refreshToken) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrUnauthorized, authErr.Code) - assert.Equal(t, 401, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeUnauthorized, apiErr.Code) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) } func TestRefreshToken_ConsumeError(t *testing.T) { @@ -582,10 +584,10 @@ func TestRefreshToken_ExpiredToken(t *testing.T) { _, _, err := svc.RefreshToken(ctx, "expired-or-invalid-token") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrUnauthorized, authErr.Code) - assert.Equal(t, 401, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeUnauthorized, apiErr.Code) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) } func TestRefreshToken_UserNotFound(t *testing.T) { @@ -607,10 +609,10 @@ func TestRefreshToken_UserNotFound(t *testing.T) { _, _, err = svc.RefreshToken(ctx, refreshToken) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrUnauthorized, authErr.Code) - assert.Equal(t, 401, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeUnauthorized, apiErr.Code) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) } // --- Logout Tests --- @@ -725,10 +727,10 @@ func TestAssumeIdentity_CannotAssumeSelf(t *testing.T) { _, _, err := svc.AssumeIdentity(ctx, "admin-123", "admin-123") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrValidationError, authErr.Code) - assert.Equal(t, 400, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeValidation, apiErr.Code) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) } func TestAssumeIdentity_TargetNotFound(t *testing.T) { @@ -741,10 +743,10 @@ func TestAssumeIdentity_TargetNotFound(t *testing.T) { _, _, err := svc.AssumeIdentity(ctx, "admin-123", "nonexistent") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrNotFound, authErr.Code) - assert.Equal(t, 404, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeNotFound, apiErr.Code) + assert.Equal(t, http.StatusNotFound, apiErr.Status) } // --- RestoreIdentity Tests --- @@ -786,10 +788,10 @@ func TestRestoreIdentity_EmptyAssumedBy(t *testing.T) { _, _, err := svc.RestoreIdentity(ctx, "") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrValidationError, authErr.Code) - assert.Equal(t, 400, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeValidation, apiErr.Code) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) } func TestRestoreIdentity_AdminNotFound(t *testing.T) { @@ -802,9 +804,9 @@ func TestRestoreIdentity_AdminNotFound(t *testing.T) { _, _, err := svc.RestoreIdentity(ctx, "deleted-admin") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrNotFound, authErr.Code) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeNotFound, apiErr.Code) } func TestRestoreIdentity_UserNotAdmin(t *testing.T) { @@ -820,10 +822,10 @@ func TestRestoreIdentity_UserNotAdmin(t *testing.T) { _, _, err := svc.RestoreIdentity(ctx, "regular-user") require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrForbidden, authErr.Code) - assert.Equal(t, 403, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeForbidden, apiErr.Code) + assert.Equal(t, http.StatusForbidden, apiErr.Status) } // --- SeedAdmin Tests --- @@ -920,10 +922,10 @@ func TestUpdateProfile_DuplicateEmail(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrDuplicateEmail, authErr.Code) - assert.Equal(t, 409, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrDuplicateEmail, apiErr.Code) + assert.Equal(t, http.StatusConflict, apiErr.Status) } func TestUpdateProfile_DuplicateUsername(t *testing.T) { @@ -944,10 +946,10 @@ func TestUpdateProfile_DuplicateUsername(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrDuplicateUsername, authErr.Code) - assert.Equal(t, 409, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrDuplicateUsername, apiErr.Code) + assert.Equal(t, http.StatusConflict, apiErr.Status) } func TestUpdateProfile_SameEmailSameUser_Succeeds(t *testing.T) { @@ -981,6 +983,33 @@ func TestUpdateProfile_SameEmailSameUser_Succeeds(t *testing.T) { assert.Equal(t, "user-123", user.ID) } +// TestUpdateProfile_OwnRecordMissing_Returns401 locks in the C8 fix: when the +// caller's own record is missing, UpdateProfile returns 401 UNAUTHORIZED to +// match GetUserByID / RefreshToken / CompleteOnboarding / ChangePassword (the +// four-method majority), not the 404 it returned before. +func TestUpdateProfile_OwnRecordMissing_Returns401(t *testing.T) { + repo := new(mockUserRepository) + svc := newTestAuthService(repo) + ctx := context.Background() + + repo.On("GetUserByEmail", ctx, "me@example.com").Return(nil, nil) + repo.On("GetUserByUsername", ctx, "myname").Return(nil, nil) + // UpdateUser reports no matching row (own record vanished). + repo.On("UpdateUser", ctx, "user-123", "myname", "me@example.com", "USD").Return(nil, nil) + + _, err := svc.UpdateProfile(ctx, "user-123", &model.UpdateProfileRequest{ + Username: "myname", + Email: "me@example.com", + Currency: "USD", + }) + + require.Error(t, err) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeUnauthorized, apiErr.Code) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) +} + // --- ChangePassword Tests --- func TestChangePassword_Success(t *testing.T) { @@ -1029,10 +1058,10 @@ func TestChangePassword_WrongCurrentPassword(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrInvalidCredentials, authErr.Code) - assert.Equal(t, 401, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrInvalidCredentials, apiErr.Code) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) } func TestChangePassword_WeakNewPassword(t *testing.T) { @@ -1052,10 +1081,10 @@ func TestChangePassword_WeakNewPassword(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrWeakPassword, authErr.Code) - assert.Equal(t, 400, authErr.Status) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrWeakPassword, apiErr.Code) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) } func TestChangePassword_NewPasswordTooLong(t *testing.T) { @@ -1077,11 +1106,11 @@ func TestChangePassword_NewPasswordTooLong(t *testing.T) { }) require.Error(t, err) - var authErr *AuthError - require.ErrorAs(t, err, &authErr) - assert.Equal(t, model.ErrWeakPassword, authErr.Code) - assert.Equal(t, 400, authErr.Status) - assert.Contains(t, authErr.Message, "must not exceed 72 characters") + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, model.ErrWeakPassword, apiErr.Code) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + assert.Contains(t, apiErr.Message, "must not exceed 72 characters") } func TestChangePassword_TokenRevocation(t *testing.T) { @@ -1109,4 +1138,3 @@ func TestChangePassword_TokenRevocation(t *testing.T) { // Any token with iat before that timestamp will be rejected by ValidateToken. repo.AssertCalled(t, "RevokeAllUserTokens", ctx, "user-123") } - diff --git a/services/auth/internal/service/jwt.go b/services/auth/internal/service/jwt.go index 6e463cac..354a92e4 100644 --- a/services/auth/internal/service/jwt.go +++ b/services/auth/internal/service/jwt.go @@ -28,13 +28,40 @@ type JWTService struct { refreshTokenTTL time.Duration } -// NewJWTService creates a new JWTService with the given secret. -func NewJWTService(secret string) *JWTService { - return &JWTService{ +// Default token lifetimes. Token lifetimes are the JWTService's domain, so the +// defaults live here; config defaults its env-driven overrides to these values. +const ( + DefaultAccessTokenTTL = 15 * time.Minute + DefaultRefreshTokenTTL = 7 * 24 * time.Hour +) + +// JWTOption overrides an optional JWTService parameter at construction time. +type JWTOption func(*JWTService) + +// WithAccessTTL overrides the access-token lifetime. +func WithAccessTTL(ttl time.Duration) JWTOption { + return func(j *JWTService) { j.accessTokenTTL = ttl } +} + +// WithRefreshTTL overrides the refresh-token lifetime. +func WithRefreshTTL(ttl time.Duration) JWTOption { + return func(j *JWTService) { j.refreshTokenTTL = ttl } +} + +// NewJWTService creates a new JWTService with the given secret. Token TTLs +// default to DefaultAccessTokenTTL / DefaultRefreshTokenTTL and can be +// overridden with WithAccessTTL / WithRefreshTTL (production sources them from +// config). +func NewJWTService(secret string, opts ...JWTOption) *JWTService { + j := &JWTService{ secret: []byte(secret), - accessTokenTTL: 15 * time.Minute, - refreshTokenTTL: 7 * 24 * time.Hour, + accessTokenTTL: DefaultAccessTokenTTL, + refreshTokenTTL: DefaultRefreshTokenTTL, + } + for _, opt := range opts { + opt(j) } + return j } // GenerateTokenPair creates an access token and a refresh token for the given user. @@ -120,8 +147,3 @@ func (j *JWTService) ValidateRefreshToken(tokenString string) (*RefreshTokenClai return claims, nil } - -// RefreshTokenTTL returns the refresh token time-to-live duration. -func (j *JWTService) RefreshTokenTTL() time.Duration { - return j.refreshTokenTTL -} diff --git a/services/auth/internal/service/jwt_test.go b/services/auth/internal/service/jwt_test.go index 77516112..c6ab5c98 100644 --- a/services/auth/internal/service/jwt_test.go +++ b/services/auth/internal/service/jwt_test.go @@ -146,8 +146,3 @@ func TestValidateRefreshToken_Garbage(t *testing.T) { _, err := jwtSvc.ValidateRefreshToken("not-a-token") assert.Error(t, err) } - -func TestRefreshTokenTTL(t *testing.T) { - jwtSvc := NewJWTService("test-secret") - assert.Equal(t, 7*24*time.Hour, jwtSvc.RefreshTokenTTL()) -} diff --git a/services/datarights/Dockerfile b/services/datarights/Dockerfile index 6e808e30..2e0d48b5 100644 --- a/services/datarights/Dockerfile +++ b/services/datarights/Dockerfile @@ -16,6 +16,10 @@ COPY finance/go.mod finance/go.sum* ./finance/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ COPY perf/go.mod ./perf/ +COPY serverkit/go.mod serverkit/go.sum* ./serverkit/ +COPY apierr/go.mod apierr/go.sum* ./apierr/ +COPY httpx/go.mod httpx/go.sum* ./httpx/ +COPY pgutil/go.mod pgutil/go.sum* ./pgutil/ RUN --mount=type=cache,target=/go/pkg/mod \ cd datarights && GOWORK=off go mod download @@ -29,6 +33,10 @@ COPY finance/ ./finance/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ COPY perf/ ./perf/ +COPY serverkit/ ./serverkit/ +COPY apierr/ ./apierr/ +COPY httpx/ ./httpx/ +COPY pgutil/ ./pgutil/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index 3ec23f41..0ffbb062 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -8,10 +8,7 @@ import ( "os" "os/signal" "syscall" - "time" - "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -19,24 +16,23 @@ import ( "github.com/ItsThompson/gofin/services/datarights/db/migrations" "github.com/ItsThompson/gofin/services/datarights/internal/config" "github.com/ItsThompson/gofin/services/datarights/internal/deletion" - deletionproviders "github.com/ItsThompson/gofin/services/datarights/internal/deletion/providers" "github.com/ItsThompson/gofin/services/datarights/internal/email" "github.com/ItsThompson/gofin/services/datarights/internal/engine" "github.com/ItsThompson/gofin/services/datarights/internal/engine/providers" "github.com/ItsThompson/gofin/services/datarights/internal/handler" - _ "github.com/ItsThompson/gofin/services/datarights/internal/metrics" // register Prometheus metrics + exportmetrics "github.com/ItsThompson/gofin/services/datarights/internal/metrics" + "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" "github.com/ItsThompson/gofin/services/datarights/internal/service" - "github.com/ItsThompson/gofin/services/dbmigrate" "github.com/ItsThompson/gofin/services/expense/proto/expensepb" "github.com/ItsThompson/gofin/services/finance/proto/financepb" "github.com/ItsThompson/gofin/services/healthcheck" - "github.com/ItsThompson/gofin/services/metrics" + "github.com/ItsThompson/gofin/services/serverkit" ) func main() { if healthcheck.ShouldRun(os.Args) { - os.Exit(healthcheck.Run("8084")) + os.Exit(healthcheck.Run(config.RESTPort())) } if err := run(); err != nil { @@ -56,34 +52,15 @@ func run() error { } // Set up structured logging - logLevel := slog.LevelInfo - switch cfg.LogLevel { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - } - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) - logger = logger.With(slog.String("service", "datarights")) + logger := serverkit.NewLogger(cfg.LogLevel, "datarights") slog.SetDefault(logger) - // Run database migrations (embedded in binary via go:embed) - if err := dbmigrate.RunWithFS(cfg.DBUrl, migrations.FS, "."); err != nil { - return fmt.Errorf("running migrations: %w", err) - } - - // Connect to PostgreSQL - pool, err := pgxpool.New(ctx, cfg.DBUrl) + // Connect to PostgreSQL (runs embedded migrations, opens the pool, pings). + pool, err := serverkit.ConnectPostgres(ctx, cfg.DBUrl, migrations.FS) if err != nil { return fmt.Errorf("connecting to database: %w", err) } defer pool.Close() - - if err := pool.Ping(ctx); err != nil { - return fmt.Errorf("pinging database: %w", err) - } logger.Info("connected to PostgreSQL") // Connect to auth service gRPC @@ -134,19 +111,20 @@ func run() error { // Build dependency graph repo := repository.NewPostgresJobRepository(pool) - // Set up export engine with a per-job provider factory. The factory closes - // over the auth and expense clients; the finance-backed providers receive a - // fresh per-job MemoizedFinanceClient (built inside the engine) so a single - // GetAllUserData call is shared across providers without leaking data across - // jobs. Registration/ZIP order: profile, expenses, tags, budget_periods, + // Set up export engine with a per-job provider factory. The engine fetches + // GetAllUserData once per job (in execute) and hands the resolved response + // here; the factory closes over the auth and expense clients (profile and the + // expenses stream self-fetch) and derives the tag map + finance-backed rows + // from the single response, so the export hits finance exactly once. + // Registration/ZIP order: profile, expenses, tags, budget_periods, // default_settings. - newExportProviders := func(finance financepb.FinanceServiceClient) []engine.DataProvider { + newExportProviders := func(financeData *financepb.AllUserDataResponse) []engine.DataProvider { return []engine.DataProvider{ providers.NewProfileProvider(authClient), - providers.NewExpensesProvider(expenseClient, finance), - providers.NewTagsProvider(finance), - providers.NewBudgetPeriodsProvider(finance), - providers.NewDefaultSettingsProvider(finance), + providers.NewExpensesProvider(expenseClient, providers.BuildTagMap(financeData)), + providers.NewTagsProvider(financeData), + providers.NewBudgetPeriodsProvider(financeData), + providers.NewDefaultSettingsProvider(financeData), } } @@ -158,20 +136,54 @@ func run() error { exportEngine := engine.NewEngine(newExportProviders, financeClient, repo, emailSender, cfg.MaxConcurrent, cfg.ExportTimeout, logger) - // Startup recovery: re-submit non-terminal export jobs + // Expose the export pool's live telemetry through the Prometheus pool gauges + // (scraped by the Grafana dashboard and the stuck-pool alert). + exportmetrics.SetPoolStats(exportEngine.ActiveJobs, exportEngine.QueuedJobs) + + // Startup recovery: re-submit non-terminal export jobs. Export resolves the + // user's email first; a failure is logged and the job is submitted anyway (it + // will fail with a descriptive error at the email step). emailResolver := service.NewAuthUserEmailResolver(authClient) - recoverJobs(ctx, repo, exportEngine, emailResolver, logger) + recoverJobs(ctx, logger, "export", repo.GetNonTerminalJobs, func(ctx context.Context, job model.RecoverableJob) { + userEmail, err := emailResolver.ResolveEmail(ctx, job.UserID) + if err != nil { + logger.Error("failed to resolve email for recovered job", + slog.String("job_id", job.ID), + slog.String("user_id", job.UserID), + slog.String("error", err.Error()), + ) + } + logger.Info("re-submitting job", + slog.String("job_id", job.ID), + slog.String("user_id", job.UserID), + ) + exportEngine.Submit(job.ID, job.UserID, userEmail) + }) exportSvc := service.NewExportService(repo, logger, service.WithEngine(exportEngine), service.WithEmailResolver(emailResolver)) // Set up deletion engine with provider registry deletionRepo := repository.NewPostgresDeletionJobRepository(pool) - deletionRegistry := deletion.NewDeletionProviderRegistry() - deletionRegistry.Register(deletionproviders.NewFinanceDeletionProvider(financeClient)) - deletionRegistry.Register(deletionproviders.NewExpenseDeletionProvider(expenseClient)) - deletionRegistry.Register(deletionproviders.NewAuthDeletionProvider(authClient)) - deletionEngine := deletion.NewDeletionEngine( + // Register the deletion providers as name+func pairs. Registration order is + // execution order: finance and expense first, auth last (a user cannot + // authenticate once auth data is gone). Each func wraps one idempotent gRPC + // delete call and discards the response. + deletionRegistry := deletion.NewRegistry() + deletionRegistry.Register(deletion.NewFuncProvider("finance", func(ctx context.Context, userID string) error { + _, err := financeClient.DeleteAllUserData(ctx, &financepb.DeleteAllUserDataRequest{UserId: userID}) + return err + })) + deletionRegistry.Register(deletion.NewFuncProvider("expense", func(ctx context.Context, userID string) error { + _, err := expenseClient.AnonymizeAllUserExpenses(ctx, &expensepb.AnonymizeRequest{UserId: userID}) + return err + })) + deletionRegistry.Register(deletion.NewFuncProvider("auth", func(ctx context.Context, userID string) error { + _, err := authClient.DeleteUserData(ctx, &authpb.DeleteUserDataRequest{UserId: userID}) + return err + })) + + deletionEngine := deletion.NewEngine( deletionRegistry, deletionRepo, cfg.MaxConcurrent, @@ -180,34 +192,23 @@ func run() error { ) // Startup recovery: re-submit non-terminal deletion jobs - recoverDeletionJobs(ctx, deletionRepo, deletionEngine, logger) + recoverJobs(ctx, logger, "deletion", deletionRepo.GetNonTerminalJobs, func(_ context.Context, job model.RecoverableDeletionJob) { + logger.Info("re-submitting deletion job", + slog.String("job_id", job.ID), + slog.String("user_id", job.UserID), + ) + deletionEngine.Submit(job.ID, job.UserID) + }) deletionSvc := service.NewDeletionService(deletionRepo, logger, service.WithDeletionEngine(deletionEngine), service.WithAuthClient(authClient), service.WithExportRepo(repo), + service.WithProtectedUsernames(cfg.ProtectedUsernames), ) - // Start REST server - if cfg.IsProduction() { - gin.SetMode(gin.ReleaseMode) - } - router := gin.New() - router.Use(gin.Recovery()) - router.Use(metrics.HTTPMetrics()) - - // Health check endpoint - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "pool": gin.H{ - "active": exportEngine.ActiveJobs(), - "max": exportEngine.MaxConcurrent(), - }, - }) - }) - - metrics.Register(router) + // Build the shared router (Recovery, HTTP metrics, /metrics, GET /health). + router := serverkit.NewRouter("datarights", cfg.IsProduction()) restHandler := handler.NewRESTHandler(exportSvc, logger) deletionHandler := handler.NewDeletionHandler(deletionSvc, logger) @@ -218,74 +219,36 @@ func run() error { Handler: router, } - go func() { - logger.Info("REST server starting", - slog.String("port", cfg.RESTPort), - ) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("REST server failed", slog.String("error", err.Error())) - } - }() - logger.Info("datarights service ready", slog.String("rest_port", cfg.RESTPort), slog.Int("max_concurrent_exports", cfg.MaxConcurrent), slog.Duration("export_timeout", cfg.ExportTimeout), ) - // Wait for shutdown signal - <-ctx.Done() - logger.Info("shutting down datarights service") - - // Graceful shutdown - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - if err := httpServer.Shutdown(shutdownCtx); err != nil { - logger.Error("REST server shutdown error", slog.String("error", err.Error())) - } - - return nil + // Serve blocks until ctx is cancelled or the HTTP server fails to bind. + // A bind failure returns non-nil so run() (and main) exit non-zero instead + // of lingering as a zombie with no listener (C5). datarights runs no gRPC + // server, so both gRPC arguments are nil. + return serverkit.Serve(ctx, httpServer, nil, nil) } -// recoverJobs re-submits any non-terminal jobs found in the database on startup. -func recoverJobs(ctx context.Context, repo repository.JobRepository, eng *engine.Engine, resolver service.UserEmailResolver, logger *slog.Logger) { - jobs, err := repo.GetNonTerminalJobs(ctx) +// recoverJobs re-submits non-terminal jobs found in the database on startup. It +// shares the query -> empty-check -> per-job submit skeleton across both +// engines; the submit closure supplies the engine-specific step (export +// resolves the user's email before re-submitting). +func recoverJobs[J any]( + ctx context.Context, + logger *slog.Logger, + kind string, + fetch func(context.Context) ([]J, error), + submit func(context.Context, J), +) { + jobs, err := fetch(ctx) if err != nil { - logger.Error("failed to query recoverable jobs", slog.String("error", err.Error())) - return - } - - if len(jobs) == 0 { - return - } - - logger.Info("recovering non-terminal jobs", slog.Int("count", len(jobs))) - - for _, job := range jobs { - userEmail, err := resolver.ResolveEmail(ctx, job.UserID) - if err != nil { - logger.Error("failed to resolve email for recovered job", - slog.String("job_id", job.ID), - slog.String("user_id", job.UserID), - slog.String("error", err.Error()), - ) - // Submit anyway: will fail at email step with descriptive error - } - - logger.Info("re-submitting job", - slog.String("job_id", job.ID), - slog.String("user_id", job.UserID), + logger.Error("failed to query recoverable jobs", + slog.String("kind", kind), + slog.String("error", err.Error()), ) - eng.Submit(job.ID, job.UserID, userEmail) - } -} - -// recoverDeletionJobs re-submits any non-terminal deletion jobs on startup. -func recoverDeletionJobs(ctx context.Context, repo repository.DeletionJobRepository, eng *deletion.DeletionEngine, logger *slog.Logger) { - jobs, err := repo.GetNonTerminalJobs(ctx) - if err != nil { - logger.Error("failed to query recoverable deletion jobs", slog.String("error", err.Error())) return } @@ -293,14 +256,13 @@ func recoverDeletionJobs(ctx context.Context, repo repository.DeletionJobReposit return } - logger.Info("recovering non-terminal deletion jobs", slog.Int("count", len(jobs))) + logger.Info("recovering non-terminal jobs", + slog.String("kind", kind), + slog.Int("count", len(jobs)), + ) for _, job := range jobs { - logger.Info("re-submitting deletion job", - slog.String("job_id", job.ID), - slog.String("user_id", job.UserID), - ) - eng.Submit(job.ID, job.UserID) + submit(ctx, job) } } diff --git a/services/datarights/go.mod b/services/datarights/go.mod index 165b67ec..ec036483 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -4,13 +4,15 @@ go 1.26 require ( github.com/ItsThompson/gofin/services/access v0.0.0 + github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/auth v0.0.0 - github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 github.com/ItsThompson/gofin/services/expense v0.0.0 github.com/ItsThompson/gofin/services/finance v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 - github.com/ItsThompson/gofin/services/metrics v0.0.0 + github.com/ItsThompson/gofin/services/httpx v0.0.0 github.com/ItsThompson/gofin/services/perf v0.0.0 + github.com/ItsThompson/gofin/services/pgutil v0.0.0 + github.com/ItsThompson/gofin/services/serverkit v0.0.0 github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.9.2 github.com/prometheus/client_golang v1.22.0 @@ -21,6 +23,8 @@ require ( replace github.com/ItsThompson/gofin/services/access => ../access +replace github.com/ItsThompson/gofin/services/apierr => ../apierr + replace github.com/ItsThompson/gofin/services/auth => ../auth replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate @@ -31,11 +35,19 @@ replace github.com/ItsThompson/gofin/services/finance => ../finance replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck +replace github.com/ItsThompson/gofin/services/httpx => ../httpx + replace github.com/ItsThompson/gofin/services/metrics => ../metrics replace github.com/ItsThompson/gofin/services/perf => ../perf +replace github.com/ItsThompson/gofin/services/pgutil => ../pgutil + +replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit + require ( + github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect + github.com/ItsThompson/gofin/services/metrics v0.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect diff --git a/services/datarights/internal/config/config.go b/services/datarights/internal/config/config.go index 13c7085a..ae6d4688 100644 --- a/services/datarights/internal/config/config.go +++ b/services/datarights/internal/config/config.go @@ -4,16 +4,22 @@ import ( "fmt" "os" "strconv" + "strings" "time" ) +// DefaultProtectedUsernames is the fallback protected-username list used when +// PROTECTED_USERNAMES is unset. These accounts cannot be deleted via the +// datarights deletion flow. The check itself is owned by the datarights +// service (moved from auth). +var DefaultProtectedUsernames = []string{"admin", "thompson"} + // Config holds all configuration for the datarights service. type Config struct { DBUrl string LogLevel string Environment string RESTPort string - GRPCPort string AuthServiceAddr string ExpenseServiceAddr string FinanceServiceAddr string @@ -24,6 +30,39 @@ type Config struct { EmailFrom string EmailEnabled bool BrandTokensPath string + ProtectedUsernames []string +} + +// DefaultRESTPort is the datarights REST listener port used when REST_PORT is +// unset. It is the single source of truth shared by the server listener and the +// --healthcheck probe so a REST_PORT override never desyncs them (US-PLATFORM-05). +const DefaultRESTPort = "8084" + +// RESTPort returns the configured REST port, honoring REST_PORT with +// DefaultRESTPort as the fallback. Both Load (the listener) and the +// --healthcheck probe call it. +func RESTPort() string { + if port := os.Getenv("REST_PORT"); port != "" { + return port + } + return DefaultRESTPort +} + +// parseProtectedUsernames splits a comma-separated PROTECTED_USERNAMES value +// into a trimmed, non-empty list. When the value is empty or yields no +// usernames it returns a fresh copy of DefaultProtectedUsernames, so callers +// never share (and cannot mutate) the package-level default slice. +func parseProtectedUsernames(raw string) []string { + var names []string + for _, part := range strings.Split(raw, ",") { + if name := strings.TrimSpace(part); name != "" { + names = append(names, name) + } + } + if len(names) == 0 { + return append([]string(nil), DefaultProtectedUsernames...) + } + return names } // Load reads configuration from environment variables and returns a Config. @@ -43,15 +82,7 @@ func Load() (*Config, error) { environment = "development" } - restPort := os.Getenv("REST_PORT") - if restPort == "" { - restPort = "8084" - } - - grpcPort := os.Getenv("GRPC_PORT") - if grpcPort == "" { - grpcPort = "9084" - } + restPort := RESTPort() authServiceAddr := os.Getenv("AUTH_SERVICE_ADDR") if authServiceAddr == "" { @@ -105,12 +136,13 @@ func Load() (*Config, error) { brandTokensPath = "/app/tokens/brand.json" } + protectedUsernames := parseProtectedUsernames(os.Getenv("PROTECTED_USERNAMES")) + return &Config{ DBUrl: dbURL, LogLevel: logLevel, Environment: environment, RESTPort: restPort, - GRPCPort: grpcPort, AuthServiceAddr: authServiceAddr, ExpenseServiceAddr: expenseServiceAddr, FinanceServiceAddr: financeServiceAddr, @@ -121,6 +153,7 @@ func Load() (*Config, error) { EmailFrom: emailFrom, EmailEnabled: emailEnabled, BrandTokensPath: brandTokensPath, + ProtectedUsernames: protectedUsernames, }, nil } diff --git a/services/datarights/internal/config/config_test.go b/services/datarights/internal/config/config_test.go new file mode 100644 index 00000000..1f6883dd --- /dev/null +++ b/services/datarights/internal/config/config_test.go @@ -0,0 +1,136 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// clearEnv blanks every datarights env var so each test starts from a known +// baseline. Load treats an empty value as unset (falling back to the default), +// except DATARIGHTS_DB_URL which is required. +func clearEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "DATARIGHTS_DB_URL", "LOG_LEVEL", "ENVIRONMENT", "REST_PORT", + "AUTH_SERVICE_ADDR", "EXPENSE_SERVICE_ADDR", "FINANCE_SERVICE_ADDR", + "EXPORT_MAX_CONCURRENT", "EXPORT_TIMEOUT_SECONDS", "DELETION_TIMEOUT_SECONDS", + "EMAIL_ENABLED", "RESEND_API_KEY", "EMAIL_FROM", "BRAND_TOKENS_PATH", + "PROTECTED_USERNAMES", + } { + t.Setenv(key, "") + } +} + +func setEnv(t *testing.T, env map[string]string) { + t.Helper() + for key, val := range env { + t.Setenv(key, val) + } +} + +func TestLoad_MissingDBURL(t *testing.T) { + clearEnv(t) + + _, err := Load() + require.Error(t, err) + assert.Contains(t, err.Error(), "DATARIGHTS_DB_URL is required") +} + +func TestLoad_Defaults(t *testing.T) { + clearEnv(t) + setEnv(t, map[string]string{"DATARIGHTS_DB_URL": "postgres://localhost/test"}) + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, "info", cfg.LogLevel) + assert.Equal(t, "development", cfg.Environment) + assert.Equal(t, DefaultRESTPort, cfg.RESTPort) + assert.Equal(t, "auth-service:9081", cfg.AuthServiceAddr) + assert.Equal(t, "expense-service:9082", cfg.ExpenseServiceAddr) + assert.Equal(t, "finance-service:9083", cfg.FinanceServiceAddr) + assert.Equal(t, 5, cfg.MaxConcurrent) + assert.Equal(t, 5*time.Minute, cfg.ExportTimeout) + assert.Equal(t, 5*time.Minute, cfg.DeletionTimeout) + assert.True(t, cfg.EmailEnabled) + assert.Equal(t, "gofin ", cfg.EmailFrom) + assert.Equal(t, "/app/tokens/brand.json", cfg.BrandTokensPath) + assert.Equal(t, []string{"admin", "thompson"}, cfg.ProtectedUsernames) +} + +func TestLoad_Overrides(t *testing.T) { + clearEnv(t) + setEnv(t, map[string]string{ + "DATARIGHTS_DB_URL": "postgres://localhost/test", + "LOG_LEVEL": "debug", + "ENVIRONMENT": "production", + "REST_PORT": "1234", + "EXPORT_MAX_CONCURRENT": "9", + "EXPORT_TIMEOUT_SECONDS": "30", + "DELETION_TIMEOUT_SECONDS": "45", + "EMAIL_ENABLED": "false", + }) + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, "debug", cfg.LogLevel) + assert.Equal(t, "production", cfg.Environment) + assert.True(t, cfg.IsProduction()) + assert.Equal(t, "1234", cfg.RESTPort) + assert.Equal(t, 9, cfg.MaxConcurrent) + assert.Equal(t, 30*time.Second, cfg.ExportTimeout) + assert.Equal(t, 45*time.Second, cfg.DeletionTimeout) + assert.False(t, cfg.EmailEnabled) +} + +func TestRESTPort(t *testing.T) { + clearEnv(t) + assert.Equal(t, DefaultRESTPort, RESTPort()) + + setEnv(t, map[string]string{"REST_PORT": "9999"}) + assert.Equal(t, "9999", RESTPort()) +} + +func TestLoad_ProtectedUsernames(t *testing.T) { + tests := []struct { + name string + value string + set bool + want []string + }{ + {name: "unset uses default", set: false, want: []string{"admin", "thompson"}}, + {name: "custom list", value: "root,superuser", set: true, want: []string{"root", "superuser"}}, + {name: "trims spaces and drops empties", value: " a , b ,, c ", set: true, want: []string{"a", "b", "c"}}, + {name: "whitespace only uses default", value: " ", set: true, want: []string{"admin", "thompson"}}, + {name: "commas only uses default", value: ",,", set: true, want: []string{"admin", "thompson"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t) + env := map[string]string{"DATARIGHTS_DB_URL": "postgres://localhost/test"} + if tt.set { + env["PROTECTED_USERNAMES"] = tt.value + } + setEnv(t, env) + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, tt.want, cfg.ProtectedUsernames) + }) + } +} + +func TestLoad_ProtectedUsernamesDefaultNotAliased(t *testing.T) { + clearEnv(t) + setEnv(t, map[string]string{"DATARIGHTS_DB_URL": "postgres://localhost/test"}) + + cfg, err := Load() + require.NoError(t, err) + + // Mutating the loaded slice must not corrupt the shared package default. + cfg.ProtectedUsernames[0] = "mutated" + assert.Equal(t, "admin", DefaultProtectedUsernames[0]) +} diff --git a/services/datarights/internal/deletion/engine.go b/services/datarights/internal/deletion/engine.go index fb5bafb3..ae01bf6e 100644 --- a/services/datarights/internal/deletion/engine.go +++ b/services/datarights/internal/deletion/engine.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "github.com/ItsThompson/gofin/services/datarights/internal/jobrunner" "github.com/ItsThompson/gofin/services/datarights/internal/repository" ) @@ -15,112 +16,77 @@ const ( backoffSecond = 2 * time.Second ) -// DeletionEngine manages a bounded pool of deletion workers. -// It is structurally similar to the export Engine but with different -// execution semantics (retry per provider, no email/zip step). -type DeletionEngine struct { - registry *DeletionProviderRegistry - repo repository.DeletionJobRepository - sem chan struct{} +// Engine manages a bounded pool of deletion workers. It shares the pool +// lifecycle with the export engine via jobrunner.Pool and injects its own +// execution strategy: each provider is run sequentially in registration order +// with per-provider retry and backoff. The deletion job repo satisfies +// jobrunner.StatusStore directly, so it is handed to the pool as the store. +type Engine struct { + registry *Registry + pool *jobrunner.Pool logger *slog.Logger - timeout time.Duration } -// NewDeletionEngine creates a deletion engine with bounded concurrency. -func NewDeletionEngine( - registry *DeletionProviderRegistry, +// NewEngine creates a deletion engine with bounded concurrency. +func NewEngine( + registry *Registry, repo repository.DeletionJobRepository, maxConcurrent int, timeout time.Duration, logger *slog.Logger, -) *DeletionEngine { - return &DeletionEngine{ +) *Engine { + e := &Engine{ registry: registry, - repo: repo, - sem: make(chan struct{}, maxConcurrent), logger: logger, - timeout: timeout, } + e.pool = jobrunner.New(maxConcurrent, timeout, repo, e.execute, logger) + return e } -// ActiveJobs returns the number of currently executing deletion jobs. -func (e *DeletionEngine) ActiveJobs() int { - return len(e.sem) +// Submit enqueues a deletion job for async processing. Non-blocking: the pool +// spawns a goroutine that blocks on the semaphore. +func (e *Engine) Submit(jobID, userID string) { + e.pool.Submit(jobID, userID) } -// MaxConcurrent returns the pool capacity. -func (e *DeletionEngine) MaxConcurrent() int { - return cap(e.sem) -} - -// Submit enqueues a deletion job for async processing. -// Non-blocking: spawns a goroutine that blocks on the semaphore. -func (e *DeletionEngine) Submit(jobID, userID string) { - go func() { - // Acquire semaphore slot (blocks if pool is full) - e.sem <- struct{}{} - defer func() { <-e.sem }() - - ctx, cancel := context.WithTimeout(context.Background(), e.timeout) - defer cancel() - - e.execute(ctx, jobID, userID) - }() -} - -// execute runs the full deletion flow for a single job. -// Called inside a goroutine after acquiring a semaphore slot. -func (e *DeletionEngine) execute(ctx context.Context, jobID, userID string) { +// execute is the injected jobrunner strategy: it runs every provider in +// registration order and returns the first exhausted-retry (or context) error. +// The pool owns the running transition, completion, and failure persistence. +func (e *Engine) execute(ctx context.Context, jobID, userID string) error { e.logger.Info("deletion job starting", slog.String("job_id", jobID), slog.String("user_id", userID), slog.String("method", "deletion.engine.execute"), ) - // Transition job to "running" - if err := e.repo.UpdateStatus(ctx, jobID, "running"); err != nil { - e.logger.Error("failed to update deletion job status to running", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("method", "deletion.engine.execute"), - slog.String("error", err.Error()), - ) - return - } - - // Execute each provider in registration order + // Execute each provider in registration order. for _, provider := range e.registry.All() { attempts, err := e.executeProvider(ctx, provider, jobID, userID) if err != nil { - // Provider exhausted retries or context expired: mark job as failed - errMsg := fmt.Sprintf("provider %s failed after %d attempts: %s", provider.Name(), attempts, err.Error()) - e.failJob(jobID, userID, errMsg) - return + // Provider exhausted retries or context expired: fail the job. + jobErr := fmt.Errorf("provider %s failed after %d attempts: %w", provider.Name(), attempts, err) + e.logger.Error("deletion job failed", + slog.String("job_id", jobID), + slog.String("user_id", userID), + slog.String("error", jobErr.Error()), + slog.String("method", "deletion.engine.execute"), + ) + return jobErr } } - // All providers succeeded: mark job as completed - if err := e.repo.CompleteJob(ctx, jobID); err != nil { - e.logger.Error("failed to complete deletion job", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("method", "deletion.engine.execute"), - slog.String("error", err.Error()), - ) - return - } - e.logger.Info("deletion job completed", slog.String("job_id", jobID), slog.String("user_id", userID), slog.String("method", "deletion.engine.execute"), ) + return nil } // executeProvider attempts a single provider up to maxRetries times with backoff. // Returns the number of attempts made and nil if the provider eventually succeeds, // or the number of attempts and the last error if retries are exhausted or context expires. -func (e *DeletionEngine) executeProvider(ctx context.Context, provider DeletionProvider, jobID, userID string) (int, error) { +func (e *Engine) executeProvider(ctx context.Context, provider Provider, jobID, userID string) (int, error) { backoffs := []time.Duration{backoffFirst, backoffSecond} var lastErr error @@ -170,23 +136,3 @@ func (e *DeletionEngine) executeProvider(ctx context.Context, provider DeletionP return maxRetries, lastErr } - -// failJob marks a job as failed using a background context (in case the original expired). -func (e *DeletionEngine) failJob(jobID, userID, errMsg string) { - e.logger.Error("deletion job failed", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("error", errMsg), - slog.String("method", "deletion.engine.failJob"), - ) - - failCtx := context.Background() - if err := e.repo.FailJob(failCtx, jobID, errMsg); err != nil { - e.logger.Error("failed to mark deletion job as failed", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("method", "deletion.engine.failJob"), - slog.String("error", err.Error()), - ) - } -} diff --git a/services/datarights/internal/deletion/engine_test.go b/services/datarights/internal/deletion/engine_test.go index 2dadfe8a..babb1b5a 100644 --- a/services/datarights/internal/deletion/engine_test.go +++ b/services/datarights/internal/deletion/engine_test.go @@ -156,7 +156,7 @@ func newTestLogger() *slog.Logger { // --------------------------------------------------------------------------- func TestRegistry_All_ReturnsInRegistrationOrder(t *testing.T) { - registry := NewDeletionProviderRegistry() + registry := NewRegistry() p1 := &mockDeletionProvider{name: "finance"} p2 := &mockDeletionProvider{name: "expense"} p3 := &mockDeletionProvider{name: "auth"} @@ -173,7 +173,7 @@ func TestRegistry_All_ReturnsInRegistrationOrder(t *testing.T) { } func TestRegistry_All_EmptyRegistryReturnsNil(t *testing.T) { - registry := NewDeletionProviderRegistry() + registry := NewRegistry() assert.Nil(t, registry.All()) } @@ -183,12 +183,12 @@ func TestRegistry_All_EmptyRegistryReturnsNil(t *testing.T) { func TestEngine_HappyPath_CompletesJob(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() registry.Register(&mockDeletionProvider{name: "finance"}) registry.Register(&mockDeletionProvider{name: "expense"}) registry.Register(&mockDeletionProvider{name: "auth"}) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-1", "user-1") require.Eventually(t, func() bool { @@ -211,7 +211,7 @@ func TestEngine_HappyPath_CompletesJob(t *testing.T) { func TestEngine_ProvidersExecuteInOrder(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() p1 := &mockDeletionProvider{name: "finance"} p2 := &mockDeletionProvider{name: "expense"} @@ -221,7 +221,7 @@ func TestEngine_ProvidersExecuteInOrder(t *testing.T) { registry.Register(p2) registry.Register(p3) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-order", "user-1") require.Eventually(t, func() bool { @@ -236,7 +236,7 @@ func TestEngine_ProvidersExecuteInOrder(t *testing.T) { func TestEngine_RetrySuccess_ContinuesToNextProvider(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() // Provider fails first attempt but succeeds on second retryProvider := &mockDeletionProvider{ @@ -247,7 +247,7 @@ func TestEngine_RetrySuccess_ContinuesToNextProvider(t *testing.T) { registry.Register(retryProvider) registry.Register(&mockDeletionProvider{name: "auth"}) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-retry", "user-1") require.Eventually(t, func() bool { @@ -264,7 +264,7 @@ func TestEngine_RetrySuccess_ContinuesToNextProvider(t *testing.T) { func TestEngine_RetryExhaustion_FailsJob(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() // Provider fails all 3 attempts failProvider := &mockDeletionProvider{ @@ -276,7 +276,7 @@ func TestEngine_RetryExhaustion_FailsJob(t *testing.T) { registry.Register(failProvider) registry.Register(&mockDeletionProvider{name: "auth"}) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-exhaust", "user-1") require.Eventually(t, func() bool { @@ -298,7 +298,7 @@ func TestEngine_RetryExhaustion_FailsJob(t *testing.T) { func TestEngine_FailFast_RemainingProvidersNotAttempted(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() // First provider fails permanently failProvider := &mockDeletionProvider{ @@ -312,7 +312,7 @@ func TestEngine_FailFast_RemainingProvidersNotAttempted(t *testing.T) { registry.Register(failProvider) registry.Register(authProvider) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-failfast", "user-1") require.Eventually(t, func() bool { @@ -325,10 +325,10 @@ func TestEngine_FailFast_RemainingProvidersNotAttempted(t *testing.T) { func TestEngine_StateTransitions_PendingToRunningToCompleted(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() registry.Register(&mockDeletionProvider{name: "finance"}) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-states", "user-1") require.Eventually(t, func() bool { @@ -346,14 +346,14 @@ func TestEngine_StateTransitions_PendingToRunningToCompleted(t *testing.T) { func TestEngine_StateTransitions_PendingToRunningToFailed(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() registry.Register(&mockDeletionProvider{ name: "finance", failUntil: 3, err: fmt.Errorf("always fails"), }) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-fail-states", "user-1") require.Eventually(t, func() bool { @@ -371,7 +371,7 @@ func TestEngine_StateTransitions_PendingToRunningToFailed(t *testing.T) { func TestEngine_ContextTimeout_CountsAsFailedAttempt(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() // Provider has a delay longer than the engine timeout slowProvider := &mockDeletionProvider{ @@ -381,7 +381,7 @@ func TestEngine_ContextTimeout_CountsAsFailedAttempt(t *testing.T) { registry.Register(slowProvider) // Very short timeout to trigger context cancellation - eng := NewDeletionEngine(registry, repo, 5, 100*time.Millisecond, newTestLogger()) + eng := NewEngine(registry, repo, 5, 100*time.Millisecond, newTestLogger()) eng.Submit("job-timeout", "user-1") require.Eventually(t, func() bool { @@ -396,7 +396,7 @@ func TestEngine_ContextTimeout_CountsAsFailedAttempt(t *testing.T) { func TestEngine_BoundedConcurrency(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() var running atomic.Int32 var maxSeen atomic.Int32 @@ -410,7 +410,7 @@ func TestEngine_BoundedConcurrency(t *testing.T) { registry.Register(trackingProvider) maxConcurrent := 3 - eng := NewDeletionEngine(registry, repo, maxConcurrent, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, maxConcurrent, 5*time.Minute, newTestLogger()) // Submit more jobs than max concurrent totalJobs := 10 @@ -430,14 +430,14 @@ func TestEngine_BoundedConcurrency(t *testing.T) { func TestEngine_SubmitIsNonBlocking(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() registry.Register(&mockDeletionProvider{ name: "slow", delay: 1 * time.Second, }) // Engine with 1 concurrency slot - eng := NewDeletionEngine(registry, repo, 1, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 1, 5*time.Minute, newTestLogger()) // Submit should return immediately even with a full semaphore start := time.Now() @@ -456,7 +456,7 @@ func TestEngine_SubmitIsNonBlocking(t *testing.T) { func TestEngine_RetrySecondAttemptSuccess(t *testing.T) { repo := &mockDeletionRepo{} - registry := NewDeletionProviderRegistry() + registry := NewRegistry() // Provider fails twice but succeeds on third (last) attempt retryProvider := &mockDeletionProvider{ @@ -466,7 +466,7 @@ func TestEngine_RetrySecondAttemptSuccess(t *testing.T) { } registry.Register(retryProvider) - eng := NewDeletionEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) + eng := NewEngine(registry, repo, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-retry-2", "user-1") require.Eventually(t, func() bool { diff --git a/services/datarights/internal/deletion/func_provider.go b/services/datarights/internal/deletion/func_provider.go new file mode 100644 index 00000000..5b4d4bde --- /dev/null +++ b/services/datarights/internal/deletion/func_provider.go @@ -0,0 +1,27 @@ +package deletion + +import "context" + +// funcProvider adapts a name and a deletion function into a Provider, so a +// caller can register a provider without declaring a struct per data domain. +type funcProvider struct { + name string + fn func(ctx context.Context, userID string) error +} + +// NewFuncProvider builds a Provider from a name and a deletion function. It +// collapses the near-identical per-domain provider structs (auth, expense, +// finance) that each wrapped a single gRPC delete call. The function must be +// idempotent (see Provider): repeated calls for a user whose data is already +// gone must return nil. +func NewFuncProvider(name string, fn func(ctx context.Context, userID string) error) Provider { + return &funcProvider{name: name, fn: fn} +} + +// Name returns the provider's human-readable identifier. +func (p *funcProvider) Name() string { return p.name } + +// Delete runs the wrapped deletion function for the given user. +func (p *funcProvider) Delete(ctx context.Context, userID string) error { + return p.fn(ctx, userID) +} diff --git a/services/datarights/internal/deletion/func_provider_test.go b/services/datarights/internal/deletion/func_provider_test.go new file mode 100644 index 00000000..a404078b --- /dev/null +++ b/services/datarights/internal/deletion/func_provider_test.go @@ -0,0 +1,65 @@ +package deletion + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFuncProvider_Name(t *testing.T) { + p := NewFuncProvider("auth", func(context.Context, string) error { return nil }) + assert.Equal(t, "auth", p.Name()) +} + +func TestNewFuncProvider_Delete_DelegatesArgsAndSuccess(t *testing.T) { + var gotUser string + p := NewFuncProvider("finance", func(_ context.Context, userID string) error { + gotUser = userID + return nil + }) + + err := p.Delete(context.Background(), "user-123") + + require.NoError(t, err) + assert.Equal(t, "user-123", gotUser) +} + +func TestNewFuncProvider_Delete_PropagatesError(t *testing.T) { + wantErr := errors.New("service unavailable") + p := NewFuncProvider("expense", func(context.Context, string) error { return wantErr }) + + err := p.Delete(context.Background(), "user-abc") + + require.ErrorIs(t, err, wantErr) +} + +func TestNewFuncProvider_Delete_Idempotent_RepeatedCallsSafe(t *testing.T) { + // A provider whose data is already gone is a no-op returning nil. Calling + // Delete repeatedly for the same user must stay nil (Provider idempotency). + calls := 0 + p := NewFuncProvider("auth", func(context.Context, string) error { + calls++ + return nil + }) + + for range 3 { + require.NoError(t, p.Delete(context.Background(), "user-1")) + } + assert.Equal(t, 3, calls) +} + +func TestNewFuncProvider_RegistrationOrderPreserved(t *testing.T) { + // Registration order is the execution order; auth must be able to run last. + registry := NewRegistry() + registry.Register(NewFuncProvider("finance", func(context.Context, string) error { return nil })) + registry.Register(NewFuncProvider("expense", func(context.Context, string) error { return nil })) + registry.Register(NewFuncProvider("auth", func(context.Context, string) error { return nil })) + + all := registry.All() + require.Len(t, all, 3) + assert.Equal(t, []string{"finance", "expense", "auth"}, + []string{all[0].Name(), all[1].Name(), all[2].Name()}) +} diff --git a/services/datarights/internal/deletion/provider.go b/services/datarights/internal/deletion/provider.go index 496a52fe..82a81a8c 100644 --- a/services/datarights/internal/deletion/provider.go +++ b/services/datarights/internal/deletion/provider.go @@ -2,11 +2,11 @@ package deletion import "context" -// DeletionProvider deletes one category of user data. +// Provider deletes one category of user data. // Each provider is responsible for all data in its domain. // Implementations must be idempotent: calling Delete for a user // whose data is already gone is a no-op (returns nil). -type DeletionProvider interface { +type Provider interface { // Name returns a human-readable identifier for this provider. // Used in logging, metrics labels, and error messages. Name() string diff --git a/services/datarights/internal/deletion/providers/auth.go b/services/datarights/internal/deletion/providers/auth.go deleted file mode 100644 index 204e6baa..00000000 --- a/services/datarights/internal/deletion/providers/auth.go +++ /dev/null @@ -1,35 +0,0 @@ -package providers - -import ( - "context" - - "github.com/ItsThompson/gofin/services/auth/proto/authpb" - "github.com/ItsThompson/gofin/services/datarights/internal/deletion" -) - -// Compile-time check that AuthDeletionProvider implements DeletionProvider. -var _ deletion.DeletionProvider = (*AuthDeletionProvider)(nil) - -// AuthDeletionProvider deletes all auth-related data for a user via gRPC. -// Must be registered last: user cannot authenticate after this provider runs. -type AuthDeletionProvider struct { - authClient authpb.AuthServiceClient -} - -// NewAuthDeletionProvider creates an AuthDeletionProvider backed by the auth gRPC client. -func NewAuthDeletionProvider(authClient authpb.AuthServiceClient) *AuthDeletionProvider { - return &AuthDeletionProvider{authClient: authClient} -} - -// Name returns a human-readable identifier for this provider. -func (p *AuthDeletionProvider) Name() string { - return "auth" -} - -// Delete removes all auth data for the given user. -func (p *AuthDeletionProvider) Delete(ctx context.Context, userID string) error { - _, err := p.authClient.DeleteUserData(ctx, &authpb.DeleteUserDataRequest{ - UserId: userID, - }) - return err -} diff --git a/services/datarights/internal/deletion/providers/auth_test.go b/services/datarights/internal/deletion/providers/auth_test.go deleted file mode 100644 index 1d63c06d..00000000 --- a/services/datarights/internal/deletion/providers/auth_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAuthDeletionProvider_Name(t *testing.T) { - p := NewAuthDeletionProvider(nil) - assert.Equal(t, "auth", p.Name()) -} - -func TestAuthDeletionProvider_Delete_Success(t *testing.T) { - mock := &mockAuthClient{} - p := NewAuthDeletionProvider(mock) - - err := p.Delete(context.Background(), "user-123") - - require.NoError(t, err) - assert.Equal(t, "user-123", mock.deleteUserDataCalledWith) -} - -func TestAuthDeletionProvider_Delete_Error(t *testing.T) { - mock := &mockAuthClient{ - deleteUserDataErr: fmt.Errorf("deadline exceeded"), - } - p := NewAuthDeletionProvider(mock) - - err := p.Delete(context.Background(), "user-abc") - - require.Error(t, err) - assert.Contains(t, err.Error(), "deadline exceeded") - assert.Equal(t, "user-abc", mock.deleteUserDataCalledWith) -} diff --git a/services/datarights/internal/deletion/providers/expense.go b/services/datarights/internal/deletion/providers/expense.go deleted file mode 100644 index bf2ec2d1..00000000 --- a/services/datarights/internal/deletion/providers/expense.go +++ /dev/null @@ -1,34 +0,0 @@ -package providers - -import ( - "context" - - "github.com/ItsThompson/gofin/services/datarights/internal/deletion" - "github.com/ItsThompson/gofin/services/expense/proto/expensepb" -) - -// Compile-time check that ExpenseDeletionProvider implements DeletionProvider. -var _ deletion.DeletionProvider = (*ExpenseDeletionProvider)(nil) - -// ExpenseDeletionProvider anonymizes all expense data for a user via gRPC. -type ExpenseDeletionProvider struct { - expenseClient expensepb.ExpenseServiceClient -} - -// NewExpenseDeletionProvider creates an ExpenseDeletionProvider backed by the expense gRPC client. -func NewExpenseDeletionProvider(expenseClient expensepb.ExpenseServiceClient) *ExpenseDeletionProvider { - return &ExpenseDeletionProvider{expenseClient: expenseClient} -} - -// Name returns a human-readable identifier for this provider. -func (p *ExpenseDeletionProvider) Name() string { - return "expense" -} - -// Delete anonymizes all expenses for the given user. -func (p *ExpenseDeletionProvider) Delete(ctx context.Context, userID string) error { - _, err := p.expenseClient.AnonymizeAllUserExpenses(ctx, &expensepb.AnonymizeRequest{ - UserId: userID, - }) - return err -} diff --git a/services/datarights/internal/deletion/providers/expense_test.go b/services/datarights/internal/deletion/providers/expense_test.go deleted file mode 100644 index 9956a82f..00000000 --- a/services/datarights/internal/deletion/providers/expense_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExpenseDeletionProvider_Name(t *testing.T) { - p := NewExpenseDeletionProvider(nil) - assert.Equal(t, "expense", p.Name()) -} - -func TestExpenseDeletionProvider_Delete_Success(t *testing.T) { - mock := &mockExpenseClient{} - p := NewExpenseDeletionProvider(mock) - - err := p.Delete(context.Background(), "user-123") - - require.NoError(t, err) - assert.Equal(t, "user-123", mock.anonymizeCalledWith) -} - -func TestExpenseDeletionProvider_Delete_Error(t *testing.T) { - mock := &mockExpenseClient{ - anonymizeErr: fmt.Errorf("service unavailable"), - } - p := NewExpenseDeletionProvider(mock) - - err := p.Delete(context.Background(), "user-789") - - require.Error(t, err) - assert.Contains(t, err.Error(), "service unavailable") - assert.Equal(t, "user-789", mock.anonymizeCalledWith) -} diff --git a/services/datarights/internal/deletion/providers/finance.go b/services/datarights/internal/deletion/providers/finance.go deleted file mode 100644 index 6021ff40..00000000 --- a/services/datarights/internal/deletion/providers/finance.go +++ /dev/null @@ -1,34 +0,0 @@ -package providers - -import ( - "context" - - "github.com/ItsThompson/gofin/services/datarights/internal/deletion" - "github.com/ItsThompson/gofin/services/finance/proto/financepb" -) - -// Compile-time check that FinanceDeletionProvider implements DeletionProvider. -var _ deletion.DeletionProvider = (*FinanceDeletionProvider)(nil) - -// FinanceDeletionProvider deletes all financial data for a user via gRPC. -type FinanceDeletionProvider struct { - financeClient financepb.FinanceServiceClient -} - -// NewFinanceDeletionProvider creates a FinanceDeletionProvider backed by the finance gRPC client. -func NewFinanceDeletionProvider(financeClient financepb.FinanceServiceClient) *FinanceDeletionProvider { - return &FinanceDeletionProvider{financeClient: financeClient} -} - -// Name returns a human-readable identifier for this provider. -func (p *FinanceDeletionProvider) Name() string { - return "finance" -} - -// Delete removes all financial data for the given user. -func (p *FinanceDeletionProvider) Delete(ctx context.Context, userID string) error { - _, err := p.financeClient.DeleteAllUserData(ctx, &financepb.DeleteAllUserDataRequest{ - UserId: userID, - }) - return err -} diff --git a/services/datarights/internal/deletion/providers/finance_test.go b/services/datarights/internal/deletion/providers/finance_test.go deleted file mode 100644 index c3440c57..00000000 --- a/services/datarights/internal/deletion/providers/finance_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFinanceDeletionProvider_Name(t *testing.T) { - p := NewFinanceDeletionProvider(nil) - assert.Equal(t, "finance", p.Name()) -} - -func TestFinanceDeletionProvider_Delete_Success(t *testing.T) { - mock := &mockFinanceClient{} - p := NewFinanceDeletionProvider(mock) - - err := p.Delete(context.Background(), "user-123") - - require.NoError(t, err) - assert.Equal(t, "user-123", mock.deleteCalledWith) -} - -func TestFinanceDeletionProvider_Delete_Error(t *testing.T) { - mock := &mockFinanceClient{ - deleteAllUserDataErr: fmt.Errorf("connection refused"), - } - p := NewFinanceDeletionProvider(mock) - - err := p.Delete(context.Background(), "user-456") - - require.Error(t, err) - assert.Contains(t, err.Error(), "connection refused") - assert.Equal(t, "user-456", mock.deleteCalledWith) -} diff --git a/services/datarights/internal/deletion/providers/integration_test.go b/services/datarights/internal/deletion/providers/integration_test.go deleted file mode 100644 index 60b01426..00000000 --- a/services/datarights/internal/deletion/providers/integration_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ItsThompson/gofin/services/datarights/internal/deletion" - "github.com/ItsThompson/gofin/services/datarights/internal/model" -) - -// --------------------------------------------------------------------------- -// Integration mock repo: tracks state transitions -// --------------------------------------------------------------------------- - -type integrationMockRepo struct { - mu sync.Mutex - statusUpdates []string - completedJobs []string - failedJobs []failedJobRecord - nonTerminalRes []model.RecoverableDeletionJob - nonTerminalErr error -} - -type failedJobRecord struct { - JobID string - ErrMsg string -} - -func (m *integrationMockRepo) CreateJob(_ context.Context, _, _ string) (*model.DeletionJob, error) { - return nil, nil -} - -func (m *integrationMockRepo) GetJob(_ context.Context, _ string) (*model.DeletionJob, error) { - return nil, nil -} - -func (m *integrationMockRepo) GetInProgressJob(_ context.Context, _ string) (*model.DeletionJob, error) { - return nil, nil -} - -func (m *integrationMockRepo) UpdateStatus(_ context.Context, _ string, status string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.statusUpdates = append(m.statusUpdates, status) - return nil -} - -func (m *integrationMockRepo) CompleteJob(_ context.Context, jobID string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.completedJobs = append(m.completedJobs, jobID) - return nil -} - -func (m *integrationMockRepo) FailJob(_ context.Context, jobID, errMsg string) error { - m.mu.Lock() - defer m.mu.Unlock() - m.failedJobs = append(m.failedJobs, failedJobRecord{JobID: jobID, ErrMsg: errMsg}) - return nil -} - -func (m *integrationMockRepo) GetNonTerminalJobs(_ context.Context) ([]model.RecoverableDeletionJob, error) { - return m.nonTerminalRes, m.nonTerminalErr -} - -// --------------------------------------------------------------------------- -// Order-tracking provider wrapper -// --------------------------------------------------------------------------- - -type orderTrackingProvider struct { - inner deletion.DeletionProvider - mu *sync.Mutex - callLog *[]string - calledAt *[]time.Time -} - -func (p *orderTrackingProvider) Name() string { - return p.inner.Name() -} - -func (p *orderTrackingProvider) Delete(ctx context.Context, userID string) error { - p.mu.Lock() - *p.callLog = append(*p.callLog, p.inner.Name()) - *p.calledAt = append(*p.calledAt, time.Now()) - p.mu.Unlock() - return p.inner.Delete(ctx, userID) -} - -// --------------------------------------------------------------------------- -// Integration tests -// --------------------------------------------------------------------------- - -func TestIntegration_AllProviders_CalledInOrder_JobCompletes(t *testing.T) { - financeClient := &mockFinanceClient{} - expenseClient := &mockExpenseClient{} - authClient := &mockAuthClient{} - - financeProv := NewFinanceDeletionProvider(financeClient) - expenseProv := NewExpenseDeletionProvider(expenseClient) - authProv := NewAuthDeletionProvider(authClient) - - // Wrap in order-tracking providers - var mu sync.Mutex - var callLog []string - var calledAt []time.Time - - wrappedFinance := &orderTrackingProvider{inner: financeProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - wrappedExpense := &orderTrackingProvider{inner: expenseProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - wrappedAuth := &orderTrackingProvider{inner: authProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - - // Register in correct order: finance → expense → auth - registry := deletion.NewDeletionProviderRegistry() - registry.Register(wrappedFinance) - registry.Register(wrappedExpense) - registry.Register(wrappedAuth) - - repo := &integrationMockRepo{} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - - engine := deletion.NewDeletionEngine(registry, repo, 2, 30*time.Second, logger) - - // Submit a job - engine.Submit("job-1", "user-42") - - // Wait for completion - require.Eventually(t, func() bool { - repo.mu.Lock() - defer repo.mu.Unlock() - return len(repo.completedJobs) == 1 - }, 5*time.Second, 10*time.Millisecond) - - // Verify all providers were called - mu.Lock() - defer mu.Unlock() - require.Len(t, callLog, 3) - assert.Equal(t, []string{"finance", "expense", "auth"}, callLog) - - // Verify correct user ID was passed to each client - assert.Equal(t, "user-42", financeClient.deleteCalledWith) - assert.Equal(t, "user-42", expenseClient.anonymizeCalledWith) - assert.Equal(t, "user-42", authClient.deleteUserDataCalledWith) - - // Verify job was completed - repo.mu.Lock() - assert.Equal(t, []string{"job-1"}, repo.completedJobs) - repo.mu.Unlock() - - // Verify status transitioned to running first - repo.mu.Lock() - require.Len(t, repo.statusUpdates, 1) - assert.Equal(t, "running", repo.statusUpdates[0]) - repo.mu.Unlock() -} - -func TestIntegration_ProviderFailure_JobFails_RemainingSkipped(t *testing.T) { - financeClient := &mockFinanceClient{} - expenseClient := &mockExpenseClient{ - anonymizeErr: fmt.Errorf("expense service down"), - } - authClient := &mockAuthClient{} - - financeProv := NewFinanceDeletionProvider(financeClient) - expenseProv := NewExpenseDeletionProvider(expenseClient) - authProv := NewAuthDeletionProvider(authClient) - - var mu sync.Mutex - var callLog []string - var calledAt []time.Time - - wrappedFinance := &orderTrackingProvider{inner: financeProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - wrappedExpense := &orderTrackingProvider{inner: expenseProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - wrappedAuth := &orderTrackingProvider{inner: authProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - - registry := deletion.NewDeletionProviderRegistry() - registry.Register(wrappedFinance) - registry.Register(wrappedExpense) - registry.Register(wrappedAuth) - - repo := &integrationMockRepo{} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - - engine := deletion.NewDeletionEngine(registry, repo, 2, 30*time.Second, logger) - - engine.Submit("job-2", "user-99") - - // Wait for failure (expense will retry 3 times with backoff) - require.Eventually(t, func() bool { - repo.mu.Lock() - defer repo.mu.Unlock() - return len(repo.failedJobs) == 1 - }, 10*time.Second, 50*time.Millisecond) - - // Verify finance was called but auth was NOT (fail-fast after expense) - mu.Lock() - assert.Contains(t, callLog, "finance") - assert.Contains(t, callLog, "expense") - assert.NotContains(t, callLog, "auth") - mu.Unlock() - - // Verify auth was never called - assert.Empty(t, authClient.deleteUserDataCalledWith) - - // Verify the failure message references the expense provider - repo.mu.Lock() - require.Len(t, repo.failedJobs, 1) - assert.Contains(t, repo.failedJobs[0].ErrMsg, "expense") - assert.Contains(t, repo.failedJobs[0].ErrMsg, "expense service down") - repo.mu.Unlock() -} diff --git a/services/datarights/internal/deletion/providers/mock_clients_test.go b/services/datarights/internal/deletion/providers/mock_clients_test.go deleted file mode 100644 index aedd480d..00000000 --- a/services/datarights/internal/deletion/providers/mock_clients_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package providers - -import ( - "context" - - "google.golang.org/grpc" - - "github.com/ItsThompson/gofin/services/auth/proto/authpb" - "github.com/ItsThompson/gofin/services/expense/proto/expensepb" - "github.com/ItsThompson/gofin/services/finance/proto/financepb" -) - -// --------------------------------------------------------------------------- -// Mock finance client -// --------------------------------------------------------------------------- - -type mockFinanceClient struct { - deleteAllUserDataErr error - deleteCalledWith string -} - -func (m *mockFinanceClient) DeleteAllUserData(_ context.Context, req *financepb.DeleteAllUserDataRequest, _ ...grpc.CallOption) (*financepb.DeleteAllUserDataResponse, error) { - m.deleteCalledWith = req.GetUserId() - if m.deleteAllUserDataErr != nil { - return nil, m.deleteAllUserDataErr - } - return &financepb.DeleteAllUserDataResponse{}, nil -} - -// No-op stubs for remaining FinanceServiceClient interface methods. -func (m *mockFinanceClient) GetDefaults(_ context.Context, _ *financepb.GetDefaultsRequest, _ ...grpc.CallOption) (*financepb.DefaultsResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) UpdateDefaults(_ context.Context, _ *financepb.UpdateDefaultsRequest, _ ...grpc.CallOption) (*financepb.DefaultsResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) CompleteOnboarding(_ context.Context, _ *financepb.CompleteOnboardingRequest, _ ...grpc.CallOption) (*financepb.DefaultsResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetAllUserData(_ context.Context, _ *financepb.GetAllUserDataRequest, _ ...grpc.CallOption) (*financepb.AllUserDataResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetCurrentPeriod(_ context.Context, _ *financepb.GetCurrentPeriodRequest, _ ...grpc.CallOption) (*financepb.PeriodResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) CreatePeriod(_ context.Context, _ *financepb.CreatePeriodRequest, _ ...grpc.CallOption) (*financepb.PeriodResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) UpdatePeriod(_ context.Context, _ *financepb.UpdatePeriodRequest, _ ...grpc.CallOption) (*financepb.PeriodResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) ListPeriods(_ context.Context, _ *financepb.ListPeriodsRequest, _ ...grpc.CallOption) (*financepb.PeriodListResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) ListTags(_ context.Context, _ *financepb.ListTagsRequest, _ ...grpc.CallOption) (*financepb.TagListResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) CreateTag(_ context.Context, _ *financepb.CreateTagRequest, _ ...grpc.CallOption) (*financepb.TagResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) UpdateTag(_ context.Context, _ *financepb.UpdateTagRequest, _ ...grpc.CallOption) (*financepb.TagResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) DeleteTag(_ context.Context, _ *financepb.DeleteTagRequest, _ ...grpc.CallOption) (*financepb.DeleteTagResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) CheckTagUsage(_ context.Context, _ *financepb.CheckTagUsageRequest, _ ...grpc.CallOption) (*financepb.TagUsageResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) CreateProRataExpense(_ context.Context, _ *financepb.CreateProRataExpenseRequest, _ ...grpc.CallOption) (*financepb.ProRataResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetUpcomingProRata(_ context.Context, _ *financepb.GetUpcomingProRataRequest, _ ...grpc.CallOption) (*financepb.UpcomingProRataListResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetPeriodSummary(_ context.Context, _ *financepb.GetPeriodSummaryRequest, _ ...grpc.CallOption) (*financepb.PeriodSummaryResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetSpendingByTag(_ context.Context, _ *financepb.GetSpendingByTagRequest, _ ...grpc.CallOption) (*financepb.TagSpendingListResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetCumulativeSpend(_ context.Context, _ *financepb.GetCumulativeSpendRequest, _ ...grpc.CallOption) (*financepb.CumulativeSpendResponse, error) { - return nil, nil -} -func (m *mockFinanceClient) GetHistoricalComparison(_ context.Context, _ *financepb.GetHistoricalComparisonRequest, _ ...grpc.CallOption) (*financepb.HistoricalComparisonResponse, error) { - return nil, nil -} - -// --------------------------------------------------------------------------- -// Mock expense client -// --------------------------------------------------------------------------- - -type mockExpenseClient struct { - anonymizeErr error - anonymizeCalledWith string -} - -func (m *mockExpenseClient) AnonymizeAllUserExpenses(_ context.Context, req *expensepb.AnonymizeRequest, _ ...grpc.CallOption) (*expensepb.AnonymizeResponse, error) { - m.anonymizeCalledWith = req.GetUserId() - if m.anonymizeErr != nil { - return nil, m.anonymizeErr - } - return &expensepb.AnonymizeResponse{}, nil -} - -// No-op stubs for remaining ExpenseServiceClient interface methods. -func (m *mockExpenseClient) CreateExpense(_ context.Context, _ *expensepb.CreateExpenseRequest, _ ...grpc.CallOption) (*expensepb.ExpenseResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) GetExpensesForPeriod(_ context.Context, _ *expensepb.GetExpensesForPeriodRequest, _ ...grpc.CallOption) (*expensepb.ExpenseListResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) GetExpense(_ context.Context, _ *expensepb.GetExpenseRequest, _ ...grpc.CallOption) (*expensepb.ExpenseResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) CountExpensesByTag(_ context.Context, _ *expensepb.CountExpensesByTagRequest, _ ...grpc.CallOption) (*expensepb.CountExpensesByTagResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) CorrectExpense(_ context.Context, _ *expensepb.CorrectExpenseRequest, _ ...grpc.CallOption) (*expensepb.ExpenseResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) GetCorrectionHistory(_ context.Context, _ *expensepb.GetCorrectionHistoryRequest, _ ...grpc.CallOption) (*expensepb.CorrectionHistoryResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) GetProRataGroup(_ context.Context, _ *expensepb.GetProRataGroupRequest, _ ...grpc.CallOption) (*expensepb.ExpenseListResponse, error) { - return nil, nil -} -func (m *mockExpenseClient) StreamAllUserExpenses(_ context.Context, _ *expensepb.StreamAllUserExpensesRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[expensepb.ExpenseData], error) { - return nil, nil -} - -// --------------------------------------------------------------------------- -// Mock auth client -// --------------------------------------------------------------------------- - -type mockAuthClient struct { - deleteUserDataErr error - deleteUserDataCalledWith string -} - -func (m *mockAuthClient) DeleteUserData(_ context.Context, req *authpb.DeleteUserDataRequest, _ ...grpc.CallOption) (*authpb.DeleteUserDataResponse, error) { - m.deleteUserDataCalledWith = req.GetUserId() - if m.deleteUserDataErr != nil { - return nil, m.deleteUserDataErr - } - return &authpb.DeleteUserDataResponse{}, nil -} - -// No-op stubs for remaining AuthServiceClient interface methods. -func (m *mockAuthClient) Register(_ context.Context, _ *authpb.RegisterRequest, _ ...grpc.CallOption) (*authpb.AuthResponse, error) { - return nil, nil -} -func (m *mockAuthClient) Login(_ context.Context, _ *authpb.LoginRequest, _ ...grpc.CallOption) (*authpb.AuthResponse, error) { - return nil, nil -} -func (m *mockAuthClient) ValidateToken(_ context.Context, _ *authpb.ValidateTokenRequest, _ ...grpc.CallOption) (*authpb.ValidateTokenResponse, error) { - return nil, nil -} -func (m *mockAuthClient) VerifyPassword(_ context.Context, _ *authpb.VerifyPasswordRequest, _ ...grpc.CallOption) (*authpb.VerifyPasswordResponse, error) { - return nil, nil -} -func (m *mockAuthClient) RefreshToken(_ context.Context, _ *authpb.RefreshTokenRequest, _ ...grpc.CallOption) (*authpb.AuthResponse, error) { - return nil, nil -} -func (m *mockAuthClient) Logout(_ context.Context, _ *authpb.LogoutRequest, _ ...grpc.CallOption) (*authpb.LogoutResponse, error) { - return nil, nil -} -func (m *mockAuthClient) AssumeIdentity(_ context.Context, _ *authpb.AssumeIdentityRequest, _ ...grpc.CallOption) (*authpb.AuthResponse, error) { - return nil, nil -} -func (m *mockAuthClient) RestoreIdentity(_ context.Context, _ *authpb.RestoreIdentityRequest, _ ...grpc.CallOption) (*authpb.AuthResponse, error) { - return nil, nil -} -func (m *mockAuthClient) ListUsers(_ context.Context, _ *authpb.ListUsersRequest, _ ...grpc.CallOption) (*authpb.ListUsersResponse, error) { - return nil, nil -} -func (m *mockAuthClient) GetUser(_ context.Context, _ *authpb.GetUserRequest, _ ...grpc.CallOption) (*authpb.UserResponse, error) { - return nil, nil -} -func (m *mockAuthClient) UpdateUser(_ context.Context, _ *authpb.UpdateUserRequest, _ ...grpc.CallOption) (*authpb.UserResponse, error) { - return nil, nil -} -func (m *mockAuthClient) ChangePassword(_ context.Context, _ *authpb.ChangePasswordRequest, _ ...grpc.CallOption) (*authpb.ChangePasswordResponse, error) { - return nil, nil -} diff --git a/services/datarights/internal/deletion/registry.go b/services/datarights/internal/deletion/registry.go index af730e96..9afb14f9 100644 --- a/services/datarights/internal/deletion/registry.go +++ b/services/datarights/internal/deletion/registry.go @@ -1,23 +1,23 @@ package deletion -// DeletionProviderRegistry holds all registered deletion providers. +// Registry holds all registered deletion providers. // Providers are registered at startup in the order they should execute. // The order is significant: auth must be last. -type DeletionProviderRegistry struct { - providers []DeletionProvider +type Registry struct { + providers []Provider } -// NewDeletionProviderRegistry creates an empty registry. -func NewDeletionProviderRegistry() *DeletionProviderRegistry { - return &DeletionProviderRegistry{} +// NewRegistry creates an empty registry. +func NewRegistry() *Registry { + return &Registry{} } // Register adds a provider. Order of registration = order of execution. -func (r *DeletionProviderRegistry) Register(p DeletionProvider) { +func (r *Registry) Register(p Provider) { r.providers = append(r.providers, p) } // All returns providers in registration order. -func (r *DeletionProviderRegistry) All() []DeletionProvider { +func (r *Registry) All() []Provider { return r.providers } diff --git a/services/datarights/internal/email/email_test.go b/services/datarights/internal/email/email_test.go index 85a795fc..05dbba65 100644 --- a/services/datarights/internal/email/email_test.go +++ b/services/datarights/internal/email/email_test.go @@ -20,9 +20,6 @@ func testTokens() BrandTokens { Muted: "#f1f5f9", MutedForeground: "#64748b", Border: "#e2e8f0", - Essentials: "#3b82f6", - Desires: "#f59e0b", - Savings: "#10b981", }, Typography: BrandTypography{ FontFamily: "'Geist', -apple-system, BlinkMacSystemFont, sans-serif", diff --git a/services/datarights/internal/email/resend.go b/services/datarights/internal/email/resend.go index 6de47ec7..e8053aca 100644 --- a/services/datarights/internal/email/resend.go +++ b/services/datarights/internal/email/resend.go @@ -20,40 +20,18 @@ var htmlTemplateContent string //go:embed templates/export_ready.txt var textTemplateContent string -// templateData holds values passed to email templates. +// templateData holds values passed to email templates. The brand token groups +// are used directly (BrandColors/BrandTypography/BrandSpacing) rather than +// mirrored field by field into parallel types; the templates access the same +// field names either way. type templateData struct { - Colors templateColors - Typography templateTypography - Spacing templateSpacing + Colors BrandColors + Typography BrandTypography + Spacing BrandSpacing ExportDate string FileName string } -// templateColors maps brand tokens to template-accessible fields. -type templateColors struct { - Background string - Foreground string - Primary string - PrimaryForeground string - Muted string - MutedForeground string - Border string -} - -// templateTypography holds font info for templates. -type templateTypography struct { - FontFamily string - FontSize map[string]string -} - -// templateSpacing holds spacing tokens for templates. -type templateSpacing struct { - Sm string - Md string - Lg string - Xl string -} - // resendAttachment represents an email attachment for the Resend API. type resendAttachment struct { Content string `json:"content"` @@ -193,25 +171,9 @@ func (s *ResendSender) SendExportEmail(ctx context.Context, toEmail string, zipB // buildTemplateData constructs the template rendering context from brand tokens. func (s *ResendSender) buildTemplateData(exportDate, fileName string) templateData { return templateData{ - Colors: templateColors{ - Background: s.tokens.Colors.Background, - Foreground: s.tokens.Colors.Foreground, - Primary: s.tokens.Colors.Primary, - PrimaryForeground: s.tokens.Colors.PrimaryForeground, - Muted: s.tokens.Colors.Muted, - MutedForeground: s.tokens.Colors.MutedForeground, - Border: s.tokens.Colors.Border, - }, - Typography: templateTypography{ - FontFamily: s.tokens.Typography.FontFamily, - FontSize: s.tokens.Typography.FontSize, - }, - Spacing: templateSpacing{ - Sm: s.tokens.Spacing.Sm, - Md: s.tokens.Spacing.Md, - Lg: s.tokens.Spacing.Lg, - Xl: s.tokens.Spacing.Xl, - }, + Colors: s.tokens.Colors, + Typography: s.tokens.Typography, + Spacing: s.tokens.Spacing, ExportDate: exportDate, FileName: fileName, } diff --git a/services/datarights/internal/email/tokens.go b/services/datarights/internal/email/tokens.go index 5b89c259..0f5e491c 100644 --- a/services/datarights/internal/email/tokens.go +++ b/services/datarights/internal/email/tokens.go @@ -16,9 +16,6 @@ type BrandColors struct { Muted string `json:"muted"` MutedForeground string `json:"mutedForeground"` Border string `json:"border"` - Essentials string `json:"essentials"` - Desires string `json:"desires"` - Savings string `json:"savings"` } // BrandTypography contains font family and size tokens. diff --git a/services/datarights/internal/engine/engine.go b/services/datarights/internal/engine/engine.go index f4caa12b..c6e426a6 100644 --- a/services/datarights/internal/engine/engine.go +++ b/services/datarights/internal/engine/engine.go @@ -6,36 +6,56 @@ import ( "fmt" "log/slog" "strings" + "sync" "time" "golang.org/x/sync/errgroup" "github.com/ItsThompson/gofin/services/datarights/internal/email" + "github.com/ItsThompson/gofin/services/datarights/internal/jobrunner" exportmetrics "github.com/ItsThompson/gofin/services/datarights/internal/metrics" "github.com/ItsThompson/gofin/services/datarights/internal/repository" "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) -// ProviderFactory builds a fresh set of data providers for a single export job, -// injecting the finance client the finance-backed providers should share. The -// factory closes over the non-finance clients (auth, expense) at startup; the -// finance client is supplied per job so each job gets its own memoized instance. -type ProviderFactory func(finance financepb.FinanceServiceClient) []DataProvider +// ProviderFactory builds a fresh set of data providers for a single export job +// from the finance data fetched once upfront. It closes over the self-fetching +// clients (auth for the profile, expense for the expenses stream) at startup; +// the resolved finance response is supplied per job so the finance-backed +// providers are pure response -> rows mappers and the export hits finance +// exactly once (in execute). +type ProviderFactory func(financeData *financepb.AllUserDataResponse) []DataProvider + +// jobState carries the per-job values the pool's fixed Execute/StatusStore +// signatures cannot thread through: userEmail flows in (set by Submit, read by +// the execute strategy) and fileSize flows out (set by execute, read by the +// StatusStore adapter's CompleteJob). One job runs per jobID, and the pool +// drives that job's transitions sequentially in a single goroutine, so a plain +// pointer needs no further locking; sync.Map guards concurrent jobs. +type jobState struct { + userEmail string + fileSize int64 +} -// Engine manages a bounded pool of export workers. +// Engine manages a bounded pool of export workers via jobrunner.Pool, injecting +// its errgroup fan-out + zip + email closure as the Execute strategy. Because +// export's CompleteJob persists file_size_bytes (and Submit carries userEmail), +// neither of which fit the generic pool signatures, the engine adapts its repo +// behind the StatusStore seam (see statusStore) rather than handing the repo to +// the pool directly. type Engine struct { newProviders ProviderFactory financeClient financepb.FinanceServiceClient repo repository.JobRepository sender email.Sender - sem chan struct{} logger *slog.Logger - timeout time.Duration + pool *jobrunner.Pool + jobs sync.Map // jobID -> *jobState } // NewEngine creates an export engine with bounded concurrency. newProviders -// builds a fresh provider set per job; financeClient is the raw finance client -// wrapped in a per-job MemoizedFinanceClient before being handed to the factory. +// builds a fresh provider set per job from the finance response that execute +// fetches once via financeClient. func NewEngine( newProviders ProviderFactory, financeClient financepb.FinanceServiceClient, @@ -45,54 +65,90 @@ func NewEngine( timeout time.Duration, logger *slog.Logger, ) *Engine { - return &Engine{ + e := &Engine{ newProviders: newProviders, financeClient: financeClient, repo: repo, sender: sender, - sem: make(chan struct{}, maxConcurrent), logger: logger, - timeout: timeout, } + e.pool = jobrunner.New(maxConcurrent, timeout, statusStore{e}, e.execute, logger) + return e } // ActiveJobs returns the number of currently executing export jobs. -func (e *Engine) ActiveJobs() int { - return len(e.sem) -} +func (e *Engine) ActiveJobs() int { return e.pool.ActiveJobs() } + +// QueuedJobs returns the number of export jobs waiting for a pool slot. +func (e *Engine) QueuedJobs() int { return e.pool.QueuedJobs() } // MaxConcurrent returns the pool capacity. -func (e *Engine) MaxConcurrent() int { - return cap(e.sem) -} +func (e *Engine) MaxConcurrent() int { return e.pool.MaxConcurrent() } -// Submit enqueues a job for asynchronous processing. +// Submit enqueues a job for asynchronous processing. Non-blocking: the pool +// spawns the worker goroutine. func (e *Engine) Submit(jobID, userID, userEmail string) { - go func() { - // Track queued state: job is waiting for a pool slot - exportmetrics.ExportPoolQueuedJobs.Inc() + e.jobs.Store(jobID, &jobState{userEmail: userEmail}) + e.pool.Submit(jobID, userID) +} - // Acquire semaphore slot (blocks if pool is full) - e.sem <- struct{}{} +func (e *Engine) loadState(jobID string) *jobState { + if v, ok := e.jobs.Load(jobID); ok { + return v.(*jobState) + } + return &jobState{} +} - // No longer queued, now active - exportmetrics.ExportPoolQueuedJobs.Dec() - exportmetrics.ExportPoolActiveJobs.Inc() +func (e *Engine) takeState(jobID string) *jobState { + if v, ok := e.jobs.LoadAndDelete(jobID); ok { + return v.(*jobState) + } + return &jobState{} +} - defer func() { - <-e.sem - exportmetrics.ExportPoolActiveJobs.Dec() - }() +// statusStore adapts the export engine's repo to jobrunner.StatusStore. The +// pool's CompleteJob carries no size, so it reads the archive size the execute +// strategy recorded for the job; export's CompleteJob still persists +// file_size_bytes. The deletion repo satisfies StatusStore directly. +type statusStore struct{ e *Engine } + +func (s statusStore) UpdateStatus(ctx context.Context, jobID, status string) error { + if err := s.e.repo.UpdateStatus(ctx, jobID, status); err != nil { + // The running transition failed, so the pool will not run execute or a + // terminal transition: drop the per-job state now so it never leaks. + s.e.jobs.Delete(jobID) + return err + } + return nil +} - ctx, cancel := context.WithTimeout(context.Background(), e.timeout) - defer cancel() +func (s statusStore) CompleteJob(ctx context.Context, jobID string) error { + return s.e.repo.CompleteJob(ctx, jobID, s.e.takeState(jobID).fileSize) +} - e.execute(ctx, jobID, userID, userEmail) - }() +func (s statusStore) FailJob(ctx context.Context, jobID, reason string) error { + s.e.jobs.Delete(jobID) + return s.e.repo.FailJob(ctx, jobID, reason) } -// execute runs the full export flow for a single job. -func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { +// execute is the injected jobrunner strategy. It reads the per-job userEmail +// stashed by Submit, runs the export work, and records the archive size for the +// pool's CompleteJob. The pool owns the running transition, completion, and +// background-context failure. +func (e *Engine) execute(ctx context.Context, jobID, userID string) error { + st := e.loadState(jobID) + fileSizeBytes, err := e.runExport(ctx, jobID, userID, st.userEmail) + if err != nil { + return err + } + st.fileSize = fileSizeBytes + return nil +} + +// runExport performs the full export work for a single job: fan-out collection, +// ZIP assembly, and email delivery. It returns the archive size on success or a +// PII-free failure whose message is persisted as the job's error. +func (e *Engine) runExport(ctx context.Context, jobID, userID, userEmail string) (int64, error) { jobStart := time.Now() e.logger.Info("export job starting", @@ -101,24 +157,18 @@ func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { slog.String("method", "engine.execute"), ) - // Transition to running - if err := e.repo.UpdateStatus(ctx, jobID, "running"); err != nil { - e.logger.Error("failed to update job status to running", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("method", "engine.execute"), - slog.String("error", err.Error()), - ) - exportmetrics.ExportJobsCompletedTotal.WithLabelValues("failed").Inc() - exportmetrics.ExportJobDurationSeconds.Observe(time.Since(jobStart).Seconds()) - return + // Fetch the user's finance data once upfront and hand the resolved response + // to the provider factory. The finance-backed providers (tags, budget + // periods, default settings, and the expenses tag map) are pure mappers over + // this response, so the export hits finance exactly once by construction. + financeData, err := e.financeClient.GetAllUserData(ctx, &financepb.GetAllUserDataRequest{UserId: userID}) + if err != nil { + if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) { + return 0, e.recordFailure(jobID, userID, "Export timed out", "finance_fetch", jobStart) + } + return 0, e.recordFailure(jobID, userID, "Failed to fetch export data", "finance_fetch", jobStart) } - - // Build a fresh provider set for this job. The finance-backed providers - // share one per-job MemoizedFinanceClient, so GetAllUserData is fetched at - // most once; a fresh instance per job prevents cross-user data leakage. - fc := NewMemoizedFinanceClient(e.financeClient) - providerSet := e.newProviders(fc) + providerSet := e.newProviders(financeData) // Collect from every provider concurrently. The providers are independent // and read-only, so each goroutine writes only its own pre-assigned index in @@ -178,21 +228,17 @@ func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { case err == nil: // all providers succeeded; fall through to ZIP assembly case ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded): - e.failJob(ctx, jobID, userID, "Export timed out", "collection", jobStart) - return + return 0, e.recordFailure(jobID, userID, "Export timed out", "collection", jobStart) case errors.As(err, &ce): - e.failJob(ctx, jobID, userID, fmt.Sprintf("Failed to collect %s data", ce.provider), "collection", jobStart) - return + return 0, e.recordFailure(jobID, userID, fmt.Sprintf("Failed to collect %s data", ce.provider), "collection", jobStart) default: - e.failJob(ctx, jobID, userID, "Failed to collect export data", "collection", jobStart) - return + return 0, e.recordFailure(jobID, userID, "Failed to collect export data", "collection", jobStart) } // Build ZIP zipBytes, err := BuildZIP(csvFiles) if err != nil { - e.failJob(ctx, jobID, userID, "Failed to build export archive", "zip_assembly", jobStart) - return + return 0, e.recordFailure(jobID, userID, "Failed to build export archive", "zip_assembly", jobStart) } fileSizeBytes := int64(len(zipBytes)) @@ -208,8 +254,7 @@ func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { // Send email with ZIP attachment emailStart := time.Now() if err := e.sender.SendExportEmail(ctx, userEmail, zipBytes); err != nil { - e.failJob(ctx, jobID, userID, fmt.Sprintf("Email delivery failed: %s", sanitizeError(err)), "email_delivery", jobStart) - return + return 0, e.recordFailure(jobID, userID, fmt.Sprintf("Email delivery failed: %s", sanitizeError(err)), "email_delivery", jobStart) } exportmetrics.ExportEmailSendDurationSeconds.Observe(time.Since(emailStart).Seconds()) @@ -220,19 +265,6 @@ func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { slog.String("method", "engine.execute"), ) - // Mark complete - if err := e.repo.CompleteJob(ctx, jobID, fileSizeBytes); err != nil { - e.logger.Error("failed to complete job", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("method", "engine.execute"), - slog.String("error", err.Error()), - ) - exportmetrics.ExportJobsCompletedTotal.WithLabelValues("failed").Inc() - exportmetrics.ExportJobDurationSeconds.Observe(time.Since(jobStart).Seconds()) - return - } - jobDuration := time.Since(jobStart).Seconds() exportmetrics.ExportJobsCompletedTotal.WithLabelValues("completed").Inc() exportmetrics.ExportJobDurationSeconds.Observe(jobDuration) @@ -244,10 +276,13 @@ func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { slog.Float64("duration_seconds", jobDuration), slog.String("method", "engine.execute"), ) + + return fileSizeBytes, nil } -// failJob marks a job as failed with a human-readable error message. -func (e *Engine) failJob(_ context.Context, jobID, userID, errMsg, stage string, jobStart time.Time) { +// recordFailure observes the failure metrics, logs the PII-free reason with its +// stage, and returns the terminal error the pool persists via FailJob. +func (e *Engine) recordFailure(jobID, userID, errMsg, stage string, jobStart time.Time) error { exportmetrics.ExportJobsCompletedTotal.WithLabelValues("failed").Inc() exportmetrics.ExportJobDurationSeconds.Observe(time.Since(jobStart).Seconds()) @@ -256,21 +291,20 @@ func (e *Engine) failJob(_ context.Context, jobID, userID, errMsg, stage string, slog.String("user_id", userID), slog.String("error", errMsg), slog.String("stage", stage), - slog.String("method", "engine.failJob"), + slog.String("method", "engine.execute"), ) - // Use a background context for the fail update in case the original context expired - failCtx := context.Background() - if err := e.repo.FailJob(failCtx, jobID, errMsg); err != nil { - e.logger.Error("failed to mark job as failed", - slog.String("job_id", jobID), - slog.String("user_id", userID), - slog.String("method", "engine.failJob"), - slog.String("error", err.Error()), - ) - } + return &failure{reason: errMsg} } +// failure is a terminal export error whose message is the final, PII-free text +// persisted to the job's error column. Storing the text in a field (not an +// errors.New/fmt.Errorf literal) keeps the user-facing capitalized phrasing +// without tripping ST1005. +type failure struct{ reason string } + +func (f *failure) Error() string { return f.reason } + // collectError carries the failing provider's name alongside the underlying // cause, so the post-Wait switch recovers the name via errors.As instead of // round-tripping structured data through the error string. It unwraps to the diff --git a/services/datarights/internal/engine/engine_bench_test.go b/services/datarights/internal/engine/engine_bench_test.go index eaef8808..e2ab20e8 100644 --- a/services/datarights/internal/engine/engine_bench_test.go +++ b/services/datarights/internal/engine/engine_bench_test.go @@ -15,7 +15,7 @@ import ( const benchProviderLatency = 10 * time.Millisecond // BenchmarkEngineCollectionFanout measures a full export run over five providers -// with differing simulated latency. It drives engine.execute end-to-end; +// with differing simulated latency. It drives engine.runExport end-to-end; // collection dominates because the stub repo, ZIP assembly, and stub sender are // effectively instant, so the reported ns/op tracks total collection latency: // ≈ max(providers) under the errgroup fan-out versus ≈ sum(providers) under the @@ -31,11 +31,11 @@ func BenchmarkEngineCollectionFanout(b *testing.B) { } } - eng := NewEngine(staticProviders(provs...), nil, &mockRepo{}, newMockSender(), 5, time.Minute, newTestLogger()) + eng := NewEngine(staticProviders(provs...), nopFinance{}, &mockRepo{}, newMockSender(), 5, time.Minute, newTestLogger()) b.ReportAllocs() b.ResetTimer() for b.Loop() { - eng.execute(context.Background(), "job-bench", "user-1", "alex@example.com") + _, _ = eng.runExport(context.Background(), "job-bench", "user-1", "alex@example.com") } } diff --git a/services/datarights/internal/engine/engine_fanout_test.go b/services/datarights/internal/engine/engine_fanout_test.go index b1db92c8..5ea74d11 100644 --- a/services/datarights/internal/engine/engine_fanout_test.go +++ b/services/datarights/internal/engine/engine_fanout_test.go @@ -67,7 +67,7 @@ func TestEngine_FanOut_ZIPOrderDeterministic(t *testing.T) { repo := &mockRepo{} sender := &capturingSender{} - eng := NewEngine(staticProviders(provs...), nil, repo, sender, 5, time.Minute, newTestLogger()) + eng := NewEngine(staticProviders(provs...), nopFinance{}, repo, sender, 5, time.Minute, newTestLogger()) eng.Submit("job-order", "user-1", "alex@example.com") require.Eventually(t, func() bool { @@ -96,7 +96,7 @@ func TestEngine_FanOut_RunsProvidersConcurrently(t *testing.T) { } repo := &mockRepo{} - eng := NewEngine(staticProviders(provs...), nil, repo, newMockSender(), 5, time.Minute, newTestLogger()) + eng := NewEngine(staticProviders(provs...), nopFinance{}, repo, newMockSender(), 5, time.Minute, newTestLogger()) eng.Submit("job-concurrent", "user-1", "") require.Eventually(t, func() bool { @@ -118,7 +118,7 @@ func TestEngine_FanOut_NamedProviderErrorSurvivesFirstError(t *testing.T) { &stubProvider{name: "profile", headers: []string{"c"}, rows: [][]string{{"v"}}, delay: 50 * time.Millisecond}, &stubProvider{name: "expenses", err: fmt.Errorf("gRPC unavailable: connection refused")}, &stubProvider{name: "tags", headers: []string{"c"}, rows: [][]string{{"v"}}, delay: 50 * time.Millisecond}, - ), nil, repo, newMockSender(), 5, time.Minute, newTestLogger()) + ), nopFinance{}, repo, newMockSender(), 5, time.Minute, newTestLogger()) eng.Submit("job-named-err", "user-1", "") require.Eventually(t, func() bool { @@ -139,7 +139,7 @@ func TestEngine_FanOut_TimeoutMapsToExportTimedOut(t *testing.T) { eng := NewEngine(staticProviders( &stubProvider{name: "profile", headers: []string{"c"}, rows: [][]string{{"v"}}}, &stubProvider{name: "expenses", headers: []string{"c"}, rows: [][]string{{"v"}}, delay: 500 * time.Millisecond}, - ), nil, repo, newMockSender(), 5, 50*time.Millisecond, newTestLogger()) + ), nopFinance{}, repo, newMockSender(), 5, 50*time.Millisecond, newTestLogger()) eng.Submit("job-timeout", "user-1", "") require.Eventually(t, func() bool { @@ -188,7 +188,7 @@ func TestEngine_FanOut_FailurePersistsViaBackgroundContext(t *testing.T) { repo := &ctxCapturingRepo{} eng := NewEngine(staticProviders( &stubProvider{name: "expenses", headers: []string{"c"}, rows: [][]string{{"v"}}, delay: 500 * time.Millisecond}, - ), nil, repo, newMockSender(), 5, 50*time.Millisecond, newTestLogger()) + ), nopFinance{}, repo, newMockSender(), 5, 50*time.Millisecond, newTestLogger()) eng.Submit("job-persist", "user-1", "") require.Eventually(t, func() bool { diff --git a/services/datarights/internal/engine/engine_test.go b/services/datarights/internal/engine/engine_test.go index d87e7372..ca90f336 100644 --- a/services/datarights/internal/engine/engine_test.go +++ b/services/datarights/internal/engine/engine_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "github.com/ItsThompson/gofin/services/datarights/internal/email" "github.com/ItsThompson/gofin/services/datarights/internal/model" @@ -137,15 +138,25 @@ func (p *stubProvider) Collect(ctx context.Context, _ string) ([][]string, error return p.rows, nil } -// staticProviders returns a ProviderFactory that ignores the injected finance -// client and always yields the given providers, for engine tests using stub +// staticProviders returns a ProviderFactory that ignores the fetched finance +// response and always yields the given providers, for engine tests using stub // providers with no finance dependency. func staticProviders(ps ...DataProvider) ProviderFactory { - return func(financepb.FinanceServiceClient) []DataProvider { + return func(*financepb.AllUserDataResponse) []DataProvider { return ps } } +// nopFinance satisfies the finance client the export engine fetches once per +// job. Orchestration tests use stub providers that ignore the response, so an +// empty response lets execute's single upfront fetch succeed without coupling +// these tests to finance data. +type nopFinance struct{ financepb.FinanceServiceClient } + +func (nopFinance) GetAllUserData(context.Context, *financepb.GetAllUserDataRequest, ...grpc.CallOption) (*financepb.AllUserDataResponse, error) { + return &financepb.AllUserDataResponse{}, nil +} + func newTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } @@ -189,7 +200,7 @@ func TestEngine_HappyPath_CompletesJob(t *testing.T) { name: "profile", headers: []string{"username", "email"}, rows: [][]string{{"alex", "alex@example.com"}}, - }), nil, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) + }), nopFinance{}, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) eng.Submit("job-1", "user-1", "alex@example.com") // Wait for async completion @@ -214,7 +225,7 @@ func TestEngine_ProviderError_FailsJob(t *testing.T) { eng := NewEngine(staticProviders(&stubProvider{ name: "profile", err: fmt.Errorf("gRPC unavailable"), - }), nil, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) + }), nopFinance{}, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) eng.Submit("job-2", "user-1", "") require.Eventually(t, func() bool { @@ -235,7 +246,7 @@ func TestEngine_Timeout_FailsJob(t *testing.T) { headers: []string{"col"}, rows: [][]string{{"val"}}, delay: 500 * time.Millisecond, - }), nil, repo, newMockSender(), 5, 50*time.Millisecond, newTestLogger()) + }), nopFinance{}, repo, newMockSender(), 5, 50*time.Millisecond, newTestLogger()) eng.Submit("job-3", "user-1", "") require.Eventually(t, func() bool { @@ -258,7 +269,7 @@ func TestEngine_ConcurrencyBounded(t *testing.T) { running: &running, maxSeen: &maxSeen, delay: 100 * time.Millisecond, - }), nil, repo, newMockSender(), maxConcurrent, 5*time.Minute, newTestLogger()) + }), nopFinance{}, repo, newMockSender(), maxConcurrent, 5*time.Minute, newTestLogger()) // Submit more jobs than max concurrent totalJobs := 10 @@ -325,7 +336,7 @@ func TestEngine_MultipleProviders_AllCollected(t *testing.T) { {"2", "30.00"}, }, }, - ), nil, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) + ), nopFinance{}, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) eng.Submit("job-multi", "user-1", "") require.Eventually(t, func() bool { @@ -349,7 +360,7 @@ func TestEngine_SecondProviderError_FailsJob(t *testing.T) { name: "expenses", err: fmt.Errorf("upstream timeout"), }, - ), nil, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) + ), nopFinance{}, repo, newMockSender(), 5, 5*time.Minute, newTestLogger()) eng.Submit("job-fail", "user-1", "") require.Eventually(t, func() bool { @@ -367,7 +378,7 @@ func TestEngine_EmailSenderCalled_OnSuccess(t *testing.T) { name: "profile", headers: []string{"username", "email"}, rows: [][]string{{"alex", "alex@example.com"}}, - }), nil, repo, sender, 5, 5*time.Minute, newTestLogger()) + }), nopFinance{}, repo, sender, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-email", "user-1", "alex@example.com") require.Eventually(t, func() bool { @@ -388,7 +399,7 @@ func TestEngine_EmailFailure_FailsJob(t *testing.T) { name: "profile", headers: []string{"username"}, rows: [][]string{{"alex"}}, - }), nil, repo, sender, 5, 5*time.Minute, newTestLogger()) + }), nopFinance{}, repo, sender, 5, 5*time.Minute, newTestLogger()) eng.Submit("job-email-fail", "user-1", "alex@example.com") require.Eventually(t, func() bool { diff --git a/services/datarights/internal/engine/export_dedup_test.go b/services/datarights/internal/engine/export_dedup_test.go index 4fa9c72a..55b55ab3 100644 --- a/services/datarights/internal/engine/export_dedup_test.go +++ b/services/datarights/internal/engine/export_dedup_test.go @@ -8,12 +8,13 @@ import ( "github.com/stretchr/testify/require" ) -// TestExport_DedupesFinanceCalls is the export finance-call deduplication regression: a -// full export must hit finance for GetAllUserData at most once and never call -// ListTags. It runs the real provider set through the engine with a finance spy -// as the raw client; the engine wraps it in a per-job MemoizedFinanceClient. -// Reverting the dedup (per-provider raw clients, or expenses.buildTagMap calling -// ListTags) makes this fail. +// TestExport_DedupesFinanceCalls is the single-fetch guarantee: a full export +// must fetch finance's GetAllUserData exactly once and never call ListTags. The +// engine fetches once in execute and hands the resolved response to the +// finance-backed providers (now pure mappers) and the derived tag map, so the +// guarantee is structural (by construction), not a memoization side effect. +// Reverting to per-provider finance fetches (or an expenses provider that +// fetches its own tag map) makes this fail. func TestExport_DedupesFinanceCalls(t *testing.T) { finance := newFinanceSpy(cannedAllUserData(), cannedTagList()) repo := newRecordingRepo() @@ -25,8 +26,8 @@ func TestExport_DedupesFinanceCalls(t *testing.T) { return repo.completedCount() == 1 }, 2*time.Second, 10*time.Millisecond, "export job did not complete; failures=%v", repo.failures()) - assert.LessOrEqual(t, finance.Count("GetAllUserData"), 1, - "export must fetch GetAllUserData at most once (dedup regressed)") + assert.Equal(t, 1, finance.Count("GetAllUserData"), + "export must fetch GetAllUserData exactly once (single-fetch guarantee)") assert.Equal(t, 0, finance.Count("ListTags"), - "export must not call ListTags; the tag map derives from shared GetAllUserData") + "export must not call ListTags; the tag map derives from the single GetAllUserData") } diff --git a/services/datarights/internal/engine/export_helpers_test.go b/services/datarights/internal/engine/export_helpers_test.go index 49945217..6e5eb6a1 100644 --- a/services/datarights/internal/engine/export_helpers_test.go +++ b/services/datarights/internal/engine/export_helpers_test.go @@ -101,18 +101,19 @@ func (f *fakeExpenseStream) Recv() (*expensepb.ExpenseData, error) { // buildRealProviders returns the export provider set in registration/ZIP order: // profile, expenses, tags, budget_periods, default_settings. The finance-backed -// providers share the injected finance client. +// providers map the shared finance response (fetched once per export); the +// expenses provider resolves tag names from the derived tag map. func buildRealProviders( auth authpb.AuthServiceClient, expense expensepb.ExpenseServiceClient, - finance financepb.FinanceServiceClient, + financeData *financepb.AllUserDataResponse, ) []engine.DataProvider { return []engine.DataProvider{ providers.NewProfileProvider(auth), - providers.NewExpensesProvider(expense, finance), - providers.NewTagsProvider(finance), - providers.NewBudgetPeriodsProvider(finance), - providers.NewDefaultSettingsProvider(finance), + providers.NewExpensesProvider(expense, providers.BuildTagMap(financeData)), + providers.NewTagsProvider(financeData), + providers.NewBudgetPeriodsProvider(financeData), + providers.NewDefaultSettingsProvider(financeData), } } @@ -180,15 +181,16 @@ func cannedExpensePages() []*expensepb.ExpenseListResponse { } } -// newExportEngine wires the real export providers through the per-job factory, -// with the finance spy as the raw client the engine wraps in a -// MemoizedFinanceClient per job. Used by the dedup regression test. +// newExportEngine wires the real export providers through the per-job factory. +// The finance spy is the engine's finance client; execute fetches GetAllUserData +// once per job and passes the resolved response to the factory. Used by the +// single-fetch regression test. func newExportEngine(finance financepb.FinanceServiceClient, repo repository.JobRepository) *engine.Engine { auth := &stubAuthClient{user: cannedUser()} expense := &stubExpenseClient{pages: cannedExpensePages()} return engine.NewEngine( - func(fc financepb.FinanceServiceClient) []engine.DataProvider { - return buildRealProviders(auth, expense, fc) + func(financeData *financepb.AllUserDataResponse) []engine.DataProvider { + return buildRealProviders(auth, expense, financeData) }, finance, repo, noopSender{}, 5, 30*time.Second, discardLogger(), ) diff --git a/services/datarights/internal/engine/export_measurement_test.go b/services/datarights/internal/engine/export_measurement_test.go index c1421184..eff373fd 100644 --- a/services/datarights/internal/engine/export_measurement_test.go +++ b/services/datarights/internal/engine/export_measurement_test.go @@ -48,9 +48,8 @@ func providerCSV(t testing.TB, p engine.DataProvider) []byte { func TestExportProviders_CSVByteIdentical(t *testing.T) { auth := &stubAuthClient{user: cannedUser()} expense := &stubExpenseClient{pages: cannedExpensePages()} - finance := newFinanceSpy(cannedAllUserData(), cannedTagList()) - for _, p := range buildRealProviders(auth, expense, finance) { + for _, p := range buildRealProviders(auth, expense, cannedAllUserData()) { golden := filepath.Join("testdata", "export", p.Name()+".csv") got := providerCSV(t, p) @@ -67,28 +66,21 @@ func TestExportProviders_CSVByteIdentical(t *testing.T) { } // BenchmarkExportCollection measures serial collection across the full provider -// set and logs the finance call shape per export. Collection runs serially here -// to isolate the deduplication shape; BenchmarkEngineCollectionFanout measures -// fan-out latency. The finance client is wrapped -// in a per-job MemoizedFinanceClient exactly as engine.execute does, so this -// records the deduped shape: GetAllUserData=1, ListTags=0 (down from the -// committed pre-dedup baseline of GetAllUserData=3 + ListTags=1). +// set. After the fetch-once refactor the finance-backed providers are pure +// mappers over the response the engine fetches once (in execute), so collection +// itself issues no finance RPC; this benchmark isolates the per-provider mapping +// plus expense-stream formatting cost. BenchmarkEngineCollectionFanout measures +// the fan-out latency of a full run; the single-fetch guarantee is asserted by +// TestExport_DedupesFinanceCalls. func BenchmarkExportCollection(b *testing.B) { auth := &stubAuthClient{user: cannedUser()} - - observed := newFinanceSpy(cannedAllUserData(), cannedTagList()) - observedFC := engine.NewMemoizedFinanceClient(observed) - collectAll(b, buildRealProviders(auth, &stubExpenseClient{pages: cannedExpensePages()}, observedFC)) - b.Logf("finance calls per export: GetAllUserData=%d ListTags=%d total=%d", - observed.Count("GetAllUserData"), observed.Count("ListTags"), observed.Total()) + financeData := cannedAllUserData() b.ReportAllocs() b.ResetTimer() for b.Loop() { - finance := newFinanceSpy(cannedAllUserData(), cannedTagList()) - fc := engine.NewMemoizedFinanceClient(finance) expense := &stubExpenseClient{pages: cannedExpensePages()} - collectAll(b, buildRealProviders(auth, expense, fc)) + collectAll(b, buildRealProviders(auth, expense, financeData)) } } diff --git a/services/datarights/internal/engine/finance_cache.go b/services/datarights/internal/engine/finance_cache.go deleted file mode 100644 index e22a3c66..00000000 --- a/services/datarights/internal/engine/finance_cache.go +++ /dev/null @@ -1,46 +0,0 @@ -package engine - -import ( - "context" - "sync" - - "google.golang.org/grpc" - - "github.com/ItsThompson/gofin/services/finance/proto/financepb" -) - -// MemoizedFinanceClient is a per-job wrapper over the shared finance gRPC -// client. It embeds financepb.FinanceServiceClient (anonymous field) so it stays -// a drop-in for the full client interface, and overrides GetAllUserData to fetch -// at most once per instance. -// -// A fresh instance MUST be created per export job; it must NEVER be a startup -// singleton, since sync.Once would cache the first user's data and serve it to -// every subsequent user (a cross-user data leak). -type MemoizedFinanceClient struct { - financepb.FinanceServiceClient // embedded: all other methods pass through - - once sync.Once - data *financepb.AllUserDataResponse - err error -} - -// NewMemoizedFinanceClient wraps inner so GetAllUserData is served at most once. -func NewMemoizedFinanceClient(inner financepb.FinanceServiceClient) *MemoizedFinanceClient { - return &MemoizedFinanceClient{FinanceServiceClient: inner} -} - -// GetAllUserData returns the memoized response, fetching from the wrapped client -// exactly once. Safe for concurrent callers: sync.Once serializes the fetch, so -// the later errgroup fan-out over providers still triggers a single RPC. All -// callers of an instance MUST pass the same request; the in and opts of calls -// after the first are ignored (safe today: each instance serves one -// single-user export job). -func (m *MemoizedFinanceClient) GetAllUserData( - ctx context.Context, in *financepb.GetAllUserDataRequest, opts ...grpc.CallOption, -) (*financepb.AllUserDataResponse, error) { - m.once.Do(func() { - m.data, m.err = m.FinanceServiceClient.GetAllUserData(ctx, in, opts...) - }) - return m.data, m.err -} diff --git a/services/datarights/internal/engine/finance_cache_test.go b/services/datarights/internal/engine/finance_cache_test.go deleted file mode 100644 index 7a530df0..00000000 --- a/services/datarights/internal/engine/finance_cache_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package engine_test - -import ( - "context" - "errors" - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ItsThompson/gofin/services/datarights/internal/engine" - "github.com/ItsThompson/gofin/services/finance/proto/financepb" -) - -func TestMemoizedFinanceClient_FetchesAtMostOnce(t *testing.T) { - inner := newFinanceSpy(cannedAllUserData(), cannedTagList()) - mc := engine.NewMemoizedFinanceClient(inner) - - for range 5 { - resp, err := mc.GetAllUserData(context.Background(), &financepb.GetAllUserDataRequest{UserId: "user-1"}) - require.NoError(t, err) - assert.Equal(t, cannedAllUserData().GetTags(), resp.GetTags()) - } - - assert.Equal(t, 1, inner.Count("GetAllUserData"), "inner client should be hit exactly once") -} - -func TestMemoizedFinanceClient_ConcurrentCallsFetchOnce(t *testing.T) { - inner := newFinanceSpy(cannedAllUserData(), cannedTagList()) - mc := engine.NewMemoizedFinanceClient(inner) - - const goroutines = 20 - var wg sync.WaitGroup - wg.Add(goroutines) - for range goroutines { - go func() { - defer wg.Done() - resp, err := mc.GetAllUserData(context.Background(), &financepb.GetAllUserDataRequest{UserId: "user-1"}) - assert.NoError(t, err) - assert.NotNil(t, resp) - }() - } - wg.Wait() - - assert.Equal(t, 1, inner.Count("GetAllUserData"), - "sync.Once must serialize concurrent fan-out callers into one fetch") -} - -func TestMemoizedFinanceClient_MemoizesError(t *testing.T) { - wantErr := errors.New("finance unavailable") - inner := newFinanceSpy(nil, cannedTagList()) - inner.dataErr = wantErr - mc := engine.NewMemoizedFinanceClient(inner) - - for range 3 { - _, err := mc.GetAllUserData(context.Background(), &financepb.GetAllUserDataRequest{UserId: "user-1"}) - require.ErrorIs(t, err, wantErr) - } - - assert.Equal(t, 1, inner.Count("GetAllUserData"), "a failed fetch is memoized too, not retried") -} - -func TestMemoizedFinanceClient_PassesThroughOtherMethods(t *testing.T) { - inner := newFinanceSpy(cannedAllUserData(), cannedTagList()) - mc := engine.NewMemoizedFinanceClient(inner) - - // ListTags is not overridden, so it must delegate to the embedded client. - resp, err := mc.ListTags(context.Background(), &financepb.ListTagsRequest{UserId: "user-1"}) - require.NoError(t, err) - assert.Equal(t, cannedTags(), resp.GetTags()) - assert.Equal(t, 1, inner.Count("ListTags")) - assert.Equal(t, 0, inner.Count("GetAllUserData")) -} diff --git a/services/datarights/internal/engine/providers/budget_periods.go b/services/datarights/internal/engine/providers/budget_periods.go index e2196227..9115ca57 100644 --- a/services/datarights/internal/engine/providers/budget_periods.go +++ b/services/datarights/internal/engine/providers/budget_periods.go @@ -2,7 +2,6 @@ package providers import ( "context" - "fmt" "strconv" "github.com/ItsThompson/gofin/services/datarights/internal/engine" @@ -12,14 +11,16 @@ import ( // Compile-time check that BudgetPeriodsProvider implements DataProvider. var _ engine.DataProvider = (*BudgetPeriodsProvider)(nil) -// BudgetPeriodsProvider fetches budget period data from the finance service. +// BudgetPeriodsProvider maps the budget periods in the shared per-job finance +// response into rows. type BudgetPeriodsProvider struct { - financeClient financepb.FinanceServiceClient + data *financepb.AllUserDataResponse } -// NewBudgetPeriodsProvider creates a BudgetPeriodsProvider backed by the finance gRPC client. -func NewBudgetPeriodsProvider(financeClient financepb.FinanceServiceClient) *BudgetPeriodsProvider { - return &BudgetPeriodsProvider{financeClient: financeClient} +// NewBudgetPeriodsProvider creates a BudgetPeriodsProvider over the finance data +// the export engine fetches once per job. +func NewBudgetPeriodsProvider(data *financepb.AllUserDataResponse) *BudgetPeriodsProvider { + return &BudgetPeriodsProvider{data: data} } // Name returns the CSV filename for this provider. @@ -35,14 +36,10 @@ func (p *BudgetPeriodsProvider) Headers() []string { } } -// Collect fetches all budget periods for the user and returns formatted rows. -func (p *BudgetPeriodsProvider) Collect(ctx context.Context, userID string) ([][]string, error) { - resp, err := p.financeClient.GetAllUserData(ctx, &financepb.GetAllUserDataRequest{UserId: userID}) - if err != nil { - return nil, fmt.Errorf("fetching user data for budget periods: %w", err) - } - - periods := resp.GetPeriods() +// Collect maps the pre-fetched user data's budget periods into rows. It is a +// pure mapper: the finance fetch happens once in the export engine. +func (p *BudgetPeriodsProvider) Collect(_ context.Context, _ string) ([][]string, error) { + periods := p.data.GetPeriods() rows := make([][]string, 0, len(periods)) for _, period := range periods { rows = append(rows, []string{ diff --git a/services/datarights/internal/engine/providers/budget_periods_test.go b/services/datarights/internal/engine/providers/budget_periods_test.go index 3f20a045..8a21c1fd 100644 --- a/services/datarights/internal/engine/providers/budget_periods_test.go +++ b/services/datarights/internal/engine/providers/budget_periods_test.go @@ -2,7 +2,6 @@ package providers import ( "context" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -27,34 +26,32 @@ func TestBudgetPeriodsProvider_Headers(t *testing.T) { } func TestBudgetPeriodsProvider_Collect_Success(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Periods: []*financepb.PeriodData{ - { - Id: "period-1", - Year: 2026, - Month: 5, - BudgetAmount: 500000, - EssentialsPercent: 50, - DesiresPercent: 30, - SavingsPercent: 20, - CreatedAt: "2026-05-01T00:00:00Z", - }, - { - Id: "period-2", - Year: 2026, - Month: 4, - BudgetAmount: 450000, - EssentialsPercent: 60, - DesiresPercent: 25, - SavingsPercent: 15, - CreatedAt: "2026-04-01T00:00:00Z", - }, + data := &financepb.AllUserDataResponse{ + Periods: []*financepb.PeriodData{ + { + Id: "period-1", + Year: 2026, + Month: 5, + BudgetAmount: 500000, + EssentialsPercent: 50, + DesiresPercent: 30, + SavingsPercent: 20, + CreatedAt: "2026-05-01T00:00:00Z", + }, + { + Id: "period-2", + Year: 2026, + Month: 4, + BudgetAmount: 450000, + EssentialsPercent: 60, + DesiresPercent: 25, + SavingsPercent: 15, + CreatedAt: "2026-04-01T00:00:00Z", }, }, } - p := NewBudgetPeriodsProvider(mockClient) + p := NewBudgetPeriodsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -67,28 +64,26 @@ func TestBudgetPeriodsProvider_Collect_Success(t *testing.T) { } func TestBudgetPeriodsProvider_Collect_AmountFormatting(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Periods: []*financepb.PeriodData{ - { - Id: "p1", - Year: 2026, - Month: 1, - BudgetAmount: 99, // 0.99 dollars - CreatedAt: "2026-01-01T00:00:00Z", - }, - { - Id: "p2", - Year: 2026, - Month: 2, - BudgetAmount: 100000, // 1000.00 dollars - CreatedAt: "2026-02-01T00:00:00Z", - }, + data := &financepb.AllUserDataResponse{ + Periods: []*financepb.PeriodData{ + { + Id: "p1", + Year: 2026, + Month: 1, + BudgetAmount: 99, // 0.99 dollars + CreatedAt: "2026-01-01T00:00:00Z", + }, + { + Id: "p2", + Year: 2026, + Month: 2, + BudgetAmount: 100000, // 1000.00 dollars + CreatedAt: "2026-02-01T00:00:00Z", }, }, } - p := NewBudgetPeriodsProvider(mockClient) + p := NewBudgetPeriodsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -97,28 +92,13 @@ func TestBudgetPeriodsProvider_Collect_AmountFormatting(t *testing.T) { } func TestBudgetPeriodsProvider_Collect_EmptyData(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Periods: []*financepb.PeriodData{}, - }, + data := &financepb.AllUserDataResponse{ + Periods: []*financepb.PeriodData{}, } - p := NewBudgetPeriodsProvider(mockClient) + p := NewBudgetPeriodsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) assert.Empty(t, rows) } - -func TestBudgetPeriodsProvider_Collect_Error(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataErr: fmt.Errorf("connection refused"), - } - - p := NewBudgetPeriodsProvider(mockClient) - rows, err := p.Collect(context.Background(), "user-123") - - assert.Nil(t, rows) - require.Error(t, err) - assert.Contains(t, err.Error(), "fetching user data for budget periods") -} diff --git a/services/datarights/internal/engine/providers/default_settings.go b/services/datarights/internal/engine/providers/default_settings.go index 2a5e07c8..d6134362 100644 --- a/services/datarights/internal/engine/providers/default_settings.go +++ b/services/datarights/internal/engine/providers/default_settings.go @@ -2,7 +2,6 @@ package providers import ( "context" - "fmt" "strconv" "github.com/ItsThompson/gofin/services/datarights/internal/engine" @@ -12,14 +11,16 @@ import ( // Compile-time check that DefaultSettingsProvider implements DataProvider. var _ engine.DataProvider = (*DefaultSettingsProvider)(nil) -// DefaultSettingsProvider fetches the user's default financial settings from the finance service. +// DefaultSettingsProvider maps the default financial settings in the shared +// per-job finance response into a single row. type DefaultSettingsProvider struct { - financeClient financepb.FinanceServiceClient + data *financepb.AllUserDataResponse } -// NewDefaultSettingsProvider creates a DefaultSettingsProvider backed by the finance gRPC client. -func NewDefaultSettingsProvider(financeClient financepb.FinanceServiceClient) *DefaultSettingsProvider { - return &DefaultSettingsProvider{financeClient: financeClient} +// NewDefaultSettingsProvider creates a DefaultSettingsProvider over the finance +// data the export engine fetches once per job. +func NewDefaultSettingsProvider(data *financepb.AllUserDataResponse) *DefaultSettingsProvider { + return &DefaultSettingsProvider{data: data} } // Name returns the CSV filename for this provider. @@ -35,15 +36,11 @@ func (p *DefaultSettingsProvider) Headers() []string { } } -// Collect fetches the user's default settings and returns a single row. -// Returns an empty result (headers only) if no defaults are configured. -func (p *DefaultSettingsProvider) Collect(ctx context.Context, userID string) ([][]string, error) { - resp, err := p.financeClient.GetAllUserData(ctx, &financepb.GetAllUserDataRequest{UserId: userID}) - if err != nil { - return nil, fmt.Errorf("fetching user data for default settings: %w", err) - } - - defaults := resp.GetDefaults() +// Collect maps the pre-fetched user data's default settings into a single row, +// or an empty result (headers only) if no defaults are configured. It is a pure +// mapper: the finance fetch happens once in the export engine. +func (p *DefaultSettingsProvider) Collect(_ context.Context, _ string) ([][]string, error) { + defaults := p.data.GetDefaults() if defaults == nil { return [][]string{}, nil } diff --git a/services/datarights/internal/engine/providers/default_settings_test.go b/services/datarights/internal/engine/providers/default_settings_test.go index 931bdd84..007c517c 100644 --- a/services/datarights/internal/engine/providers/default_settings_test.go +++ b/services/datarights/internal/engine/providers/default_settings_test.go @@ -2,7 +2,6 @@ package providers import ( "context" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -27,19 +26,17 @@ func TestDefaultSettingsProvider_Headers(t *testing.T) { } func TestDefaultSettingsProvider_Collect_Success(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Defaults: &financepb.DefaultsData{ - BudgetAmount: 500000, - EssentialsPercent: 50, - DesiresPercent: 30, - SavingsPercent: 20, - Currency: "USD", - }, + data := &financepb.AllUserDataResponse{ + Defaults: &financepb.DefaultsData{ + BudgetAmount: 500000, + EssentialsPercent: 50, + DesiresPercent: 30, + SavingsPercent: 20, + Currency: "USD", }, } - p := NewDefaultSettingsProvider(mockClient) + p := NewDefaultSettingsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -50,19 +47,17 @@ func TestDefaultSettingsProvider_Collect_Success(t *testing.T) { } func TestDefaultSettingsProvider_Collect_AmountFormatting(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Defaults: &financepb.DefaultsData{ - BudgetAmount: 4599, - EssentialsPercent: 50, - DesiresPercent: 30, - SavingsPercent: 20, - Currency: "EUR", - }, + data := &financepb.AllUserDataResponse{ + Defaults: &financepb.DefaultsData{ + BudgetAmount: 4599, + EssentialsPercent: 50, + DesiresPercent: 30, + SavingsPercent: 20, + Currency: "EUR", }, } - p := NewDefaultSettingsProvider(mockClient) + p := NewDefaultSettingsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -71,28 +66,13 @@ func TestDefaultSettingsProvider_Collect_AmountFormatting(t *testing.T) { } func TestDefaultSettingsProvider_Collect_NilDefaults(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Defaults: nil, - }, + data := &financepb.AllUserDataResponse{ + Defaults: nil, } - p := NewDefaultSettingsProvider(mockClient) + p := NewDefaultSettingsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) assert.Empty(t, rows) } - -func TestDefaultSettingsProvider_Collect_Error(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataErr: fmt.Errorf("deadline exceeded"), - } - - p := NewDefaultSettingsProvider(mockClient) - rows, err := p.Collect(context.Background(), "user-123") - - assert.Nil(t, rows) - require.Error(t, err) - assert.Contains(t, err.Error(), "fetching user data for default settings") -} diff --git a/services/datarights/internal/engine/providers/expenses.go b/services/datarights/internal/engine/providers/expenses.go index 705c98d2..ab29528c 100644 --- a/services/datarights/internal/engine/providers/expenses.go +++ b/services/datarights/internal/engine/providers/expenses.go @@ -9,7 +9,6 @@ import ( "github.com/ItsThompson/gofin/services/datarights/internal/engine" "github.com/ItsThompson/gofin/services/expense/proto/expensepb" - "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) // Compile-time check that ExpensesProvider implements DataProvider. @@ -21,20 +20,24 @@ var _ engine.DataProvider = (*ExpensesProvider)(nil) // O(expensesPageSize) instead of O(total rows). const expensesPageSize = 100 -// ExpensesProvider fetches all user expenses with pagination and resolves tag names. +// ExpensesProvider streams all user expenses with pagination and resolves tag +// names from a tag map derived once from the shared per-job finance response. type ExpensesProvider struct { expenseClient expensepb.ExpenseServiceClient - financeClient financepb.FinanceServiceClient + tagMap map[string]string } -// NewExpensesProvider creates an ExpensesProvider backed by expense and finance gRPC clients. +// NewExpensesProvider creates an ExpensesProvider backed by the expense gRPC +// client. The tag map (tag id -> name) is derived once upfront from the shared +// finance response, so the expenses provider self-fetches only its expense +// stream and never calls finance itself. func NewExpensesProvider( expenseClient expensepb.ExpenseServiceClient, - financeClient financepb.FinanceServiceClient, + tagMap map[string]string, ) *ExpensesProvider { return &ExpensesProvider{ expenseClient: expenseClient, - financeClient: financeClient, + tagMap: tagMap, } } @@ -54,7 +57,7 @@ func (p *ExpensesProvider) Headers() []string { } // Collect streams every expense for the user in chronological order, resolves -// tag names, and returns the formatted CSV rows. +// tag names via the injected tag map, and returns the formatted CSV rows. // // It consumes the StreamAllUserExpenses server stream and formats each row as it // arrives (see streamExpenses) rather than buffering the whole raw-proto history @@ -62,13 +65,8 @@ func (p *ExpensesProvider) Headers() []string { // collects and hands to BuildZIP; a sink that writes each row onward keeps the // consumer itself at O(pageSize) (see the bounded-memory benchmark). func (p *ExpensesProvider) Collect(ctx context.Context, userID string) ([][]string, error) { - tagMap, err := p.buildTagMap(ctx, userID) - if err != nil { - return nil, fmt.Errorf("fetching tags for name resolution: %w", err) - } - var rows [][]string - if err := p.streamExpenses(ctx, userID, tagMap, func(row []string) error { + if err := p.streamExpenses(ctx, userID, p.tagMap, func(row []string) error { rows = append(rows, row) return nil }); err != nil { @@ -78,26 +76,6 @@ func (p *ExpensesProvider) Collect(ctx context.Context, userID string) ([][]stri return rows, nil } -// buildTagMap fetches the user's tags and returns a map of tag ID to tag name. -// It derives the tag map from GetAllUserData().GetTags() (shared with the tags, -// budget_periods, and default_settings providers via the per-job memoized -// finance client) instead of a separate ListTags call, so the export hits -// finance once for this data. -func (p *ExpensesProvider) buildTagMap(ctx context.Context, userID string) (map[string]string, error) { - resp, err := p.financeClient.GetAllUserData(ctx, &financepb.GetAllUserDataRequest{UserId: userID}) - if err != nil { - return nil, err - } - - tags := resp.GetTags() - tagMap := make(map[string]string, len(tags)) - for _, tag := range tags { - tagMap[tag.GetId()] = tag.GetName() - } - - return tagMap, nil -} - // streamExpenses consumes the StreamAllUserExpenses server stream and invokes // emit once per expense, formatted into a CSV row via formatRow, in the order // the server sends them (chronological: created_at ASC, id ASC). It holds at diff --git a/services/datarights/internal/engine/providers/expenses_test.go b/services/datarights/internal/engine/providers/expenses_test.go index 64bba7a3..56a6f429 100644 --- a/services/datarights/internal/engine/providers/expenses_test.go +++ b/services/datarights/internal/engine/providers/expenses_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/ItsThompson/gofin/services/expense/proto/expensepb" - "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) func TestExpensesProvider_Name(t *testing.T) { @@ -30,14 +29,7 @@ func TestExpensesProvider_Headers(t *testing.T) { } func TestExpensesProvider_Collect_Success(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{ - {Id: "tag-1", Name: "Food"}, - {Id: "tag-2", Name: "Transport"}, - }, - }, - } + tagMap := map[string]string{"tag-1": "Food", "tag-2": "Transport"} expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ @@ -58,7 +50,7 @@ func TestExpensesProvider_Collect_Success(t *testing.T) { }, } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, tagMap) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -73,37 +65,31 @@ func TestExpensesProvider_Collect_Success(t *testing.T) { } func TestExpensesProvider_Collect_ProRataExpense(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{ - {Id: "tag-1", Name: "Rent"}, - }, - }, - } + tagMap := map[string]string{"tag-1": "Rent"} expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ { - Id: "exp-pr-1", - Name: "Rent (1/3)", - Amount: 50000, - Currency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - IsProRata: true, - ProRataGroup: "group-abc", - ProRataIndex: 1, - ProRataTotal: 3, - CreatedAt: "2026-05-01T10:00:00Z", + Id: "exp-pr-1", + Name: "Rent (1/3)", + Amount: 50000, + Currency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + IsProRata: true, + ProRataGroup: "group-abc", + ProRataIndex: 1, + ProRataTotal: 3, + CreatedAt: "2026-05-01T10:00:00Z", }, }, } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, tagMap) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -118,11 +104,7 @@ func TestExpensesProvider_Collect_ProRataExpense(t *testing.T) { } func TestExpensesProvider_Collect_MissingTagResolvesToUnknown(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{}, // No tags - }, - } + tagMap := map[string]string{} // No tags expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ @@ -142,7 +124,7 @@ func TestExpensesProvider_Collect_MissingTagResolvesToUnknown(t *testing.T) { }, } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, tagMap) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -151,13 +133,7 @@ func TestExpensesProvider_Collect_MissingTagResolvesToUnknown(t *testing.T) { } func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{ - {Id: "tag-1", Name: "Food"}, - }, - }, - } + tagMap := map[string]string{"tag-1": "Food"} // The server streams every expense in one ordered pass (chronological: // created_at ASC, id ASC); the consumer no longer pages. @@ -169,7 +145,7 @@ func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { }, } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, tagMap) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -183,13 +159,9 @@ func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { } func TestExpensesProvider_Collect_EmptyData(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{Tags: []*financepb.TagData{}}, - } - expenseClient := &mockExpenseServiceClient{streamRows: nil} - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, map[string]string{}) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -197,15 +169,11 @@ func TestExpensesProvider_Collect_EmptyData(t *testing.T) { } func TestExpensesProvider_Collect_StreamOpenError(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{Tags: []*financepb.TagData{}}, - } - expenseClient := &mockExpenseServiceClient{ streamOpenErr: fmt.Errorf("connection refused"), } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, map[string]string{}) rows, err := p.Collect(context.Background(), "user-123") assert.Nil(t, rows) @@ -214,11 +182,7 @@ func TestExpensesProvider_Collect_StreamOpenError(t *testing.T) { } func TestExpensesProvider_Collect_MidStreamRecvError(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{{Id: "tag-1", Name: "Food"}}, - }, - } + tagMap := map[string]string{"tag-1": "Food"} // Two rows arrive, then the third Recv fails: the error must propagate // (not be swallowed as a clean EOF). @@ -231,7 +195,7 @@ func TestExpensesProvider_Collect_MidStreamRecvError(t *testing.T) { recvErrAt: 3, } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, tagMap) rows, err := p.Collect(context.Background(), "user-123") assert.Nil(t, rows) @@ -240,27 +204,8 @@ func TestExpensesProvider_Collect_MidStreamRecvError(t *testing.T) { assert.Contains(t, err.Error(), "stream reset") } -func TestExpensesProvider_Collect_TagServiceError(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataErr: fmt.Errorf("service unavailable"), - } - - expenseClient := &mockExpenseServiceClient{} - - p := NewExpensesProvider(expenseClient, financeClient) - rows, err := p.Collect(context.Background(), "user-123") - - assert.Nil(t, rows) - require.Error(t, err) - assert.Contains(t, err.Error(), "fetching tags") -} - func TestExpensesProvider_Collect_CorrectedExpense(t *testing.T) { - financeClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{{Id: "tag-1", Name: "Food"}}, - }, - } + tagMap := map[string]string{"tag-1": "Food"} expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ @@ -281,11 +226,11 @@ func TestExpensesProvider_Collect_CorrectedExpense(t *testing.T) { }, } - p := NewExpensesProvider(expenseClient, financeClient) + p := NewExpensesProvider(expenseClient, tagMap) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) require.Len(t, rows, 1) - assert.Equal(t, "corrected", rows[0][9]) // status + assert.Equal(t, "corrected", rows[0][9]) // status assert.Equal(t, "exp-original", rows[0][10]) // corrects_id } diff --git a/services/datarights/internal/engine/providers/mock_clients_test.go b/services/datarights/internal/engine/providers/mock_clients_test.go index 60904b51..11c00251 100644 --- a/services/datarights/internal/engine/providers/mock_clients_test.go +++ b/services/datarights/internal/engine/providers/mock_clients_test.go @@ -7,82 +7,8 @@ import ( "google.golang.org/grpc" "github.com/ItsThompson/gofin/services/expense/proto/expensepb" - "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) -// mockFinanceServiceClient implements FinanceServiceClient for tests. -type mockFinanceServiceClient struct { - getAllUserDataResp *financepb.AllUserDataResponse - getAllUserDataErr error -} - -func (m *mockFinanceServiceClient) GetAllUserData(_ context.Context, _ *financepb.GetAllUserDataRequest, _ ...grpc.CallOption) (*financepb.AllUserDataResponse, error) { - if m.getAllUserDataErr != nil { - return nil, m.getAllUserDataErr - } - return m.getAllUserDataResp, nil -} - -func (m *mockFinanceServiceClient) ListTags(_ context.Context, _ *financepb.ListTagsRequest, _ ...grpc.CallOption) (*financepb.TagListResponse, error) { - return nil, nil -} - -// Implement remaining interface methods as no-ops. -func (m *mockFinanceServiceClient) GetDefaults(_ context.Context, _ *financepb.GetDefaultsRequest, _ ...grpc.CallOption) (*financepb.DefaultsResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) UpdateDefaults(_ context.Context, _ *financepb.UpdateDefaultsRequest, _ ...grpc.CallOption) (*financepb.DefaultsResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) CompleteOnboarding(_ context.Context, _ *financepb.CompleteOnboardingRequest, _ ...grpc.CallOption) (*financepb.DefaultsResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) GetCurrentPeriod(_ context.Context, _ *financepb.GetCurrentPeriodRequest, _ ...grpc.CallOption) (*financepb.PeriodResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) CreatePeriod(_ context.Context, _ *financepb.CreatePeriodRequest, _ ...grpc.CallOption) (*financepb.PeriodResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) UpdatePeriod(_ context.Context, _ *financepb.UpdatePeriodRequest, _ ...grpc.CallOption) (*financepb.PeriodResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) ListPeriods(_ context.Context, _ *financepb.ListPeriodsRequest, _ ...grpc.CallOption) (*financepb.PeriodListResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) CreateTag(_ context.Context, _ *financepb.CreateTagRequest, _ ...grpc.CallOption) (*financepb.TagResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) UpdateTag(_ context.Context, _ *financepb.UpdateTagRequest, _ ...grpc.CallOption) (*financepb.TagResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) DeleteTag(_ context.Context, _ *financepb.DeleteTagRequest, _ ...grpc.CallOption) (*financepb.DeleteTagResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) CheckTagUsage(_ context.Context, _ *financepb.CheckTagUsageRequest, _ ...grpc.CallOption) (*financepb.TagUsageResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) CreateProRataExpense(_ context.Context, _ *financepb.CreateProRataExpenseRequest, _ ...grpc.CallOption) (*financepb.ProRataResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) GetUpcomingProRata(_ context.Context, _ *financepb.GetUpcomingProRataRequest, _ ...grpc.CallOption) (*financepb.UpcomingProRataListResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) GetPeriodSummary(_ context.Context, _ *financepb.GetPeriodSummaryRequest, _ ...grpc.CallOption) (*financepb.PeriodSummaryResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) GetSpendingByTag(_ context.Context, _ *financepb.GetSpendingByTagRequest, _ ...grpc.CallOption) (*financepb.TagSpendingListResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) GetCumulativeSpend(_ context.Context, _ *financepb.GetCumulativeSpendRequest, _ ...grpc.CallOption) (*financepb.CumulativeSpendResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) GetHistoricalComparison(_ context.Context, _ *financepb.GetHistoricalComparisonRequest, _ ...grpc.CallOption) (*financepb.HistoricalComparisonResponse, error) { - return nil, nil -} -func (m *mockFinanceServiceClient) DeleteAllUserData(_ context.Context, _ *financepb.DeleteAllUserDataRequest, _ ...grpc.CallOption) (*financepb.DeleteAllUserDataResponse, error) { - return nil, nil -} - // mockExpenseServiceClient implements ExpenseServiceClient for tests. The // expenses provider consumes StreamAllUserExpenses, so streamRows seeds the // server stream (one ExpenseData per Recv, in order). streamOpenErr fails the diff --git a/services/datarights/internal/engine/providers/tagmap.go b/services/datarights/internal/engine/providers/tagmap.go new file mode 100644 index 00000000..11f662fe --- /dev/null +++ b/services/datarights/internal/engine/providers/tagmap.go @@ -0,0 +1,17 @@ +package providers + +import "github.com/ItsThompson/gofin/services/finance/proto/financepb" + +// BuildTagMap derives a tag-id -> tag-name lookup from the shared per-job +// finance response. The export fetches GetAllUserData once (in engine.execute); +// this map and the tags/budget_periods/default_settings rows all derive from +// that single response, so the export hits finance exactly once per job. The +// expenses provider consumes the returned map to resolve tag names. +func BuildTagMap(data *financepb.AllUserDataResponse) map[string]string { + tags := data.GetTags() + tagMap := make(map[string]string, len(tags)) + for _, tag := range tags { + tagMap[tag.GetId()] = tag.GetName() + } + return tagMap +} diff --git a/services/datarights/internal/engine/providers/tagmap_test.go b/services/datarights/internal/engine/providers/tagmap_test.go new file mode 100644 index 00000000..8c7d92ec --- /dev/null +++ b/services/datarights/internal/engine/providers/tagmap_test.go @@ -0,0 +1,33 @@ +package providers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ItsThompson/gofin/services/finance/proto/financepb" +) + +func TestBuildTagMap_MapsIDToName(t *testing.T) { + data := &financepb.AllUserDataResponse{ + Tags: []*financepb.TagData{ + {Id: "tag-1", Name: "Food"}, + {Id: "tag-2", Name: "Transport"}, + }, + } + + got := BuildTagMap(data) + + assert.Equal(t, map[string]string{"tag-1": "Food", "tag-2": "Transport"}, got) +} + +func TestBuildTagMap_EmptyTags(t *testing.T) { + got := BuildTagMap(&financepb.AllUserDataResponse{Tags: []*financepb.TagData{}}) + assert.Empty(t, got) +} + +func TestBuildTagMap_NilResponseIsSafe(t *testing.T) { + // Protobuf getters are nil-safe; a nil response yields an empty map. + got := BuildTagMap(nil) + assert.Empty(t, got) +} diff --git a/services/datarights/internal/engine/providers/tags.go b/services/datarights/internal/engine/providers/tags.go index c4db1aba..766d08aa 100644 --- a/services/datarights/internal/engine/providers/tags.go +++ b/services/datarights/internal/engine/providers/tags.go @@ -2,7 +2,6 @@ package providers import ( "context" - "fmt" "github.com/ItsThompson/gofin/services/datarights/internal/engine" "github.com/ItsThompson/gofin/services/finance/proto/financepb" @@ -11,14 +10,15 @@ import ( // Compile-time check that TagsProvider implements DataProvider. var _ engine.DataProvider = (*TagsProvider)(nil) -// TagsProvider fetches user tags from the finance service. +// TagsProvider maps the tags in the shared per-job finance response into rows. type TagsProvider struct { - financeClient financepb.FinanceServiceClient + data *financepb.AllUserDataResponse } -// NewTagsProvider creates a TagsProvider backed by the finance gRPC client. -func NewTagsProvider(financeClient financepb.FinanceServiceClient) *TagsProvider { - return &TagsProvider{financeClient: financeClient} +// NewTagsProvider creates a TagsProvider over the finance data the export engine +// fetches once per job. +func NewTagsProvider(data *financepb.AllUserDataResponse) *TagsProvider { + return &TagsProvider{data: data} } // Name returns the CSV filename for this provider. @@ -31,14 +31,10 @@ func (p *TagsProvider) Headers() []string { return []string{"id", "name", "is_default", "created_at"} } -// Collect fetches all tags for the user and returns formatted rows. -func (p *TagsProvider) Collect(ctx context.Context, userID string) ([][]string, error) { - resp, err := p.financeClient.GetAllUserData(ctx, &financepb.GetAllUserDataRequest{UserId: userID}) - if err != nil { - return nil, fmt.Errorf("fetching user data for tags: %w", err) - } - - tags := resp.GetTags() +// Collect maps the pre-fetched user data's tags into rows. It is a pure mapper: +// the finance fetch happens once in the export engine, so Collect issues no RPC. +func (p *TagsProvider) Collect(_ context.Context, _ string) ([][]string, error) { + tags := p.data.GetTags() rows := make([][]string, 0, len(tags)) for _, tag := range tags { rows = append(rows, []string{ diff --git a/services/datarights/internal/engine/providers/tags_test.go b/services/datarights/internal/engine/providers/tags_test.go index 43a75b6f..debfe747 100644 --- a/services/datarights/internal/engine/providers/tags_test.go +++ b/services/datarights/internal/engine/providers/tags_test.go @@ -2,7 +2,6 @@ package providers import ( "context" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -23,26 +22,24 @@ func TestTagsProvider_Headers(t *testing.T) { } func TestTagsProvider_Collect_Success(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{ - { - Id: "tag-1", - Name: "Food", - IsDefault: true, - CreatedAt: "2025-06-01T10:00:00Z", - }, - { - Id: "tag-2", - Name: "Transport", - IsDefault: false, - CreatedAt: "2025-07-15T14:30:00Z", - }, + data := &financepb.AllUserDataResponse{ + Tags: []*financepb.TagData{ + { + Id: "tag-1", + Name: "Food", + IsDefault: true, + CreatedAt: "2025-06-01T10:00:00Z", + }, + { + Id: "tag-2", + Name: "Transport", + IsDefault: false, + CreatedAt: "2025-07-15T14:30:00Z", }, }, } - p := NewTagsProvider(mockClient) + p := NewTagsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -53,13 +50,11 @@ func TestTagsProvider_Collect_Success(t *testing.T) { } func TestTagsProvider_Collect_EmptyData(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{}, - }, + data := &financepb.AllUserDataResponse{ + Tags: []*financepb.TagData{}, } - p := NewTagsProvider(mockClient) + p := NewTagsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) @@ -67,43 +62,26 @@ func TestTagsProvider_Collect_EmptyData(t *testing.T) { } func TestTagsProvider_Collect_NilTags(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: nil, - }, + data := &financepb.AllUserDataResponse{ + Tags: nil, } - p := NewTagsProvider(mockClient) + p := NewTagsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) assert.Empty(t, rows) } -func TestTagsProvider_Collect_Error(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataErr: fmt.Errorf("service unavailable"), - } - - p := NewTagsProvider(mockClient) - rows, err := p.Collect(context.Background(), "user-123") - - assert.Nil(t, rows) - require.Error(t, err) - assert.Contains(t, err.Error(), "fetching user data for tags") -} - func TestTagsProvider_Collect_BoolFormatting(t *testing.T) { - mockClient := &mockFinanceServiceClient{ - getAllUserDataResp: &financepb.AllUserDataResponse{ - Tags: []*financepb.TagData{ - {Id: "t1", Name: "Default Tag", IsDefault: true, CreatedAt: "2025-01-01T00:00:00Z"}, - {Id: "t2", Name: "Custom Tag", IsDefault: false, CreatedAt: "2025-01-02T00:00:00Z"}, - }, + data := &financepb.AllUserDataResponse{ + Tags: []*financepb.TagData{ + {Id: "t1", Name: "Default Tag", IsDefault: true, CreatedAt: "2025-01-01T00:00:00Z"}, + {Id: "t2", Name: "Custom Tag", IsDefault: false, CreatedAt: "2025-01-02T00:00:00Z"}, }, } - p := NewTagsProvider(mockClient) + p := NewTagsProvider(data) rows, err := p.Collect(context.Background(), "user-123") require.NoError(t, err) diff --git a/services/datarights/internal/handler/deletion.go b/services/datarights/internal/handler/deletion.go index 32422349..dad568c1 100644 --- a/services/datarights/internal/handler/deletion.go +++ b/services/datarights/internal/handler/deletion.go @@ -8,6 +8,7 @@ import ( "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/service" + "github.com/ItsThompson/gofin/services/httpx" ) // DeletionHandler handles HTTP requests for deletion job endpoints. @@ -35,27 +36,19 @@ func (h *DeletionHandler) handlers() map[string]gin.HandlerFunc { // CreateDeletion handles POST /api/datarights/deletions. // Returns 202 Accepted for a new job, or 200 OK for an existing in-progress job. func (h *DeletionHandler) CreateDeletion(c *gin.Context) { - adminUserID := c.GetHeader("X-User-ID") - if adminUserID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + adminUserID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.CreateDeletionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body: userId and password are required", - }) + if !httpx.BindJSON(c, &req) { return } result, err := h.deletionService.CreateJob(c.Request.Context(), req.UserID, adminUserID, req.Password) if err != nil { - h.handleError(c, err) + respondError(c, h.logger, err) return } @@ -84,12 +77,7 @@ func (h *DeletionHandler) CreateDeletion(c *gin.Context) { // GetDeletion handles GET /api/datarights/deletions/:id. // Returns the current state of a deletion job. func (h *DeletionHandler) GetDeletion(c *gin.Context) { - adminUserID := c.GetHeader("X-User-ID") - if adminUserID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + if _, ok := httpx.RequireUserID(c); !ok { return } @@ -97,7 +85,7 @@ func (h *DeletionHandler) GetDeletion(c *gin.Context) { job, err := h.deletionService.GetJob(c.Request.Context(), jobID) if err != nil { - h.handleError(c, err) + respondError(c, h.logger, err) return } @@ -110,22 +98,3 @@ func (h *DeletionHandler) GetDeletion(c *gin.Context) { CompletedAt: job.CompletedAt, }) } - -// handleError maps service errors to HTTP responses. -func (h *DeletionHandler) handleError(c *gin.Context, err error) { - if svcErr, ok := err.(*service.ServiceError); ok { - c.JSON(svcErr.Status, model.ApiError{ - Code: svcErr.Code, - Message: svcErr.Message, - }) - return - } - - h.logger.Error("unexpected error in deletion handler", - slog.String("error", err.Error()), - ) - c.JSON(http.StatusInternalServerError, model.ApiError{ - Code: model.ErrInternalServerError, - Message: "An unexpected error occurred", - }) -} diff --git a/services/datarights/internal/handler/deletion_test.go b/services/datarights/internal/handler/deletion_test.go index c4fbb722..7b88b922 100644 --- a/services/datarights/internal/handler/deletion_test.go +++ b/services/datarights/internal/handler/deletion_test.go @@ -20,7 +20,9 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/proto/authpb" + "github.com/ItsThompson/gofin/services/datarights/internal/config" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" "github.com/ItsThompson/gofin/services/datarights/internal/service" @@ -213,7 +215,9 @@ func setupDeletionTestRouter( gin.SetMode(gin.TestMode) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - opts := []service.DeletionServiceOption{} + opts := []service.DeletionServiceOption{ + service.WithProtectedUsernames(config.DefaultProtectedUsernames), + } if authClient != nil { opts = append(opts, service.WithAuthClient(authClient)) } @@ -338,7 +342,7 @@ func TestCreateDeletion_InvalidPassword_Returns401(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, model.ErrInvalidCredentials, resp.Code) @@ -362,7 +366,7 @@ func TestCreateDeletion_SelfDeletion_Returns400(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Contains(t, resp.Message, "Cannot delete your own account") @@ -387,7 +391,7 @@ func TestCreateDeletion_ProtectedUsername_Returns403(t *testing.T) { assert.Equal(t, http.StatusForbidden, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, model.ErrProtectedUser, resp.Code) @@ -417,7 +421,7 @@ func TestCreateDeletion_ExportConflict_Returns409(t *testing.T) { assert.Equal(t, http.StatusConflict, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, model.ErrExportConflict, resp.Code) @@ -443,7 +447,7 @@ func TestCreateDeletion_AuthServiceUnavailable_Returns503(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, model.ErrServiceUnavailable, resp.Code) @@ -462,10 +466,10 @@ func TestCreateDeletion_Unauthenticated_Returns401(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrUnauthorized, resp.Code) + assert.Equal(t, apierr.CodeUnauthorized, resp.Code) } func TestCreateDeletion_MissingUserId_Returns400(t *testing.T) { @@ -482,10 +486,12 @@ func TestCreateDeletion_MissingUserId_Returns400(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrValidationError, resp.Code) + assert.Equal(t, apierr.CodeValidation, resp.Code) + // C6: bind failures now carry field-level validation detail. + assert.Equal(t, "required", resp.Fields["UserID"]) } func TestCreateDeletion_MissingPassword_Returns400(t *testing.T) { @@ -502,10 +508,12 @@ func TestCreateDeletion_MissingPassword_Returns400(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrValidationError, resp.Code) + assert.Equal(t, apierr.CodeValidation, resp.Code) + // C6: bind failures now carry field-level validation detail. + assert.Equal(t, "required", resp.Fields["Password"]) } func TestCreateDeletion_DBError_Returns500(t *testing.T) { @@ -530,10 +538,10 @@ func TestCreateDeletion_DBError_Returns500(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrInternalServerError, resp.Code) + assert.Equal(t, apierr.CodeInternal, resp.Code) mockRepo.AssertExpectations(t) mockExportRepo.AssertExpectations(t) } @@ -586,10 +594,10 @@ func TestGetDeletion_NotFound_Returns404(t *testing.T) { assert.Equal(t, http.StatusNotFound, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrNotFound, resp.Code) + assert.Equal(t, apierr.CodeNotFound, resp.Code) mockRepo.AssertExpectations(t) } @@ -604,10 +612,10 @@ func TestGetDeletion_Unauthenticated_Returns401(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrUnauthorized, resp.Code) + assert.Equal(t, apierr.CodeUnauthorized, resp.Code) } func TestGetDeletion_CompletedJob_ReturnsCompletedAt(t *testing.T) { diff --git a/services/datarights/internal/handler/respond.go b/services/datarights/internal/handler/respond.go new file mode 100644 index 00000000..56ab3f41 --- /dev/null +++ b/services/datarights/internal/handler/respond.go @@ -0,0 +1,23 @@ +package handler + +import ( + "errors" + "log/slog" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/apierr" +) + +// respondError maps a service error to an HTTP response via apierr.Respond. +// apierr.Respond classifies an *apierr.Error to its status/code and everything +// else to a 500; because it takes no logger, this helper logs unclassified +// errors first so the unexpected-500 observability the old handleError provided +// is preserved. +func respondError(c *gin.Context, logger *slog.Logger, err error) { + var apiErr *apierr.Error + if !errors.As(err, &apiErr) { + logger.Error("unexpected error", slog.String("error", err.Error())) + } + apierr.Respond(c, err) +} diff --git a/services/datarights/internal/handler/rest.go b/services/datarights/internal/handler/rest.go index 092e3929..ec54a2e5 100644 --- a/services/datarights/internal/handler/rest.go +++ b/services/datarights/internal/handler/rest.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" "strconv" @@ -8,9 +9,11 @@ import ( "github.com/gin-gonic/gin" "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/apierr" exportmetrics "github.com/ItsThompson/gofin/services/datarights/internal/metrics" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/service" + "github.com/ItsThompson/gofin/services/httpx" ) // RESTHandler handles HTTP requests for the datarights service. @@ -59,32 +62,33 @@ func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { // CreateExport handles POST /api/datarights/exports. // Returns 202 for new jobs, 200 for deduplicated in-progress jobs, or 429 for rate limited. func (h *RESTHandler) CreateExport(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } result, err := h.exportService.CreateJob(c.Request.Context(), userID) if err != nil { - if rateLimitErr, ok := err.(*service.RateLimitError); ok { + var rateLimitErr *service.RateLimitError + if errors.As(err, &rateLimitErr) { exportmetrics.ExportRateLimitRejectionsTotal.Inc() h.logger.Warn("export rate limit rejected", slog.String("user_id", userID), slog.Time("next_allowed_at", rateLimitErr.RetryAfter), slog.String("method", "handler.CreateExport"), ) - c.JSON(http.StatusTooManyRequests, model.RateLimitedResponse{ - Code: model.ErrRateLimited, - Message: "Export limit reached. You can request another export after " + rateLimitErr.RetryAfter.Format("2006-01-02") + ".", - RetryAfter: rateLimitErr.RetryAfter, + // C9: the retry timing moves to the standard Retry-After header and + // the body becomes the standard {code, message}. datarights pre-maps + // its RateLimitError here so apierr.Respond stays generic. + c.Header("Retry-After", rateLimitErr.RetryAfter.UTC().Format(http.TimeFormat)) + apierr.Respond(c, &apierr.Error{ + Code: model.ErrRateLimited, + Message: "Export limit reached. You can request another export after " + rateLimitErr.RetryAfter.Format("2006-01-02") + ".", + Status: http.StatusTooManyRequests, }) return } - h.handleError(c, err) + respondError(c, h.logger, err) return } @@ -105,12 +109,8 @@ func (h *RESTHandler) CreateExport(c *gin.Context) { // ListExports handles GET /api/datarights/exports. // Returns a paginated list of the user's export jobs. func (h *RESTHandler) ListExports(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } @@ -131,7 +131,7 @@ func (h *RESTHandler) ListExports(c *gin.Context) { result, err := h.exportService.ListJobs(c.Request.Context(), userID, page, pageSize) if err != nil { - h.handleError(c, err) + respondError(c, h.logger, err) return } @@ -141,12 +141,8 @@ func (h *RESTHandler) ListExports(c *gin.Context) { // GetExport handles GET /api/datarights/exports/:id. // Returns a single export job if owned by the authenticated user. func (h *RESTHandler) GetExport(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } @@ -154,28 +150,9 @@ func (h *RESTHandler) GetExport(c *gin.Context) { job, err := h.exportService.GetJob(c.Request.Context(), jobID, userID) if err != nil { - h.handleError(c, err) + respondError(c, h.logger, err) return } c.JSON(http.StatusOK, model.JobResponse{Job: job}) } - -// handleError maps service errors to HTTP responses. -func (h *RESTHandler) handleError(c *gin.Context, err error) { - if svcErr, ok := err.(*service.ServiceError); ok { - c.JSON(svcErr.Status, model.ApiError{ - Code: svcErr.Code, - Message: svcErr.Message, - }) - return - } - - h.logger.Error("unexpected error", - slog.String("error", err.Error()), - ) - c.JSON(http.StatusInternalServerError, model.ApiError{ - Code: model.ErrInternalServerError, - Message: "An unexpected error occurred", - }) -} diff --git a/services/datarights/internal/handler/rest_test.go b/services/datarights/internal/handler/rest_test.go index 738ca27a..ae369db8 100644 --- a/services/datarights/internal/handler/rest_test.go +++ b/services/datarights/internal/handler/rest_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" "github.com/ItsThompson/gofin/services/datarights/internal/service" @@ -191,17 +192,20 @@ func TestCreateExport_RateLimited_Returns429(t *testing.T) { assert.Equal(t, http.StatusTooManyRequests, rec.Code) - var resp model.RateLimitedResponse + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, model.ErrRateLimited, resp.Code) assert.Contains(t, resp.Message, "Export limit reached") + // C9: retry timing is carried by the standard Retry-After header, not a body field. expectedRetryAfter := fiveDaysAgo.Add(service.RateLimitWindow) - assert.Equal(t, expectedRetryAfter.Unix(), resp.RetryAfter.Unix()) + retryAfter, parseErr := http.ParseTime(rec.Header().Get("Retry-After")) + require.NoError(t, parseErr) + assert.Equal(t, expectedRetryAfter.Unix(), retryAfter.Unix()) } -func TestCreateExport_RateLimited_IncludesRetryAfterTimestamp(t *testing.T) { +func TestCreateExport_RateLimited_SetsRetryAfterHeader(t *testing.T) { mockRepo := new(mockJobRepository) twoDaysAgo := time.Now().UTC().Add(-2 * 24 * time.Hour).Truncate(time.Second) @@ -225,14 +229,14 @@ func TestCreateExport_RateLimited_IncludesRetryAfterTimestamp(t *testing.T) { assert.Equal(t, http.StatusTooManyRequests, rec.Code) - var resp model.RateLimitedResponse - err := json.Unmarshal(rec.Body.Bytes(), &resp) - require.NoError(t, err) - - // retryAfter should be createdAt + 30 days + // C9: the Retry-After header carries createdAt + 30 days; there is no + // retryAfter body field. + header := rec.Header().Get("Retry-After") + require.NotEmpty(t, header) + retryAfter, parseErr := http.ParseTime(header) + require.NoError(t, parseErr) expectedRetry := twoDaysAgo.Add(30 * 24 * time.Hour) - assert.Equal(t, expectedRetry.Unix(), resp.RetryAfter.Unix()) - assert.False(t, resp.RetryAfter.IsZero()) + assert.Equal(t, expectedRetry.Unix(), retryAfter.Unix()) } func TestCreateExport_DBError_Returns500(t *testing.T) { @@ -248,10 +252,10 @@ func TestCreateExport_DBError_Returns500(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrInternalServerError, resp.Code) + assert.Equal(t, apierr.CodeInternal, resp.Code) } func TestCreateExport_Unauthenticated(t *testing.T) { @@ -265,10 +269,10 @@ func TestCreateExport_Unauthenticated(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrUnauthorized, resp.Code) + assert.Equal(t, apierr.CodeUnauthorized, resp.Code) } func TestGetExport_Success(t *testing.T) { @@ -314,10 +318,10 @@ func TestGetExport_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, rec.Code) - var resp model.ApiError + var resp apierr.APIError err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) - assert.Equal(t, model.ErrNotFound, resp.Code) + assert.Equal(t, apierr.CodeNotFound, resp.Code) } func TestGetExport_DifferentUser(t *testing.T) { diff --git a/services/datarights/internal/jobrunner/pool.go b/services/datarights/internal/jobrunner/pool.go new file mode 100644 index 00000000..e821a925 --- /dev/null +++ b/services/datarights/internal/jobrunner/pool.go @@ -0,0 +1,117 @@ +// Package jobrunner provides the bounded-worker-pool lifecycle shared by the +// datarights export and deletion engines. Both engines run structurally +// identical pools (a semaphore, a running-status transition, a timeout-derived +// context, and a background-context fail-on-timeout) and differ only in the +// per-job work they perform. That work is injected as an [Execute] strategy; +// the pool owns everything around it. +// +// jobrunner is internal to datarights: it is imported only by +// internal/deletion and internal/engine and is not promoted to a workspace +// module. +package jobrunner + +import ( + "context" + "log/slog" + "sync/atomic" + "time" +) + +// runningStatus is the status a job is transitioned to before its work runs. +const runningStatus = "running" + +// StatusStore persists a job's lifecycle transitions. The deletion job repo +// satisfies it directly; the export engine adapts its repo behind this seam so +// its CompleteJob can still persist file_size_bytes. +type StatusStore interface { + UpdateStatus(ctx context.Context, jobID, status string) error + FailJob(ctx context.Context, jobID, reason string) error + CompleteJob(ctx context.Context, jobID string) error +} + +// Execute is the injected per-job strategy. Export injects its errgroup fan-out +// + zip + email closure; deletion injects its sequential-retry-with-backoff +// closure. The pool owns slot acquisition, the running transition, and terminal +// persistence around this call; the strategy owns the work and returns the +// PII-free failure reason (its Error string is persisted verbatim). +type Execute func(ctx context.Context, jobID, userID string) error + +// Pool owns the bounded-worker-pool lifecycle. A single Pool is created per +// engine and reused across all of that engine's jobs. +type Pool struct { + sem chan struct{} + queued atomic.Int64 + timeout time.Duration + store StatusStore + execute Execute + log *slog.Logger +} + +// New creates a Pool bounded to maxConcurrent in-flight jobs, each run with the +// given per-job timeout, persisting transitions through store and performing +// work via execute. +func New(maxConcurrent int, timeout time.Duration, store StatusStore, execute Execute, log *slog.Logger) *Pool { + return &Pool{ + sem: make(chan struct{}, maxConcurrent), + timeout: timeout, + store: store, + execute: execute, + log: log, + } +} + +// Submit is non-blocking: it marks the job queued and spawns a worker goroutine +// that acquires a slot, transitions the job to running, runs execute, then +// completes or fails it. +func (p *Pool) Submit(jobID, userID string) { + p.queued.Add(1) + go p.run(jobID, userID) +} + +func (p *Pool) run(jobID, userID string) { + p.sem <- struct{}{} // acquire slot (blocks if the pool is full) + p.queued.Add(-1) + defer func() { <-p.sem }() + + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + + if err := p.store.UpdateStatus(ctx, jobID, runningStatus); err != nil { + p.log.Error("failed to mark job running", + slog.String("job_id", jobID), + slog.String("user_id", userID), + slog.String("error", err.Error()), + ) + return + } + + if err := p.execute(ctx, jobID, userID); err != nil { + // The job context may already be expired (timeout), so persist the + // terminal failure through a fresh background context. + if failErr := p.store.FailJob(context.Background(), jobID, err.Error()); failErr != nil { + p.log.Error("failed to mark job failed", + slog.String("job_id", jobID), + slog.String("user_id", userID), + slog.String("error", failErr.Error()), + ) + } + return + } + + if err := p.store.CompleteJob(ctx, jobID); err != nil { + p.log.Error("failed to complete job", + slog.String("job_id", jobID), + slog.String("user_id", userID), + slog.String("error", err.Error()), + ) + } +} + +// ActiveJobs returns the number of jobs currently holding a pool slot. +func (p *Pool) ActiveJobs() int { return len(p.sem) } + +// QueuedJobs returns the number of submitted jobs still waiting for a slot. +func (p *Pool) QueuedJobs() int { return int(p.queued.Load()) } + +// MaxConcurrent returns the pool capacity. +func (p *Pool) MaxConcurrent() int { return cap(p.sem) } diff --git a/services/datarights/internal/jobrunner/pool_test.go b/services/datarights/internal/jobrunner/pool_test.go new file mode 100644 index 00000000..2df7227d --- /dev/null +++ b/services/datarights/internal/jobrunner/pool_test.go @@ -0,0 +1,267 @@ +package jobrunner + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Fake StatusStore +// --------------------------------------------------------------------------- + +// fakeStore is a thread-safe StatusStore that records every transition. It is a +// real collaborator for the pool: the tests exercise the actual pool goroutine, +// semaphore, and context handling against it (no mocking of the pool internals). +type fakeStore struct { + mu sync.Mutex + statusUpdates []statusCall + completed []string + failed []failCall + + updateErr error + completeErr error +} + +type statusCall struct { + jobID string + status string +} + +type failCall struct { + jobID string + reason string + ctxLive bool // whether the context passed to FailJob was still live +} + +func (s *fakeStore) UpdateStatus(_ context.Context, jobID, status string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.statusUpdates = append(s.statusUpdates, statusCall{jobID: jobID, status: status}) + return s.updateErr +} + +func (s *fakeStore) CompleteJob(_ context.Context, jobID string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.completeErr != nil { + return s.completeErr + } + s.completed = append(s.completed, jobID) + return nil +} + +func (s *fakeStore) FailJob(ctx context.Context, jobID, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failed = append(s.failed, failCall{jobID: jobID, reason: reason, ctxLive: ctx.Err() == nil}) + return nil +} + +func (s *fakeStore) statusSnapshot() []statusCall { + s.mu.Lock() + defer s.mu.Unlock() + return append([]statusCall(nil), s.statusUpdates...) +} + +func (s *fakeStore) completedSnapshot() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.completed...) +} + +func (s *fakeStore) failedSnapshot() []failCall { + s.mu.Lock() + defer s.mu.Unlock() + return append([]failCall(nil), s.failed...) +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// --------------------------------------------------------------------------- +// Lifecycle transitions +// --------------------------------------------------------------------------- + +func TestPool_Success_TransitionsToRunningThenCompletes(t *testing.T) { + store := &fakeStore{} + var executed atomic.Bool + execute := func(_ context.Context, _, _ string) error { + executed.Store(true) + return nil + } + + pool := New(5, time.Minute, store, execute, testLogger()) + pool.Submit("job-1", "user-1") + + require.Eventually(t, func() bool { + return len(store.completedSnapshot()) == 1 + }, 2*time.Second, 10*time.Millisecond) + + assert.True(t, executed.Load(), "execute strategy must run") + assert.Equal(t, []statusCall{{jobID: "job-1", status: "running"}}, store.statusSnapshot()) + assert.Equal(t, []string{"job-1"}, store.completedSnapshot()) + assert.Empty(t, store.failedSnapshot()) +} + +func TestPool_ExecuteError_FailsJobWithBackgroundContext(t *testing.T) { + store := &fakeStore{} + execute := func(_ context.Context, _, _ string) error { + return errors.New("strategy blew up") + } + + pool := New(5, time.Minute, store, execute, testLogger()) + pool.Submit("job-err", "user-1") + + require.Eventually(t, func() bool { + return len(store.failedSnapshot()) == 1 + }, 2*time.Second, 10*time.Millisecond) + + failed := store.failedSnapshot() + assert.Equal(t, "job-err", failed[0].jobID) + assert.Equal(t, "strategy blew up", failed[0].reason) + assert.True(t, failed[0].ctxLive, "FailJob must run under a fresh background context") + assert.Empty(t, store.completedSnapshot(), "a failed job must not be completed") +} + +func TestPool_Timeout_FailsJobViaBackgroundContext(t *testing.T) { + store := &fakeStore{} + // The strategy honors the job context; with a tiny timeout it returns the + // expired-context error, exactly like a real per-provider deadline. + execute := func(ctx context.Context, _, _ string) error { + <-ctx.Done() + return ctx.Err() + } + + pool := New(5, 50*time.Millisecond, store, execute, testLogger()) + pool.Submit("job-timeout", "user-1") + + require.Eventually(t, func() bool { + return len(store.failedSnapshot()) == 1 + }, 2*time.Second, 10*time.Millisecond) + + failed := store.failedSnapshot() + assert.Equal(t, "job-timeout", failed[0].jobID) + assert.Equal(t, context.DeadlineExceeded.Error(), failed[0].reason) + assert.True(t, failed[0].ctxLive, + "the job context is expired, so FailJob must use a fresh background context") + assert.Empty(t, store.completedSnapshot()) +} + +func TestPool_UpdateStatusError_SkipsExecuteAndTerminalWrites(t *testing.T) { + store := &fakeStore{updateErr: errors.New("db down")} + var executed atomic.Bool + execute := func(_ context.Context, _, _ string) error { + executed.Store(true) + return nil + } + + pool := New(5, time.Minute, store, execute, testLogger()) + pool.Submit("job-nostart", "user-1") + + require.Eventually(t, func() bool { + return len(store.statusSnapshot()) == 1 + }, 2*time.Second, 10*time.Millisecond) + // Give the worker goroutine room to (incorrectly) proceed if the guard fails. + time.Sleep(50 * time.Millisecond) + + assert.False(t, executed.Load(), "execute must not run when the running transition fails") + assert.Empty(t, store.completedSnapshot()) + assert.Empty(t, store.failedSnapshot()) +} + +// --------------------------------------------------------------------------- +// Concurrency and telemetry +// --------------------------------------------------------------------------- + +func TestPool_BoundsConcurrencyToMaxConcurrent(t *testing.T) { + const maxConcurrent = 3 + store := &fakeStore{} + + var running, maxSeen atomic.Int32 + execute := func(_ context.Context, _, _ string) error { + cur := running.Add(1) + defer running.Add(-1) + for { + prev := maxSeen.Load() + if cur <= prev || maxSeen.CompareAndSwap(prev, cur) { + break + } + } + time.Sleep(40 * time.Millisecond) + return nil + } + + pool := New(maxConcurrent, time.Minute, store, execute, testLogger()) + assert.Equal(t, maxConcurrent, pool.MaxConcurrent()) + + const total = 12 + for i := 0; i < total; i++ { + pool.Submit("job", "user-1") + } + + require.Eventually(t, func() bool { + return len(store.completedSnapshot()) == total + }, 5*time.Second, 20*time.Millisecond) + + assert.LessOrEqual(t, int(maxSeen.Load()), maxConcurrent, + "in-flight jobs exceeded MaxConcurrent: saw %d, limit %d", maxSeen.Load(), maxConcurrent) +} + +func TestPool_ActiveJobs_ReflectsInFlightJobs(t *testing.T) { + store := &fakeStore{} + release := make(chan struct{}) + execute := func(_ context.Context, _, _ string) error { + <-release // hold the slot until the test releases it + return nil + } + + pool := New(5, time.Minute, store, execute, testLogger()) + for i := 0; i < 3; i++ { + pool.Submit("job", "user-1") + } + + require.Eventually(t, func() bool { + return pool.ActiveJobs() == 3 + }, 2*time.Second, 10*time.Millisecond, "expected 3 in-flight jobs") + + close(release) + + require.Eventually(t, func() bool { + return pool.ActiveJobs() == 0 && len(store.completedSnapshot()) == 3 + }, 2*time.Second, 10*time.Millisecond, "in-flight count should drain to 0 after release") +} + +func TestPool_QueuedJobs_ReflectsJobsWaitingForASlot(t *testing.T) { + store := &fakeStore{} + release := make(chan struct{}) + execute := func(_ context.Context, _, _ string) error { + <-release + return nil + } + + pool := New(2, time.Minute, store, execute, testLogger()) + for i := 0; i < 5; i++ { + pool.Submit("job", "user-1") + } + + // 2 jobs hold slots; the remaining 3 are queued waiting for one. + require.Eventually(t, func() bool { + return pool.ActiveJobs() == 2 && pool.QueuedJobs() == 3 + }, 2*time.Second, 10*time.Millisecond, "expected 2 active and 3 queued") + + close(release) + + require.Eventually(t, func() bool { + return pool.QueuedJobs() == 0 && pool.ActiveJobs() == 0 && len(store.completedSnapshot()) == 5 + }, 2*time.Second, 10*time.Millisecond) +} diff --git a/services/datarights/internal/metrics/metrics.go b/services/datarights/internal/metrics/metrics.go index 38a6d3bf..14ac0894 100644 --- a/services/datarights/internal/metrics/metrics.go +++ b/services/datarights/internal/metrics/metrics.go @@ -4,6 +4,8 @@ package metrics import ( + "sync/atomic" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) @@ -98,20 +100,46 @@ var ( // Pool gauges // --------------------------------------------------------------------------- -var ( - // ExportPoolActiveJobs tracks currently running export goroutines. - ExportPoolActiveJobs = promauto.NewGauge( +// poolAccessors reads live pool telemetry. The export engine's jobrunner.Pool +// owns the semaphore, so the pool gauges are exposed as GaugeFuncs that read +// pool.ActiveJobs()/QueuedJobs() at scrape time rather than being Inc/Dec'd. +type poolAccessors struct { + active func() int + queued func() int +} + +// poolStats holds the live accessors. It defaults to zero-returning stubs so the +// gauges are registered (and report 0) before SetPoolStats wires the real pool. +var poolStats atomic.Pointer[poolAccessors] + +func init() { + poolStats.Store(&poolAccessors{ + active: func() int { return 0 }, + queued: func() int { return 0 }, + }) + + // ExportPoolActiveJobs reports the number of currently running export jobs. + promauto.NewGaugeFunc( prometheus.GaugeOpts{ Name: "export_pool_active_jobs", Help: "Number of currently running export jobs", }, + func() float64 { return float64(poolStats.Load().active()) }, ) - // ExportPoolQueuedJobs tracks jobs waiting for a pool slot. - ExportPoolQueuedJobs = promauto.NewGauge( + // ExportPoolQueuedJobs reports the number of export jobs waiting for a slot. + promauto.NewGaugeFunc( prometheus.GaugeOpts{ Name: "export_pool_queued_jobs", Help: "Number of export jobs waiting for a pool slot", }, + func() float64 { return float64(poolStats.Load().queued()) }, ) -) +} + +// SetPoolStats wires the live export-pool accessors for the pool gauges. It is +// called once from main after the export engine is constructed; before that the +// gauges report 0. +func SetPoolStats(active, queued func() int) { + poolStats.Store(&poolAccessors{active: active, queued: queued}) +} diff --git a/services/datarights/internal/metrics/metrics_test.go b/services/datarights/internal/metrics/metrics_test.go index 0d0866c2..039094e8 100644 --- a/services/datarights/internal/metrics/metrics_test.go +++ b/services/datarights/internal/metrics/metrics_test.go @@ -2,6 +2,7 @@ package metrics import ( "strings" + "sync/atomic" "testing" "github.com/prometheus/client_golang/prometheus" @@ -68,28 +69,45 @@ func TestExportRateLimitRejectionsTotal_Increments(t *testing.T) { assert.GreaterOrEqual(t, value, float64(1)) } -func TestExportPoolActiveJobs_GaugeOperations(t *testing.T) { - ExportPoolActiveJobs.Set(0) // Reset for deterministic test - ExportPoolActiveJobs.Inc() - ExportPoolActiveJobs.Inc() - ExportPoolActiveJobs.Inc() +func TestExportPoolGauges_ReflectLivePoolStats(t *testing.T) { + var active, queued atomic.Int64 + SetPoolStats( + func() int { return int(active.Load()) }, + func() int { return int(queued.Load()) }, + ) + // Restore the zero-returning stubs so other tests are unaffected. + t.Cleanup(func() { + SetPoolStats(func() int { return 0 }, func() int { return 0 }) + }) - assert.Equal(t, float64(3), testutil.ToFloat64(ExportPoolActiveJobs)) + active.Store(3) + queued.Store(2) - ExportPoolActiveJobs.Dec() - assert.Equal(t, float64(2), testutil.ToFloat64(ExportPoolActiveJobs)) -} + assert.Equal(t, float64(3), gaugeValue(t, "export_pool_active_jobs")) + assert.Equal(t, float64(2), gaugeValue(t, "export_pool_queued_jobs")) -func TestExportPoolQueuedJobs_GaugeOperations(t *testing.T) { - ExportPoolQueuedJobs.Set(0) // Reset for deterministic test - ExportPoolQueuedJobs.Inc() - ExportPoolQueuedJobs.Inc() + active.Store(0) + queued.Store(0) - assert.Equal(t, float64(2), testutil.ToFloat64(ExportPoolQueuedJobs)) + assert.Equal(t, float64(0), gaugeValue(t, "export_pool_active_jobs")) + assert.Equal(t, float64(0), gaugeValue(t, "export_pool_queued_jobs")) +} - ExportPoolQueuedJobs.Dec() - ExportPoolQueuedJobs.Dec() - assert.Equal(t, float64(0), testutil.ToFloat64(ExportPoolQueuedJobs)) +// gaugeValue gathers the default registry and returns the first sample of the +// named gauge family. +func gaugeValue(t *testing.T, name string) float64 { + t.Helper() + families, err := prometheus.DefaultGatherer.Gather() + require.NoError(t, err) + for _, family := range families { + if family.GetName() != name { + continue + } + require.NotEmpty(t, family.GetMetric()) + return family.GetMetric()[0].GetGauge().GetValue() + } + t.Fatalf("metric %q not found", name) + return 0 } func TestExportDataCollectionDurationSeconds_RecordsProviderLabel(t *testing.T) { diff --git a/services/datarights/internal/model/requests.go b/services/datarights/internal/model/requests.go index 01abc66f..32a52e45 100644 --- a/services/datarights/internal/model/requests.go +++ b/services/datarights/internal/model/requests.go @@ -2,10 +2,6 @@ package model import "time" -// CreateExportRequest is the parsed request for creating an export job. -// Currently empty since no body is required: the user ID comes from the header. -type CreateExportRequest struct{} - // JobResponse wraps a single job in the standard response envelope. type JobResponse struct { Job *ExportJob `json:"job"` @@ -20,31 +16,15 @@ type JobListResponse struct { HasMore bool `json:"hasMore"` } -// RateLimitedResponse is returned when the user has exceeded the export rate limit. -type RateLimitedResponse struct { - Code string `json:"code"` - Message string `json:"message"` - RetryAfter time.Time `json:"retryAfter"` -} - -// ApiError follows the standard error contract. -type ApiError struct { - Code string `json:"code"` - Message string `json:"message"` - Fields map[string]string `json:"fields,omitempty"` -} - -// Common error codes. +// Datarights-specific error codes. The shared codes (UNAUTHORIZED, NOT_FOUND, +// VALIDATION_ERROR, INTERNAL_SERVER_ERROR) are sourced from the apierr package; +// only the codes unique to datarights are declared here. const ( - ErrUnauthorized = "UNAUTHORIZED" - ErrInvalidCredentials = "INVALID_CREDENTIALS" - ErrNotFound = "NOT_FOUND" - ErrInternalServerError = "INTERNAL_SERVER_ERROR" - ErrRateLimited = "RATE_LIMITED" - ErrValidationError = "VALIDATION_ERROR" - ErrProtectedUser = "PROTECTED_USER" - ErrExportConflict = "EXPORT_CONFLICT" - ErrServiceUnavailable = "SERVICE_UNAVAILABLE" + ErrInvalidCredentials = "INVALID_CREDENTIALS" + ErrRateLimited = "RATE_LIMITED" + ErrProtectedUser = "PROTECTED_USER" + ErrExportConflict = "EXPORT_CONFLICT" + ErrServiceUnavailable = "SERVICE_UNAVAILABLE" ) // CreateDeletionRequest is the parsed request body for creating a deletion job. diff --git a/services/datarights/internal/repository/postgres.go b/services/datarights/internal/repository/postgres.go index 8f84896a..f6f4fc6b 100644 --- a/services/datarights/internal/repository/postgres.go +++ b/services/datarights/internal/repository/postgres.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ItsThompson/gofin/services/datarights/internal/model" + "github.com/ItsThompson/gofin/services/pgutil" ) // PostgresJobRepository implements JobRepository using PostgreSQL. @@ -64,7 +64,7 @@ func (r *PostgresJobRepository) GetJob(ctx context.Context, jobID string) (*mode &job.UpdatedAt, ) if err != nil { - if err == pgx.ErrNoRows { + if pgutil.IsNoRows(err) { return nil, nil } return nil, fmt.Errorf("getting export job: %w", err) @@ -93,7 +93,7 @@ func (r *PostgresJobRepository) GetInProgressJob(ctx context.Context, userID str &job.UpdatedAt, ) if err != nil { - if err == pgx.ErrNoRows { + if pgutil.IsNoRows(err) { return nil, nil } return nil, fmt.Errorf("getting in-progress job: %w", err) @@ -124,7 +124,7 @@ func (r *PostgresJobRepository) GetLatestNonFailedJob(ctx context.Context, userI &job.UpdatedAt, ) if err != nil { - if err == pgx.ErrNoRows { + if pgutil.IsNoRows(err) { return nil, nil } return nil, fmt.Errorf("getting latest non-failed job: %w", err) diff --git a/services/datarights/internal/repository/postgres_deletion.go b/services/datarights/internal/repository/postgres_deletion.go index 343b0bf5..25a5407e 100644 --- a/services/datarights/internal/repository/postgres_deletion.go +++ b/services/datarights/internal/repository/postgres_deletion.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/ItsThompson/gofin/services/datarights/internal/model" + "github.com/ItsThompson/gofin/services/pgutil" ) // PostgresDeletionJobRepository implements DeletionJobRepository using PostgreSQL. @@ -64,7 +64,7 @@ func (r *PostgresDeletionJobRepository) GetJob(ctx context.Context, jobID string &job.UpdatedAt, ) if err != nil { - if err == pgx.ErrNoRows { + if pgutil.IsNoRows(err) { return nil, nil } return nil, fmt.Errorf("getting deletion job: %w", err) @@ -93,7 +93,7 @@ func (r *PostgresDeletionJobRepository) GetInProgressJob(ctx context.Context, us &job.UpdatedAt, ) if err != nil { - if err == pgx.ErrNoRows { + if pgutil.IsNoRows(err) { return nil, nil } return nil, fmt.Errorf("getting in-progress deletion job: %w", err) diff --git a/services/datarights/internal/service/deletion.go b/services/datarights/internal/service/deletion.go index fe4c77aa..6ce6eac7 100644 --- a/services/datarights/internal/service/deletion.go +++ b/services/datarights/internal/service/deletion.go @@ -9,6 +9,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/proto/authpb" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" @@ -23,11 +24,12 @@ type CreateDeletionJobResult struct { // DeletionService handles deletion job lifecycle with full validation chain. type DeletionService struct { - repo repository.DeletionJobRepository - exportRepo repository.JobRepository - authClient authpb.AuthServiceClient - engine DeletionJobSubmitter - logger *slog.Logger + repo repository.DeletionJobRepository + exportRepo repository.JobRepository + authClient authpb.AuthServiceClient + engine DeletionJobSubmitter + protectedUsernames []string + logger *slog.Logger } // NewDeletionService creates a new DeletionService. @@ -70,6 +72,14 @@ func WithDeletionEngine(engine DeletionJobSubmitter) DeletionServiceOption { } } +// WithProtectedUsernames sets the usernames that cannot be deleted (sourced +// from config). When unset, no username is treated as protected. +func WithProtectedUsernames(names []string) DeletionServiceOption { + return func(s *DeletionService) { + s.protectedUsernames = names + } +} + // CreateJob applies the full validation chain and creates a deletion job. // // Validation order: @@ -92,11 +102,7 @@ func (s *DeletionService) CreateJob(ctx context.Context, userID, adminUserID, pa // Guard 2: Self-deletion prevention if userID == adminUserID { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Cannot delete your own account", - Status: http.StatusBadRequest, - } + return nil, apierr.Validation("Cannot delete your own account", nil) } // Guard 3: Protected username enforcement @@ -157,11 +163,7 @@ func (s *DeletionService) GetJob(ctx context.Context, jobID string) (*model.Dele } if job == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: "Deletion job not found", - Status: http.StatusNotFound, - } + return nil, apierr.NotFound("Deletion job not found") } return job, nil @@ -180,7 +182,7 @@ func (s *DeletionService) verifyPassword(ctx context.Context, adminUserID, passw if err != nil { st, ok := status.FromError(err) if ok && st.Code() == codes.Unavailable { - return &ServiceError{ + return &apierr.Error{ Code: model.ErrServiceUnavailable, Message: "Auth service unavailable", Status: http.StatusServiceUnavailable, @@ -190,7 +192,7 @@ func (s *DeletionService) verifyPassword(ctx context.Context, adminUserID, passw } if !resp.GetValid() { - return &ServiceError{ + return &apierr.Error{ Code: model.ErrInvalidCredentials, Message: "Invalid credentials", Status: http.StatusUnauthorized, @@ -212,7 +214,7 @@ func (s *DeletionService) checkProtectedUsername(ctx context.Context, userID str if err != nil { st, ok := status.FromError(err) if ok && st.Code() == codes.Unavailable { - return &ServiceError{ + return &apierr.Error{ Code: model.ErrServiceUnavailable, Message: "Auth service unavailable", Status: http.StatusServiceUnavailable, @@ -221,8 +223,8 @@ func (s *DeletionService) checkProtectedUsername(ctx context.Context, userID str return fmt.Errorf("fetching user for protected check: %w", err) } - if isProtectedUsername(resp.GetUsername()) { - return &ServiceError{ + if isProtectedUsername(s.protectedUsernames, resp.GetUsername()) { + return &apierr.Error{ Code: model.ErrProtectedUser, Message: "Cannot delete a protected user account", Status: http.StatusForbidden, @@ -244,11 +246,7 @@ func (s *DeletionService) checkExportConflict(ctx context.Context, userID string } if exportJob != nil { - return &ServiceError{ - Code: model.ErrExportConflict, - Message: "Cannot delete user while data export is in progress", - Status: http.StatusConflict, - } + return apierr.Conflict(model.ErrExportConflict, "Cannot delete user while data export is in progress") } return nil diff --git a/services/datarights/internal/service/deletion_submitter.go b/services/datarights/internal/service/deletion_submitter.go index 99cbda5a..e19feb53 100644 --- a/services/datarights/internal/service/deletion_submitter.go +++ b/services/datarights/internal/service/deletion_submitter.go @@ -1,7 +1,7 @@ package service // DeletionJobSubmitter submits a deletion job for async processing. -// This interface decouples the DeletionService from the concrete DeletionEngine, +// This interface decouples the DeletionService from the concrete deletion.Engine, // allowing tests to stub the submission step. type DeletionJobSubmitter interface { Submit(jobID, userID string) diff --git a/services/datarights/internal/service/deletion_test.go b/services/datarights/internal/service/deletion_test.go index bd93d1da..d30f4dc9 100644 --- a/services/datarights/internal/service/deletion_test.go +++ b/services/datarights/internal/service/deletion_test.go @@ -16,7 +16,9 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/auth/proto/authpb" + "github.com/ItsThompson/gofin/services/datarights/internal/config" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" ) @@ -227,7 +229,9 @@ func newTestDeletionService( engine DeletionJobSubmitter, ) *DeletionService { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - opts := []DeletionServiceOption{} + opts := []DeletionServiceOption{ + WithProtectedUsernames(config.DefaultProtectedUsernames), + } if authClient != nil { opts = append(opts, WithAuthClient(authClient)) } @@ -265,8 +269,8 @@ func TestDeletionService_CreateJob_InvalidPassword_Returns401(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, model.ErrInvalidCredentials, svcErr.Code) assert.Equal(t, http.StatusUnauthorized, svcErr.Status) } @@ -315,8 +319,8 @@ func TestDeletionService_CreateJob_AuthServiceUnavailable_Returns503(t *testing. assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, model.ErrServiceUnavailable, svcErr.Code) assert.Equal(t, http.StatusServiceUnavailable, svcErr.Status) } @@ -336,9 +340,9 @@ func TestDeletionService_CreateJob_SelfDeletion_Returns400(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Equal(t, http.StatusBadRequest, svcErr.Status) assert.Contains(t, svcErr.Message, "Cannot delete your own account") } @@ -359,8 +363,8 @@ func TestDeletionService_CreateJob_ProtectedUsername_Admin_Returns403(t *testing assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, model.ErrProtectedUser, svcErr.Code) assert.Equal(t, http.StatusForbidden, svcErr.Status) } @@ -379,8 +383,8 @@ func TestDeletionService_CreateJob_ProtectedUsername_Thompson_Returns403(t *test assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, model.ErrProtectedUser, svcErr.Code) assert.Equal(t, http.StatusForbidden, svcErr.Status) } @@ -428,8 +432,8 @@ func TestDeletionService_CreateJob_GetUser_Unavailable_Returns503(t *testing.T) assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, model.ErrServiceUnavailable, svcErr.Code) assert.Equal(t, http.StatusServiceUnavailable, svcErr.Status) } @@ -455,8 +459,8 @@ func TestDeletionService_CreateJob_ExportInProgress_Returns409(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, model.ErrExportConflict, svcErr.Code) assert.Equal(t, http.StatusConflict, svcErr.Status) assert.Contains(t, svcErr.Message, "Cannot delete user while data export is in progress") @@ -619,9 +623,9 @@ func TestDeletionService_GetJob_NotFound(t *testing.T) { assert.Nil(t, job) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) assert.Equal(t, http.StatusNotFound, svcErr.Status) mockRepo.AssertExpectations(t) } diff --git a/services/datarights/internal/service/export.go b/services/datarights/internal/service/export.go index e6fa32b6..14896a70 100644 --- a/services/datarights/internal/service/export.go +++ b/services/datarights/internal/service/export.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/datarights/internal/engine" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" @@ -14,17 +15,6 @@ import ( // RateLimitWindow is the minimum duration between successful exports for a user. const RateLimitWindow = 30 * 24 * time.Hour -// ServiceError is a typed error that carries an HTTP status code and error code. -type ServiceError struct { - Code string - Message string - Status int -} - -func (e *ServiceError) Error() string { - return e.Message -} - // RateLimitError is returned when the user has exceeded the export rate limit. type RateLimitError struct { RetryAfter time.Time @@ -155,11 +145,7 @@ func (s *ExportService) GetJob(ctx context.Context, jobID, userID string) (*mode } if job == nil || job.UserID != userID { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: "Export job not found", - Status: 404, - } + return nil, apierr.NotFound("Export job not found") } return job, nil diff --git a/services/datarights/internal/service/export_test.go b/services/datarights/internal/service/export_test.go index dfc946da..d5029e98 100644 --- a/services/datarights/internal/service/export_test.go +++ b/services/datarights/internal/service/export_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/repository" ) @@ -332,10 +333,10 @@ func TestGetJob_NotFound(t *testing.T) { assert.Nil(t, job) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, 404, svcErr.Status) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) } func TestGetJob_DifferentUser(t *testing.T) { @@ -357,8 +358,8 @@ func TestGetJob_DifferentUser(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + var svcErr *apierr.Error + require.ErrorAs(t, err, &svcErr) assert.Equal(t, 404, svcErr.Status) } diff --git a/services/datarights/internal/service/protected_usernames.go b/services/datarights/internal/service/protected_usernames.go index 9f2f625e..46f7c3e3 100644 --- a/services/datarights/internal/service/protected_usernames.go +++ b/services/datarights/internal/service/protected_usernames.go @@ -1,13 +1,11 @@ package service -// protectedUsernames lists usernames that cannot be deleted. -// This check is owned by the datarights service (moved from auth). -var protectedUsernames = []string{"admin", "thompson"} - -// isProtectedUsername checks if a username is in the protected list. -func isProtectedUsername(username string) bool { - for _, protected := range protectedUsernames { - if username == protected { +// isProtectedUsername reports whether username is in the protected list. The +// protected list is owned by the datarights service (moved from auth) and is +// injected from config via WithProtectedUsernames. +func isProtectedUsername(protected []string, username string) bool { + for _, name := range protected { + if username == name { return true } } diff --git a/services/datarights/perf/baseline/export.txt b/services/datarights/perf/baseline/export.txt index 6664a64b..f7436d68 100644 --- a/services/datarights/perf/baseline/export.txt +++ b/services/datarights/perf/baseline/export.txt @@ -18,10 +18,12 @@ ns_op: 1395 bytes_op: 3883 allocs_op: 58 -# Target after dedup (this ticket): a fresh per-job MemoizedFinanceClient serves -# GetAllUserData at most once and the expenses provider derives its tag map from -# GetAllUserData().GetTags(), so ListTags drops to 0: -# get_all_user_data_calls: <= 1 +# Structural single-fetch (ticket #13): the export engine fetches GetAllUserData +# exactly once in execute and passes the resolved response to the finance-backed +# providers (now pure response->rows mappers); the expenses tag map derives from +# that same GetAllUserData().GetTags(), so ListTags is never called. The +# guarantee is structural (one call site), not a memoization wrapper: +# get_all_user_data_calls: 1 (exactly once, by construction) # list_tags_calls: 0 # Enforced by TestExport_DedupesFinanceCalls in internal/engine (test-backend CI). diff --git a/services/dbmigrate/dbmigrate.go b/services/dbmigrate/dbmigrate.go index 8f90fcff..12679b19 100644 --- a/services/dbmigrate/dbmigrate.go +++ b/services/dbmigrate/dbmigrate.go @@ -4,66 +4,15 @@ package dbmigrate import ( "database/sql" - "errors" "fmt" "log/slog" "net/url" "strings" - "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" - _ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/lib/pq" ) -// Run connects to the database at dbURL, reads migrations from migrationsPath, -// and applies any pending migrations. It returns nil when all migrations are -// applied (including the case where no new migrations exist). -// -// If the connection URL contains a search_path parameter referencing a schema -// that doesn't yet exist, Run creates the schema before running migrations. -// This solves the chicken-and-egg problem where golang-migrate's postgres driver -// requires the schema to exist on connect. -// -// A non-nil error is returned when: -// - The migrations path doesn't exist or contains no migration files -// - The database URL is invalid or unreachable -// - A migration fails to apply (the database is left in a dirty state) -func Run(dbURL, migrationsPath string) error { - if migrationsPath == "" { - return fmt.Errorf("dbmigrate: migrations path is empty") - } - - if err := ensureSchema(dbURL); err != nil { - return fmt.Errorf("dbmigrate: ensuring schema exists: %w", err) - } - - sourceURL := fmt.Sprintf("file://%s", migrationsPath) - - m, err := migrate.New(sourceURL, dbURL) - if err != nil { - return fmt.Errorf("dbmigrate: creating migrator: %w", err) - } - defer m.Close() //nolint:errcheck - - err = m.Up() - if errors.Is(err, migrate.ErrNoChange) { - slog.Info("dbmigrate: no new migrations to apply") - return nil - } - if err != nil { - return fmt.Errorf("dbmigrate: applying migrations: %w", err) - } - - version, dirty, _ := m.Version() - slog.Info("dbmigrate: migrations applied successfully", - slog.Uint64("version", uint64(version)), - slog.Bool("dirty", dirty), - ) - - return nil -} - // ensureSchema parses the search_path from the database URL and creates the // schema if it doesn't exist. This must run before golang-migrate connects, // because the postgres driver fails with "no schema" if search_path references diff --git a/services/dbmigrate/dbmigrate_test.go b/services/dbmigrate/dbmigrate_test.go deleted file mode 100644 index 94c890fd..00000000 --- a/services/dbmigrate/dbmigrate_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package dbmigrate_test - -import ( - "os" - "testing" - - "github.com/ItsThompson/gofin/services/dbmigrate" -) - -func TestRun_EmptyPath(t *testing.T) { - err := dbmigrate.Run("postgres://user:pass@localhost/db", "") - if err == nil { - t.Fatal("expected error for empty migrations path, got nil") - } - expected := "dbmigrate: migrations path is empty" - if err.Error() != expected { - t.Errorf("expected error %q, got %q", expected, err.Error()) - } -} - -func TestRun_NonexistentPath(t *testing.T) { - err := dbmigrate.Run( - "postgres://user:pass@localhost/db", - "/nonexistent/path/to/migrations", - ) - if err == nil { - t.Fatal("expected error for nonexistent migrations path, got nil") - } -} - -func TestRun_InvalidDBURL(t *testing.T) { - // Use valid migration files but an invalid DB URL to test connection failure. - err := dbmigrate.Run( - "postgres://invalid:invalid@localhost:59999/nonexistent?connect_timeout=1", - "./testdata/valid_migrations", - ) - if err == nil { - t.Fatal("expected error for unreachable database, got nil") - } -} - -// TestRun_Success requires a running PostgreSQL instance. -// Set TEST_DATABASE_URL to run this test (e.g., via `just dev-infra`). -func TestRun_Success(t *testing.T) { - dbURL := os.Getenv("TEST_DATABASE_URL") - if dbURL == "" { - t.Skip("TEST_DATABASE_URL not set: skipping integration test") - } - - // First run: applies migrations - err := dbmigrate.Run(dbURL, "./testdata/valid_migrations") - if err != nil { - t.Fatalf("expected nil on first run, got: %v", err) - } - - // Second run: idempotent (no new migrations) - err = dbmigrate.Run(dbURL, "./testdata/valid_migrations") - if err != nil { - t.Fatalf("expected nil on idempotent rerun, got: %v", err) - } -} - -// TestRun_InvalidSQL requires a running PostgreSQL instance. -// Verifies that a malformed migration returns an error. -func TestRun_InvalidSQL(t *testing.T) { - dbURL := os.Getenv("TEST_DATABASE_URL") - if dbURL == "" { - t.Skip("TEST_DATABASE_URL not set: skipping integration test") - } - - err := dbmigrate.Run(dbURL, "./testdata/invalid_migrations") - if err == nil { - t.Fatal("expected error for invalid SQL migration, got nil") - } -} diff --git a/services/expense/Dockerfile b/services/expense/Dockerfile index 4479a900..fb6474bb 100644 --- a/services/expense/Dockerfile +++ b/services/expense/Dockerfile @@ -11,6 +11,10 @@ COPY expense/go.mod expense/go.sum* ./expense/ COPY access/go.mod access/go.sum* ./access/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ +COPY serverkit/go.mod serverkit/go.sum* ./serverkit/ +COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ +COPY apierr/go.mod apierr/go.sum* ./apierr/ +COPY httpx/go.mod httpx/go.sum* ./httpx/ RUN --mount=type=cache,target=/go/pkg/mod \ cd expense && GOWORK=off go mod download @@ -19,6 +23,10 @@ COPY expense/ ./expense/ COPY access/ ./access/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ +COPY serverkit/ ./serverkit/ +COPY dbmigrate/ ./dbmigrate/ +COPY apierr/ ./apierr/ +COPY httpx/ ./httpx/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/expense/cmd/immudb_local.go b/services/expense/cmd/immudb_local.go index 240733c0..fdc417e3 100644 --- a/services/expense/cmd/immudb_local.go +++ b/services/expense/cmd/immudb_local.go @@ -57,6 +57,14 @@ func (c *inMemoryImmudbClient) SQLExec(_ context.Context, sql string, params map return &repository.SQLResult{}, nil } + // UPDATE is not implemented by this in-memory stub. Falling through to a + // silent empty result would let a local CorrectExpense leave the original + // row active (double-counting) and make anonymize a silent no-op, so fail + // loudly instead. The real immudb client (immudb_prod.go) handles UPDATE. + if strings.HasPrefix(sqlLower, "update") { + return nil, fmt.Errorf("UPDATE is unsupported in the local in-memory immudb stub") + } + return &repository.SQLResult{}, nil } @@ -81,14 +89,16 @@ func (c *inMemoryImmudbClient) SQLQuery(_ context.Context, sql string, params ma }, nil } - // SELECT by ID (with user scoping) + // SELECT by ID, always scoped to the requesting user. The user_id must match + // exactly: there is no empty-userID bypass, so the stub can never return + // another user's row (matches the real repository's user-scoped query). if strings.Contains(sqlLower, "where id = @id") { id, _ := params["id"].(string) userID, _ := params["user_id"].(string) for _, row := range c.rows { rowID := fmt.Sprintf("%v", row["id"]) rowUserID := fmt.Sprintf("%v", row["user_id"]) - if rowID == id && (userID == "" || rowUserID == userID) { + if rowID == id && rowUserID == userID { return &repository.SQLResult{ Rows: []repository.SQLRow{rowToSQLRow(row)}, }, nil diff --git a/services/expense/cmd/immudb_local_test.go b/services/expense/cmd/immudb_local_test.go new file mode 100644 index 00000000..a77405d9 --- /dev/null +++ b/services/expense/cmd/immudb_local_test.go @@ -0,0 +1,64 @@ +//go:build !docker + +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestInMemoryClient() *inMemoryImmudbClient { + return &inMemoryImmudbClient{rows: make([]map[string]interface{}, 0)} +} + +// TestInMemoryClient_UpdateReturnsExplicitError locks in C3: the local stub no +// longer silently no-ops an UPDATE (which would leave a corrected expense's +// original row active and double-count); it fails loudly instead. +func TestInMemoryClient_UpdateReturnsExplicitError(t *testing.T) { + client := newTestInMemoryClient() + + result, err := client.SQLExec(context.Background(), + "UPDATE expenses SET status = 'corrected' WHERE id = @id;", + map[string]interface{}{"id": "exp-1"}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "unsupported in the local in-memory immudb stub") +} + +// TestInMemoryClient_SelectByIDIsUserScoped locks in the C2 dev-client fix: the +// empty-userID OR-bypass on select-by-id is removed, so the stub can no longer +// return another user's row (or any row) under an empty user scope. +func TestInMemoryClient_SelectByIDIsUserScoped(t *testing.T) { + ctx := context.Background() + client := newTestInMemoryClient() + + _, err := client.SQLExec(ctx, + "INSERT INTO expenses (id, user_id, status) VALUES (@id, @user_id, @status);", + map[string]interface{}{"id": "exp-1", "user_id": "user-a", "status": "active"}) + require.NoError(t, err) + + const selectByID = "SELECT id, user_id, status FROM expenses WHERE id = @id AND user_id = @user_id;" + + // Empty user scope: the old bypass would have returned user-a's row. + emptyScope, err := client.SQLQuery(ctx, selectByID, + map[string]interface{}{"id": "exp-1", "user_id": ""}) + require.NoError(t, err) + assert.Empty(t, emptyScope.Rows) + + // A different user cannot read user-a's row. + otherUser, err := client.SQLQuery(ctx, selectByID, + map[string]interface{}{"id": "exp-1", "user_id": "user-b"}) + require.NoError(t, err) + assert.Empty(t, otherUser.Rows) + + // The owner still reads their own row. + owner, err := client.SQLQuery(ctx, selectByID, + map[string]interface{}{"id": "exp-1", "user_id": "user-a"}) + require.NoError(t, err) + require.Len(t, owner.Rows, 1) + assert.Equal(t, "exp-1", owner.Rows[0].Values[0].GetString()) +} diff --git a/services/expense/cmd/main.go b/services/expense/cmd/main.go index 09da8c89..46f5677a 100644 --- a/services/expense/cmd/main.go +++ b/services/expense/cmd/main.go @@ -11,21 +11,18 @@ import ( "syscall" "time" - "github.com/gin-gonic/gin" - "google.golang.org/grpc" - "github.com/ItsThompson/gofin/services/expense/internal/config" "github.com/ItsThompson/gofin/services/expense/internal/handler" "github.com/ItsThompson/gofin/services/expense/internal/repository" "github.com/ItsThompson/gofin/services/expense/internal/service" - "github.com/ItsThompson/gofin/services/healthcheck" - "github.com/ItsThompson/gofin/services/metrics" pb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" + "github.com/ItsThompson/gofin/services/healthcheck" + "github.com/ItsThompson/gofin/services/serverkit" ) func main() { if healthcheck.ShouldRun(os.Args) { - os.Exit(healthcheck.Run("8082")) + os.Exit(healthcheck.Run(config.ResolveRESTPort())) } if err := run(); err != nil { @@ -45,20 +42,11 @@ func run() error { } // Set up structured logging - logLevel := slog.LevelInfo - switch cfg.LogLevel { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - } - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) - logger = logger.With(slog.String("service", "expense")) + logger := serverkit.NewLogger(cfg.LogLevel, "expense") slog.SetDefault(logger) - // Connect to immudb + // Connect to immudb. expense is immudb-backed (not Postgres), so it does not + // use serverkit.ConnectPostgres. immudbClient, err := connectImmudb(ctx, cfg, logger) if err != nil { return fmt.Errorf("connecting to immudb: %w", err) @@ -74,12 +62,10 @@ func run() error { } // Build dependency graph - expenseSvc := service.NewExpenseService(repo, logger) + expenseSvc := service.NewExpenseService(repo, time.Now, logger) - // Start gRPC server - grpcServer := grpc.NewServer( - grpc.UnaryInterceptor(metrics.UnaryServerInterceptor()), - ) + // Build the gRPC server and pre-bind its listener so a bind failure surfaces. + grpcServer := serverkit.NewGRPCServer() grpcHandler := handler.NewGRPCHandler(expenseSvc, logger) pb.RegisterExpenseServiceServer(grpcServer, grpcHandler) @@ -88,29 +74,8 @@ func run() error { return fmt.Errorf("listening on gRPC port %s: %w", cfg.GRPCPort, err) } - go func() { - logger.Info("gRPC server starting", - slog.String("port", cfg.GRPCPort), - ) - if err := grpcServer.Serve(grpcLis); err != nil { - logger.Error("gRPC server failed", slog.String("error", err.Error())) - } - }() - - // Start REST server - if cfg.IsProduction() { - gin.SetMode(gin.ReleaseMode) - } - router := gin.New() - router.Use(gin.Recovery()) - router.Use(metrics.HTTPMetrics()) - - metrics.Register(router) - - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - + // Build the REST router and server. + router := serverkit.NewRouter("expense", cfg.IsProduction()) restHandler := handler.NewRESTHandler(expenseSvc, logger) restHandler.RegisterRoutes(router) @@ -119,32 +84,12 @@ func run() error { Handler: router, } - go func() { - logger.Info("REST server starting", - slog.String("port", cfg.RESTPort), - ) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("REST server failed", slog.String("error", err.Error())) - } - }() - logger.Info("expense service ready", slog.String("rest_port", cfg.RESTPort), slog.String("grpc_port", cfg.GRPCPort), ) - // Wait for shutdown signal - <-ctx.Done() - logger.Info("shutting down expense service") - - // Graceful shutdown: give in-flight requests up to 10 seconds - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - grpcServer.GracefulStop() - if err := httpServer.Shutdown(shutdownCtx); err != nil { - logger.Error("REST server shutdown error", slog.String("error", err.Error())) - } - - return nil + // Serve blocks until ctx is cancelled or a server fails fatally (e.g. a REST + // bind failure), returning that error so the process exits non-zero (C5). + return serverkit.Serve(ctx, httpServer, grpcServer, grpcLis) } diff --git a/services/expense/go.mod b/services/expense/go.mod index 5aa490c9..9b3cfdd2 100644 --- a/services/expense/go.mod +++ b/services/expense/go.mod @@ -4,8 +4,11 @@ go 1.26 require ( github.com/ItsThompson/gofin/services/access v0.0.0 + github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 + github.com/ItsThompson/gofin/services/httpx v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 + github.com/ItsThompson/gofin/services/serverkit v0.0.0 github.com/codenotary/immudb v1.11.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 @@ -20,7 +23,16 @@ replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck replace github.com/ItsThompson/gofin/services/metrics => ../metrics +replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit + +replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate + +replace github.com/ItsThompson/gofin/services/apierr => ../apierr + +replace github.com/ItsThompson/gofin/services/httpx => ../httpx + require ( + github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 // indirect github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 // indirect @@ -39,14 +51,22 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-migrate/migrate/v4 v4.18.3 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -65,7 +85,6 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.5.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.10.0 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cobra v1.6.1 // indirect @@ -77,9 +96,11 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.9.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect diff --git a/services/expense/go.sum b/services/expense/go.sum index 4bcc91f7..2c99ed17 100644 --- a/services/expense/go.sum +++ b/services/expense/go.sum @@ -36,8 +36,12 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= github.com/aead/chacha20poly1305 v0.0.0-20170617001512-233f39982aeb/go.mod h1:UzH9IX1MMqOcwhoNOIjmTQeAxrFgzs50j4golQtXXxU= @@ -87,6 +91,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= +github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -95,6 +109,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -132,6 +148,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -199,6 +217,11 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92Bcuy github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -207,6 +230,14 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -239,15 +270,25 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/o1egl/paseto v1.0.0 h1:bwpvPu2au176w4IBlhbyUv/S5VPptERIA99Oap5qUd0= github.com/o1egl/paseto v1.0.0/go.mod h1:5HxsZPmw/3RI2pAwGo1HhOOwSdvBpcuVzO7uDkm+CLU= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= @@ -331,6 +372,8 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= @@ -342,6 +385,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -485,7 +530,6 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/services/expense/internal/config/config.go b/services/expense/internal/config/config.go index 28a5434a..15c76683 100644 --- a/services/expense/internal/config/config.go +++ b/services/expense/internal/config/config.go @@ -5,6 +5,21 @@ import ( "os" ) +// DefaultRESTPort is the single source of truth for the expense REST port +// default. It backs both the listener (via Load) and the --healthcheck probe +// (via ResolveRESTPort) so the two never desync (US-PLATFORM-05). +const DefaultRESTPort = "8082" + +// ResolveRESTPort returns the REST port from REST_PORT, falling back to +// DefaultRESTPort. The --healthcheck branch runs before Load, so it calls this +// to probe the same port the listener will bind. +func ResolveRESTPort() string { + if p := os.Getenv("REST_PORT"); p != "" { + return p + } + return DefaultRESTPort +} + // Config holds all configuration for the expense service, loaded from environment variables. type Config struct { ImmudbAddr string @@ -44,10 +59,7 @@ func Load() (*Config, error) { environment = "development" } - restPort := os.Getenv("REST_PORT") - if restPort == "" { - restPort = "8082" - } + restPort := ResolveRESTPort() grpcPort := os.Getenv("GRPC_PORT") if grpcPort == "" { diff --git a/services/expense/internal/config/config_test.go b/services/expense/internal/config/config_test.go index 24d810ae..3d51bd32 100644 --- a/services/expense/internal/config/config_test.go +++ b/services/expense/internal/config/config_test.go @@ -60,6 +60,18 @@ func TestLoad_CustomPorts(t *testing.T) { assert.Equal(t, "9998", cfg.GRPCPort) } +func TestResolveRESTPort(t *testing.T) { + t.Run("defaults when unset", func(t *testing.T) { + _ = os.Unsetenv("REST_PORT") + assert.Equal(t, DefaultRESTPort, ResolveRESTPort()) + }) + + t.Run("honors REST_PORT override", func(t *testing.T) { + t.Setenv("REST_PORT", "9999") + assert.Equal(t, "9999", ResolveRESTPort()) + }) +} + func TestIsProduction(t *testing.T) { t.Setenv("IMMUDB_ADDR", "localhost:3322") diff --git a/services/expense/internal/handler/grpc.go b/services/expense/internal/handler/grpc.go index 79447c56..f42ad343 100644 --- a/services/expense/internal/handler/grpc.go +++ b/services/expense/internal/handler/grpc.go @@ -8,14 +8,14 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/service" pb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" ) -// GRPCHandler implements the ExpenseService gRPC server. -// CreateExpense, GetExpensesForPeriod, and GetExpense are implemented. -// All other RPCs return Unimplemented (stubs for later tickets). +// GRPCHandler implements the ExpenseService gRPC server. Each RPC delegates to +// the shared ExpenseService and maps service errors to gRPC status codes. type GRPCHandler struct { pb.UnimplementedExpenseServiceServer expenseService *service.ExpenseService @@ -91,8 +91,6 @@ func (h *GRPCHandler) GetExpense(ctx context.Context, req *pb.GetExpenseRequest) }, nil } -// Stub RPCs: return Unimplemented for later tickets. - func (h *GRPCHandler) CorrectExpense(ctx context.Context, req *pb.CorrectExpenseRequest) (*pb.ExpenseResponse, error) { expense, err := h.expenseService.CorrectExpense(ctx, req.GetUserId(), req.GetExpenseId(), &model.CorrectExpenseRequest{ Name: req.GetName(), @@ -110,44 +108,6 @@ func (h *GRPCHandler) CorrectExpense(ctx context.Context, req *pb.CorrectExpense }, nil } -func (h *GRPCHandler) GetCorrectionHistory(ctx context.Context, req *pb.GetCorrectionHistoryRequest) (*pb.CorrectionHistoryResponse, error) { - // The proto request only has expense_id; extract user_id from context - // or use a convention. For now, since the proto doesn't carry user_id, - // we pass an empty string. The REST API is the primary consumer. - entries, err := h.expenseService.GetCorrectionHistory(ctx, "", req.GetExpenseId()) - if err != nil { - return nil, mapServiceError(err) - } - - protoEntries := make([]*pb.ExpenseData, len(entries)) - for i, entry := range entries { - protoEntries[i] = expenseToProto(entry) - } - - return &pb.CorrectionHistoryResponse{ - Entries: protoEntries, - }, nil -} - -func (h *GRPCHandler) GetProRataGroup(ctx context.Context, req *pb.GetProRataGroupRequest) (*pb.ExpenseListResponse, error) { - // Similar to GetCorrectionHistory: proto doesn't carry user_id. - // REST API is the primary consumer. - expenses, err := h.expenseService.GetProRataGroup(ctx, "", req.GetGroupId()) - if err != nil { - return nil, mapServiceError(err) - } - - protoExpenses := make([]*pb.ExpenseData, len(expenses)) - for i, expense := range expenses { - protoExpenses[i] = expenseToProto(expense) - } - - return &pb.ExpenseListResponse{ - Data: protoExpenses, - Total: int64(len(protoExpenses)), - }, nil -} - func (h *GRPCHandler) CountExpensesByTag(ctx context.Context, req *pb.CountExpensesByTagRequest) (*pb.CountExpensesByTagResponse, error) { count, err := h.expenseService.CountExpensesByTag(ctx, req.GetUserId(), req.GetTagId()) if err != nil { @@ -166,8 +126,8 @@ func (h *GRPCHandler) StreamAllUserExpenses(req *pb.StreamAllUserExpensesRequest if err == nil { return nil } - var svcErr *service.ServiceError - if errors.As(err, &svcErr) { + var apiErr *apierr.Error + if errors.As(err, &apiErr) { return mapServiceError(err) } // Normalize context cancellation / deadline so gRPC reports codes.Canceled / @@ -215,21 +175,23 @@ func expenseToProto(e *model.Expense) *pb.ExpenseData { } } -// mapServiceError converts a service-layer error to a gRPC status error. +// mapServiceError converts a service-layer error to a gRPC status error. It +// classifies via errors.As so a %w-wrapped *apierr.Error still maps to the +// correct gRPC status code. func mapServiceError(err error) error { - var svcErr *service.ServiceError - if errors.As(err, &svcErr) { - switch svcErr.Code { - case model.ErrValidationError: - return status.Error(codes.InvalidArgument, svcErr.Message) - case model.ErrNotFound: - return status.Error(codes.NotFound, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) { + switch apiErr.Code { + case apierr.CodeValidation: + return status.Error(codes.InvalidArgument, apiErr.Message) + case apierr.CodeNotFound: + return status.Error(codes.NotFound, apiErr.Message) case model.ErrAlreadyCorrected: - return status.Error(codes.FailedPrecondition, svcErr.Message) + return status.Error(codes.FailedPrecondition, apiErr.Message) case model.ErrPeriodLocked: - return status.Error(codes.PermissionDenied, svcErr.Message) + return status.Error(codes.PermissionDenied, apiErr.Message) default: - return status.Error(codes.Internal, svcErr.Message) + return status.Error(codes.Internal, apiErr.Message) } } return status.Error(codes.Internal, "internal error") diff --git a/services/expense/internal/handler/grpc_test.go b/services/expense/internal/handler/grpc_test.go index c21bf65f..84a729b5 100644 --- a/services/expense/internal/handler/grpc_test.go +++ b/services/expense/internal/handler/grpc_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -13,16 +14,69 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/service" pb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" ) func newTestGRPCHandler(repo *mockExpenseRepository) *GRPCHandler { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, logger) + expenseSvc := service.NewExpenseService(repo, time.Now, logger) return NewGRPCHandler(expenseSvc, logger) } +// TestGRPC_RemovedReadRPCsAreNotRegistered locks in C2: the GetCorrectionHistory +// and GetProRataGroup RPCs hardcoded an empty ("") user scope and had no +// consumer, so they were removed from the proto and the generated service +// descriptor. The correction-history and pro-rata reads are served over REST. +// This guards against re-introducing an unscoped read RPC. +func TestGRPC_RemovedReadRPCsAreNotRegistered(t *testing.T) { + registered := make(map[string]bool) + for _, m := range pb.ExpenseService_ServiceDesc.Methods { + registered[m.MethodName] = true + } + for _, s := range pb.ExpenseService_ServiceDesc.Streams { + registered[s.StreamName] = true + } + + assert.NotContains(t, registered, "GetCorrectionHistory") + assert.NotContains(t, registered, "GetProRataGroup") + + // The rest of the gRPC surface is unchanged. + assert.Contains(t, registered, "CreateExpense") + assert.Contains(t, registered, "GetExpensesForPeriod") + assert.Contains(t, registered, "GetExpense") + assert.Contains(t, registered, "CorrectExpense") + assert.Contains(t, registered, "CountExpensesByTag") + assert.Contains(t, registered, "AnonymizeAllUserExpenses") + assert.Contains(t, registered, "StreamAllUserExpenses") +} + +// TestGRPC_GetExpense_WrappedTypedErrorClassifies locks in C7 (gRPC): a typed +// *apierr.Error that the service %w-wraps before it reaches the gRPC handler must +// still classify via errors.As (not collapse to codes.Internal). GetExpense +// wraps every repo error with %w ("getting expense: %w"), so a typed NOT_FOUND +// returned by the repo reaches the handler wrapped; mapServiceError must still +// map it to codes.NotFound. +func TestGRPC_GetExpense_WrappedTypedErrorClassifies(t *testing.T) { + repo := new(mockExpenseRepository) + handler := newTestGRPCHandler(repo) + + repo.On("GetExpenseByID", mock.Anything, "exp-1", "user-1"). + Return(nil, apierr.NotFound("expense exp-1 not found")) + + resp, err := handler.GetExpense(context.Background(), &pb.GetExpenseRequest{ + UserId: "user-1", + Id: "exp-1", + }) + assert.Nil(t, resp) + require.Error(t, err) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + // --- AnonymizeAllUserExpenses gRPC handler tests --- func TestGRPC_AnonymizeAllUserExpenses_Success(t *testing.T) { diff --git a/services/expense/internal/handler/rest.go b/services/expense/internal/handler/rest.go index 3695af47..e0244f18 100644 --- a/services/expense/internal/handler/rest.go +++ b/services/expense/internal/handler/rest.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" "strconv" @@ -8,8 +9,10 @@ import ( "github.com/gin-gonic/gin" "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/service" + "github.com/ItsThompson/gofin/services/httpx" ) // RESTHandler handles HTTP requests for the expense service. @@ -53,27 +56,19 @@ func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { // CreateExpense handles POST /api/expenses. func (h *RESTHandler) CreateExpense(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.CreateExpenseRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } expense, err := h.expenseService.CreateExpense(c.Request.Context(), userID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -84,40 +79,27 @@ func (h *RESTHandler) CreateExpense(c *gin.Context) { // GetExpenses handles GET /api/expenses?year=YYYY&month=MM&page=1&pageSize=50. func (h *RESTHandler) GetExpenses(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } yearStr := c.Query("year") monthStr := c.Query("month") if yearStr == "" || monthStr == "" { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "year and month query parameters are required", - }) + apierr.Respond(c, apierr.Validation("year and month query parameters are required", nil)) return } year, err := strconv.ParseInt(yearStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "year must be a valid integer", - }) + apierr.Respond(c, apierr.Validation("year must be a valid integer", nil)) return } month, err := strconv.ParseInt(monthStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "month must be a valid integer", - }) + apierr.Respond(c, apierr.Validation("month must be a valid integer", nil)) return } @@ -143,14 +125,9 @@ func (h *RESTHandler) GetExpenses(c *gin.Context) { Month: int32(month), Page: int32(page), PageSize: int32(pageSize), - Sort: c.Query("sort"), - Type: c.Query("type"), - TagID: c.Query("tagId"), - DateFrom: c.Query("dateFrom"), - DateTo: c.Query("dateTo"), }) if svcErr != nil { - h.handleError(c, svcErr) + h.respondError(c, svcErr) return } @@ -159,24 +136,20 @@ func (h *RESTHandler) GetExpenses(c *gin.Context) { // GetExpenseSuggestions handles GET /api/expenses/suggestions?page=1&pageSize=50. func (h *RESTHandler) GetExpenseSuggestions(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } page, ok := parsePositiveIntQuery(c, "page", 1) if !ok { - c.JSON(http.StatusBadRequest, model.ApiError{Code: model.ErrValidationError, Message: "page must be a positive integer"}) + apierr.Respond(c, apierr.Validation("page must be a positive integer", nil)) return } pageSize, ok := parsePositiveIntQuery(c, "pageSize", 50) if !ok || pageSize > 100 { - c.JSON(http.StatusBadRequest, model.ApiError{Code: model.ErrValidationError, Message: "pageSize must be a positive integer no greater than 100"}) + apierr.Respond(c, apierr.Validation("pageSize must be a positive integer no greater than 100", nil)) return } @@ -186,7 +159,7 @@ func (h *RESTHandler) GetExpenseSuggestions(c *gin.Context) { PageSize: int32(pageSize), }) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -207,19 +180,15 @@ func parsePositiveIntQuery(c *gin.Context, key string, defaultValue int64) (int6 // GetExpense handles GET /api/expenses/:id. func (h *RESTHandler) GetExpense(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } id := c.Param("id") expense, err := h.expenseService.GetExpense(c.Request.Context(), userID, id) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -228,51 +197,36 @@ func (h *RESTHandler) GetExpense(c *gin.Context) { }) } -// handleError maps service errors to HTTP responses following the ApiError contract. -func (h *RESTHandler) handleError(c *gin.Context, err error) { - if svcErr, ok := err.(*service.ServiceError); ok { - c.JSON(svcErr.Status, model.ApiError{ - Code: svcErr.Code, - Message: svcErr.Message, - Fields: svcErr.Fields, - }) - return +// respondError delegates the wire mapping to apierr.Respond (which classifies +// via errors.As), logging any unexpected non-*apierr.Error at error level first +// so 500s stay observable (apierr.Respond takes no logger). +func (h *RESTHandler) respondError(c *gin.Context, err error) { + var apiErr *apierr.Error + if !errors.As(err, &apiErr) { + h.logger.Error("unexpected error", + slog.String("error", err.Error()), + ) } - - h.logger.Error("unexpected error", - slog.String("error", err.Error()), - ) - c.JSON(http.StatusInternalServerError, model.ApiError{ - Code: model.ErrInternalServerError, - Message: "An unexpected error occurred", - }) + apierr.Respond(c, err) } // CorrectExpense handles POST /api/expenses/:id/correct. func (h *RESTHandler) CorrectExpense(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } expenseID := c.Param("id") var req model.CorrectExpenseRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } expense, err := h.expenseService.CorrectExpense(c.Request.Context(), userID, expenseID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -283,12 +237,8 @@ func (h *RESTHandler) CorrectExpense(c *gin.Context) { // GetCorrectionHistory handles GET /api/expenses/:id/history. func (h *RESTHandler) GetCorrectionHistory(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } @@ -296,7 +246,7 @@ func (h *RESTHandler) GetCorrectionHistory(c *gin.Context) { entries, err := h.expenseService.GetCorrectionHistory(c.Request.Context(), userID, expenseID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -307,12 +257,8 @@ func (h *RESTHandler) GetCorrectionHistory(c *gin.Context) { // GetProRataGroup handles GET /api/expenses/prorata/:groupId. func (h *RESTHandler) GetProRataGroup(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } @@ -320,7 +266,7 @@ func (h *RESTHandler) GetProRataGroup(c *gin.Context) { expenses, err := h.expenseService.GetProRataGroup(c.Request.Context(), userID, groupID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } diff --git a/services/expense/internal/handler/rest_test.go b/services/expense/internal/handler/rest_test.go index e46e021a..33e0af8a 100644 --- a/services/expense/internal/handler/rest_test.go +++ b/services/expense/internal/handler/rest_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/repository" "github.com/ItsThompson/gofin/services/expense/internal/service" @@ -104,7 +105,7 @@ func (m *mockExpenseRepository) GetExpensesByUserAfter(ctx context.Context, user func setupTestRouter(repo *mockExpenseRepository) *gin.Engine { gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, logger) + expenseSvc := service.NewExpenseService(repo, time.Now, logger) h := NewRESTHandler(expenseSvc, logger) r := gin.New() h.RegisterRoutes(r) @@ -202,9 +203,9 @@ func TestCreateExpenseHandler_InvalidBody(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) } func TestCreateExpenseHandler_ValidationError(t *testing.T) { @@ -225,9 +226,9 @@ func TestCreateExpenseHandler_ValidationError(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) assert.Equal(t, "validation failed", errResp.Message) require.NotNil(t, errResp.Fields) assert.Equal(t, "amount must be positive", errResp.Fields["amount"]) @@ -250,7 +251,7 @@ func TestCreateExpenseHandler_InvalidExpenseType(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) require.NotNil(t, errResp.Fields) assert.Contains(t, errResp.Fields["expenseType"], "essentials, desires, savings") @@ -274,7 +275,7 @@ func TestCreateExpenseHandler_MultipleFieldErrors(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) require.NotNil(t, errResp.Fields) assert.Len(t, errResp.Fields, 3) // name, amount, expenseType @@ -415,9 +416,9 @@ func TestGetExpenseHandler_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrNotFound, errResp.Code) + assert.Equal(t, apierr.CodeNotFound, errResp.Code) } // --- CorrectExpense Handler Tests --- @@ -426,7 +427,7 @@ func TestGetExpenseHandler_NotFound(t *testing.T) { func setupTestRouterWithClock(repo *mockExpenseRepository, now time.Time) *gin.Engine { gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, logger).WithClock(func() time.Time { return now }) + expenseSvc := service.NewExpenseService(repo, func() time.Time { return now }, logger) h := NewRESTHandler(expenseSvc, logger) r := gin.New() h.RegisterRoutes(r) @@ -488,7 +489,7 @@ func TestCorrectExpenseHandler_AlreadyCorrected(t *testing.T) { }) assert.Equal(t, http.StatusConflict, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrAlreadyCorrected, errResp.Code) } @@ -512,7 +513,7 @@ func TestCorrectExpenseHandler_PeriodLocked(t *testing.T) { }) assert.Equal(t, http.StatusForbidden, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrPeriodLocked, errResp.Code) } diff --git a/services/expense/internal/handler/suggestions_test.go b/services/expense/internal/handler/suggestions_test.go index c9431d5a..17bdb528 100644 --- a/services/expense/internal/handler/suggestions_test.go +++ b/services/expense/internal/handler/suggestions_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" ) @@ -20,9 +21,9 @@ func TestGetExpenseSuggestionsHandler_RequiresUserID(t *testing.T) { w := doJSONWithUserID(r, http.MethodGet, "/api/expenses/suggestions", "", nil) assert.Equal(t, http.StatusUnauthorized, w.Code) - var response model.ApiError + var response apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, model.ErrUnauthorized, response.Code) + assert.Equal(t, apierr.CodeUnauthorized, response.Code) } func TestGetExpenseSuggestionsHandler_DefaultsPaginationAndReturnsShape(t *testing.T) { @@ -65,9 +66,9 @@ func TestGetExpenseSuggestionsHandler_RejectsInvalidPagination(t *testing.T) { w := doJSONWithUserID(r, http.MethodGet, path, "user-1", nil) assert.Equal(t, http.StatusBadRequest, w.Code) - var response model.ApiError + var response apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, model.ErrValidationError, response.Code) + assert.Equal(t, apierr.CodeValidation, response.Code) }) } } @@ -80,7 +81,7 @@ func TestGetExpenseSuggestionsHandler_RepositoryFailureReturnsInternalServerErro w := doJSONWithUserID(r, http.MethodGet, "/api/expenses/suggestions?page=1&pageSize=50", "user-1", nil) assert.Equal(t, http.StatusInternalServerError, w.Code) - var response model.ApiError + var response apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, model.ErrInternalServerError, response.Code) + assert.Equal(t, apierr.CodeInternal, response.Code) } diff --git a/services/expense/internal/model/errors.go b/services/expense/internal/model/errors.go index f2167720..36e543a3 100644 --- a/services/expense/internal/model/errors.go +++ b/services/expense/internal/model/errors.go @@ -1,19 +1,9 @@ package model -// ApiError follows the error contract from 10-nonfunctional.md: -// { code, message, fields? } -type ApiError struct { - Code string `json:"code"` - Message string `json:"message"` - Fields map[string]string `json:"fields,omitempty"` -} - -// Common error codes +// Expense-specific error codes. The shared codes (VALIDATION_ERROR, +// UNAUTHORIZED, NOT_FOUND, INTERNAL_SERVER_ERROR) are single-sourced in the +// shared apierr package; reference them from there. const ( - ErrValidationError = "VALIDATION_ERROR" - ErrUnauthorized = "UNAUTHORIZED" - ErrNotFound = "NOT_FOUND" - ErrInternalServerError = "INTERNAL_SERVER_ERROR" - ErrAlreadyCorrected = "ALREADY_CORRECTED" - ErrPeriodLocked = "PERIOD_LOCKED" + ErrAlreadyCorrected = "ALREADY_CORRECTED" + ErrPeriodLocked = "PERIOD_LOCKED" ) diff --git a/services/expense/internal/model/requests.go b/services/expense/internal/model/requests.go index c5d78b61..e3eed488 100644 --- a/services/expense/internal/model/requests.go +++ b/services/expense/internal/model/requests.go @@ -3,7 +3,7 @@ package model // CreateExpenseRequest is the input for POST /api/expenses. // Note: binding tags are intentionally omitted. Validation is handled by the // service layer's validateCreateExpenseRequest, which returns field-level error -// details in the ApiError response. +// details in the error response. type CreateExpenseRequest struct { Name string `json:"name"` Amount int64 `json:"amount"` @@ -22,17 +22,15 @@ type CreateExpenseRequest struct { } // GetExpensesRequest holds the parsed query parameters for GET /api/expenses. +// The repository query is scoped to user_id + period and ordered by +// expense_date DESC; all further filtering and sorting is done client-side, so +// no sort/type/tag/date-range fields are carried here. type GetExpensesRequest struct { - UserID string - Year int32 - Month int32 - Page int32 - PageSize int32 - Sort string // e.g., "date:asc", "amount:desc" - Type string // comma-separated expense types - TagID string // comma-separated tag IDs - DateFrom string // ISO date - DateTo string // ISO date + UserID string + Year int32 + Month int32 + Page int32 + PageSize int32 } // ExpenseResponse is the JSON body returned for a single expense. diff --git a/services/expense/internal/repository/immudb.go b/services/expense/internal/repository/immudb.go index 44e457b0..fd3144fb 100644 --- a/services/expense/internal/repository/immudb.go +++ b/services/expense/internal/repository/immudb.go @@ -168,7 +168,10 @@ func (r *ImmudbExpenseRepository) GetExpensesForPeriod(ctx context.Context, user expenses := make([]*model.Expense, 0, len(result.Rows)) for _, row := range result.Rows { - expense := rowToExpense(row) + expense, convErr := rowToExpense(row) + if convErr != nil { + return nil, 0, fmt.Errorf("mapping expense row: %w", convErr) + } expenses = append(expenses, expense) } @@ -193,7 +196,11 @@ func (r *ImmudbExpenseRepository) GetExpenseByID(ctx context.Context, id string, return nil, nil } - return rowToExpense(result.Rows[0]), nil + expense, err := rowToExpense(result.Rows[0]) + if err != nil { + return nil, fmt.Errorf("mapping expense row: %w", err) + } + return expense, nil } // CountExpensesByTag returns the count of active expenses referencing the given tag @@ -247,9 +254,18 @@ func (r *ImmudbExpenseRepository) GetActiveExpenseSuggestionInputs(ctx context.C return inputs, nil } -// Column order must match the SELECT clause in queries. -func rowToExpense(row SQLRow) *model.Expense { +// expenseColumnCount is the number of columns rowToExpense expects in a result +// row. It must match the expenseSelectColumns list and the ExpenseData schema. +const expenseColumnCount = 17 + +// rowToExpense maps a result row to an Expense. The column order must match the +// SELECT clause in queries. It returns an error on a short/malformed row rather +// than panicking on an out-of-range index. +func rowToExpense(row SQLRow) (*model.Expense, error) { values := row.Values + if len(values) < expenseColumnCount { + return nil, fmt.Errorf("expense row has %d values, want %d", len(values), expenseColumnCount) + } return &model.Expense{ ID: values[0].GetString(), UserID: values[1].GetString(), @@ -268,7 +284,7 @@ func rowToExpense(row SQLRow) *model.Expense { ProRataIndex: int32(values[14].GetInt()), ProRataTotal: int32(values[15].GetInt()), CreatedAt: values[16].GetString(), - } + }, nil } // Column order must match GetActiveExpenseSuggestionInputs SELECT clause. @@ -374,7 +390,10 @@ func (r *ImmudbExpenseRepository) GetCorrectionHistory(ctx context.Context, expe break } - next := rowToExpense(result.Rows[0]) + next, convErr := rowToExpense(result.Rows[0]) + if convErr != nil { + return nil, fmt.Errorf("mapping expense row: %w", convErr) + } if visited[next.ID] { break // Safety } @@ -403,7 +422,11 @@ func (r *ImmudbExpenseRepository) GetProRataGroup(ctx context.Context, groupID s expenses := make([]*model.Expense, 0, len(result.Rows)) for _, row := range result.Rows { - expenses = append(expenses, rowToExpense(row)) + expense, convErr := rowToExpense(row) + if convErr != nil { + return nil, fmt.Errorf("mapping expense row: %w", convErr) + } + expenses = append(expenses, expense) } return expenses, nil @@ -451,7 +474,11 @@ func (r *ImmudbExpenseRepository) GetExpensesByUserAfter(ctx context.Context, us rows := make([]*model.Expense, 0, len(result.Rows)) for _, row := range result.Rows { - rows = append(rows, rowToExpense(row)) + expense, convErr := rowToExpense(row) + if convErr != nil { + return nil, ExpenseCursor{}, false, fmt.Errorf("mapping expense row: %w", convErr) + } + rows = append(rows, expense) } // The overflow row (pageSize+1th) means more rows remain. Drop it from the diff --git a/services/expense/internal/repository/immudb_test.go b/services/expense/internal/repository/immudb_test.go index f805a348..14983c22 100644 --- a/services/expense/internal/repository/immudb_test.go +++ b/services/expense/internal/repository/immudb_test.go @@ -75,3 +75,21 @@ func TestGetActiveExpenseSuggestionInputs_ReadsActiveRowsForUserAndMapsFields(t assert.True(t, inputs[0].IsProRata) assert.Equal(t, "group-1", inputs[0].ProRataGroup) } + +func TestGetExpenseByID_ShortRowReturnsErrorNotPanic(t *testing.T) { + // A malformed/short row (fewer than the 17 selected columns) must produce a + // wrapped error rather than an index-out-of-range panic. + client := &fakeImmudbClient{result: &SQLResult{Rows: []SQLRow{{Values: []SQLValue{ + fakeSQLValue{stringValue: "exp-1"}, + fakeSQLValue{stringValue: "user-1"}, + fakeSQLValue{stringValue: "Groceries"}, + }}}}} + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + repo := NewImmudbExpenseRepository(client, logger) + + expense, err := repo.GetExpenseByID(context.Background(), "exp-1", "user-1") + + require.Error(t, err) + assert.Nil(t, expense) + assert.Contains(t, err.Error(), "expense row has 3 values, want 17") +} diff --git a/services/expense/internal/repository/repository.go b/services/expense/internal/repository/repository.go index 847d0306..266a69d6 100644 --- a/services/expense/internal/repository/repository.go +++ b/services/expense/internal/repository/repository.go @@ -56,11 +56,6 @@ type ExpenseRepository interface { AnonymizeAllUserExpenses(ctx context.Context, userID string) error } -// SchemaInitializer creates the required tables and indexes on startup. -type SchemaInitializer interface { - InitSchema(ctx context.Context) error -} - // ExpenseCursor identifies the last row seen during a keyset walk, used to seek // the next page without OFFSET. CreatedAt holds an RFC3339 timestamp in // canonical fixed-precision UTC form (lexicographically sortable); an empty @@ -76,8 +71,7 @@ const DefaultStreamPageSize int32 = 100 // SQLResult represents the result of an SQL query row. type SQLResult struct { - Columns []string - Rows []SQLRow + Rows []SQLRow } // SQLRow represents a single row from an SQL query result. diff --git a/services/expense/internal/service/expense.go b/services/expense/internal/service/expense.go index 1e2cf59b..7f6d42e7 100644 --- a/services/expense/internal/service/expense.go +++ b/services/expense/internal/service/expense.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "log/slog" + "net/http" "regexp" "time" "github.com/google/uuid" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/repository" "github.com/ItsThompson/gofin/services/metrics" @@ -23,45 +25,28 @@ type ExpenseService struct { clock func() time.Time } -// NewExpenseService creates a new ExpenseService. +// NewExpenseService creates a new ExpenseService. The clock seam supplies the +// current time for CreatedAt stamping and the period-lock check; production +// passes time.Now and tests inject a fixed clock. func NewExpenseService( repo repository.ExpenseRepository, + clock func() time.Time, logger *slog.Logger, ) *ExpenseService { return &ExpenseService{ repo: repo, logger: logger, - clock: time.Now, + clock: clock, } } -// WithClock returns a copy of the service with a custom clock function. -// Used in tests to inject a fixed time. -func (s *ExpenseService) WithClock(clock func() time.Time) *ExpenseService { - s.clock = clock - return s -} - -// ServiceError is a typed error that carries an HTTP status code, error code, -// and optional field-level validation details. -type ServiceError struct { - Code string - Message string - Status int - Fields map[string]string -} - -func (e *ServiceError) Error() string { - return e.Message -} - // CreateExpense validates and creates a new expense entry in the ledger. func (s *ExpenseService) CreateExpense(ctx context.Context, userID string, req *model.CreateExpenseRequest) (*model.Expense, error) { if err := validateCreateExpenseRequest(req); err != nil { return nil, err } - now := time.Now().UTC().Format(time.RFC3339) + now := s.clock().UTC().Format(time.RFC3339) expense := &model.Expense{ ID: uuid.New().String(), UserID: userID, @@ -103,18 +88,10 @@ func (s *ExpenseService) CreateExpense(ctx context.Context, userID string, req * // GetExpensesForPeriod returns materialized expenses for a period with pagination. func (s *ExpenseService) GetExpensesForPeriod(ctx context.Context, req *model.GetExpensesRequest) (*model.ExpenseListResponse, error) { if req.Year < 1 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "year must be positive", - Status: 400, - } + return nil, apierr.Validation("year must be positive", nil) } if req.Month < 1 || req.Month > 12 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "month must be between 1 and 12", - Status: 400, - } + return nil, apierr.Validation("month must be between 1 and 12", nil) } page := req.Page @@ -145,11 +122,7 @@ func (s *ExpenseService) GetExpensesForPeriod(ctx context.Context, req *model.Ge // GetExpense returns a single expense by ID, scoped to the requesting user. func (s *ExpenseService) GetExpense(ctx context.Context, userID string, id string) (*model.Expense, error) { if id == "" { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "expense ID is required", - Status: 400, - } + return nil, apierr.Validation("expense ID is required", nil) } expense, err := s.repo.GetExpenseByID(ctx, id, userID) @@ -157,11 +130,7 @@ func (s *ExpenseService) GetExpense(ctx context.Context, userID string, id strin return nil, fmt.Errorf("getting expense: %w", err) } if expense == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: fmt.Sprintf("expense %s not found", id), - Status: 404, - } + return nil, apierr.NotFound(fmt.Sprintf("expense %s not found", id)) } return expense, nil @@ -170,11 +139,7 @@ func (s *ExpenseService) GetExpense(ctx context.Context, userID string, id strin // CountExpensesByTag returns the count of active expenses for a user that reference a given tag. func (s *ExpenseService) CountExpensesByTag(ctx context.Context, userID string, tagID string) (int64, error) { if tagID == "" { - return 0, &ServiceError{ - Code: model.ErrValidationError, - Message: "tag_id is required", - Status: 400, - } + return 0, apierr.Validation("tag_id is required", nil) } count, err := s.repo.CountExpensesByTag(ctx, userID, tagID) @@ -190,11 +155,7 @@ func (s *ExpenseService) CountExpensesByTag(ctx context.Context, userID string, // is created atomically. Returns the new correction entry. func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expenseID string, req *model.CorrectExpenseRequest) (*model.Expense, error) { if expenseID == "" { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "expense ID is required", - Status: 400, - } + return nil, apierr.Validation("expense ID is required", nil) } if err := validateCorrectExpenseRequest(req); err != nil { @@ -207,20 +168,12 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe return nil, fmt.Errorf("fetching expense for correction: %w", err) } if original == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: fmt.Sprintf("expense %s not found", expenseID), - Status: 404, - } + return nil, apierr.NotFound(fmt.Sprintf("expense %s not found", expenseID)) } // Check if already corrected if original.Status != "active" { - return nil, &ServiceError{ - Code: model.ErrAlreadyCorrected, - Message: "this expense has already been corrected", - Status: 409, - } + return nil, apierr.Conflict(model.ErrAlreadyCorrected, "this expense has already been corrected") } // Check if the expense is in the current budget period @@ -228,10 +181,10 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe currentYear := int32(now.Year()) currentMonth := int32(now.Month()) if original.PeriodYear != currentYear || original.PeriodMonth != currentMonth { - return nil, &ServiceError{ + return nil, &apierr.Error{ Code: model.ErrPeriodLocked, Message: "cannot correct expenses from a past period", - Status: 403, + Status: http.StatusForbidden, } } @@ -253,7 +206,7 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe ProRataGroup: original.ProRataGroup, ProRataIndex: original.ProRataIndex, ProRataTotal: original.ProRataTotal, - CreatedAt: time.Now().UTC().Format(time.RFC3339), + CreatedAt: s.clock().UTC().Format(time.RFC3339), } created, err := s.repo.CorrectExpense(ctx, original, correction) @@ -277,11 +230,7 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe // ordered chronologically (original first, latest correction last). func (s *ExpenseService) GetCorrectionHistory(ctx context.Context, userID string, expenseID string) ([]*model.Expense, error) { if expenseID == "" { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "expense ID is required", - Status: 400, - } + return nil, apierr.Validation("expense ID is required", nil) } chain, err := s.repo.GetCorrectionHistory(ctx, expenseID, userID) @@ -289,11 +238,7 @@ func (s *ExpenseService) GetCorrectionHistory(ctx context.Context, userID string return nil, fmt.Errorf("getting correction history: %w", err) } if chain == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: fmt.Sprintf("expense %s not found", expenseID), - Status: 404, - } + return nil, apierr.NotFound(fmt.Sprintf("expense %s not found", expenseID)) } return chain, nil @@ -302,11 +247,7 @@ func (s *ExpenseService) GetCorrectionHistory(ctx context.Context, userID string // GetProRataGroup returns all expenses belonging to a pro-rata group. func (s *ExpenseService) GetProRataGroup(ctx context.Context, userID string, groupID string) ([]*model.Expense, error) { if groupID == "" { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "group ID is required", - Status: 400, - } + return nil, apierr.Validation("group ID is required", nil) } expenses, err := s.repo.GetProRataGroup(ctx, groupID, userID) @@ -332,11 +273,7 @@ func (s *ExpenseService) GetProRataGroup(ctx context.Context, userID string, gro // the goroutine. func (s *ExpenseService) StreamAllUserExpenses(ctx context.Context, userID string, pageSize int32, send func(*model.Expense) error) error { if userID == "" { - return &ServiceError{ - Code: model.ErrValidationError, - Message: "user_id is required", - Status: 400, - } + return apierr.Validation("user_id is required", nil) } if pageSize < 1 { pageSize = repository.DefaultStreamPageSize @@ -404,11 +341,7 @@ func (s *ExpenseService) produceExpensePages(ctx context.Context, userID string, // Idempotent: calling for already-redacted data returns success. func (s *ExpenseService) AnonymizeAllUserExpenses(ctx context.Context, userID string) error { if userID == "" { - return &ServiceError{ - Code: model.ErrValidationError, - Message: "user_id is required", - Status: 400, - } + return apierr.Validation("user_id is required", nil) } if err := s.repo.AnonymizeAllUserExpenses(ctx, userID); err != nil { @@ -424,7 +357,7 @@ func (s *ExpenseService) AnonymizeAllUserExpenses(ctx context.Context, userID st } // validateCorrectExpenseRequest checks all required fields for a correction. -func validateCorrectExpenseRequest(req *model.CorrectExpenseRequest) *ServiceError { +func validateCorrectExpenseRequest(req *model.CorrectExpenseRequest) *apierr.Error { fields := make(map[string]string) if req.Name == "" { @@ -446,18 +379,13 @@ func validateCorrectExpenseRequest(req *model.CorrectExpenseRequest) *ServiceErr } if len(fields) > 0 { - return &ServiceError{ - Code: model.ErrValidationError, - Message: "validation failed", - Status: 400, - Fields: fields, - } + return apierr.Validation("validation failed", fields) } return nil } // validateCreateExpenseRequest checks all required fields and business rules. -func validateCreateExpenseRequest(req *model.CreateExpenseRequest) *ServiceError { +func validateCreateExpenseRequest(req *model.CreateExpenseRequest) *apierr.Error { fields := make(map[string]string) if req.Name == "" { @@ -488,12 +416,7 @@ func validateCreateExpenseRequest(req *model.CreateExpenseRequest) *ServiceError } if len(fields) > 0 { - return &ServiceError{ - Code: model.ErrValidationError, - Message: "validation failed", - Status: 400, - Fields: fields, - } + return apierr.Validation("validation failed", fields) } return nil } diff --git a/services/expense/internal/service/expense_test.go b/services/expense/internal/service/expense_test.go index bb7122a0..767d1c0a 100644 --- a/services/expense/internal/service/expense_test.go +++ b/services/expense/internal/service/expense_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/repository" ) @@ -98,7 +99,16 @@ func (m *mockExpenseRepository) GetExpensesByUserAfter(ctx context.Context, user func newTestService(repo *mockExpenseRepository) *ExpenseService { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - return NewExpenseService(repo, logger) + return NewExpenseService(repo, time.Now, logger) +} + +// requireAPIError asserts that err carries an *apierr.Error (via errors.As, so a +// %w-wrapped typed error still matches) and returns it for further assertions. +func requireAPIError(t *testing.T, err error) *apierr.Error { + t.Helper() + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + return apiErr } func validCreateRequest() *model.CreateExpenseRequest { @@ -165,9 +175,8 @@ func TestCreateExpense_AmountMustBePositive(t *testing.T) { _, err := svc.CreateExpense(context.Background(), "user-1", req) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Equal(t, 400, svcErr.Status) }) } @@ -198,9 +207,8 @@ func TestCreateExpense_RequiredFields(t *testing.T) { _, err := svc.CreateExpense(context.Background(), "user-1", req) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) }) } } @@ -215,9 +223,8 @@ func TestCreateExpense_InvalidExpenseType(t *testing.T) { _, err := svc.CreateExpense(context.Background(), "user-1", req) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } func TestCreateExpense_ValidExpenseTypes(t *testing.T) { @@ -266,9 +273,8 @@ func TestCreateExpense_InvalidDateFormat(t *testing.T) { _, err := svc.CreateExpense(context.Background(), "user-1", req) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) }) } } @@ -359,9 +365,8 @@ func TestGetExpensesForPeriod_InvalidMonth(t *testing.T) { }) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } // --- GetExpense tests --- @@ -388,9 +393,8 @@ func TestGetExpense_NotFound(t *testing.T) { _, err := svc.GetExpense(context.Background(), "user-1", "exp-999") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) assert.Equal(t, 404, svcErr.Status) } @@ -401,9 +405,8 @@ func TestGetExpense_EmptyID(t *testing.T) { _, err := svc.GetExpense(context.Background(), "user-1", "") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } // --- CorrectExpense tests --- @@ -436,9 +439,8 @@ func validCorrectRequest() *model.CorrectExpenseRequest { } func newTestServiceWithClock(repo *mockExpenseRepository, now time.Time) *ExpenseService { - svc := newTestService(repo) - svc.WithClock(func() time.Time { return now }) - return svc + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + return NewExpenseService(repo, func() time.Time { return now }, logger) } func TestCorrectExpense_Success(t *testing.T) { @@ -502,8 +504,7 @@ func TestCorrectExpense_AlreadyCorrected(t *testing.T) { _, err := svc.CorrectExpense(context.Background(), "user-1", "exp-original", validCorrectRequest()) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrAlreadyCorrected, svcErr.Code) assert.Equal(t, 409, svcErr.Status) } @@ -534,8 +535,7 @@ func TestCorrectExpense_PeriodLocked(t *testing.T) { _, err := svc.CorrectExpense(context.Background(), "user-1", "exp-past", validCorrectRequest()) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrPeriodLocked, svcErr.Code) assert.Equal(t, 403, svcErr.Status) } @@ -550,9 +550,8 @@ func TestCorrectExpense_NotFound(t *testing.T) { _, err := svc.CorrectExpense(context.Background(), "user-1", "exp-missing", validCorrectRequest()) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) assert.Equal(t, 404, svcErr.Status) } @@ -563,9 +562,8 @@ func TestCorrectExpense_EmptyID(t *testing.T) { _, err := svc.CorrectExpense(context.Background(), "user-1", "", validCorrectRequest()) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } func TestCorrectExpense_ValidationErrors(t *testing.T) { @@ -593,9 +591,8 @@ func TestCorrectExpense_ValidationErrors(t *testing.T) { _, err := svc.CorrectExpense(context.Background(), "user-1", "exp-1", req) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.NotEmpty(t, svcErr.Fields[tt.field]) }) } @@ -650,9 +647,8 @@ func TestGetCorrectionHistory_NotFound(t *testing.T) { _, err := svc.GetCorrectionHistory(context.Background(), "user-1", "exp-missing") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) } func TestGetCorrectionHistory_EmptyID(t *testing.T) { @@ -662,9 +658,8 @@ func TestGetCorrectionHistory_EmptyID(t *testing.T) { _, err := svc.GetCorrectionHistory(context.Background(), "user-1", "") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } // --- GetProRataGroup tests --- @@ -693,9 +688,8 @@ func TestGetProRataGroup_EmptyGroupID(t *testing.T) { _, err := svc.GetProRataGroup(context.Background(), "user-1", "") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } // --- AnonymizeAllUserExpenses tests --- @@ -735,9 +729,8 @@ func TestAnonymizeAllUserExpenses_EmptyUserID(t *testing.T) { err := svc.AnonymizeAllUserExpenses(context.Background(), "") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Equal(t, 400, svcErr.Status) } diff --git a/services/expense/internal/service/stream_test.go b/services/expense/internal/service/stream_test.go index bbf6a896..672ff107 100644 --- a/services/expense/internal/service/stream_test.go +++ b/services/expense/internal/service/stream_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/repository" ) @@ -170,9 +171,8 @@ func TestStreamAllUserExpenses_ValidationErrorForEmptyUserID(t *testing.T) { err := svc.StreamAllUserExpenses(context.Background(), "", 10, func(*model.Expense) error { return nil }) - var svcErr *ServiceError - require.ErrorAs(t, err, &svcErr) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } func TestStreamAllUserExpenses_DefaultsPageSizeWhenNonPositive(t *testing.T) { @@ -220,7 +220,7 @@ func TestStreamAllUserExpenses_ProducerLookAheadBoundedToOnePage(t *testing.T) { page: page, next: repository.ExpenseCursor{CreatedAt: "2026-05-01T00:00:00Z", ID: "exp-9"}, } - svc := NewExpenseService(repo, slog.New(slog.NewJSONHandler(io.Discard, nil))) + svc := NewExpenseService(repo, time.Now, slog.New(slog.NewJSONHandler(io.Discard, nil))) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/services/expense/internal/service/suggestions.go b/services/expense/internal/service/suggestions.go index ba9f3da7..fa1f8225 100644 --- a/services/expense/internal/service/suggestions.go +++ b/services/expense/internal/service/suggestions.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" ) @@ -20,13 +21,13 @@ type suggestionGroup struct { // GetExpenseSuggestions returns active-only, exact-name suggestions for a user. func (s *ExpenseService) GetExpenseSuggestions(ctx context.Context, req *model.ExpenseSuggestionRequest) (*model.ExpenseSuggestionListResponse, error) { if req.UserID == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "user_id is required", Status: 400} + return nil, apierr.Validation("user_id is required", nil) } if req.Page < 1 { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "page must be positive", Status: 400} + return nil, apierr.Validation("page must be positive", nil) } if req.PageSize < 1 || req.PageSize > maxSuggestionPageSize { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "pageSize must be between 1 and 100", Status: 400} + return nil, apierr.Validation("pageSize must be between 1 and 100", nil) } inputs, err := s.repo.GetActiveExpenseSuggestionInputs(ctx, req.UserID) diff --git a/services/expense/internal/service/suggestions_test.go b/services/expense/internal/service/suggestions_test.go index c6a51272..82ed88e3 100644 --- a/services/expense/internal/service/suggestions_test.go +++ b/services/expense/internal/service/suggestions_test.go @@ -10,14 +10,13 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" ) func TestGetExpenseSuggestions_AggregatesActiveInputsAndPaginates(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ {ID: "exp-1", Name: "Groceries", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-old", CreatedAt: "2026-05-20T10:00:00Z"}, @@ -43,9 +42,7 @@ func TestGetExpenseSuggestions_AggregatesActiveInputsAndPaginates(t *testing.T) func TestGetExpenseSuggestions_CountsProRataGroupOnce(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ {ID: "exp-1", Name: "Insurance", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-01T10:00:00Z", IsProRata: true, ProRataGroup: "group-1"}, @@ -62,9 +59,7 @@ func TestGetExpenseSuggestions_CountsProRataGroupOnce(t *testing.T) { func TestGetExpenseSuggestions_RankingTieBreakers(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ {ID: "exp-1", Name: "Coffee", Amount: 500, Currency: "USD", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-30T10:00:00Z"}, @@ -84,9 +79,7 @@ func TestGetExpenseSuggestions_RankingTieBreakers(t *testing.T) { func TestGetExpenseSuggestions_UsesIDTieBreakerForLatestValues(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ {ID: "exp-1", Name: "Lunch", Amount: 1200, Currency: "USD", ExpenseType: "desires", TagID: "tag-old", CreatedAt: "2026-05-31T10:00:00Z"}, @@ -104,9 +97,7 @@ func TestGetExpenseSuggestions_UsesIDTieBreakerForLatestValues(t *testing.T) { func TestGetExpenseSuggestions_AssignsRecencyBucketWeights(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ {ID: "exp-1", Name: "Today", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-1", CreatedAt: "2026-06-01T01:00:00Z"}, @@ -126,9 +117,7 @@ func TestGetExpenseSuggestions_AssignsRecencyBucketWeights(t *testing.T) { func TestGetExpenseSuggestions_PaginatesRankedSuggestions(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) inputs := make([]*model.ExpenseSuggestionInput, 0, 51) for i := 0; i < 51; i++ { inputs = append(inputs, &model.ExpenseSuggestionInput{ @@ -160,9 +149,7 @@ func TestGetExpenseSuggestions_PaginatesRankedSuggestions(t *testing.T) { func TestGetExpenseSuggestions_ReturnsEmptyPageWhenPageStartExceedsTotal(t *testing.T) { repo := new(mockExpenseRepository) - svc := newTestService(repo).WithClock(func() time.Time { - return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) - }) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ {ID: "exp-1", Name: "Groceries", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, @@ -211,9 +198,8 @@ func TestGetExpenseSuggestions_ValidationAndRepositoryFailure(t *testing.T) { svc := newTestService(new(mockExpenseRepository)) _, err := svc.GetExpenseSuggestions(context.Background(), tt.req) require.Error(t, err) - var svcErr *ServiceError - require.ErrorAs(t, err, &svcErr) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) }) } diff --git a/services/expense/proto/expense.proto b/services/expense/proto/expense.proto index 72b24675..afbff62b 100644 --- a/services/expense/proto/expense.proto +++ b/services/expense/proto/expense.proto @@ -29,10 +29,8 @@ service ExpenseService { // GDPR: anonymize all expenses for a user (field redaction, not deletion) rpc AnonymizeAllUserExpenses(AnonymizeRequest) returns (AnonymizeResponse); - // Stubs: implemented in later tickets + // Correction flow (mutates the ledger; REST is the primary consumer). rpc CorrectExpense(CorrectExpenseRequest) returns (ExpenseResponse); - rpc GetCorrectionHistory(GetCorrectionHistoryRequest) returns (CorrectionHistoryResponse); - rpc GetProRataGroup(GetProRataGroupRequest) returns (ExpenseListResponse); } // --------------------------------------------------------------------------- @@ -113,7 +111,7 @@ message GetExpenseRequest { } // --------------------------------------------------------------------------- -// Stubs (messages defined, RPCs return Unimplemented) +// CorrectExpense request // --------------------------------------------------------------------------- message CorrectExpenseRequest { @@ -126,6 +124,14 @@ message CorrectExpenseRequest { string expense_date = 7; } +// --------------------------------------------------------------------------- +// Messages retained without an RPC. GetCorrectionHistory and GetProRataGroup +// were removed (they hardcoded an empty user scope and had no consumer); the +// correction-history and pro-rata reads are served over REST. These generated +// types are kept because an out-of-service gRPC client mock still references +// them. +// --------------------------------------------------------------------------- + message GetCorrectionHistoryRequest { string expense_id = 1; } diff --git a/services/expense/proto/expensepb/expense.pb.go b/services/expense/proto/expensepb/expense.pb.go index 9f1c2ca4..36c02be6 100644 --- a/services/expense/proto/expensepb/expense.pb.go +++ b/services/expense/proto/expensepb/expense.pb.go @@ -1119,7 +1119,7 @@ const file_proto_expense_proto_rawDesc = "" + "\x05count\x18\x01 \x01(\x03R\x05count\"+\n" + "\x10AnonymizeRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\"\x13\n" + - "\x11AnonymizeResponse2\x84\x06\n" + + "\x11AnonymizeResponse2\xd0\x04\n" + "\x0eExpenseService\x12H\n" + "\rCreateExpense\x12\x1d.expense.CreateExpenseRequest\x1a\x18.expense.ExpenseResponse\x12Z\n" + "\x14GetExpensesForPeriod\x12$.expense.GetExpensesForPeriodRequest\x1a\x1c.expense.ExpenseListResponse\x12B\n" + @@ -1128,9 +1128,7 @@ const file_proto_expense_proto_rawDesc = "" + "\x12CountExpensesByTag\x12\".expense.CountExpensesByTagRequest\x1a#.expense.CountExpensesByTagResponse\x12V\n" + "\x15StreamAllUserExpenses\x12%.expense.StreamAllUserExpensesRequest\x1a\x14.expense.ExpenseData0\x01\x12Q\n" + "\x18AnonymizeAllUserExpenses\x12\x19.expense.AnonymizeRequest\x1a\x1a.expense.AnonymizeResponse\x12J\n" + - "\x0eCorrectExpense\x12\x1e.expense.CorrectExpenseRequest\x1a\x18.expense.ExpenseResponse\x12`\n" + - "\x14GetCorrectionHistory\x12$.expense.GetCorrectionHistoryRequest\x1a\".expense.CorrectionHistoryResponse\x12P\n" + - "\x0fGetProRataGroup\x12\x1f.expense.GetProRataGroupRequest\x1a\x1c.expense.ExpenseListResponseB?Z=github.com/ItsThompson/gofin/services/expense/proto/expensepbb\x06proto3" + "\x0eCorrectExpense\x12\x1e.expense.CorrectExpenseRequest\x1a\x18.expense.ExpenseResponseB?Z=github.com/ItsThompson/gofin/services/expense/proto/expensepbb\x06proto3" var ( file_proto_expense_proto_rawDescOnce sync.Once @@ -1173,19 +1171,15 @@ var file_proto_expense_proto_depIdxs = []int32{ 10, // 7: expense.ExpenseService.StreamAllUserExpenses:input_type -> expense.StreamAllUserExpensesRequest 13, // 8: expense.ExpenseService.AnonymizeAllUserExpenses:input_type -> expense.AnonymizeRequest 6, // 9: expense.ExpenseService.CorrectExpense:input_type -> expense.CorrectExpenseRequest - 7, // 10: expense.ExpenseService.GetCorrectionHistory:input_type -> expense.GetCorrectionHistoryRequest - 9, // 11: expense.ExpenseService.GetProRataGroup:input_type -> expense.GetProRataGroupRequest - 2, // 12: expense.ExpenseService.CreateExpense:output_type -> expense.ExpenseResponse - 4, // 13: expense.ExpenseService.GetExpensesForPeriod:output_type -> expense.ExpenseListResponse - 2, // 14: expense.ExpenseService.GetExpense:output_type -> expense.ExpenseResponse - 12, // 15: expense.ExpenseService.CountExpensesByTag:output_type -> expense.CountExpensesByTagResponse - 0, // 16: expense.ExpenseService.StreamAllUserExpenses:output_type -> expense.ExpenseData - 14, // 17: expense.ExpenseService.AnonymizeAllUserExpenses:output_type -> expense.AnonymizeResponse - 2, // 18: expense.ExpenseService.CorrectExpense:output_type -> expense.ExpenseResponse - 8, // 19: expense.ExpenseService.GetCorrectionHistory:output_type -> expense.CorrectionHistoryResponse - 4, // 20: expense.ExpenseService.GetProRataGroup:output_type -> expense.ExpenseListResponse - 12, // [12:21] is the sub-list for method output_type - 3, // [3:12] is the sub-list for method input_type + 2, // 10: expense.ExpenseService.CreateExpense:output_type -> expense.ExpenseResponse + 4, // 11: expense.ExpenseService.GetExpensesForPeriod:output_type -> expense.ExpenseListResponse + 2, // 12: expense.ExpenseService.GetExpense:output_type -> expense.ExpenseResponse + 12, // 13: expense.ExpenseService.CountExpensesByTag:output_type -> expense.CountExpensesByTagResponse + 0, // 14: expense.ExpenseService.StreamAllUserExpenses:output_type -> expense.ExpenseData + 14, // 15: expense.ExpenseService.AnonymizeAllUserExpenses:output_type -> expense.AnonymizeResponse + 2, // 16: expense.ExpenseService.CorrectExpense:output_type -> expense.ExpenseResponse + 10, // [10:17] is the sub-list for method output_type + 3, // [3:10] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name diff --git a/services/expense/proto/expensepb/expense_grpc.pb.go b/services/expense/proto/expensepb/expense_grpc.pb.go index 698a74e5..7cd3841e 100644 --- a/services/expense/proto/expensepb/expense_grpc.pb.go +++ b/services/expense/proto/expensepb/expense_grpc.pb.go @@ -26,8 +26,6 @@ const ( ExpenseService_StreamAllUserExpenses_FullMethodName = "/expense.ExpenseService/StreamAllUserExpenses" ExpenseService_AnonymizeAllUserExpenses_FullMethodName = "/expense.ExpenseService/AnonymizeAllUserExpenses" ExpenseService_CorrectExpense_FullMethodName = "/expense.ExpenseService/CorrectExpense" - ExpenseService_GetCorrectionHistory_FullMethodName = "/expense.ExpenseService/GetCorrectionHistory" - ExpenseService_GetProRataGroup_FullMethodName = "/expense.ExpenseService/GetProRataGroup" ) // ExpenseServiceClient is the client API for ExpenseService service. @@ -44,13 +42,18 @@ type ExpenseServiceClient interface { // for a user in chronological order (created_at ASC, id ASC). The server pages // internally with a keyset cursor, bounding server memory to O(page_size); the // client writes rows incrementally. + // + // WIRE BREAK: this replaced a unary GetAllUserExpenses RPC (renamed, request + // message swapped, field number 2 reused for page_size). It is an intentional + // atomic internal break: the sole consumer (datarights export) is cut over in + // the same change and no proto bytes are persisted, so there is no + // cross-version wire path and the reserve-field rules for incremental rollout + // do not apply. StreamAllUserExpenses(ctx context.Context, in *StreamAllUserExpensesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExpenseData], error) // GDPR: anonymize all expenses for a user (field redaction, not deletion) AnonymizeAllUserExpenses(ctx context.Context, in *AnonymizeRequest, opts ...grpc.CallOption) (*AnonymizeResponse, error) - // Stubs: implemented in later tickets + // Correction flow (mutates the ledger; REST is the primary consumer). CorrectExpense(ctx context.Context, in *CorrectExpenseRequest, opts ...grpc.CallOption) (*ExpenseResponse, error) - GetCorrectionHistory(ctx context.Context, in *GetCorrectionHistoryRequest, opts ...grpc.CallOption) (*CorrectionHistoryResponse, error) - GetProRataGroup(ctx context.Context, in *GetProRataGroupRequest, opts ...grpc.CallOption) (*ExpenseListResponse, error) } type expenseServiceClient struct { @@ -140,26 +143,6 @@ func (c *expenseServiceClient) CorrectExpense(ctx context.Context, in *CorrectEx return out, nil } -func (c *expenseServiceClient) GetCorrectionHistory(ctx context.Context, in *GetCorrectionHistoryRequest, opts ...grpc.CallOption) (*CorrectionHistoryResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CorrectionHistoryResponse) - err := c.cc.Invoke(ctx, ExpenseService_GetCorrectionHistory_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *expenseServiceClient) GetProRataGroup(ctx context.Context, in *GetProRataGroupRequest, opts ...grpc.CallOption) (*ExpenseListResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpenseListResponse) - err := c.cc.Invoke(ctx, ExpenseService_GetProRataGroup_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - // ExpenseServiceServer is the server API for ExpenseService service. // All implementations must embed UnimplementedExpenseServiceServer // for forward compatibility. @@ -174,13 +157,18 @@ type ExpenseServiceServer interface { // for a user in chronological order (created_at ASC, id ASC). The server pages // internally with a keyset cursor, bounding server memory to O(page_size); the // client writes rows incrementally. + // + // WIRE BREAK: this replaced a unary GetAllUserExpenses RPC (renamed, request + // message swapped, field number 2 reused for page_size). It is an intentional + // atomic internal break: the sole consumer (datarights export) is cut over in + // the same change and no proto bytes are persisted, so there is no + // cross-version wire path and the reserve-field rules for incremental rollout + // do not apply. StreamAllUserExpenses(*StreamAllUserExpensesRequest, grpc.ServerStreamingServer[ExpenseData]) error // GDPR: anonymize all expenses for a user (field redaction, not deletion) AnonymizeAllUserExpenses(context.Context, *AnonymizeRequest) (*AnonymizeResponse, error) - // Stubs: implemented in later tickets + // Correction flow (mutates the ledger; REST is the primary consumer). CorrectExpense(context.Context, *CorrectExpenseRequest) (*ExpenseResponse, error) - GetCorrectionHistory(context.Context, *GetCorrectionHistoryRequest) (*CorrectionHistoryResponse, error) - GetProRataGroup(context.Context, *GetProRataGroupRequest) (*ExpenseListResponse, error) mustEmbedUnimplementedExpenseServiceServer() } @@ -212,12 +200,6 @@ func (UnimplementedExpenseServiceServer) AnonymizeAllUserExpenses(context.Contex func (UnimplementedExpenseServiceServer) CorrectExpense(context.Context, *CorrectExpenseRequest) (*ExpenseResponse, error) { return nil, status.Error(codes.Unimplemented, "method CorrectExpense not implemented") } -func (UnimplementedExpenseServiceServer) GetCorrectionHistory(context.Context, *GetCorrectionHistoryRequest) (*CorrectionHistoryResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetCorrectionHistory not implemented") -} -func (UnimplementedExpenseServiceServer) GetProRataGroup(context.Context, *GetProRataGroupRequest) (*ExpenseListResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetProRataGroup not implemented") -} func (UnimplementedExpenseServiceServer) mustEmbedUnimplementedExpenseServiceServer() {} func (UnimplementedExpenseServiceServer) testEmbeddedByValue() {} @@ -358,42 +340,6 @@ func _ExpenseService_CorrectExpense_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } -func _ExpenseService_GetCorrectionHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetCorrectionHistoryRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ExpenseServiceServer).GetCorrectionHistory(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ExpenseService_GetCorrectionHistory_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ExpenseServiceServer).GetCorrectionHistory(ctx, req.(*GetCorrectionHistoryRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ExpenseService_GetProRataGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetProRataGroupRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ExpenseServiceServer).GetProRataGroup(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ExpenseService_GetProRataGroup_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ExpenseServiceServer).GetProRataGroup(ctx, req.(*GetProRataGroupRequest)) - } - return interceptor(ctx, in, info, handler) -} - // ExpenseService_ServiceDesc is the grpc.ServiceDesc for ExpenseService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -425,14 +371,6 @@ var ExpenseService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CorrectExpense", Handler: _ExpenseService_CorrectExpense_Handler, }, - { - MethodName: "GetCorrectionHistory", - Handler: _ExpenseService_GetCorrectionHistory_Handler, - }, - { - MethodName: "GetProRataGroup", - Handler: _ExpenseService_GetProRataGroup_Handler, - }, }, Streams: []grpc.StreamDesc{ { diff --git a/services/finance/Dockerfile b/services/finance/Dockerfile index e19477cc..e8a282c9 100644 --- a/services/finance/Dockerfile +++ b/services/finance/Dockerfile @@ -14,6 +14,10 @@ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ COPY expense/go.mod expense/go.sum* ./expense/ COPY perf/go.mod ./perf/ +COPY serverkit/go.mod serverkit/go.sum* ./serverkit/ +COPY apierr/go.mod apierr/go.sum* ./apierr/ +COPY httpx/go.mod httpx/go.sum* ./httpx/ +COPY pgutil/go.mod pgutil/go.sum* ./pgutil/ RUN --mount=type=cache,target=/go/pkg/mod \ cd finance && GOWORK=off go mod download @@ -25,6 +29,10 @@ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ COPY expense/ ./expense/ COPY perf/ ./perf/ +COPY serverkit/ ./serverkit/ +COPY apierr/ ./apierr/ +COPY httpx/ ./httpx/ +COPY pgutil/ ./pgutil/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/finance/cmd/main.go b/services/finance/cmd/main.go index 3e5ebcdd..0703a09b 100644 --- a/services/finance/cmd/main.go +++ b/services/finance/cmd/main.go @@ -11,27 +11,24 @@ import ( "syscall" "time" - "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + expensepb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" "github.com/ItsThompson/gofin/services/finance/db/migrations" "github.com/ItsThompson/gofin/services/finance/internal/config" "github.com/ItsThompson/gofin/services/finance/internal/db" "github.com/ItsThompson/gofin/services/finance/internal/handler" "github.com/ItsThompson/gofin/services/finance/internal/repository" "github.com/ItsThompson/gofin/services/finance/internal/service" - "github.com/ItsThompson/gofin/services/dbmigrate" - "github.com/ItsThompson/gofin/services/healthcheck" - "github.com/ItsThompson/gofin/services/metrics" - expensepb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" + "github.com/ItsThompson/gofin/services/healthcheck" + "github.com/ItsThompson/gofin/services/serverkit" ) func main() { if healthcheck.ShouldRun(os.Args) { - os.Exit(healthcheck.Run("8083")) + os.Exit(healthcheck.Run(config.ResolveRESTPort())) } if err := run(); err != nil { @@ -51,41 +48,17 @@ func run() error { } // Set up structured logging - logLevel := slog.LevelInfo - switch cfg.LogLevel { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - } - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) - logger = logger.With(slog.String("service", "finance")) + logger := serverkit.NewLogger(cfg.LogLevel, "finance") slog.SetDefault(logger) - // Run database migrations (embedded in binary via go:embed) - if err := dbmigrate.RunWithFS(cfg.DBUrl, migrations.FS, "."); err != nil { - return fmt.Errorf("running migrations: %w", err) - } - - // Connect to PostgreSQL - pool, err := pgxpool.New(ctx, cfg.DBUrl) + // Run embedded migrations, open the pool, and ping it. + pool, err := serverkit.ConnectPostgres(ctx, cfg.DBUrl, migrations.FS) if err != nil { - return fmt.Errorf("connecting to database: %w", err) + return err } defer pool.Close() - - if err := pool.Ping(ctx); err != nil { - return fmt.Errorf("pinging database: %w", err) - } logger.Info("connected to PostgreSQL") - // Log the expense service address (connection not required yet per ticket notes) - logger.Info("expense service configured", - slog.String("addr", cfg.ExpenseServiceAddr), - ) - // Connect to expense service gRPC for dashboard aggregation expenseConn, err := grpc.NewClient( cfg.ExpenseServiceAddr, @@ -106,12 +79,10 @@ func run() error { expenseClient := service.NewGRPCExpenseClient( expensepb.NewExpenseServiceClient(expenseConn), ) - financeSvc := service.NewFinanceService(repo, txBeginner, logger).WithExpenseClient(expenseClient) + financeSvc := service.NewFinanceService(repo, txBeginner, expenseClient, time.Now, logger) - // Start gRPC server - grpcServer := grpc.NewServer( - grpc.UnaryInterceptor(metrics.UnaryServerInterceptor()), - ) + // Build the gRPC server and pre-bind its listener so a bind failure surfaces. + grpcServer := serverkit.NewGRPCServer() grpcHandler := handler.NewGRPCHandler(financeSvc, logger) pb.RegisterFinanceServiceServer(grpcServer, grpcHandler) @@ -120,29 +91,8 @@ func run() error { return fmt.Errorf("listening on gRPC port %s: %w", cfg.GRPCPort, err) } - go func() { - logger.Info("gRPC server starting", - slog.String("port", cfg.GRPCPort), - ) - if err := grpcServer.Serve(grpcLis); err != nil { - logger.Error("gRPC server failed", slog.String("error", err.Error())) - } - }() - - // Start REST server - if cfg.IsProduction() { - gin.SetMode(gin.ReleaseMode) - } - router := gin.New() - router.Use(gin.Recovery()) - router.Use(metrics.HTTPMetrics()) - - metrics.Register(router) - - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) - + // Build the REST router and server. + router := serverkit.NewRouter("finance", cfg.IsProduction()) restHandler := handler.NewRESTHandler(financeSvc, logger) restHandler.RegisterRoutes(router) @@ -151,32 +101,12 @@ func run() error { Handler: router, } - go func() { - logger.Info("REST server starting", - slog.String("port", cfg.RESTPort), - ) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("REST server failed", slog.String("error", err.Error())) - } - }() - logger.Info("finance service ready", slog.String("rest_port", cfg.RESTPort), slog.String("grpc_port", cfg.GRPCPort), ) - // Wait for shutdown signal - <-ctx.Done() - logger.Info("shutting down finance service") - - // Graceful shutdown: give in-flight requests up to 10 seconds - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - grpcServer.GracefulStop() - if err := httpServer.Shutdown(shutdownCtx); err != nil { - logger.Error("REST server shutdown error", slog.String("error", err.Error())) - } - - return nil + // Serve blocks until ctx is cancelled or a server fails fatally (e.g. a REST + // bind failure), returning that error so the process exits non-zero (C5). + return serverkit.Serve(ctx, httpServer, grpcServer, grpcLis) } diff --git a/services/finance/go.mod b/services/finance/go.mod index 242589d3..38759d47 100644 --- a/services/finance/go.mod +++ b/services/finance/go.mod @@ -4,11 +4,13 @@ go 1.26 require ( github.com/ItsThompson/gofin/services/access v0.0.0 - github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 + github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/expense v0.0.0-00010101000000-000000000000 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 - github.com/ItsThompson/gofin/services/metrics v0.0.0 + github.com/ItsThompson/gofin/services/httpx v0.0.0 github.com/ItsThompson/gofin/services/perf v0.0.0 + github.com/ItsThompson/gofin/services/pgutil v0.0.0 + github.com/ItsThompson/gofin/services/serverkit v0.0.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 @@ -30,7 +32,17 @@ replace github.com/ItsThompson/gofin/services/expense => ../expense replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate +replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit + +replace github.com/ItsThompson/gofin/services/apierr => ../apierr + +replace github.com/ItsThompson/gofin/services/httpx => ../httpx + +replace github.com/ItsThompson/gofin/services/pgutil => ../pgutil + require ( + github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect + github.com/ItsThompson/gofin/services/metrics v0.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect diff --git a/services/finance/internal/config/config.go b/services/finance/internal/config/config.go index 625c4c8c..1de969c7 100644 --- a/services/finance/internal/config/config.go +++ b/services/finance/internal/config/config.go @@ -5,6 +5,21 @@ import ( "os" ) +// DefaultRESTPort is the single source of truth for the finance REST port +// default. It backs both the listener (via Load) and the --healthcheck probe +// (via ResolveRESTPort) so the two never desync (US-PLATFORM-05). +const DefaultRESTPort = "8083" + +// ResolveRESTPort returns the REST port from REST_PORT, falling back to +// DefaultRESTPort. The --healthcheck branch runs before Load, so it calls this +// to probe the same port the listener will bind. +func ResolveRESTPort() string { + if p := os.Getenv("REST_PORT"); p != "" { + return p + } + return DefaultRESTPort +} + // Config holds all configuration for the finance service, loaded from environment variables. type Config struct { DBUrl string @@ -38,10 +53,7 @@ func Load() (*Config, error) { environment = "development" } - restPort := os.Getenv("REST_PORT") - if restPort == "" { - restPort = "8083" - } + restPort := ResolveRESTPort() grpcPort := os.Getenv("GRPC_PORT") if grpcPort == "" { diff --git a/services/finance/internal/handler/grpc.go b/services/finance/internal/handler/grpc.go index 45f36a22..366c891d 100644 --- a/services/finance/internal/handler/grpc.go +++ b/services/finance/internal/handler/grpc.go @@ -2,19 +2,21 @@ package handler import ( "context" + "errors" "log/slog" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/service" pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) -// GRPCHandler implements the FinanceService gRPC server. -// CompleteOnboarding and GetDefaults are implemented. -// All other RPCs return Unimplemented (stubs for later tickets). +// GRPCHandler implements the FinanceService gRPC server. RPCs without an +// explicit method are served by the embedded UnimplementedFinanceServiceServer, +// which returns codes.Unimplemented. type GRPCHandler struct { pb.UnimplementedFinanceServiceServer financeService *service.FinanceService @@ -32,8 +34,9 @@ func NewGRPCHandler(financeService *service.FinanceService, logger *slog.Logger) func (h *GRPCHandler) GetDefaults(ctx context.Context, req *pb.GetDefaultsRequest) (*pb.DefaultsResponse, error) { defaults, err := h.financeService.GetDefaults(ctx, req.GetUserId()) if err != nil { - if svcErr, ok := err.(*service.ServiceError); ok && svcErr.Code == model.ErrNotFound { - return nil, status.Error(codes.NotFound, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) && apiErr.Code == apierr.CodeNotFound { + return nil, status.Error(codes.NotFound, apiErr.Message) } return nil, status.Error(codes.Internal, "failed to get defaults") } @@ -59,8 +62,9 @@ func (h *GRPCHandler) CompleteOnboarding(ctx context.Context, req *pb.CompleteOn Currency: req.GetCurrency(), }) if err != nil { - if svcErr, ok := err.(*service.ServiceError); ok { - return nil, status.Error(codes.InvalidArgument, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) { + return nil, status.Error(codes.InvalidArgument, apiErr.Message) } return nil, status.Error(codes.Internal, "failed to complete onboarding") } @@ -77,32 +81,12 @@ func (h *GRPCHandler) CompleteOnboarding(ctx context.Context, req *pb.CompleteOn }, nil } -// Stub RPCs: return Unimplemented for later tickets. -func (h *GRPCHandler) UpdateDefaults(ctx context.Context, req *pb.UpdateDefaultsRequest) (*pb.DefaultsResponse, error) { - return nil, status.Error(codes.Unimplemented, "UpdateDefaults not yet implemented") -} - -func (h *GRPCHandler) GetCurrentPeriod(ctx context.Context, req *pb.GetCurrentPeriodRequest) (*pb.PeriodResponse, error) { - return nil, status.Error(codes.Unimplemented, "GetCurrentPeriod not yet implemented") -} - -func (h *GRPCHandler) CreatePeriod(ctx context.Context, req *pb.CreatePeriodRequest) (*pb.PeriodResponse, error) { - return nil, status.Error(codes.Unimplemented, "CreatePeriod not yet implemented") -} - -func (h *GRPCHandler) UpdatePeriod(ctx context.Context, req *pb.UpdatePeriodRequest) (*pb.PeriodResponse, error) { - return nil, status.Error(codes.Unimplemented, "UpdatePeriod not yet implemented") -} - -func (h *GRPCHandler) ListPeriods(ctx context.Context, req *pb.ListPeriodsRequest) (*pb.PeriodListResponse, error) { - return nil, status.Error(codes.Unimplemented, "ListPeriods not yet implemented") -} - func (h *GRPCHandler) ListTags(ctx context.Context, req *pb.ListTagsRequest) (*pb.TagListResponse, error) { tags, err := h.financeService.ListTags(ctx, req.GetUserId()) if err != nil { - if svcErr, ok := err.(*service.ServiceError); ok { - return nil, status.Error(codes.Internal, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) { + return nil, status.Error(codes.Internal, apiErr.Message) } return nil, status.Error(codes.Internal, "failed to list tags") } @@ -123,12 +107,13 @@ func (h *GRPCHandler) ListTags(ctx context.Context, req *pb.ListTagsRequest) (*p func (h *GRPCHandler) CreateTag(ctx context.Context, req *pb.CreateTagRequest) (*pb.TagResponse, error) { tag, err := h.financeService.CreateTag(ctx, req.GetUserId(), &model.CreateTagRequest{Name: req.GetName()}) if err != nil { - if svcErr, ok := err.(*service.ServiceError); ok { - switch svcErr.Code { - case model.ErrValidationError: - return nil, status.Error(codes.InvalidArgument, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) { + switch apiErr.Code { + case apierr.CodeValidation: + return nil, status.Error(codes.InvalidArgument, apiErr.Message) case model.ErrDuplicateTag: - return nil, status.Error(codes.AlreadyExists, svcErr.Message) + return nil, status.Error(codes.AlreadyExists, apiErr.Message) } } return nil, status.Error(codes.Internal, "failed to create tag") @@ -140,14 +125,15 @@ func (h *GRPCHandler) CreateTag(ctx context.Context, req *pb.CreateTagRequest) ( func (h *GRPCHandler) UpdateTag(ctx context.Context, req *pb.UpdateTagRequest) (*pb.TagResponse, error) { tag, err := h.financeService.UpdateTag(ctx, req.GetUserId(), req.GetTagId(), &model.UpdateTagRequest{Name: req.GetName()}) if err != nil { - if svcErr, ok := err.(*service.ServiceError); ok { - switch svcErr.Code { - case model.ErrValidationError: - return nil, status.Error(codes.InvalidArgument, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) { + switch apiErr.Code { + case apierr.CodeValidation: + return nil, status.Error(codes.InvalidArgument, apiErr.Message) case model.ErrDuplicateTag: - return nil, status.Error(codes.AlreadyExists, svcErr.Message) - case model.ErrNotFound: - return nil, status.Error(codes.NotFound, svcErr.Message) + return nil, status.Error(codes.AlreadyExists, apiErr.Message) + case apierr.CodeNotFound: + return nil, status.Error(codes.NotFound, apiErr.Message) } } return nil, status.Error(codes.Internal, "failed to update tag") @@ -159,14 +145,15 @@ func (h *GRPCHandler) UpdateTag(ctx context.Context, req *pb.UpdateTagRequest) ( func (h *GRPCHandler) DeleteTag(ctx context.Context, req *pb.DeleteTagRequest) (*pb.DeleteTagResponse, error) { err := h.financeService.DeleteTag(ctx, req.GetUserId(), req.GetTagId()) if err != nil { - if svcErr, ok := err.(*service.ServiceError); ok { - switch svcErr.Code { - case model.ErrNotFound: - return nil, status.Error(codes.NotFound, svcErr.Message) + var apiErr *apierr.Error + if errors.As(err, &apiErr) { + switch apiErr.Code { + case apierr.CodeNotFound: + return nil, status.Error(codes.NotFound, apiErr.Message) case model.ErrDefaultTag: - return nil, status.Error(codes.PermissionDenied, svcErr.Message) + return nil, status.Error(codes.PermissionDenied, apiErr.Message) case model.ErrTagInUse: - return nil, status.Error(codes.FailedPrecondition, svcErr.Message) + return nil, status.Error(codes.FailedPrecondition, apiErr.Message) } } return nil, status.Error(codes.Internal, "failed to delete tag") @@ -175,34 +162,6 @@ func (h *GRPCHandler) DeleteTag(ctx context.Context, req *pb.DeleteTagRequest) ( return &pb.DeleteTagResponse{}, nil } -func (h *GRPCHandler) CheckTagUsage(ctx context.Context, req *pb.CheckTagUsageRequest) (*pb.TagUsageResponse, error) { - return nil, status.Error(codes.Unimplemented, "CheckTagUsage not yet implemented") -} - -func (h *GRPCHandler) CreateProRataExpense(ctx context.Context, req *pb.CreateProRataExpenseRequest) (*pb.ProRataResponse, error) { - return nil, status.Error(codes.Unimplemented, "CreateProRataExpense not yet implemented") -} - -func (h *GRPCHandler) GetUpcomingProRata(ctx context.Context, req *pb.GetUpcomingProRataRequest) (*pb.UpcomingProRataListResponse, error) { - return nil, status.Error(codes.Unimplemented, "GetUpcomingProRata not yet implemented") -} - -func (h *GRPCHandler) GetPeriodSummary(ctx context.Context, req *pb.GetPeriodSummaryRequest) (*pb.PeriodSummaryResponse, error) { - return nil, status.Error(codes.Unimplemented, "GetPeriodSummary not yet implemented") -} - -func (h *GRPCHandler) GetSpendingByTag(ctx context.Context, req *pb.GetSpendingByTagRequest) (*pb.TagSpendingListResponse, error) { - return nil, status.Error(codes.Unimplemented, "GetSpendingByTag not yet implemented") -} - -func (h *GRPCHandler) GetCumulativeSpend(ctx context.Context, req *pb.GetCumulativeSpendRequest) (*pb.CumulativeSpendResponse, error) { - return nil, status.Error(codes.Unimplemented, "GetCumulativeSpend not yet implemented") -} - -func (h *GRPCHandler) GetHistoricalComparison(ctx context.Context, req *pb.GetHistoricalComparisonRequest) (*pb.HistoricalComparisonResponse, error) { - return nil, status.Error(codes.Unimplemented, "GetHistoricalComparison not yet implemented") -} - func (h *GRPCHandler) GetAllUserData(ctx context.Context, req *pb.GetAllUserDataRequest) (*pb.AllUserDataResponse, error) { userID := req.GetUserId() if userID == "" { diff --git a/services/finance/internal/handler/grpc_test.go b/services/finance/internal/handler/grpc_test.go index 4a48d993..ca9a25fe 100644 --- a/services/finance/internal/handler/grpc_test.go +++ b/services/finance/internal/handler/grpc_test.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/service" pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" @@ -21,10 +22,31 @@ import ( func setupGRPCHandler(repo *mockFinanceRepository) *GRPCHandler { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, new(mockTxBeginner), logger) + financeSvc := service.NewFinanceService(repo, new(mockTxBeginner), nil, time.Now, logger) return NewGRPCHandler(financeSvc, logger) } +// TestGetDefaults_WrappedTypedErrorClassifies locks in C7: a typed *apierr.Error +// that has been %w-wrapped before reaching the gRPC handler must still classify +// via errors.As (not collapse to codes.Internal). The service wraps every repo +// error with %w ("getting defaults: %w"), so a typed NOT_FOUND returned by the +// repo reaches the handler wrapped; the handler must still map it to NotFound. +func TestGetDefaults_WrappedTypedErrorClassifies(t *testing.T) { + repo := new(mockFinanceRepository) + handler := setupGRPCHandler(repo) + + repo.On("GetDefaults", mock.Anything, "user-1"). + Return(nil, apierr.NotFound("defaults missing")) + + resp, err := handler.GetDefaults(context.Background(), &pb.GetDefaultsRequest{UserId: "user-1"}) + assert.Nil(t, resp) + require.Error(t, err) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + func TestGetAllUserData_Success(t *testing.T) { repo := new(mockFinanceRepository) handler := setupGRPCHandler(repo) @@ -140,7 +162,7 @@ func TestDeleteAllUserData_Success(t *testing.T) { tx := &mockTx{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger) + financeSvc := service.NewFinanceService(repo, txBeginner, nil, time.Now, logger) handler := NewGRPCHandler(financeSvc, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) @@ -164,7 +186,7 @@ func TestDeleteAllUserData_Idempotent(t *testing.T) { tx := &mockTx{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger) + financeSvc := service.NewFinanceService(repo, txBeginner, nil, time.Now, logger) handler := NewGRPCHandler(financeSvc, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) @@ -201,7 +223,7 @@ func TestDeleteAllUserData_DatabaseError(t *testing.T) { tx := &mockTx{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger) + financeSvc := service.NewFinanceService(repo, txBeginner, nil, time.Now, logger) handler := NewGRPCHandler(financeSvc, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) @@ -227,7 +249,7 @@ func TestDeleteAllUserData_TransactionBeginError(t *testing.T) { txBeginner := new(mockTxBeginner) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger) + financeSvc := service.NewFinanceService(repo, txBeginner, nil, time.Now, logger) handler := NewGRPCHandler(financeSvc, logger) txBeginner.On("BeginTx", mock.Anything).Return(nil, fmt.Errorf("pool exhausted")) diff --git a/services/finance/internal/handler/rest.go b/services/finance/internal/handler/rest.go index fcdabcf6..ef008ff1 100644 --- a/services/finance/internal/handler/rest.go +++ b/services/finance/internal/handler/rest.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" "strconv" @@ -8,8 +9,10 @@ import ( "github.com/gin-gonic/gin" "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/service" + "github.com/ItsThompson/gofin/services/httpx" ) // RESTHandler handles HTTP requests for the finance service. @@ -65,27 +68,19 @@ func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { // CompleteOnboarding handles POST /api/finance/onboarding. // Saves default settings and seeds default tags for the user. func (h *RESTHandler) CompleteOnboarding(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.OnboardingRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } defaults, err := h.financeService.CompleteOnboarding(c.Request.Context(), userID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -96,18 +91,14 @@ func (h *RESTHandler) CompleteOnboarding(c *gin.Context) { // GetDefaults handles GET /api/finance/defaults. func (h *RESTHandler) GetDefaults(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } defaults, err := h.financeService.GetDefaults(c.Request.Context(), userID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -119,27 +110,19 @@ func (h *RESTHandler) GetDefaults(c *gin.Context) { // UpdateDefaults handles PUT /api/finance/defaults. // Updates the user's default budget settings. Does not affect current or past periods. func (h *RESTHandler) UpdateDefaults(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.UpdateDefaultsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } defaults, err := h.financeService.UpdateDefaults(c.Request.Context(), userID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -150,46 +133,36 @@ func (h *RESTHandler) UpdateDefaults(c *gin.Context) { // GetCurrentPeriod handles GET /api/finance/periods/current?year=YYYY&month=MM. func (h *RESTHandler) GetCurrentPeriod(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } yearStr := c.Query("year") monthStr := c.Query("month") if yearStr == "" || monthStr == "" { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "year and month query parameters are required", - }) + apierr.Respond(c, apierr.Validation("year and month query parameters are required", map[string]string{ + "year": "required", + "month": "required", + })) return } year, err := strconv.ParseInt(yearStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "year must be a valid integer", - }) + apierr.Respond(c, apierr.Validation("year must be a valid integer", map[string]string{"year": "must be a valid integer"})) return } month, err := strconv.ParseInt(monthStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "month must be a valid integer", - }) + apierr.Respond(c, apierr.Validation("month must be a valid integer", map[string]string{"month": "must be a valid integer"})) return } period, svcErr := h.financeService.GetCurrentPeriod(c.Request.Context(), userID, int32(year), int32(month)) if svcErr != nil { - h.handleError(c, svcErr) + h.respondError(c, svcErr) return } @@ -201,27 +174,19 @@ func (h *RESTHandler) GetCurrentPeriod(c *gin.Context) { // CreatePeriod handles POST /api/finance/periods. // Creates a budget period, auto-creates missed months, and applies pending pro-rata. func (h *RESTHandler) CreatePeriod(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.CreatePeriodRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } result, err := h.financeService.CreatePeriodWithProRata(c.Request.Context(), userID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -231,18 +196,14 @@ func (h *RESTHandler) CreatePeriod(c *gin.Context) { // ListPeriods handles GET /api/finance/periods. // Returns all budget periods for the authenticated user, ordered by year/month descending. func (h *RESTHandler) ListPeriods(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } periods, err := h.financeService.ListPeriods(c.Request.Context(), userID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -254,18 +215,14 @@ func (h *RESTHandler) ListPeriods(c *gin.Context) { // ListTags handles GET /api/finance/tags. // Returns all tags for the authenticated user, ordered alphabetically. func (h *RESTHandler) ListTags(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } tags, err := h.financeService.ListTags(c.Request.Context(), userID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -277,27 +234,19 @@ func (h *RESTHandler) ListTags(c *gin.Context) { // CreateTag handles POST /api/finance/tags. // Creates a new custom tag. Name must be unique per user (case-insensitive), max 50 chars. func (h *RESTHandler) CreateTag(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.CreateTagRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } tag, err := h.financeService.CreateTag(c.Request.Context(), userID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -309,29 +258,21 @@ func (h *RESTHandler) CreateTag(c *gin.Context) { // UpdateTag handles PUT /api/finance/tags/:id. // Renames a tag (any tag, including defaults). func (h *RESTHandler) UpdateTag(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } tagID := c.Param("id") var req model.UpdateTagRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } tag, err := h.financeService.UpdateTag(c.Request.Context(), userID, tagID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -343,12 +284,8 @@ func (h *RESTHandler) UpdateTag(c *gin.Context) { // DeleteTag handles DELETE /api/finance/tags/:id. // Deletes a tag only if it's not a default and not referenced by expenses or schedules. func (h *RESTHandler) DeleteTag(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } @@ -356,7 +293,7 @@ func (h *RESTHandler) DeleteTag(c *gin.Context) { err := h.financeService.DeleteTag(c.Request.Context(), userID, tagID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -372,7 +309,7 @@ func (h *RESTHandler) GetPeriodSummary(c *gin.Context) { summary, err := h.financeService.GetPeriodSummary(c.Request.Context(), userID, year, month) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -390,7 +327,7 @@ func (h *RESTHandler) GetSpendingByTag(c *gin.Context) { tags, err := h.financeService.GetSpendingByTag(c.Request.Context(), userID, year, month) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -408,7 +345,7 @@ func (h *RESTHandler) GetCumulativeSpend(c *gin.Context) { points, err := h.financeService.GetCumulativeSpend(c.Request.Context(), userID, year, month) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -420,29 +357,21 @@ func (h *RESTHandler) GetCumulativeSpend(c *gin.Context) { // UpdatePeriod handles PUT /api/finance/periods/:id. // Updates the current period's budget and E/D/S split. Past periods return 403. func (h *RESTHandler) UpdatePeriod(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } periodID := c.Param("id") var req model.UpdatePeriodRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } period, err := h.financeService.UpdatePeriod(c.Request.Context(), userID, periodID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -460,7 +389,7 @@ func (h *RESTHandler) GetHistoricalComparison(c *gin.Context) { comparison, err := h.financeService.GetHistoricalComparison(c.Request.Context(), userID, year, month) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -479,16 +408,13 @@ func (h *RESTHandler) GetSpendingTrends(c *gin.Context) { monthsStr := c.DefaultQuery("months", "6") months, err := strconv.ParseInt(monthsStr, 10, 32) if err != nil || months < 1 || months > 12 { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "months must be between 1 and 12", - }) + apierr.Respond(c, apierr.Validation("months must be between 1 and 12", map[string]string{"months": "must be between 1 and 12"})) return } trends, err := h.financeService.GetSpendingTrends(c.Request.Context(), userID, year, month, int32(months)) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -500,40 +426,30 @@ func (h *RESTHandler) GetSpendingTrends(c *gin.Context) { // parseUserAndPeriodParams extracts and validates X-User-ID, year, and month from the request. // Returns (userID, year, month, ok). When ok is false, an error response has already been sent. func (h *RESTHandler) parseUserAndPeriodParams(c *gin.Context) (string, int32, int32, bool) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return "", 0, 0, false } yearStr := c.Query("year") monthStr := c.Query("month") if yearStr == "" || monthStr == "" { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "year and month query parameters are required", - }) + apierr.Respond(c, apierr.Validation("year and month query parameters are required", map[string]string{ + "year": "required", + "month": "required", + })) return "", 0, 0, false } year, err := strconv.ParseInt(yearStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "year must be a valid integer", - }) + apierr.Respond(c, apierr.Validation("year must be a valid integer", map[string]string{"year": "must be a valid integer"})) return "", 0, 0, false } month, err := strconv.ParseInt(monthStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "month must be a valid integer", - }) + apierr.Respond(c, apierr.Validation("month must be a valid integer", map[string]string{"month": "must be a valid integer"})) return "", 0, 0, false } @@ -543,27 +459,19 @@ func (h *RESTHandler) parseUserAndPeriodParams(c *gin.Context) (string, int32, i // CreateProRataExpense handles POST /api/finance/prorata. // Creates a pro-rata expense: writes first installment, schedules future ones. func (h *RESTHandler) CreateProRataExpense(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } var req model.CreateProRataRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.ApiError{ - Code: model.ErrValidationError, - Message: "Invalid request body", - }) + if !httpx.BindJSON(c, &req) { return } result, err := h.financeService.CreateProRataExpense(c.Request.Context(), userID, &req) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -573,18 +481,14 @@ func (h *RESTHandler) CreateProRataExpense(c *gin.Context) { // GetUpcomingProRata handles GET /api/finance/prorata/upcoming. // Returns all pending pro-rata schedules for the user. func (h *RESTHandler) GetUpcomingProRata(c *gin.Context) { - userID := c.GetHeader("X-User-ID") - if userID == "" { - c.JSON(http.StatusUnauthorized, model.ApiError{ - Code: model.ErrUnauthorized, - Message: "Authentication required", - }) + userID, ok := httpx.RequireUserID(c) + if !ok { return } schedules, err := h.financeService.GetUpcomingProRata(c.Request.Context(), userID) if err != nil { - h.handleError(c, err) + h.respondError(c, err) return } @@ -593,21 +497,15 @@ func (h *RESTHandler) GetUpcomingProRata(c *gin.Context) { }) } -// handleError maps service errors to HTTP responses following the ApiError contract. -func (h *RESTHandler) handleError(c *gin.Context, err error) { - if svcErr, ok := err.(*service.ServiceError); ok { - c.JSON(svcErr.Status, model.ApiError{ - Code: svcErr.Code, - Message: svcErr.Message, - }) - return - } - - h.logger.Error("unexpected error", - slog.String("error", err.Error()), - ) - c.JSON(http.StatusInternalServerError, model.ApiError{ - Code: model.ErrInternalServerError, - Message: "An unexpected error occurred", - }) +// respondError delegates the wire mapping to apierr.Respond (which classifies +// via errors.As), logging any unexpected non-*apierr.Error at error level first +// so 500s stay observable (apierr.Respond takes no logger). +func (h *RESTHandler) respondError(c *gin.Context, err error) { + var apiErr *apierr.Error + if !errors.As(err, &apiErr) { + h.logger.Error("unexpected error", + slog.String("error", err.Error()), + ) + } + apierr.Respond(c, err) } diff --git a/services/finance/internal/handler/rest_test.go b/services/finance/internal/handler/rest_test.go index 9fd859f9..8c505a6f 100644 --- a/services/finance/internal/handler/rest_test.go +++ b/services/finance/internal/handler/rest_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/repository" "github.com/ItsThompson/gofin/services/finance/internal/service" @@ -210,7 +211,7 @@ func setupTestRouter(repo *mockFinanceRepository, txBeginner *mockTxBeginner) *g gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger) + financeSvc := service.NewFinanceService(repo, txBeginner, nil, time.Now, logger) h := NewRESTHandler(financeSvc, logger) r := gin.New() @@ -306,12 +307,40 @@ func TestCompleteOnboardingHandler_InvalidSplit(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) assert.Contains(t, errResp.Message, "sum to 100%") } +func TestCompleteOnboardingHandler_MultiFieldValidationEmitsFields(t *testing.T) { + repo := new(mockFinanceRepository) + txBeginner := new(mockTxBeginner) + r := setupTestRouter(repo, txBeginner) + + // 50/50/50 sums to 150: a multi-field validation failure (C6). The wire + // response must carry field-level detail for every offending percentage, + // end to end through apierr.Respond. + w := doJSONWithUserID(r, "POST", "/api/finance/onboarding", "user-123", map[string]interface{}{ + "budgetAmount": 300000, + "essentialsPercent": 50, + "desiresPercent": 50, + "savingsPercent": 50, + "currency": "USD", + }) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var errResp apierr.APIError + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Equal(t, apierr.CodeValidation, errResp.Code) + assert.Equal(t, map[string]string{ + "essentialsPercent": "must sum to 100 with desires and savings", + "desiresPercent": "must sum to 100 with essentials and savings", + "savingsPercent": "must sum to 100 with essentials and desires", + }, errResp.Fields) +} + func TestCompleteOnboardingHandler_ZeroPercentAllocations(t *testing.T) { repo := new(mockFinanceRepository) txBeginner := new(mockTxBeginner) @@ -416,9 +445,9 @@ func TestCompleteOnboardingHandler_TagSeedingFailureRollsBack(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrInternalServerError, errResp.Code) + assert.Equal(t, apierr.CodeInternal, errResp.Code) // Verify rollback was called (commit should NOT have been called) tx.AssertCalled(t, "Rollback", mock.Anything) @@ -507,9 +536,9 @@ func TestGetDefaultsHandler_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrNotFound, errResp.Code) + assert.Equal(t, apierr.CodeNotFound, errResp.Code) } // --- GetCurrentPeriod Handler Tests --- @@ -557,7 +586,7 @@ func TestGetCurrentPeriodHandler_PeriodNotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrPeriodNotFound, errResp.Code) assert.Contains(t, errResp.Message, "2026-05") @@ -701,9 +730,9 @@ func TestCreatePeriodHandler_InvalidSplit(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) assert.Contains(t, errResp.Message, "sum to 100%") } @@ -792,9 +821,9 @@ func TestUpdateDefaultsHandler_InvalidSplit(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) assert.Contains(t, errResp.Message, "sum to 100%") } @@ -878,7 +907,7 @@ func setupTestRouterWithExpenseClient(repo *mockFinanceRepository, txBeginner *m gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger).WithExpenseClient(expClient) + financeSvc := service.NewFinanceService(repo, txBeginner, expClient, time.Now, logger) h := NewRESTHandler(financeSvc, logger) r := gin.New() @@ -941,7 +970,7 @@ func TestGetPeriodSummaryHandler_PeriodNotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrPeriodNotFound, errResp.Code) } @@ -1194,7 +1223,7 @@ func TestCreateTagHandler_DuplicateName(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrDuplicateTag, errResp.Code) } @@ -1294,7 +1323,7 @@ func TestDeleteTagHandler_DefaultTagForbidden(t *testing.T) { assert.Equal(t, http.StatusForbidden, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrDefaultTag, errResp.Code) assert.Contains(t, errResp.Message, "Default tags cannot be deleted") @@ -1317,7 +1346,7 @@ func TestDeleteTagHandler_TagInUse(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrTagInUse, errResp.Code) assert.Contains(t, errResp.Message, "5 expense(s)") @@ -1339,7 +1368,7 @@ func setupTestRouterWithNowFunc(repo *mockFinanceRepository, txBeginner *mockTxB gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - financeSvc := service.NewFinanceService(repo, txBeginner, logger).WithExpenseClient(expClient).WithNowFunc(nowFunc) + financeSvc := service.NewFinanceService(repo, txBeginner, expClient, nowFunc, logger) h := NewRESTHandler(financeSvc, logger) r := gin.New() @@ -1406,7 +1435,7 @@ func TestUpdatePeriodHandler_PastPeriodLocked(t *testing.T) { assert.Equal(t, http.StatusForbidden, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) assert.Equal(t, model.ErrPeriodLocked, errResp.Code) } @@ -1428,9 +1457,9 @@ func TestUpdatePeriodHandler_InvalidSplit(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) - var errResp model.ApiError + var errResp apierr.APIError require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) - assert.Equal(t, model.ErrValidationError, errResp.Code) + assert.Equal(t, apierr.CodeValidation, errResp.Code) assert.Contains(t, errResp.Message, "sum to 100%") } diff --git a/services/finance/internal/model/errors.go b/services/finance/internal/model/errors.go index a7079205..f84cec21 100644 --- a/services/finance/internal/model/errors.go +++ b/services/finance/internal/model/errors.go @@ -1,22 +1,13 @@ package model -// ApiError follows the error contract from 10-nonfunctional.md: -// { code, message, fields? } -type ApiError struct { - Code string `json:"code"` - Message string `json:"message"` - Fields map[string]string `json:"fields,omitempty"` -} - -// Common error codes +// Finance-specific error codes. The shared, cross-service codes +// (VALIDATION_ERROR, UNAUTHORIZED, NOT_FOUND, INTERNAL_SERVER_ERROR) are +// single-sourced in the apierr module; only codes unique to finance are +// declared here. They remain valid apierr.Error Code strings. const ( - ErrValidationError = "VALIDATION_ERROR" - ErrUnauthorized = "UNAUTHORIZED" - ErrNotFound = "NOT_FOUND" - ErrPeriodNotFound = "PERIOD_NOT_FOUND" - ErrInternalServerError = "INTERNAL_SERVER_ERROR" - ErrDuplicateTag = "DUPLICATE_TAG" - ErrTagInUse = "TAG_IN_USE" - ErrDefaultTag = "DEFAULT_TAG" - ErrPeriodLocked = "PERIOD_LOCKED" + ErrPeriodNotFound = "PERIOD_NOT_FOUND" + ErrDuplicateTag = "DUPLICATE_TAG" + ErrTagInUse = "TAG_IN_USE" + ErrDefaultTag = "DEFAULT_TAG" + ErrPeriodLocked = "PERIOD_LOCKED" ) diff --git a/services/finance/internal/repository/postgres.go b/services/finance/internal/repository/postgres.go index a06803f5..ac4c949c 100644 --- a/services/finance/internal/repository/postgres.go +++ b/services/finance/internal/repository/postgres.go @@ -2,15 +2,15 @@ package repository import ( "context" - "errors" "fmt" + "github.com/google/uuid" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/ItsThompson/gofin/services/finance/internal/db" "github.com/ItsThompson/gofin/services/finance/internal/model" + "github.com/ItsThompson/gofin/services/pgutil" ) // PostgresFinanceRepository implements FinanceRepository using sqlc-generated queries. @@ -24,9 +24,9 @@ func NewPostgresFinanceRepository(queries *db.Queries) *PostgresFinanceRepositor } func (r *PostgresFinanceRepository) UpsertDefaults(ctx context.Context, settings *model.DefaultSettings) (*model.DefaultSettings, error) { - uid := pgtype.UUID{} - if err := uid.Scan(settings.UserID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(settings.UserID) + if err != nil { + return nil, err } row, err := r.queries.UpsertDefaults(ctx, db.UpsertDefaultsParams{ @@ -44,14 +44,14 @@ func (r *PostgresFinanceRepository) UpsertDefaults(ctx context.Context, settings } func (r *PostgresFinanceRepository) GetDefaults(ctx context.Context, userID string) (*model.DefaultSettings, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.GetDefaults(ctx, uid) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -60,9 +60,9 @@ func (r *PostgresFinanceRepository) GetDefaults(ctx context.Context, userID stri } func (r *PostgresFinanceRepository) CreateTag(ctx context.Context, userID, name string, isDefault bool) (*model.Tag, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.CreateTag(ctx, db.CreateTagParams{ @@ -77,18 +77,18 @@ func (r *PostgresFinanceRepository) CreateTag(ctx context.Context, userID, name } func (r *PostgresFinanceRepository) GetTag(ctx context.Context, tagID, userID string) (*model.Tag, error) { - tid := pgtype.UUID{} - if err := tid.Scan(tagID); err != nil { - return nil, fmt.Errorf("parsing tag ID: %w", err) + tid, err := pgutil.ParseUUID(tagID) + if err != nil { + return nil, err } - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.GetTagByID(ctx, db.GetTagByIDParams{ID: tid, UserID: uid}) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -97,9 +97,9 @@ func (r *PostgresFinanceRepository) GetTag(ctx context.Context, tagID, userID st } func (r *PostgresFinanceRepository) ListTags(ctx context.Context, userID string) ([]*model.Tag, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } rows, err := r.queries.ListTags(ctx, uid) @@ -115,18 +115,18 @@ func (r *PostgresFinanceRepository) ListTags(ctx context.Context, userID string) } func (r *PostgresFinanceRepository) UpdateTag(ctx context.Context, tagID, userID, name string) (*model.Tag, error) { - tid := pgtype.UUID{} - if err := tid.Scan(tagID); err != nil { - return nil, fmt.Errorf("parsing tag ID: %w", err) + tid, err := pgutil.ParseUUID(tagID) + if err != nil { + return nil, err } - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.UpdateTag(ctx, db.UpdateTagParams{Name: name, ID: tid, UserID: uid}) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -135,42 +135,42 @@ func (r *PostgresFinanceRepository) UpdateTag(ctx context.Context, tagID, userID } func (r *PostgresFinanceRepository) DeleteTag(ctx context.Context, tagID, userID string) error { - tid := pgtype.UUID{} - if err := tid.Scan(tagID); err != nil { - return fmt.Errorf("parsing tag ID: %w", err) + tid, err := pgutil.ParseUUID(tagID) + if err != nil { + return err } - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return err } return r.queries.DeleteTag(ctx, db.DeleteTagParams{ID: tid, UserID: uid}) } func (r *PostgresFinanceRepository) CountUserTags(ctx context.Context, userID string) (int64, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return 0, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return 0, err } return r.queries.CountUserTags(ctx, uid) } func (r *PostgresFinanceRepository) CountTagInProRata(ctx context.Context, tagID, userID string) (int64, error) { - tid := pgtype.UUID{} - if err := tid.Scan(tagID); err != nil { - return 0, fmt.Errorf("parsing tag ID: %w", err) + tid, err := pgutil.ParseUUID(tagID) + if err != nil { + return 0, err } - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return 0, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return 0, err } return r.queries.CountTagInProRata(ctx, db.CountTagInProRataParams{TagID: tid, UserID: uid}) } func (r *PostgresFinanceRepository) GetCurrentPeriod(ctx context.Context, userID string, year, month int32) (*model.BudgetPeriod, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.GetCurrentPeriod(ctx, db.GetCurrentPeriodParams{ @@ -179,7 +179,7 @@ func (r *PostgresFinanceRepository) GetCurrentPeriod(ctx context.Context, userID Month: month, }) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -188,9 +188,9 @@ func (r *PostgresFinanceRepository) GetCurrentPeriod(ctx context.Context, userID } func (r *PostgresFinanceRepository) CreatePeriod(ctx context.Context, period *model.BudgetPeriod) (*model.BudgetPeriod, error) { - uid := pgtype.UUID{} - if err := uid.Scan(period.UserID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(period.UserID) + if err != nil { + return nil, err } row, err := r.queries.CreatePeriod(ctx, db.CreatePeriodParams{ @@ -209,13 +209,13 @@ func (r *PostgresFinanceRepository) CreatePeriod(ctx context.Context, period *mo } func (r *PostgresFinanceRepository) GetPeriodByID(ctx context.Context, periodID, userID string) (*model.BudgetPeriod, error) { - pid := pgtype.UUID{} - if err := pid.Scan(periodID); err != nil { - return nil, fmt.Errorf("parsing period ID: %w", err) + pid, err := pgutil.ParseUUID(periodID) + if err != nil { + return nil, err } - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.GetPeriodByID(ctx, db.GetPeriodByIDParams{ @@ -223,7 +223,7 @@ func (r *PostgresFinanceRepository) GetPeriodByID(ctx context.Context, periodID, UserID: uid, }) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -232,13 +232,13 @@ func (r *PostgresFinanceRepository) GetPeriodByID(ctx context.Context, periodID, } func (r *PostgresFinanceRepository) UpdatePeriod(ctx context.Context, period *model.BudgetPeriod) (*model.BudgetPeriod, error) { - pid := pgtype.UUID{} - if err := pid.Scan(period.ID); err != nil { - return nil, fmt.Errorf("parsing period ID: %w", err) + pid, err := pgutil.ParseUUID(period.ID) + if err != nil { + return nil, err } - uid := pgtype.UUID{} - if err := uid.Scan(period.UserID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(period.UserID) + if err != nil { + return nil, err } row, err := r.queries.UpdatePeriod(ctx, db.UpdatePeriodParams{ @@ -250,7 +250,7 @@ func (r *PostgresFinanceRepository) UpdatePeriod(ctx context.Context, period *mo UserID: uid, }) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -259,9 +259,9 @@ func (r *PostgresFinanceRepository) UpdatePeriod(ctx context.Context, period *mo } func (r *PostgresFinanceRepository) ListPeriods(ctx context.Context, userID string) ([]*model.BudgetPeriod, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } rows, err := r.queries.ListPeriods(ctx, uid) @@ -277,14 +277,14 @@ func (r *PostgresFinanceRepository) ListPeriods(ctx context.Context, userID stri } func (r *PostgresFinanceRepository) GetLatestPeriod(ctx context.Context, userID string) (*model.BudgetPeriod, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } row, err := r.queries.GetLatestPeriod(ctx, uid) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if pgutil.IsNoRows(err) { return nil, nil } return nil, err @@ -293,17 +293,17 @@ func (r *PostgresFinanceRepository) GetLatestPeriod(ctx context.Context, userID } func (r *PostgresFinanceRepository) CreateProRataSchedule(ctx context.Context, schedule *model.ProRataSchedule) (*model.ProRataSchedule, error) { - uid := pgtype.UUID{} - if err := uid.Scan(schedule.UserID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(schedule.UserID) + if err != nil { + return nil, err } - groupID := pgtype.UUID{} - if err := groupID.Scan(schedule.ProRataGroup); err != nil { - return nil, fmt.Errorf("parsing pro-rata group: %w", err) + groupID, err := pgutil.ParseUUID(schedule.ProRataGroup) + if err != nil { + return nil, err } - tagID := pgtype.UUID{} - if err := tagID.Scan(schedule.TagID); err != nil { - return nil, fmt.Errorf("parsing tag ID: %w", err) + tagID, err := pgutil.ParseUUID(schedule.TagID) + if err != nil { + return nil, err } row, err := r.queries.CreateProRataSchedule(ctx, db.CreateProRataScheduleParams{ @@ -326,9 +326,9 @@ func (r *PostgresFinanceRepository) CreateProRataSchedule(ctx context.Context, s } func (r *PostgresFinanceRepository) GetPendingProRata(ctx context.Context, userID string, year, month int32) ([]*model.ProRataSchedule, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } rows, err := r.queries.GetPendingProRata(ctx, db.GetPendingProRataParams{ @@ -348,17 +348,17 @@ func (r *PostgresFinanceRepository) GetPendingProRata(ctx context.Context, userI } func (r *PostgresFinanceRepository) MarkProRataApplied(ctx context.Context, scheduleID string) error { - sid := pgtype.UUID{} - if err := sid.Scan(scheduleID); err != nil { - return fmt.Errorf("parsing schedule ID: %w", err) + sid, err := pgutil.ParseUUID(scheduleID) + if err != nil { + return err } return r.queries.MarkProRataApplied(ctx, sid) } func (r *PostgresFinanceRepository) GetUpcomingProRata(ctx context.Context, userID string) ([]*model.ProRataSchedule, error) { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return nil, fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return nil, err } rows, err := r.queries.GetUpcomingProRata(ctx, uid) @@ -374,9 +374,9 @@ func (r *PostgresFinanceRepository) GetUpcomingProRata(ctx context.Context, user } func (r *PostgresFinanceRepository) DeleteAllUserData(ctx context.Context, userID string) error { - uid := pgtype.UUID{} - if err := uid.Scan(userID); err != nil { - return fmt.Errorf("parsing user ID: %w", err) + uid, err := pgutil.ParseUUID(userID) + if err != nil { + return err } // Delete in consistent order: pro_rata_schedules → tags → budget_periods → default_settings @@ -398,7 +398,7 @@ func (r *PostgresFinanceRepository) DeleteAllUserData(ctx context.Context, userI func dbDefaultsToModel(d db.FinanceDefaultSetting) *model.DefaultSettings { return &model.DefaultSettings{ - UserID: formatUUID(d.UserID.Bytes), + UserID: uuid.UUID(d.UserID.Bytes).String(), BudgetAmount: d.BudgetAmount, EssentialsPercent: d.EssentialsPercent, DesiresPercent: d.DesiresPercent, @@ -411,8 +411,8 @@ func dbDefaultsToModel(d db.FinanceDefaultSetting) *model.DefaultSettings { func dbTagToModel(t db.FinanceTag) *model.Tag { return &model.Tag{ - ID: formatUUID(t.ID.Bytes), - UserID: formatUUID(t.UserID.Bytes), + ID: uuid.UUID(t.ID.Bytes).String(), + UserID: uuid.UUID(t.UserID.Bytes).String(), Name: t.Name, IsDefault: t.IsDefault, CreatedAt: t.CreatedAt.Time, @@ -422,8 +422,8 @@ func dbTagToModel(t db.FinanceTag) *model.Tag { func dbPeriodToModel(p db.FinanceBudgetPeriod) *model.BudgetPeriod { return &model.BudgetPeriod{ - ID: formatUUID(p.ID.Bytes), - UserID: formatUUID(p.UserID.Bytes), + ID: uuid.UUID(p.ID.Bytes).String(), + UserID: uuid.UUID(p.UserID.Bytes).String(), Year: p.Year, Month: p.Month, BudgetAmount: p.BudgetAmount, @@ -437,14 +437,14 @@ func dbPeriodToModel(p db.FinanceBudgetPeriod) *model.BudgetPeriod { func dbScheduleToModel(s db.FinanceProRataSchedule) *model.ProRataSchedule { result := &model.ProRataSchedule{ - ID: formatUUID(s.ID.Bytes), - UserID: formatUUID(s.UserID.Bytes), - ProRataGroup: formatUUID(s.ProRataGroup.Bytes), + ID: uuid.UUID(s.ID.Bytes).String(), + UserID: uuid.UUID(s.UserID.Bytes).String(), + ProRataGroup: uuid.UUID(s.ProRataGroup.Bytes).String(), Name: s.Name, Amount: s.Amount, Currency: s.Currency, ExpenseType: s.ExpenseType, - TagID: formatUUID(s.TagID.Bytes), + TagID: uuid.UUID(s.TagID.Bytes).String(), TargetYear: s.TargetYear, TargetMonth: s.TargetMonth, InstallmentIndex: s.InstallmentIndex, @@ -459,11 +459,6 @@ func dbScheduleToModel(s db.FinanceProRataSchedule) *model.ProRataSchedule { return result } -func formatUUID(b [16]byte) string { - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) -} - // PostgresTxBeginner implements TxBeginner using pgxpool. type PostgresTxBeginner struct { pool *pgxpool.Pool diff --git a/services/finance/internal/service/alluserdata_test.go b/services/finance/internal/service/alluserdata_test.go index 68222c66..d315512f 100644 --- a/services/finance/internal/service/alluserdata_test.go +++ b/services/finance/internal/service/alluserdata_test.go @@ -20,7 +20,7 @@ import ( func newAllUserDataTestService(repo *mockRepo) *FinanceService { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - return NewFinanceService(repo, new(mockTxBeg), logger) + return NewFinanceService(repo, new(mockTxBeg), nil, time.Now, logger) } func TestGetAllUserData_UserWithData(t *testing.T) { diff --git a/services/finance/internal/service/dashboard.go b/services/finance/internal/service/dashboard.go index edbe048d..7410f23c 100644 --- a/services/finance/internal/service/dashboard.go +++ b/services/finance/internal/service/dashboard.go @@ -26,7 +26,7 @@ func (s *FinanceService) GetPeriodSummary(ctx context.Context, userID string, ye return nil, fmt.Errorf("fetching expenses: %w", err) } - return ComputePeriodSummary(period, expenses, year, month, time.Now()), nil + return ComputePeriodSummary(period, expenses, year, month, s.nowFunc()), nil } // GetSpendingByTag computes per-tag spending for a budget period. @@ -94,10 +94,6 @@ func (s *FinanceService) GetSpendingTrends(ctx context.Context, userID string, y } // Generate the list of year-month pairs going backwards from anchor - type yearMonth struct { - year int32 - month int32 - } window := make([]yearMonth, 0, months) y, m := year, month for i := int32(0); i < months; i++ { diff --git a/services/finance/internal/service/dashboard_test.go b/services/finance/internal/service/dashboard_test.go index 7b96d239..6f53a43f 100644 --- a/services/finance/internal/service/dashboard_test.go +++ b/services/finance/internal/service/dashboard_test.go @@ -700,7 +700,7 @@ func (r *fakeFanoutRepo) GetCurrentPeriod(_ context.Context, _ string, year, mon // discarded logger and no transaction beginner (the read paths never open a tx). func newFanoutService(repo repository.FinanceRepository, exp ExpenseClient) *FinanceService { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - return NewFinanceService(repo, nil, logger).WithExpenseClient(exp) + return NewFinanceService(repo, nil, exp, time.Now, logger) } // --- GetSpendingTrends fan-out regression tests --- diff --git a/services/finance/internal/service/deleteuserdata_test.go b/services/finance/internal/service/deleteuserdata_test.go index 409a99b9..8a57025e 100644 --- a/services/finance/internal/service/deleteuserdata_test.go +++ b/services/finance/internal/service/deleteuserdata_test.go @@ -53,7 +53,7 @@ func TestDeleteAllUserData_ServiceSuccess(t *testing.T) { tx := &mockTxForDeletion{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewFinanceService(nil, txBeginner, logger) + svc := NewFinanceService(nil, txBeginner, nil, nil, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) tx.On("Rollback", mock.Anything).Return(nil) @@ -73,7 +73,7 @@ func TestDeleteAllUserData_ServiceIdempotent_NoData(t *testing.T) { tx := &mockTxForDeletion{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewFinanceService(nil, txBeginner, logger) + svc := NewFinanceService(nil, txBeginner, nil, nil, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) tx.On("Rollback", mock.Anything).Return(nil) @@ -91,7 +91,7 @@ func TestDeleteAllUserData_ServiceTransactionBeginError(t *testing.T) { txBeginner := new(mockTxBeginnerForDeletion) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewFinanceService(nil, txBeginner, logger) + svc := NewFinanceService(nil, txBeginner, nil, nil, logger) txBeginner.On("BeginTx", mock.Anything).Return(nil, fmt.Errorf("pool exhausted")) @@ -106,7 +106,7 @@ func TestDeleteAllUserData_ServiceRepositoryError_RollsBack(t *testing.T) { tx := &mockTxForDeletion{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewFinanceService(nil, txBeginner, logger) + svc := NewFinanceService(nil, txBeginner, nil, nil, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) tx.On("Rollback", mock.Anything).Return(nil) @@ -127,7 +127,7 @@ func TestDeleteAllUserData_ServiceCommitError(t *testing.T) { tx := &mockTxForDeletion{repo: txRepo} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewFinanceService(nil, txBeginner, logger) + svc := NewFinanceService(nil, txBeginner, nil, nil, logger) txBeginner.On("BeginTx", mock.Anything).Return(tx, nil) tx.On("Rollback", mock.Anything).Return(nil) diff --git a/services/finance/internal/service/finance.go b/services/finance/internal/service/finance.go index fe1f9efe..b0192dd2 100644 --- a/services/finance/internal/service/finance.go +++ b/services/finance/internal/service/finance.go @@ -2,17 +2,17 @@ package service import ( "context" - "errors" "fmt" "log/slog" + "net/http" "strings" "time" "unicode/utf8" - "github.com/jackc/pgx/v5/pgconn" - + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/repository" + "github.com/ItsThompson/gofin/services/pgutil" ) // DefaultTags are seeded when a user completes onboarding. @@ -38,70 +38,93 @@ type FinanceService struct { logger *slog.Logger } -// NewFinanceService creates a new FinanceService. +// NewFinanceService creates a new FinanceService with all dependencies injected. +// expenseClient is always supplied, so the dashboard and pro-rata paths +// dereference it without a nil guard. nowFunc is the clock seam (pass time.Now +// in production); a nil nowFunc defaults to time.Now. func NewFinanceService( repo repository.FinanceRepository, txBeginner repository.TxBeginner, + expenseClient ExpenseClient, + nowFunc func() time.Time, logger *slog.Logger, ) *FinanceService { + if nowFunc == nil { + nowFunc = time.Now + } return &FinanceService{ - repo: repo, - txBeginner: txBeginner, - nowFunc: time.Now, - logger: logger, + repo: repo, + txBeginner: txBeginner, + expenseClient: expenseClient, + nowFunc: nowFunc, + logger: logger, } } -// WithExpenseClient returns a copy of the service with the expense client set. -// This is used to inject the gRPC client after the service is constructed. -func (s *FinanceService) WithExpenseClient(client ExpenseClient) *FinanceService { - s.expenseClient = client - return s -} - -// WithNowFunc overrides the clock function used for time-dependent logic. -// Used in tests to inject a fixed time. -func (s *FinanceService) WithNowFunc(f func() time.Time) *FinanceService { - s.nowFunc = f - return s -} - -// ServiceError is a typed error that carries an HTTP status code and error code. -type ServiceError struct { - Code string - Message string - Status int +// ValidateEDSSplit checks that essentials + desires + savings == 100. On +// failure it returns an *apierr.Error whose Fields map names every offending +// percentage, so the response carries field-level detail (C6). +func ValidateEDSSplit(essentials, desires, savings int32) *apierr.Error { + if negFields := negativeEDSFields(essentials, desires, savings); len(negFields) > 0 { + return apierr.Validation("E/D/S percentages must be non-negative", negFields) + } + if overFields := overHundredEDSFields(essentials, desires, savings); len(overFields) > 0 { + return apierr.Validation("E/D/S percentages must not exceed 100", overFields) + } + if total := essentials + desires + savings; total != 100 { + return apierr.Validation( + fmt.Sprintf("E/D/S split must sum to 100%%, got %d%%", total), + map[string]string{ + "essentialsPercent": "must sum to 100 with desires and savings", + "desiresPercent": "must sum to 100 with essentials and savings", + "savingsPercent": "must sum to 100 with essentials and desires", + }, + ) + } + return nil } -func (e *ServiceError) Error() string { - return e.Message +func negativeEDSFields(essentials, desires, savings int32) map[string]string { + fields := map[string]string{} + if essentials < 0 { + fields["essentialsPercent"] = "must be non-negative" + } + if desires < 0 { + fields["desiresPercent"] = "must be non-negative" + } + if savings < 0 { + fields["savingsPercent"] = "must be non-negative" + } + return fields } -// ValidateEDSSplit checks that essentials + desires + savings == 100. -func ValidateEDSSplit(essentials, desires, savings int32) error { - if essentials < 0 || desires < 0 || savings < 0 { - return fmt.Errorf("E/D/S percentages must be non-negative") +func overHundredEDSFields(essentials, desires, savings int32) map[string]string { + fields := map[string]string{} + if essentials > 100 { + fields["essentialsPercent"] = "must not exceed 100" } - if essentials > 100 || desires > 100 || savings > 100 { - return fmt.Errorf("E/D/S percentages must not exceed 100") + if desires > 100 { + fields["desiresPercent"] = "must not exceed 100" } - total := essentials + desires + savings - if total != 100 { - return fmt.Errorf("E/D/S split must sum to 100%%, got %d%%", total) + if savings > 100 { + fields["savingsPercent"] = "must not exceed 100" } - return nil + return fields +} + +// budgetAmountError returns a VALIDATION_ERROR for a negative budget amount. +func budgetAmountError() *apierr.Error { + return apierr.Validation("Budget amount must be non-negative", map[string]string{ + "budgetAmount": "must be non-negative", + }) } // CompleteOnboarding saves the user's default settings and seeds default tags. // Both operations run in a transaction: if tag seeding fails, the defaults // upsert is rolled back. func (s *FinanceService) CompleteOnboarding(ctx context.Context, userID string, req *model.OnboardingRequest) (*model.DefaultSettings, error) { - if err := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); err != nil { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: err.Error(), - Status: 400, - } + if verr := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); verr != nil { + return nil, verr } tx, err := s.txBeginner.BeginTx(ctx) @@ -152,11 +175,7 @@ func (s *FinanceService) GetDefaults(ctx context.Context, userID string) (*model return nil, fmt.Errorf("getting defaults: %w", err) } if defaults == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: "Default settings not found", - Status: 404, - } + return nil, apierr.NotFound("Default settings not found") } return defaults, nil } @@ -164,20 +183,12 @@ func (s *FinanceService) GetDefaults(ctx context.Context, userID string) (*model // UpdateDefaults updates the user's default budget settings. // Does not affect current or past budget periods. func (s *FinanceService) UpdateDefaults(ctx context.Context, userID string, req *model.UpdateDefaultsRequest) (*model.DefaultSettings, error) { - if err := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); err != nil { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: err.Error(), - Status: 400, - } + if verr := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); verr != nil { + return nil, verr } if req.BudgetAmount < 0 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Budget amount must be non-negative", - Status: 400, - } + return nil, budgetAmountError() } defaults, err := s.repo.UpsertDefaults(ctx, &model.DefaultSettings{ @@ -201,14 +212,12 @@ func (s *FinanceService) UpdateDefaults(ctx context.Context, userID string, req } // GetCurrentPeriod retrieves the budget period for a given user, year, and month. -// Returns a PERIOD_NOT_FOUND ServiceError (404) when no period exists. +// Returns a PERIOD_NOT_FOUND apierr.Error (404) when no period exists. func (s *FinanceService) GetCurrentPeriod(ctx context.Context, userID string, year, month int32) (*model.BudgetPeriod, error) { if month < 1 || month > 12 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Month must be between 1 and 12", - Status: 400, - } + return nil, apierr.Validation("Month must be between 1 and 12", map[string]string{ + "month": "must be between 1 and 12", + }) } period, err := s.repo.GetCurrentPeriod(ctx, userID, year, month) @@ -216,62 +225,12 @@ func (s *FinanceService) GetCurrentPeriod(ctx context.Context, userID string, ye return nil, fmt.Errorf("getting current period: %w", err) } if period == nil { - return nil, &ServiceError{ + return nil, &apierr.Error{ Code: model.ErrPeriodNotFound, Message: fmt.Sprintf("No budget period found for %d-%02d", year, month), - Status: 404, - } - } - return period, nil -} - -// CreatePeriod creates a new budget period for the given user. -// Validates the E/D/S split sums to 100% before persisting. -func (s *FinanceService) CreatePeriod(ctx context.Context, userID string, req *model.CreatePeriodRequest) (*model.BudgetPeriod, error) { - if err := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); err != nil { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: err.Error(), - Status: 400, - } - } - - if req.Month < 1 || req.Month > 12 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Month must be between 1 and 12", - Status: 400, + Status: http.StatusNotFound, } } - - if req.BudgetAmount < 0 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Budget amount must be non-negative", - Status: 400, - } - } - - period, err := s.repo.CreatePeriod(ctx, &model.BudgetPeriod{ - UserID: userID, - Year: req.Year, - Month: req.Month, - BudgetAmount: req.BudgetAmount, - EssentialsPercent: req.EssentialsPercent, - DesiresPercent: req.DesiresPercent, - SavingsPercent: req.SavingsPercent, - }) - if err != nil { - return nil, fmt.Errorf("creating period: %w", err) - } - - s.logger.Info("budget period created", - slog.String("method", "CreatePeriod"), - slog.String("user_id", userID), - slog.Int("year", int(req.Year)), - slog.Int("month", int(req.Month)), - ) - return period, nil } @@ -287,20 +246,12 @@ func (s *FinanceService) ListPeriods(ctx context.Context, userID string) ([]*mod // UpdatePeriod updates the budget and E/D/S split for a period. // Only the current period can be updated: past periods are immutable (PERIOD_LOCKED). func (s *FinanceService) UpdatePeriod(ctx context.Context, userID, periodID string, req *model.UpdatePeriodRequest) (*model.BudgetPeriod, error) { - if err := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); err != nil { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: err.Error(), - Status: 400, - } + if verr := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); verr != nil { + return nil, verr } if req.BudgetAmount < 0 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Budget amount must be non-negative", - Status: 400, - } + return nil, budgetAmountError() } // Fetch the period to check ownership and whether it's current. @@ -309,20 +260,16 @@ func (s *FinanceService) UpdatePeriod(ctx context.Context, userID, periodID stri return nil, fmt.Errorf("fetching period: %w", err) } if existing == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: "Budget period not found", - Status: 404, - } + return nil, apierr.NotFound("Budget period not found") } // Enforce: only the current month's period can be edited. now := s.nowFunc() if existing.Year != int32(now.Year()) || existing.Month != int32(now.Month()) { - return nil, &ServiceError{ + return nil, &apierr.Error{ Code: model.ErrPeriodLocked, Message: "Past periods are read-only and cannot be modified", - Status: 403, + Status: http.StatusForbidden, } } @@ -338,11 +285,7 @@ func (s *FinanceService) UpdatePeriod(ctx context.Context, userID, periodID stri return nil, fmt.Errorf("updating period: %w", err) } if updated == nil { - return nil, &ServiceError{ - Code: model.ErrNotFound, - Message: "Budget period not found", - Status: 404, - } + return nil, apierr.NotFound("Budget period not found") } s.logger.Info("budget period updated", @@ -396,17 +339,14 @@ func (s *FinanceService) ListTags(ctx context.Context, userID string) ([]*model. func (s *FinanceService) CreateTag(ctx context.Context, userID string, req *model.CreateTagRequest) (*model.Tag, error) { name := strings.TrimSpace(req.Name) - if name == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Tag name is required", Status: 400} - } - if utf8.RuneCountInString(name) > 50 { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Tag name must be 50 characters or fewer", Status: 400} + if verr := validateTagName(name); verr != nil { + return nil, verr } tag, err := s.repo.CreateTag(ctx, userID, name, false) if err != nil { - if isDuplicateKeyError(err) { - return nil, &ServiceError{Code: model.ErrDuplicateTag, Message: fmt.Sprintf("A tag named %q already exists", name), Status: 409} + if _, ok := pgutil.IsUniqueViolation(err); ok { + return nil, duplicateTagError(name) } return nil, fmt.Errorf("creating tag: %w", err) } @@ -417,46 +357,61 @@ func (s *FinanceService) CreateTag(ctx context.Context, userID string, req *mode func (s *FinanceService) UpdateTag(ctx context.Context, userID, tagID string, req *model.UpdateTagRequest) (*model.Tag, error) { name := strings.TrimSpace(req.Name) - if name == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Tag name is required", Status: 400} - } - if utf8.RuneCountInString(name) > 50 { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Tag name must be 50 characters or fewer", Status: 400} + if verr := validateTagName(name); verr != nil { + return nil, verr } tag, err := s.repo.UpdateTag(ctx, tagID, userID, name) if err != nil { - if isDuplicateKeyError(err) { - return nil, &ServiceError{Code: model.ErrDuplicateTag, Message: fmt.Sprintf("A tag named %q already exists", name), Status: 409} + if _, ok := pgutil.IsUniqueViolation(err); ok { + return nil, duplicateTagError(name) } return nil, fmt.Errorf("updating tag: %w", err) } if tag == nil { - return nil, &ServiceError{Code: model.ErrNotFound, Message: "Tag not found", Status: 404} + return nil, apierr.NotFound("Tag not found") } s.logger.Info("tag updated", slog.String("method", "UpdateTag"), slog.String("user_id", userID), slog.String("tag_id", tagID)) return tag, nil } +// validateTagName enforces the non-empty and length constraints shared by tag +// creation and rename, returning a VALIDATION_ERROR with a name field on failure. +func validateTagName(name string) *apierr.Error { + if name == "" { + return apierr.Validation("Tag name is required", map[string]string{"name": "required"}) + } + if utf8.RuneCountInString(name) > 50 { + return apierr.Validation("Tag name must be 50 characters or fewer", map[string]string{"name": "must be 50 characters or fewer"}) + } + return nil +} + +// duplicateTagError is the 409 returned when a tag name collides. +func duplicateTagError(name string) *apierr.Error { + return apierr.Conflict(model.ErrDuplicateTag, fmt.Sprintf("A tag named %q already exists", name)) +} + func (s *FinanceService) DeleteTag(ctx context.Context, userID, tagID string) error { tag, err := s.repo.GetTag(ctx, tagID, userID) if err != nil { return fmt.Errorf("getting tag: %w", err) } if tag == nil { - return &ServiceError{Code: model.ErrNotFound, Message: "Tag not found", Status: 404} + return apierr.NotFound("Tag not found") } if tag.IsDefault { - return &ServiceError{Code: model.ErrDefaultTag, Message: "Default tags cannot be deleted, only renamed", Status: 403} + return &apierr.Error{ + Code: model.ErrDefaultTag, + Message: "Default tags cannot be deleted, only renamed", + Status: http.StatusForbidden, + } } - var expenseCount int64 - if s.expenseClient != nil { - expenseCount, err = s.expenseClient.CountExpensesByTag(ctx, userID, tagID) - if err != nil { - return fmt.Errorf("checking tag usage in expenses: %w", err) - } + expenseCount, err := s.expenseClient.CountExpensesByTag(ctx, userID, tagID) + if err != nil { + return fmt.Errorf("checking tag usage in expenses: %w", err) } proRataCount, err := s.repo.CountTagInProRata(ctx, tagID, userID) @@ -472,7 +427,7 @@ func (s *FinanceService) DeleteTag(ctx context.Context, userID, tagID string) er if proRataCount > 0 { parts = append(parts, fmt.Sprintf("%d pending schedule(s)", proRataCount)) } - return &ServiceError{Code: model.ErrTagInUse, Message: fmt.Sprintf("Tag is referenced by %s", strings.Join(parts, " and ")), Status: 409} + return apierr.Conflict(model.ErrTagInUse, fmt.Sprintf("Tag is referenced by %s", strings.Join(parts, " and "))) } if err := s.repo.DeleteTag(ctx, tagID, userID); err != nil { @@ -482,11 +437,3 @@ func (s *FinanceService) DeleteTag(ctx context.Context, userID, tagID string) er s.logger.Info("tag deleted", slog.String("method", "DeleteTag"), slog.String("user_id", userID), slog.String("tag_id", tagID)) return nil } - -func isDuplicateKeyError(err error) bool { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - return pgErr.Code == "23505" - } - return false -} diff --git a/services/finance/internal/service/finance_test.go b/services/finance/internal/service/finance_test.go index ec478b9b..f04f4bf8 100644 --- a/services/finance/internal/service/finance_test.go +++ b/services/finance/internal/service/finance_test.go @@ -4,11 +4,14 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/apierr" ) func TestValidateEDSSplit_Valid(t *testing.T) { tests := []struct { - name string + name string essentials, desires, savings int32 }{ {"default 50/30/20", 50, 30, 20}, @@ -20,17 +23,16 @@ func TestValidateEDSSplit_Valid(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateEDSSplit(tt.essentials, tt.desires, tt.savings) - assert.NoError(t, err) + assert.Nil(t, ValidateEDSSplit(tt.essentials, tt.desires, tt.savings)) }) } } func TestValidateEDSSplit_Invalid(t *testing.T) { tests := []struct { - name string + name string essentials, desires, savings int32 - errContains string + errContains string }{ {"sum 99", 50, 30, 19, "sum to 100%"}, {"sum 101", 50, 30, 21, "sum to 100%"}, @@ -43,9 +45,35 @@ func TestValidateEDSSplit_Invalid(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateEDSSplit(tt.essentials, tt.desires, tt.savings) - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errContains) + verr := ValidateEDSSplit(tt.essentials, tt.desires, tt.savings) + require.NotNil(t, verr) + assert.Equal(t, apierr.CodeValidation, verr.Code) + assert.Contains(t, verr.Message, tt.errContains) }) } } + +// TestValidateEDSSplit_FieldsPopulated locks in C6: a multi-field validation +// failure carries every offending field in Fields, not just a flat message. +func TestValidateEDSSplit_FieldsPopulated(t *testing.T) { + t.Run("sum mismatch names all three percentages", func(t *testing.T) { + verr := ValidateEDSSplit(50, 50, 50) // sums to 150 + require.NotNil(t, verr) + assert.Equal(t, apierr.CodeValidation, verr.Code) + assert.Equal(t, map[string]string{ + "essentialsPercent": "must sum to 100 with desires and savings", + "desiresPercent": "must sum to 100 with essentials and savings", + "savingsPercent": "must sum to 100 with essentials and desires", + }, verr.Fields) + }) + + t.Run("multiple negatives name only the offending fields", func(t *testing.T) { + verr := ValidateEDSSplit(-10, -20, 130) + require.NotNil(t, verr) + assert.Equal(t, apierr.CodeValidation, verr.Code) + assert.Equal(t, map[string]string{ + "essentialsPercent": "must be non-negative", + "desiresPercent": "must be non-negative", + }, verr.Fields) + }) +} diff --git a/services/finance/internal/service/period_test.go b/services/finance/internal/service/period_test.go index d734ca7b..f1ea21a0 100644 --- a/services/finance/internal/service/period_test.go +++ b/services/finance/internal/service/period_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" ) @@ -39,8 +40,7 @@ func makePeriod(id string, year int32, month int32) *model.BudgetPeriod { func TestUpdatePeriod_Success(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) - svc.WithNowFunc(fixedNow(2026, 5, 15)) + svc := newTagTestServiceNow(repo, txBeg, nil, fixedNow(2026, 5, 15)) existing := makePeriod("period-1", 2026, 5) repo.On("GetPeriodByID", mock.Anything, "period-1", "user-1").Return(existing, nil) @@ -74,9 +74,8 @@ func TestUpdatePeriod_Success(t *testing.T) { func TestUpdatePeriod_PastPeriodLocked(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) // Current time is May 2026, trying to update April 2026 period - svc.WithNowFunc(fixedNow(2026, 5, 15)) + svc := newTagTestServiceNow(repo, txBeg, nil, fixedNow(2026, 5, 15)) existing := makePeriod("period-old", 2026, 4) repo.On("GetPeriodByID", mock.Anything, "period-old", "user-1").Return(existing, nil) @@ -91,8 +90,7 @@ func TestUpdatePeriod_PastPeriodLocked(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrPeriodLocked, svcErr.Code) assert.Equal(t, 403, svcErr.Status) assert.Contains(t, svcErr.Message, "read-only") @@ -101,9 +99,8 @@ func TestUpdatePeriod_PastPeriodLocked(t *testing.T) { func TestUpdatePeriod_PastYearLocked(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) // Current time is Jan 2026, trying to update Dec 2025 period - svc.WithNowFunc(fixedNow(2026, 1, 10)) + svc := newTagTestServiceNow(repo, txBeg, nil, fixedNow(2026, 1, 10)) existing := makePeriod("period-old-year", 2025, 12) repo.On("GetPeriodByID", mock.Anything, "period-old-year", "user-1").Return(existing, nil) @@ -118,8 +115,7 @@ func TestUpdatePeriod_PastYearLocked(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrPeriodLocked, svcErr.Code) } @@ -138,9 +134,8 @@ func TestUpdatePeriod_InvalidSplit(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Contains(t, svcErr.Message, "sum to 100%") } @@ -159,17 +154,15 @@ func TestUpdatePeriod_NegativeBudget(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Contains(t, svcErr.Message, "non-negative") } func TestUpdatePeriod_NotFound(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) - svc.WithNowFunc(fixedNow(2026, 5, 15)) + svc := newTagTestServiceNow(repo, txBeg, nil, fixedNow(2026, 5, 15)) repo.On("GetPeriodByID", mock.Anything, "nonexistent", "user-1").Return(nil, nil) @@ -183,17 +176,15 @@ func TestUpdatePeriod_NotFound(t *testing.T) { assert.Nil(t, result) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) assert.Equal(t, 404, svcErr.Status) } func TestUpdatePeriod_ZeroBudgetAllowed(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) - svc := newTagTestService(repo, txBeg, nil) - svc.WithNowFunc(fixedNow(2026, 5, 15)) + svc := newTagTestServiceNow(repo, txBeg, nil, fixedNow(2026, 5, 15)) existing := makePeriod("period-1", 2026, 5) repo.On("GetPeriodByID", mock.Anything, "period-1", "user-1").Return(existing, nil) diff --git a/services/finance/internal/service/prorata.go b/services/finance/internal/service/prorata.go index 7d244e0f..bdfde036 100644 --- a/services/finance/internal/service/prorata.go +++ b/services/finance/internal/service/prorata.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" ) @@ -55,30 +56,26 @@ func monthLabel(year int32, month int32) string { func (s *FinanceService) CreateProRataExpense(ctx context.Context, userID string, req *model.CreateProRataRequest) (*model.ProRataResponse, error) { // Validate inputs if strings.TrimSpace(req.Name) == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Name is required", Status: 400} + return nil, apierr.Validation("Name is required", map[string]string{"name": "required"}) } if req.TotalAmount <= 0 { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Total amount must be positive", Status: 400} + return nil, apierr.Validation("Total amount must be positive", map[string]string{"totalAmount": "must be positive"}) } if req.Months < 2 { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Pro-rata requires at least 2 months", Status: 400} + return nil, apierr.Validation("Pro-rata requires at least 2 months", map[string]string{"months": "must be at least 2"}) } validTypes := map[string]bool{"essentials": true, "desires": true, "savings": true} if !validTypes[req.ExpenseType] { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Expense type must be essentials, desires, or savings", Status: 400} + return nil, apierr.Validation("Expense type must be essentials, desires, or savings", map[string]string{"expenseType": "must be essentials, desires, or savings"}) } if strings.TrimSpace(req.Currency) == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Currency is required", Status: 400} + return nil, apierr.Validation("Currency is required", map[string]string{"currency": "required"}) } if strings.TrimSpace(req.TagID) == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Tag ID is required", Status: 400} + return nil, apierr.Validation("Tag ID is required", map[string]string{"tagId": "required"}) } if strings.TrimSpace(req.ExpenseDate) == "" { - return nil, &ServiceError{Code: model.ErrValidationError, Message: "Expense date is required", Status: 400} - } - - if s.expenseClient == nil { - return nil, fmt.Errorf("expense client not configured") + return nil, apierr.Validation("Expense date is required", map[string]string{"expenseDate": "required"}) } installments := CalculateInstallments(req.TotalAmount, req.Months) @@ -137,11 +134,7 @@ func (s *FinanceService) CreateProRataExpense(ctx context.Context, userID string slog.Int("installment_index", int(i)), slog.String("error", err.Error()), ) - return nil, &ServiceError{ - Code: model.ErrInternalServerError, - Message: "First installment was created but schedule creation failed. Please contact support.", - Status: 500, - } + return nil, apierr.Internal("First installment was created but schedule creation failed. Please contact support.") } schedules = append(schedules, schedule) } @@ -196,10 +189,6 @@ func (s *FinanceService) applyPendingProRata(ctx context.Context, userID string, return nil, nil } - if s.expenseClient == nil { - return nil, fmt.Errorf("expense client not configured") - } - applied := make([]*model.ProRataSchedule, 0, len(pending)) for _, schedule := range pending { // Determine the expense date: first day of the target month @@ -258,26 +247,14 @@ func (s *FinanceService) applyPendingProRata(ctx context.Context, userID string, // schedules. It also handles missed months: if the user has skipped months, intermediate // periods are auto-created with defaults and their pro-rata installments are applied. func (s *FinanceService) CreatePeriodWithProRata(ctx context.Context, userID string, req *model.CreatePeriodRequest) (*model.CreatePeriodResponse, error) { - if err := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); err != nil { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: err.Error(), - Status: 400, - } + if verr := ValidateEDSSplit(req.EssentialsPercent, req.DesiresPercent, req.SavingsPercent); verr != nil { + return nil, verr } if req.Month < 1 || req.Month > 12 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Month must be between 1 and 12", - Status: 400, - } + return nil, apierr.Validation("Month must be between 1 and 12", map[string]string{"month": "must be between 1 and 12"}) } if req.BudgetAmount < 0 { - return nil, &ServiceError{ - Code: model.ErrValidationError, - Message: "Budget amount must be non-negative", - Status: 400, - } + return nil, budgetAmountError() } var autoCreatedMonths []string diff --git a/services/finance/internal/service/prorata_test.go b/services/finance/internal/service/prorata_test.go index cdd26da7..b9c7ebb1 100644 --- a/services/finance/internal/service/prorata_test.go +++ b/services/finance/internal/service/prorata_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" ) @@ -135,8 +136,7 @@ func TestCreateProRataExpense_Success(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestService(repo, txBeg, expClient) - svc.WithNowFunc(fixedNow(2026, 5, 15)) + svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 5, 15)) expClient.On("CreateExpense", mock.Anything, mock.MatchedBy(func(req CreateExpenseInput) bool { return req.Name == "Annual subscription" && @@ -184,8 +184,7 @@ func TestCreateProRataExpense_YearRollover(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestService(repo, txBeg, expClient) - svc.WithNowFunc(fixedNow(2026, 11, 1)) + svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 11, 1)) expClient.On("CreateExpense", mock.Anything, mock.Anything). Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-11-01T00:00:00Z"}, nil) @@ -231,9 +230,8 @@ func TestCreateProRataExpense_Validation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { _, err := svc.CreateProRataExpense(context.Background(), "user-1", tt.req) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Contains(t, svcErr.Message, tt.msg) }) } @@ -243,8 +241,7 @@ func TestCreateProRataExpense_ScheduleFailure(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) - svc := newTagTestService(repo, txBeg, expClient) - svc.WithNowFunc(fixedNow(2026, 5, 15)) + svc := newTagTestServiceNow(repo, txBeg, expClient, fixedNow(2026, 5, 15)) expClient.On("CreateExpense", mock.Anything, mock.Anything). Return(&CreatedExpenseData{ID: "exp-1", CreatedAt: "2026-05-15T12:00:00Z"}, nil) @@ -258,9 +255,8 @@ func TestCreateProRataExpense_ScheduleFailure(t *testing.T) { }) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrInternalServerError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeInternal, svcErr.Code) assert.Contains(t, svcErr.Message, "schedule creation failed") } diff --git a/services/finance/internal/service/tag_test.go b/services/finance/internal/service/tag_test.go index e88bef16..00aa080b 100644 --- a/services/finance/internal/service/tag_test.go +++ b/services/finance/internal/service/tag_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/repository" ) @@ -216,12 +217,29 @@ func (m *mockExpClient) CreateExpense(ctx context.Context, req CreateExpenseInpu } func newTagTestService(repo *mockRepo, txBeg *mockTxBeg, expClient *mockExpClient) *FinanceService { + return newTagTestServiceNow(repo, txBeg, expClient, time.Now) +} + +// newTagTestServiceNow builds a FinanceService with an injected clock. A nil +// expClient is passed through as a nil ExpenseClient interface (paths that read +// the client are not exercised by those callers). +func newTagTestServiceNow(repo *mockRepo, txBeg *mockTxBeg, expClient *mockExpClient, nowFunc func() time.Time) *FinanceService { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewFinanceService(repo, txBeg, logger) + var ec ExpenseClient if expClient != nil { - svc.WithExpenseClient(expClient) + ec = expClient } - return svc + return NewFinanceService(repo, txBeg, ec, nowFunc, logger) +} + +// requireAPIError asserts err is (or wraps) an *apierr.Error and returns it, so +// tests read the classified Code/Status/Message. Using errors.As keeps the +// assertion robust to a future %w-wrap of the typed error (C7). +func requireAPIError(t *testing.T, err error) *apierr.Error { + t.Helper() + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + return apiErr } func makeTag(id, name string, isDefault bool) *model.Tag { @@ -315,8 +333,7 @@ func TestCreateTag_DuplicateName(t *testing.T) { assert.Nil(t, tag) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrDuplicateTag, svcErr.Code) assert.Equal(t, 409, svcErr.Status) } @@ -331,9 +348,8 @@ func TestCreateTag_NameTooLong(t *testing.T) { assert.Nil(t, tag) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) assert.Contains(t, svcErr.Message, "50 characters") } @@ -346,9 +362,8 @@ func TestCreateTag_EmptyName(t *testing.T) { assert.Nil(t, tag) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrValidationError, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeValidation, svcErr.Code) } // --- UpdateTag Tests --- @@ -378,9 +393,8 @@ func TestUpdateTag_NotFound(t *testing.T) { assert.Nil(t, tag) require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) } // --- DeleteTag Tests --- @@ -416,8 +430,7 @@ func TestDeleteTag_DefaultTagBlocked(t *testing.T) { err := svc.DeleteTag(context.Background(), "user-1", "tag-bills") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrDefaultTag, svcErr.Code) assert.Equal(t, 403, svcErr.Status) assert.Contains(t, svcErr.Message, "Default tags cannot be deleted") @@ -439,8 +452,7 @@ func TestDeleteTag_InUseByExpenses(t *testing.T) { err := svc.DeleteTag(context.Background(), "user-1", "tag-custom") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrTagInUse, svcErr.Code) assert.Equal(t, 409, svcErr.Status) assert.Contains(t, svcErr.Message, "3 expense(s)") @@ -462,8 +474,7 @@ func TestDeleteTag_InUseByProRataSchedules(t *testing.T) { err := svc.DeleteTag(context.Background(), "user-1", "tag-custom") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrTagInUse, svcErr.Code) assert.Contains(t, svcErr.Message, "2 pending schedule(s)") } @@ -484,8 +495,7 @@ func TestDeleteTag_InUseByBothExpensesAndSchedules(t *testing.T) { err := svc.DeleteTag(context.Background(), "user-1", "tag-custom") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrTagInUse, svcErr.Code) assert.Contains(t, svcErr.Message, "5 expense(s)") assert.Contains(t, svcErr.Message, "3 pending schedule(s)") @@ -502,7 +512,6 @@ func TestDeleteTag_NotFound(t *testing.T) { err := svc.DeleteTag(context.Background(), "user-1", "nonexistent") require.Error(t, err) - svcErr, ok := err.(*ServiceError) - require.True(t, ok) - assert.Equal(t, model.ErrNotFound, svcErr.Code) + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeNotFound, svcErr.Code) } diff --git a/services/gateway/Dockerfile b/services/gateway/Dockerfile index e22e909e..32acf0ef 100644 --- a/services/gateway/Dockerfile +++ b/services/gateway/Dockerfile @@ -12,15 +12,23 @@ COPY access/go.mod access/go.sum* ./access/ COPY auth/go.mod auth/go.sum* ./auth/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ +COPY serverkit/go.mod serverkit/go.sum* ./serverkit/ +COPY apierr/go.mod apierr/go.sum* ./apierr/ +COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ RUN --mount=type=cache,target=/go/pkg/mod \ cd gateway && GOWORK=off go mod download -# Copy only what the gateway build needs: gateway source + access + auth proto + healthcheck + metrics. +# Copy only what the gateway build needs: gateway source + access + auth proto +# + healthcheck + metrics + the shared serverkit/apierr platform (and dbmigrate, +# pulled in transitively by serverkit). COPY gateway/ ./gateway/ COPY access/ ./access/ COPY auth/proto/ ./auth/proto/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ +COPY serverkit/ ./serverkit/ +COPY apierr/ ./apierr/ +COPY dbmigrate/ ./dbmigrate/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ diff --git a/services/gateway/cmd/grpc_validator.go b/services/gateway/cmd/grpc_validator.go index ff772b4d..cbe3cff3 100644 --- a/services/gateway/cmd/grpc_validator.go +++ b/services/gateway/cmd/grpc_validator.go @@ -58,7 +58,6 @@ func (v *GRPCTokenValidator) ValidateToken(ctx context.Context, accessToken stri return &access.TokenValidationResult{ UserID: resp.GetUserId(), Role: resp.GetRole(), - Username: resp.GetUsername(), AssumedBy: resp.GetAssumedBy(), }, nil } diff --git a/services/gateway/cmd/grpc_validator_test.go b/services/gateway/cmd/grpc_validator_test.go index ca80801c..015c215d 100644 --- a/services/gateway/cmd/grpc_validator_test.go +++ b/services/gateway/cmd/grpc_validator_test.go @@ -149,18 +149,18 @@ func TestGRPCTokenValidator_MapsResponseFields(t *testing.T) { }{ { name: "plain user session", - resp: &authpb.ValidateTokenResponse{UserId: "user-1", Role: "user", Username: "alex"}, - want: access.TokenValidationResult{UserID: "user-1", Role: "user", Username: "alex"}, + resp: &authpb.ValidateTokenResponse{UserId: "user-1", Role: "user"}, + want: access.TokenValidationResult{UserID: "user-1", Role: "user"}, }, { name: "admin session", - resp: &authpb.ValidateTokenResponse{UserId: "admin-1", Role: "admin", Username: "root"}, - want: access.TokenValidationResult{UserID: "admin-1", Role: "admin", Username: "root"}, + resp: &authpb.ValidateTokenResponse{UserId: "admin-1", Role: "admin"}, + want: access.TokenValidationResult{UserID: "admin-1", Role: "admin"}, }, { name: "assumed session carries AssumedBy", - resp: &authpb.ValidateTokenResponse{UserId: "target-1", Role: "user", Username: "target", AssumedBy: "admin-1"}, - want: access.TokenValidationResult{UserID: "target-1", Role: "user", Username: "target", AssumedBy: "admin-1"}, + resp: &authpb.ValidateTokenResponse{UserId: "target-1", Role: "user", AssumedBy: "admin-1"}, + want: access.TokenValidationResult{UserID: "target-1", Role: "user", AssumedBy: "admin-1"}, }, } diff --git a/services/gateway/cmd/main.go b/services/gateway/cmd/main.go index b2b129d2..cac6e215 100644 --- a/services/gateway/cmd/main.go +++ b/services/gateway/cmd/main.go @@ -9,19 +9,20 @@ import ( "os" "os/signal" "syscall" - "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + sharedaccess "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/gateway/internal/config" "github.com/ItsThompson/gofin/services/gateway/internal/router" "github.com/ItsThompson/gofin/services/healthcheck" + "github.com/ItsThompson/gofin/services/serverkit" ) func main() { if healthcheck.ShouldRun(os.Args) { - os.Exit(healthcheck.Run("8080")) + os.Exit(healthcheck.Run(config.ResolvePort())) } if err := run(); err != nil { @@ -40,18 +41,11 @@ func run() error { return fmt.Errorf("loading config: %w", err) } - // Set up structured logging. - logLevel := slog.LevelInfo - switch cfg.LogLevel { - case "debug": - logLevel = slog.LevelDebug - case "warn": - logLevel = slog.LevelWarn - case "error": - logLevel = slog.LevelError - } - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) - logger = logger.With(slog.String("service", "gateway")) + // Set up structured logging via serverkit (JSON slog handler with the + // "service" attribute attached to every record). Installed as the default so + // slog.Warn call sites (e.g. config.Load's oversized-timeout warning) share + // the same handler. + logger := serverkit.NewLogger(cfg.LogLevel, "gateway") slog.SetDefault(logger) // Establish gRPC connection to the auth service for token validation. @@ -97,7 +91,7 @@ func run() error { ExpenseREST: expenseURL, FinanceREST: financeURL, DatarightsREST: datarightsURL, - }, logger, cfg.IsProduction()) + }, sharedaccess.Prefixes(), logger, cfg.IsProduction()) // Start the HTTP server. server := &http.Server{ @@ -105,29 +99,17 @@ func run() error { Handler: engine, } - go func() { - logger.Info("API gateway starting", - slog.String("port", cfg.Port), - slog.String("auth_rest", cfg.AuthServiceREST), - slog.String("expense_rest", cfg.ExpenseServiceREST), - slog.String("finance_rest", cfg.FinanceServiceREST), - slog.String("datarights_rest", cfg.DatarightsServiceREST), - ) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("server failed", slog.String("error", err.Error())) - } - }() - - // Wait for shutdown signal. - <-ctx.Done() - logger.Info("shutting down API gateway") - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - logger.Error("server shutdown error", slog.String("error", err.Error())) - } + logger.Info("API gateway starting", + slog.String("port", cfg.Port), + slog.String("auth_rest", cfg.AuthServiceREST), + slog.String("expense_rest", cfg.ExpenseServiceREST), + slog.String("finance_rest", cfg.FinanceServiceREST), + slog.String("datarights_rest", cfg.DatarightsServiceREST), + ) - return nil + // serverkit.Serve owns the serve/shutdown lifecycle and returns any fatal + // bind error so run() exits non-zero instead of lingering with no listener + // (the C5 zombie bug). The gateway runs no gRPC server, so both gRPC args + // are nil. + return serverkit.Serve(ctx, server, nil, nil) } diff --git a/services/gateway/cmd/run_test.go b/services/gateway/cmd/run_test.go new file mode 100644 index 00000000..1ab0c670 --- /dev/null +++ b/services/gateway/cmd/run_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "net" + "testing" + "time" +) + +// TestRun_RESTBindFailure_ReturnsError is the gateway's slice of the C5 +// zombie-on-bind-failure fix (US-PLATFORM-02): when the REST listener cannot +// bind (its port is already in use), run() must return a non-nil error so +// main() exits non-zero and the container restarts, instead of lingering with +// no listener. serverkit.Serve surfaces the bind error; this pins that the +// gateway's run() propagates it rather than swallowing it in a goroutine. +func TestRun_RESTBindFailure_ReturnsError(t *testing.T) { + // Occupy the wildcard port the gateway will try to bind (":PORT"). Binding + // the same wildcard address is what makes the conflict deterministic: a + // 127.0.0.1-only occupant would not reliably collide with the server's + // ":PORT" bind. + occupied, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatalf("failed to occupy a port: %v", err) + } + defer func() { _ = occupied.Close() }() + + _, port, err := net.SplitHostPort(occupied.Addr().String()) + if err != nil { + t.Fatalf("failed to parse occupied port: %v", err) + } + + t.Setenv("AUTH_SERVICE_ADDR", "auth-service:9081") + t.Setenv("AUTH_SERVICE_REST", "http://auth-service:8081") + t.Setenv("EXPENSE_SERVICE_REST", "http://expense-service:8082") + t.Setenv("FINANCE_SERVICE_REST", "http://finance-service:8083") + t.Setenv("DATARIGHTS_SERVICE_REST", "http://datarights-service:8084") + t.Setenv("PORT", port) + + // run() installs its own signal context and, with no signal sent, only + // returns on a fatal serve error here. Guard against a hang: a regression + // that reintroduces the zombie bug (swallowing the bind error) would block. + done := make(chan error, 1) + go func() { done <- run() }() + + select { + case err := <-done: + if err == nil { + t.Fatal("run() = nil, want a non-nil error when the REST port cannot bind") + } + case <-time.After(10 * time.Second): + t.Fatal("run() did not return within 10s; the REST bind error was swallowed (zombie bug)") + } +} diff --git a/services/gateway/go.mod b/services/gateway/go.mod index d0b7321c..32c2e60b 100644 --- a/services/gateway/go.mod +++ b/services/gateway/go.mod @@ -7,9 +7,11 @@ require ( // Locally this resolves via go.work; in Docker builds (GOWORK=off) the // replace directive below points to the sibling module. github.com/ItsThompson/gofin/services/access v0.0.0 + github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/auth v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 + github.com/ItsThompson/gofin/services/serverkit v0.0.0 github.com/gin-gonic/gin v1.12.0 github.com/stretchr/testify v1.11.1 google.golang.org/grpc v1.80.0 @@ -19,13 +21,20 @@ require ( // Required for Docker builds where go.work is not available. replace github.com/ItsThompson/gofin/services/access => ../access +replace github.com/ItsThompson/gofin/services/apierr => ../apierr + replace github.com/ItsThompson/gofin/services/auth => ../auth +replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate + replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck replace github.com/ItsThompson/gofin/services/metrics => ../metrics +replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit + require ( + github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect @@ -40,9 +49,17 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-migrate/migrate/v4 v4.18.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -58,9 +75,11 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.9.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect diff --git a/services/gateway/go.sum b/services/gateway/go.sum index 847e2e75..32ff6331 100644 --- a/services/gateway/go.sum +++ b/services/gateway/go.sum @@ -1,3 +1,7 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -14,6 +18,18 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= +github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -36,6 +52,10 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -43,6 +63,19 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -57,17 +90,31 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -90,6 +137,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -104,6 +152,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= @@ -114,6 +164,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= @@ -122,6 +174,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index 9c3aecd9..b19631fb 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -10,7 +10,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/ItsThompson/gofin/services/access" + sharedaccess "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/apierr" ) // Identity headers the gateway sets for downstream services after a successful @@ -52,16 +53,16 @@ const ( // // The per-level switch is fail-safe: only Authenticated passes without a role // check, and any level that is not explicitly allowed is denied (403). -func AccessControl(validator TokenValidator, resolve func(method, path string) access.Access, logger *slog.Logger) gin.HandlerFunc { +func AccessControl(validator TokenValidator, resolve func(method, path string) sharedaccess.Level, logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { stripIdentityHeaders(c) level := resolve(c.Request.Method, c.Request.URL.Path) - if level == access.Public { + if level == sharedaccess.Public { c.Next() return } - if level == access.Deny { + if level == sharedaccess.Deny { // An unclassified path is not a real route, so no identity is // needed: refuse it with a 403 before the cookie is read. abortForbidden(c, logger) @@ -105,17 +106,17 @@ func AccessControl(validator TokenValidator, resolve func(method, path string) a setIdentityHeaders(c, result) switch level { - case access.Personal: + case sharedaccess.Personal: if result.Role != roleUser { rejectForbidden(c, logger, result) return } - case access.Admin: + case sharedaccess.Admin: if result.Role != roleAdmin { rejectForbidden(c, logger, result) return } - case access.Authenticated: + case sharedaccess.Authenticated: // Any valid token passes; no role check. default: // Fail-safe by construction: Public and Deny are short-circuited @@ -135,11 +136,11 @@ func AccessControl(validator TokenValidator, resolve func(method, path string) a // and delegates every /api route to the shared registry resolver. Keeping this // composition in the gateway is why services/access never needs to know about // gateway-owned routes. -func GatewayResolve(method, path string) access.Access { +func GatewayResolve(method, path string) sharedaccess.Level { if path == "/health" || path == "/metrics" { - return access.Public + return sharedaccess.Public } - return access.Resolve(method, path) + return sharedaccess.Resolve(method, path) } // stripIdentityHeaders removes client-supplied identity headers before @@ -163,12 +164,12 @@ func setIdentityHeaders(c *gin.Context, result *TokenValidationResult) { } } -// abortUnauthorized ends the request with the unchanged 401 contract. +// abortUnauthorized ends the request with the unchanged 401 contract, encoded +// through the shared apierr wire struct. c.Abort halts the middleware chain +// (apierr.Respond only writes the body/status; it does not abort). func abortUnauthorized(c *gin.Context, message string) { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "code": "UNAUTHORIZED", - "message": message, - }) + apierr.Respond(c, apierr.Unauthorized(message)) + c.Abort() } // isValidationTimeout reports whether a ValidateToken error is the bounded @@ -190,12 +191,15 @@ func isValidationTimeout(err error) bool { // abortUnavailable ends the request with 503 SERVICE_UNAVAILABLE, used when the // auth dependency is unhealthy (validation timed out) rather than the client's -// token being invalid. +// token being invalid. SERVICE_UNAVAILABLE is a gateway-specific code (not one +// of apierr's shared codes), so the typed error is constructed inline. func abortUnavailable(c *gin.Context) { - c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ - "code": "SERVICE_UNAVAILABLE", - "message": "Authentication service unavailable", + apierr.Respond(c, &apierr.Error{ + Code: "SERVICE_UNAVAILABLE", + Message: "Authentication service unavailable", + Status: http.StatusServiceUnavailable, }) + c.Abort() } // rejectForbidden ends the request with the unchanged 403 code contract (the @@ -224,10 +228,13 @@ func abortForbidden(c *gin.Context, logger *slog.Logger) { } // writeForbidden emits the shared 403 body contract (FORBIDDEN / "Access -// denied") used by both the role-denied and unclassified-route paths. +// denied") used by both the role-denied and unclassified-route paths, encoded +// through the shared apierr wire struct. func writeForbidden(c *gin.Context) { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ - "code": "FORBIDDEN", - "message": "Access denied", + apierr.Respond(c, &apierr.Error{ + Code: apierr.CodeForbidden, + Message: "Access denied", + Status: http.StatusForbidden, }) + c.Abort() } diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index a0f037a0..036cb81c 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -437,7 +437,7 @@ func TestAccessControl_UnknownLevel_DeniesByDefault(t *testing.T) { // A resolve func that returns an out-of-enum access level for every path. // A valid token reaches the middleware's switch with a level that matches // none of the known cases and must fall through to the fail-safe deny. - resolve := func(_, _ string) sharedaccess.Access { return sharedaccess.Access(99) } + resolve := func(_, _ string) sharedaccess.Level { return sharedaccess.Level(99) } engine := gin.New() engine.Use(access.AccessControl(validator, resolve, silentLogger())) @@ -482,7 +482,7 @@ func TestGatewayResolve(t *testing.T) { name string method string path string - want sharedaccess.Access + want sharedaccess.Level }{ {"health is public", http.MethodGet, "/health", sharedaccess.Public}, {"metrics is public", http.MethodGet, "/metrics", sharedaccess.Public}, @@ -500,6 +500,47 @@ func TestGatewayResolve(t *testing.T) { } } +// TestAccessControl_ErrorBodies_MatchApierrWireShape pins that routing the +// gateway's middleware errors through apierr.Respond preserves the exact +// {code,message} wire bytes clients already receive, so the migration is not a +// schema change. Covers the 401 (missing cookie), 403 (denied route), and 503 +// (auth-dependency timeout) paths. Asserts the raw body (not JSONEq) so the +// byte-for-byte claim is pinned: field order and the absence of a trailing +// newline are part of the contract. +func TestAccessControl_ErrorBodies_MatchApierrWireShape(t *testing.T) { + t.Run("401 missing cookie", func(t *testing.T) { + engine := buildEngine(&fakeValidator{}, silentLogger(), http.MethodGet, "/api/auth/me", okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, `{"code":"UNAUTHORIZED","message":"Authentication required"}`, rec.Body.String()) + }) + + t.Run("403 denied unclassified route", func(t *testing.T) { + engine := buildEngine(&fakeValidator{}, silentLogger(), http.MethodGet, "/api/unclassified", okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/unclassified", nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, `{"code":"FORBIDDEN","message":"Access denied"}`, rec.Body.String()) + }) + + t.Run("503 auth dependency timeout", func(t *testing.T) { + validator := &fakeValidator{err: context.DeadlineExceeded} + engine := buildEngine(validator, silentLogger(), http.MethodGet, "/api/finance/periods", okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/finance/periods", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + assert.Equal(t, `{"code":"SERVICE_UNAVAILABLE","message":"Authentication service unavailable"}`, rec.Body.String()) + }) +} + // bytesBuffer is a minimal io.Writer that also parses the last JSON log line. type bytesBuffer struct { data []byte diff --git a/services/gateway/internal/access/validator.go b/services/gateway/internal/access/validator.go index 587d2171..238481ad 100644 --- a/services/gateway/internal/access/validator.go +++ b/services/gateway/internal/access/validator.go @@ -8,7 +8,6 @@ import "context" type TokenValidationResult struct { UserID string Role string - Username string AssumedBy string } diff --git a/services/gateway/internal/config/config.go b/services/gateway/internal/config/config.go index f6e0ed00..c7526053 100644 --- a/services/gateway/internal/config/config.go +++ b/services/gateway/internal/config/config.go @@ -29,6 +29,11 @@ const defaultValidateTimeout = 3 * time.Second // near-unbounded tail this bound exists to prevent. const maxRecommendedValidateTimeout = 30 * time.Second +// DefaultPort is the gateway's default HTTP listen port. It is the single +// source of truth shared by Load (the listener) and the --healthcheck probe so +// the two can never desync (US-PLATFORM-05). +const DefaultPort = "8080" + // Config holds all configuration for the API gateway, loaded from environment variables. type Config struct { AuthServiceAddr string // gRPC address for auth service (e.g., "auth-service:9081") @@ -80,10 +85,7 @@ func Load() (*Config, error) { environment = "development" } - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } + port := ResolvePort() validateTimeout := defaultValidateTimeout if raw := os.Getenv("GATEWAY_VALIDATE_TIMEOUT"); raw != "" { @@ -120,3 +122,16 @@ func Load() (*Config, error) { func (c *Config) IsProduction() bool { return c.Environment != "development" } + +// ResolvePort returns the configured HTTP port: the PORT env override when set, +// else DefaultPort. Both the listener (via Load) and the --healthcheck probe +// call this, so overriding PORT moves them together instead of desyncing the +// probe from a hardcoded literal (US-PLATFORM-05). It is standalone (not a +// Config method) so the pre-config --healthcheck branch can call it without a +// full Load. +func ResolvePort() string { + if port := os.Getenv("PORT"); port != "" { + return port + } + return DefaultPort +} diff --git a/services/gateway/internal/config/config_test.go b/services/gateway/internal/config/config_test.go index 90ba792b..a8e311a3 100644 --- a/services/gateway/internal/config/config_test.go +++ b/services/gateway/internal/config/config_test.go @@ -61,6 +61,55 @@ func TestLoad_ValidateTimeout_RejectsNonPositive(t *testing.T) { } } +// TestResolvePort_DefaultsWhenUnset confirms the probe/listener port falls back +// to DefaultPort when PORT is not set. +func TestResolvePort_DefaultsWhenUnset(t *testing.T) { + t.Setenv("PORT", "") + if got := ResolvePort(); got != DefaultPort { + t.Errorf("ResolvePort() = %q, want default %q", got, DefaultPort) + } +} + +// TestResolvePort_HonorsOverride confirms a PORT override is returned verbatim, +// so the --healthcheck probe targets the same port the listener binds +// (US-PLATFORM-05). +func TestResolvePort_HonorsOverride(t *testing.T) { + t.Setenv("PORT", "9090") + if got := ResolvePort(); got != "9090" { + t.Errorf("ResolvePort() = %q, want the override 9090", got) + } +} + +// TestLoad_PortDefaultsToDefaultPort proves Load sources the listener port from +// the same ResolvePort helper the probe uses, defaulting to DefaultPort. +func TestLoad_PortDefaultsToDefaultPort(t *testing.T) { + setGatewayEnv(t) + t.Setenv("PORT", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Port != DefaultPort { + t.Errorf("cfg.Port = %q, want default %q", cfg.Port, DefaultPort) + } +} + +// TestLoad_PortHonorsOverride proves a PORT override reaches the listener, +// matching what the probe resolves. +func TestLoad_PortHonorsOverride(t *testing.T) { + setGatewayEnv(t) + t.Setenv("PORT", "9090") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Port != "9090" { + t.Errorf("cfg.Port = %q, want the override 9090", cfg.Port) + } +} + // captureDefaultLogger swaps slog's default logger for one writing to the // returned buffer (Load emits its oversized-value warning through slog.Warn, // i.e. the default logger) and restores it on cleanup. diff --git a/services/gateway/internal/proxy/proxy.go b/services/gateway/internal/proxy/proxy.go index 1eff02bd..f556e9eb 100644 --- a/services/gateway/internal/proxy/proxy.go +++ b/services/gateway/internal/proxy/proxy.go @@ -1,12 +1,15 @@ package proxy import ( + "encoding/json" "log/slog" "net" "net/http" "net/http/httputil" "net/url" "time" + + "github.com/ItsThompson/gofin/services/apierr" ) // NewServiceProxy creates a reverse proxy that forwards requests to the given @@ -41,7 +44,14 @@ func NewServiceProxy(target *url.URL, logger *slog.Logger) http.Handler { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte(`{"code":"BAD_GATEWAY","message":"Downstream service is unavailable"}`)) + // Encode via the shared apierr wire struct so every gateway error body + // (including this raw-ResponseWriter path, which has no gin.Context for + // apierr.Respond) shares one shape. BAD_GATEWAY is gateway-specific. + body, _ := json.Marshal(apierr.APIError{ + Code: "BAD_GATEWAY", + Message: "Downstream service is unavailable", + }) + _, _ = w.Write(body) } // Use a transport with reasonable timeouts for inter-service communication. diff --git a/services/gateway/internal/router/router.go b/services/gateway/internal/router/router.go index e036d0f5..4499637b 100644 --- a/services/gateway/internal/router/router.go +++ b/services/gateway/internal/router/router.go @@ -24,10 +24,14 @@ type ServiceURLs struct { } // New creates a configured Gin engine with all gateway routes, middleware, -// and reverse proxy handlers wired up. +// and reverse proxy handlers wired up. The proxy groups are derived from the +// injected prefixes (the shared services/access inventory, obtained via +// sharedaccess.Prefixes()); injecting them keeps New a pure function of its +// inputs and lets tests construct a wiring without mutating shared state. func New( validator access.TokenValidator, serviceURLs *ServiceURLs, + prefixes []sharedaccess.ProxyPrefix, logger *slog.Logger, isProduction bool, ) *gin.Engine { @@ -64,12 +68,12 @@ func New( "datarights": proxy.NewServiceProxy(serviceURLs.DatarightsREST, logger), } - // Derive the proxy wiring from the shared prefix inventory so onboarding a - // service is a single edit to services/access.ProxyPrefixes (which the + // Derive the proxy wiring from the injected prefix inventory so onboarding a + // service is a single edit to services/access.proxyPrefixes (which the // cross-check test pins to the Registry). Access is enforced globally by // AccessControl against the Registry, not per group. Fail fast if a prefix // names a service with no proxy handler. - for _, p := range sharedaccess.ProxyPrefixes { + for _, p := range prefixes { handler, ok := proxies[p.Service] if !ok { panic(fmt.Sprintf( diff --git a/services/gateway/internal/router/router_test.go b/services/gateway/internal/router/router_test.go index 17d9eb24..ffbc2a09 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -39,9 +39,8 @@ func validCookie() *http.Cookie { func adminValidator() *mockValidator { return &mockValidator{ result: &access.TokenValidationResult{ - UserID: "admin-1", - Role: "admin", - Username: "admin", + UserID: "admin-1", + Role: "admin", }, } } @@ -49,9 +48,8 @@ func adminValidator() *mockValidator { func userValidator() *mockValidator { return &mockValidator{ result: &access.TokenValidationResult{ - UserID: "user-1", - Role: "user", - Username: "alice", + UserID: "user-1", + Role: "user", }, } } @@ -96,7 +94,7 @@ func setupGateway(t *testing.T, validator access.TokenValidator) func(method, pa ExpenseREST: expenseURL, FinanceREST: financeURL, DatarightsREST: datarightsURL, - }, newSilentLogger(), false) + }, sharedaccess.Prefixes(), newSilentLogger(), false) // Use a real HTTP test server so the response writer supports CloseNotifier // (required by httputil.ReverseProxy). @@ -317,15 +315,13 @@ func TestRouter_HealthEndpoint(t *testing.T) { } // TestRouter_New_PanicsOnPrefixWithNoProxy pins the data-driven wiring's -// fail-fast contract: because router.New derives its proxy groups from -// sharedaccess.ProxyPrefixes, a prefix naming a service with no proxy handler +// fail-fast contract: because router.New derives its proxy groups from the +// injected prefix inventory, a prefix naming a service with no proxy handler // is a wiring bug that must panic at construction rather than silently drop the -// prefix. The bad prefix is appended and restored so other tests are unaffected. +// prefix. The bad prefix is injected directly (no shared-global mutation). func TestRouter_New_PanicsOnPrefixWithNoProxy(t *testing.T) { - original := sharedaccess.ProxyPrefixes - sharedaccess.ProxyPrefixes = append(append([]sharedaccess.ProxyPrefix{}, original...), + badPrefixes := append(sharedaccess.Prefixes(), sharedaccess.ProxyPrefix{Prefix: "/api/ghost", Service: "ghost"}) - t.Cleanup(func() { sharedaccess.ProxyPrefixes = original }) defer func() { r := recover() @@ -340,5 +336,5 @@ func TestRouter_New_PanicsOnPrefixWithNoProxy(t *testing.T) { ExpenseREST: u, FinanceREST: u, DatarightsREST: u, - }, newSilentLogger(), false) + }, badPrefixes, newSilentLogger(), false) } diff --git a/services/gateway/perf/baseline/validate.txt b/services/gateway/perf/baseline/validate.txt index ce476f4f..60404e12 100644 --- a/services/gateway/perf/baseline/validate.txt +++ b/services/gateway/perf/baseline/validate.txt @@ -25,7 +25,7 @@ BenchmarkValidateHappyPath-14 447896 2678 ns/op 8467 B/op 59 allocs/ Per the `services/perf` README, wall-clock ns/op and absolute allocs/op are same-machine references (arm64 dev here vs amd64 CI), not a CI gate. This path's regression guard is **behavioral** (see below), not an allocation count, so no -`AssertMaxAllocs` bound is asserted for it. +allocation-bound assertion is made for it. ## The meaningful "before": no upper latency bound diff --git a/services/go.work b/services/go.work index 0dbf95e8..b0a402a5 100644 --- a/services/go.work +++ b/services/go.work @@ -2,6 +2,7 @@ go 1.26 use ( ./access + ./apierr ./auth ./datarights ./dbmigrate @@ -9,6 +10,9 @@ use ( ./finance ./gateway ./healthcheck + ./httpx ./metrics ./perf + ./pgutil + ./serverkit ) diff --git a/services/httpx/bind.go b/services/httpx/bind.go new file mode 100644 index 00000000..f0c1fc94 --- /dev/null +++ b/services/httpx/bind.go @@ -0,0 +1,38 @@ +package httpx + +import ( + "errors" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + + "github.com/ItsThompson/gofin/services/apierr" +) + +// BindJSON binds the request body into dst. On failure it writes a 400 +// apierr.Error, attaching field-level detail when the validator reports it, +// and returns false. It is generic over the target type so call sites stay a +// single line. +func BindJSON[T any](c *gin.Context, dst *T) bool { + if err := c.ShouldBindJSON(dst); err != nil { + apierr.Respond(c, apierr.Validation("Invalid request body", validationFields(err))) + return false + } + return true +} + +// validationFields extracts a field->failed-rule map from a binding error when +// it is a validator.ValidationErrors. Malformed-JSON and other bind errors +// carry no field detail, so it returns nil (Fields is omitted on the wire). +func validationFields(err error) map[string]string { + var verrs validator.ValidationErrors + if !errors.As(err, &verrs) { + return nil + } + + fields := make(map[string]string, len(verrs)) + for _, fe := range verrs { + fields[fe.Field()] = fe.Tag() + } + return fields +} diff --git a/services/httpx/go.mod b/services/httpx/go.mod new file mode 100644 index 00000000..6974a2b4 --- /dev/null +++ b/services/httpx/go.mod @@ -0,0 +1,46 @@ +module github.com/ItsThompson/gofin/services/httpx + +go 1.26 + +require ( + github.com/ItsThompson/gofin/services/apierr v0.0.0 + github.com/gin-gonic/gin v1.12.0 + github.com/go-playground/validator/v10 v10.30.1 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/ItsThompson/gofin/services/apierr => ../apierr diff --git a/services/httpx/go.sum b/services/httpx/go.sum new file mode 100644 index 00000000..01a6a0ef --- /dev/null +++ b/services/httpx/go.sum @@ -0,0 +1,91 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/httpx/httpx_test.go b/services/httpx/httpx_test.go new file mode 100644 index 00000000..1fd5b884 --- /dev/null +++ b/services/httpx/httpx_test.go @@ -0,0 +1,121 @@ +package httpx_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/apierr" + "github.com/ItsThompson/gofin/services/httpx" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body +} + +func newContextWithRequest(req *http.Request) (*gin.Context, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + return c, w +} + +func TestRequireUserID_PresentHeaderReturnsValue(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/finance", nil) + req.Header.Set("X-User-ID", "user-123") + c, w := newContextWithRequest(req) + + userID, ok := httpx.RequireUserID(c) + + assert.True(t, ok) + assert.Equal(t, "user-123", userID) + assert.Equal(t, http.StatusOK, w.Code, "no error response should be written") +} + +func TestRequireUserID_MissingHeaderWrites401(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/finance", nil) + c, w := newContextWithRequest(req) + + userID, ok := httpx.RequireUserID(c) + + assert.False(t, ok) + assert.Empty(t, userID) + require.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, apierr.CodeUnauthorized, decodeBody(t, w)["code"]) +} + +func TestRequireUserID_EmptyHeaderWrites401(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/finance", nil) + req.Header.Set("X-User-ID", "") + c, w := newContextWithRequest(req) + + _, ok := httpx.RequireUserID(c) + + assert.False(t, ok) + require.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, apierr.CodeUnauthorized, decodeBody(t, w)["code"]) +} + +type createExpenseRequest struct { + Amount int `json:"amount" binding:"required"` + Note string `json:"note"` +} + +func newJSONContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/expense", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + return newContextWithRequest(req) +} + +func TestBindJSON_ValidBodyPopulatesDst(t *testing.T) { + c, w := newJSONContext(`{"amount": 42, "note": "lunch"}`) + + var req createExpenseRequest + ok := httpx.BindJSON(c, &req) + + assert.True(t, ok) + assert.Equal(t, 42, req.Amount) + assert.Equal(t, "lunch", req.Note) + assert.Equal(t, http.StatusOK, w.Code, "no error response should be written") +} + +func TestBindJSON_MalformedBodyWrites400(t *testing.T) { + c, w := newJSONContext(`{"amount": `) + + var req createExpenseRequest + ok := httpx.BindJSON(c, &req) + + assert.False(t, ok) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, apierr.CodeValidation, decodeBody(t, w)["code"]) +} + +func TestBindJSON_ValidationFailureAttachesFieldDetail(t *testing.T) { + // "amount" is required; omitting it triggers validator.ValidationErrors. + c, w := newJSONContext(`{"note": "lunch"}`) + + var req createExpenseRequest + ok := httpx.BindJSON(c, &req) + + assert.False(t, ok) + require.Equal(t, http.StatusBadRequest, w.Code) + body := decodeBody(t, w) + assert.Equal(t, apierr.CodeValidation, body["code"]) + + fields, present := body["fields"].(map[string]any) + require.True(t, present, "validator failures must surface field detail") + assert.Equal(t, "required", fields["Amount"]) +} diff --git a/services/httpx/userid.go b/services/httpx/userid.go new file mode 100644 index 00000000..0648d19d --- /dev/null +++ b/services/httpx/userid.go @@ -0,0 +1,22 @@ +// Package httpx provides the request guards duplicated across the GoFin HTTP +// handlers: trusted X-User-ID presence and JSON body binding. Both write an +// apierr response on failure so handlers collapse to a one-line guard. +package httpx + +import ( + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/apierr" +) + +// RequireUserID reads the trusted X-User-ID header injected by the gateway +// after token validation. On an empty or missing header it writes a 401 +// apierr.Error and returns ok=false; the caller must return immediately. +func RequireUserID(c *gin.Context) (userID string, ok bool) { + userID = c.GetHeader("X-User-ID") + if userID == "" { + apierr.Respond(c, apierr.Unauthorized("Authentication required")) + return "", false + } + return userID, true +} diff --git a/services/metrics/interceptor_test.go b/services/metrics/interceptor_test.go new file mode 100644 index 00000000..76e23b4b --- /dev/null +++ b/services/metrics/interceptor_test.go @@ -0,0 +1,72 @@ +package metrics_test + +import ( + "context" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/ItsThompson/gofin/services/metrics" +) + +// TestUnaryServerInterceptor_RecordsSuccessOutcome drives the interceptor with a +// stub handler that returns no error and asserts grpc_requests_total is +// incremented for the "OK" status label, that the handler ran, and that the +// handler's response and nil error propagate back to the caller. +func TestUnaryServerInterceptor_RecordsSuccessOutcome(t *testing.T) { + interceptor := metrics.UnaryServerInterceptor() + // A unique method isolates this test's series from other tests that also + // touch the process-global GRPCRequestsTotal collector. + const method = "/test.Service/InterceptorSuccess" + info := &grpc.UnaryServerInfo{FullMethod: method} + + handlerCalled := false + handler := func(_ context.Context, req any) (any, error) { + handlerCalled = true + return "handled", nil + } + + before := testutil.ToFloat64(metrics.GRPCRequestsTotal.WithLabelValues(method, codes.OK.String())) + + resp, err := interceptor(context.Background(), "req", info, handler) + + require.NoError(t, err) + assert.True(t, handlerCalled, "interceptor must invoke the wrapped handler") + assert.Equal(t, "handled", resp, "interceptor must return the handler's response") + + after := testutil.ToFloat64(metrics.GRPCRequestsTotal.WithLabelValues(method, codes.OK.String())) + assert.Equal(t, 1.0, after-before, "success outcome must record one OK request") +} + +// TestUnaryServerInterceptor_RecordsErrorOutcome drives the interceptor with a +// stub handler that returns a gRPC status error and asserts grpc_requests_total +// is incremented for that status-code label, and that the handler's error +// propagates back unchanged. +func TestUnaryServerInterceptor_RecordsErrorOutcome(t *testing.T) { + interceptor := metrics.UnaryServerInterceptor() + const method = "/test.Service/InterceptorError" + info := &grpc.UnaryServerInfo{FullMethod: method} + + wantErr := status.Error(codes.NotFound, "missing") + handlerCalled := false + handler := func(_ context.Context, req any) (any, error) { + handlerCalled = true + return nil, wantErr + } + + before := testutil.ToFloat64(metrics.GRPCRequestsTotal.WithLabelValues(method, codes.NotFound.String())) + + resp, err := interceptor(context.Background(), "req", info, handler) + + require.ErrorIs(t, err, wantErr, "interceptor must propagate the handler's error") + assert.True(t, handlerCalled, "interceptor must invoke the wrapped handler") + assert.Nil(t, resp, "interceptor must return the handler's nil response on error") + + after := testutil.ToFloat64(metrics.GRPCRequestsTotal.WithLabelValues(method, codes.NotFound.String())) + assert.Equal(t, 1.0, after-before, "error outcome must record one NotFound request") +} diff --git a/services/metrics/metrics.go b/services/metrics/metrics.go index 58dc3535..97860b23 100644 --- a/services/metrics/metrics.go +++ b/services/metrics/metrics.go @@ -1,6 +1,6 @@ // Package metrics provides shared Prometheus instrumentation for all gofin -// Go services: HTTP middleware, gRPC interceptors, database metric helpers, -// and custom business metric definitions. +// Go services: HTTP middleware, gRPC interceptors, and custom business metric +// definitions. package metrics import ( @@ -60,30 +60,6 @@ var ( ) ) -// --------------------------------------------------------------------------- -// Database metrics -// --------------------------------------------------------------------------- - -var ( - // DBQueryDuration observes database query latency in seconds. - DBQueryDuration = promauto.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "db_query_duration_seconds", - Help: "Database query duration in seconds", - Buckets: prometheus.DefBuckets, - }, - []string{"query_name"}, - ) - - // ActiveConnections tracks the current number of active database connections. - ActiveConnections = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "active_connections", - Help: "Number of active database connections", - }, - ) -) - // --------------------------------------------------------------------------- // Custom business metrics // --------------------------------------------------------------------------- @@ -119,16 +95,6 @@ var ( // Helpers // --------------------------------------------------------------------------- -// ObserveQuery records the duration of a named database query. -func ObserveQuery(queryName string, seconds float64) { - DBQueryDuration.WithLabelValues(queryName).Observe(seconds) -} - -// SetActiveConnections updates the active database connection gauge. -func SetActiveConnections(count float64) { - ActiveConnections.Set(count) -} - // Register adds the /metrics endpoint to a Gin engine, serving the default // Prometheus registry in exposition format. func Register(r *gin.Engine) { diff --git a/services/metrics/metrics_test.go b/services/metrics/metrics_test.go index 5aac8992..a245da35 100644 --- a/services/metrics/metrics_test.go +++ b/services/metrics/metrics_test.go @@ -34,7 +34,6 @@ func TestMetricsEndpoint_ReturnsPrometheusFormat(t *testing.T) { metrics.GRPCRequestsTotal.WithLabelValues("/test.Service/Method", "OK").Inc() metrics.GRPCRequestDuration.WithLabelValues("/test.Service/Method").Observe(0.01) - metrics.ObserveQuery("test_query", 0.05) metrics.TokenRefreshTotal.WithLabelValues("success").Inc() // Now scrape /metrics. @@ -51,11 +50,11 @@ func TestMetricsEndpoint_ReturnsPrometheusFormat(t *testing.T) { assert.Contains(t, body, "# TYPE http_request_duration_seconds histogram") assert.Contains(t, body, "# HELP grpc_requests_total") assert.Contains(t, body, "# HELP grpc_request_duration_seconds") - assert.Contains(t, body, "# HELP db_query_duration_seconds") - assert.Contains(t, body, "# HELP active_connections") assert.Contains(t, body, "# HELP expense_entries_total") assert.Contains(t, body, "# HELP corrections_total") assert.Contains(t, body, "# HELP token_refresh_total") + // The dead ActiveConnections gauge was removed; its series must never reappear. + assert.NotContains(t, body, "active_connections") } func TestHTTPMetrics_RecordsRequestMetrics(t *testing.T) { @@ -133,34 +132,6 @@ func TestHTTPMetrics_UsesRouteTemplate(t *testing.T) { assert.NotContains(t, body, `path="/api/items/abc-123"`) } -func TestObserveQuery_RecordsDBMetric(t *testing.T) { - router := gin.New() - metrics.Register(router) - - metrics.ObserveQuery("get_user", 0.042) - - req := httptest.NewRequest(http.MethodGet, "/metrics", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - body := w.Body.String() - assert.Contains(t, body, `db_query_duration_seconds_bucket{query_name="get_user"`) -} - -func TestSetActiveConnections_SetsGauge(t *testing.T) { - router := gin.New() - metrics.Register(router) - - metrics.SetActiveConnections(5) - - req := httptest.NewRequest(http.MethodGet, "/metrics", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - body := w.Body.String() - assert.Contains(t, body, "active_connections 5") -} - func TestCustomMetrics_IncrementAndAppear(t *testing.T) { router := gin.New() metrics.Register(router) diff --git a/services/perf/README.md b/services/perf/README.md index b1cdbacd..f984c37c 100644 --- a/services/perf/README.md +++ b/services/perf/README.md @@ -22,7 +22,7 @@ and gate on are the ones that reproduce on any machine.** | Upstream call counts (gRPC/repo) | Yes, structural | Committed baseline + CI assertion (`CallCounter`) | | Query counts / rows scanned | Yes, structural | Committed baseline + CI assertion | | Complexity-scaling ratio (cost across input sizes) | Yes, the *ratio* is portable | Committed baseline + growth-ratio assertion | -| `allocs/op`, `B/op` | Per arch/compiler only | Baseline is documentation; CI asserts **bounds/ratios**, never committed absolutes (`AssertMaxAllocs`) | +| `allocs/op`, `B/op` | Per arch/compiler only | Baseline is documentation; CI asserts **bounds/ratios** over `testing.AllocsPerRun`, never committed absolutes | | Wall-clock `ns/op` | No | Same-machine before/after reference only, never a gate | **Durable, portable signals are allocs bounds, call counts, and scaling ratios. @@ -39,7 +39,7 @@ captured on the maintainer's machine (Apple M-series, **arm64**); CI runs on documentation for the same-machine before/after story: they are **not** valid CI thresholds. CI alloc assertions use only arch-stable forms: -- `AssertMaxAllocs(t, max, fn)` max-bounds ("≤ N allocations") with headroom. +- Max-bounds ("≤ N allocations") over `testing.AllocsPerRun`, with headroom. - Growth-ratios ("allocs must not scale with input size"). Never assert a committed absolute alloc number in CI. @@ -72,19 +72,6 @@ if got := spy.Count("GetAllUserData"); got > 1 { } ``` -### `AssertMaxAllocs` - -Wraps `testing.AllocsPerRun` with a clear observed-vs-allowed failure message. -Pass an arch-stable upper bound with headroom, not the exact number captured on -one machine. - -```go -perf.AssertMaxAllocs(t, 8, func() { - _ = encodeRow(row) -}) -// fails with: "allocations exceeded bound: observed 12 allocs/op, allowed <= 8" -``` - > **Intentionally not provided:** a `baseline.Load`/`baseline.Compare` helper. > Nothing would consume it: the before/after comparison is `benchstat old.txt > new.txt` (local, reviewed in the PR diff) and the CI gate is hand-written @@ -158,8 +145,8 @@ Optimization slices commit baselines under snapshots). Each file records the portable metrics as primary content, with wall-clock clearly labeled as a same-machine reference. The committed baseline is documentation reviewed in the PR diff; the non-flaky regression gate is the -efficiency assertion (`CallCounter` bounds, query-shape checks, `AssertMaxAllocs` -bounds, growth-ratios) that rides the existing `test-backend` job. +efficiency assertion (`CallCounter` bounds, query-shape checks, alloc bounds, +growth-ratios) that rides the existing `test-backend` job. ## Testing this module diff --git a/services/perf/allocs.go b/services/perf/allocs.go deleted file mode 100644 index 2b205d21..00000000 --- a/services/perf/allocs.go +++ /dev/null @@ -1,41 +0,0 @@ -package perf - -import "testing" - -// allocRunsPerAssertion is the number of measured runs passed to -// testing.AllocsPerRun; its integer-floored average smooths sporadic background -// allocations. -const allocRunsPerAssertion = 100 - -// allocReporter is the subset of testing.TB that AssertMaxAllocs needs to report -// a failure. testing.TB satisfies it; tests supply a fake to exercise the -// failure path without failing the enclosing test (testing.TB is sealed and -// cannot be implemented outside the testing package). -type allocReporter interface { - Helper() - Errorf(format string, args ...any) -} - -// AssertMaxAllocs fails t if fn allocates more than maxAllocs times per run, -// reporting observed vs allowed allocations. -// -// maxAllocs is an arch-stable upper bound with headroom, NOT a committed absolute -// alloc count: allocs/op differ between architectures (arm64 dev vs amd64 CI) -// and Go versions, so pick a bound the path should never exceed rather than the -// exact number captured on one machine. See README.md. -// -// This helper is a shipped part of the measurement foundation but currently has -// no caller; it is reserved for a non-scaling allocation-bound path that -// warrants a fixed bound (growth-ratio tests cover the scaling paths instead). -func AssertMaxAllocs(t testing.TB, maxAllocs float64, fn func()) { - t.Helper() - assertMaxAllocs(t, maxAllocs, fn) -} - -func assertMaxAllocs(r allocReporter, maxAllocs float64, fn func()) { - r.Helper() - observed := testing.AllocsPerRun(allocRunsPerAssertion, fn) - if observed > maxAllocs { - r.Errorf("allocations exceeded bound: observed %.0f allocs/op, allowed <= %.0f", observed, maxAllocs) - } -} diff --git a/services/perf/allocs_test.go b/services/perf/allocs_test.go deleted file mode 100644 index f340cc75..00000000 --- a/services/perf/allocs_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package perf - -import ( - "fmt" - "strings" - "testing" -) - -// allocSink gives allocations in the test closures a heap escape so -// testing.AllocsPerRun counts them; without an escape the compiler may -// stack-allocate and report zero. It is read back to confirm the allocation -// actually happened (and to keep it from being a write-only variable). -var allocSink []byte - -// fakeReporter stands in for testing.TB so the failure path can be exercised -// without failing the enclosing test. testing.TB is sealed and cannot be -// implemented outside the testing package, so AssertMaxAllocs delegates to -// assertMaxAllocs, which accepts the narrower allocReporter. -type fakeReporter struct { - helperCalls int - messages []string -} - -func (f *fakeReporter) Helper() { f.helperCalls++ } - -func (f *fakeReporter) Errorf(format string, args ...any) { - f.messages = append(f.messages, fmt.Sprintf(format, args...)) -} - -func TestAssertMaxAllocs_FailsWhenOverBound(t *testing.T) { - r := &fakeReporter{} - - assertMaxAllocs(r, 0, func() { allocSink = make([]byte, 64) }) - - if len(allocSink) != 64 { - t.Fatalf("sink not populated; allocation may have been optimized away (len=%d)", len(allocSink)) - } - if r.helperCalls == 0 { - t.Error("expected Helper() to be called") - } - if len(r.messages) != 1 { - t.Fatalf("expected exactly 1 failure message, got %d: %v", len(r.messages), r.messages) - } - msg := r.messages[0] - if !strings.Contains(msg, "observed") || !strings.Contains(msg, "allowed") { - t.Errorf("failure message should report observed vs allowed, got: %q", msg) - } - if !strings.Contains(msg, "<= 0") { - t.Errorf("failure message should report the allowed bound (0), got: %q", msg) - } -} - -func TestAssertMaxAllocs_PassesWhenWithinBound(t *testing.T) { - r := &fakeReporter{} - - assertMaxAllocs(r, 2, func() { allocSink = make([]byte, 32) }) - - if len(r.messages) != 0 { - t.Errorf("expected no failure within bound, got: %v", r.messages) - } -} - -func TestAssertMaxAllocs_PassesForZeroAllocFunc(t *testing.T) { - r := &fakeReporter{} - - assertMaxAllocs(r, 0, func() {}) - - if len(r.messages) != 0 { - t.Errorf("expected no failure for zero-allocation func, got: %v", r.messages) - } -} - -// TestAssertMaxAllocs_PublicPassPath drives the exported wrapper with a real -// *testing.T to confirm it does not fail the test when fn stays within bound. -func TestAssertMaxAllocs_PublicPassPath(t *testing.T) { - AssertMaxAllocs(t, 5, func() { allocSink = make([]byte, 16) }) -} diff --git a/services/perf/counter.go b/services/perf/counter.go index a90b166b..24674bd8 100644 --- a/services/perf/counter.go +++ b/services/perf/counter.go @@ -1,8 +1,7 @@ // Package perf provides shared test helpers for the gofin latency & efficiency // epic: a concurrency-safe call counter that spy client/repo implementations -// embed, and an allocation-bound assertion over testing.AllocsPerRun. It carries -// no runtime dependencies and is imported only from test files. See README.md -// for the benchmark / pprof / benchstat workflow. +// embed. It carries no runtime dependencies and is imported only from test +// files. See README.md for the benchmark / pprof / benchstat workflow. package perf import "sync" diff --git a/services/pgutil/go.mod b/services/pgutil/go.mod new file mode 100644 index 00000000..d41cf06b --- /dev/null +++ b/services/pgutil/go.mod @@ -0,0 +1,16 @@ +module github.com/ItsThompson/gofin/services/pgutil + +go 1.26 + +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/services/pgutil/go.sum b/services/pgutil/go.sum new file mode 100644 index 00000000..5761a949 --- /dev/null +++ b/services/pgutil/go.sum @@ -0,0 +1,25 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/pgutil/pgutil.go b/services/pgutil/pgutil.go new file mode 100644 index 00000000..9f42fee7 --- /dev/null +++ b/services/pgutil/pgutil.go @@ -0,0 +1,45 @@ +// Package pgutil holds the small pgx/pgtype boilerplate helpers that every +// Postgres-backed GoFin service would otherwise re-implement: parsing a string +// into a pgtype.UUID, detecting pgx's no-rows sentinel, and detecting a +// Postgres unique-constraint violation. +package pgutil + +import ( + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" +) + +// sqlStateUniqueViolation is the Postgres SQLSTATE for a unique-constraint +// violation (class 23 integrity-constraint violation). +const sqlStateUniqueViolation = "23505" + +// ParseUUID parses s into a pgtype.UUID. On failure it returns the zero value +// and a wrapped "parsing UUID: ..." error so every call site reports a +// consistent message. +func ParseUUID(s string) (pgtype.UUID, error) { + var uid pgtype.UUID + if err := uid.Scan(s); err != nil { + return pgtype.UUID{}, fmt.Errorf("parsing UUID: %w", err) + } + return uid, nil +} + +// IsNoRows reports whether err is (or wraps) pgx.ErrNoRows. +func IsNoRows(err error) bool { + return errors.Is(err, pgx.ErrNoRows) +} + +// IsUniqueViolation reports whether err is (or wraps) a Postgres +// unique-constraint violation (SQLSTATE 23505). When ok is true, constraint is +// the name of the violated constraint (empty if the server did not report one). +func IsUniqueViolation(err error) (constraint string, ok bool) { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == sqlStateUniqueViolation { + return pgErr.ConstraintName, true + } + return "", false +} diff --git a/services/pgutil/pgutil_test.go b/services/pgutil/pgutil_test.go new file mode 100644 index 00000000..306c62d8 --- /dev/null +++ b/services/pgutil/pgutil_test.go @@ -0,0 +1,119 @@ +package pgutil_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/ItsThompson/gofin/services/pgutil" +) + +func TestParseUUID_Valid(t *testing.T) { + want := uuid.New() + + got, err := pgutil.ParseUUID(want.String()) + if err != nil { + t.Fatalf("ParseUUID(%q) returned error: %v", want, err) + } + if !got.Valid { + t.Errorf("ParseUUID(%q).Valid = false, want true", want) + } + // The parsed bytes must round-trip back to the original UUID. This is the + // exact conversion the call sites use (uuid.UUID(b).String()). + if roundTrip := uuid.UUID(got.Bytes); roundTrip != want { + t.Errorf("ParseUUID(%q) round-trip = %v, want %v", want, roundTrip, want) + } +} + +func TestParseUUID_Invalid(t *testing.T) { + got, err := pgutil.ParseUUID("not-a-uuid") + if err == nil { + t.Fatal("ParseUUID(\"not-a-uuid\") returned nil error, want error") + } + if got.Valid { + t.Errorf("ParseUUID(\"not-a-uuid\").Valid = true, want false") + } + if !strings.HasPrefix(err.Error(), "parsing UUID: ") { + t.Errorf("error = %q, want %q prefix", err.Error(), "parsing UUID: ") + } + // The wrap must be verb-style (errors.Unwrap non-nil) so callers can + // errors.Is/As the underlying scan cause. + if errors.Unwrap(err) == nil { + t.Error("ParseUUID error does not wrap the underlying cause") + } +} + +func TestIsNoRows(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "bare ErrNoRows", err: pgx.ErrNoRows, want: true}, + {name: "wrapped ErrNoRows", err: fmt.Errorf("query row: %w", pgx.ErrNoRows), want: true}, + {name: "unrelated error", err: errors.New("connection reset"), want: false}, + {name: "nil error", err: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pgutil.IsNoRows(tt.err); got != tt.want { + t.Errorf("IsNoRows(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestIsUniqueViolation(t *testing.T) { + tests := []struct { + name string + err error + wantConstraint string + wantOK bool + }{ + { + name: "unique violation", + err: &pgconn.PgError{Code: "23505", ConstraintName: "users_email_key"}, + wantConstraint: "users_email_key", + wantOK: true, + }, + { + name: "wrapped unique violation", + err: fmt.Errorf("insert user: %w", &pgconn.PgError{Code: "23505", ConstraintName: "tags_user_id_name_key"}), + wantConstraint: "tags_user_id_name_key", + wantOK: true, + }, + { + name: "different sqlstate (foreign key violation)", + err: &pgconn.PgError{Code: "23503", ConstraintName: "fk_user"}, + wantOK: false, + }, + { + name: "non-pg error", + err: errors.New("connection reset"), + wantOK: false, + }, + { + name: "nil error", + err: nil, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotConstraint, gotOK := pgutil.IsUniqueViolation(tt.err) + if gotOK != tt.wantOK { + t.Errorf("IsUniqueViolation(%v) ok = %v, want %v", tt.err, gotOK, tt.wantOK) + } + if gotConstraint != tt.wantConstraint { + t.Errorf("IsUniqueViolation(%v) constraint = %q, want %q", tt.err, gotConstraint, tt.wantConstraint) + } + }) + } +} diff --git a/services/serverkit/doc.go b/services/serverkit/doc.go new file mode 100644 index 00000000..7f7d4b6c --- /dev/null +++ b/services/serverkit/doc.go @@ -0,0 +1,10 @@ +// Package serverkit owns the bootstrap spine and serve/shutdown lifecycle +// shared by all gofin Go services: JSON slog logger construction, Postgres +// connection (migrate + pool + ping), gin router and gRPC server assembly, and +// a single Serve function that runs the servers, blocks until the context is +// cancelled, performs a bounded graceful shutdown, and surfaces the first fatal +// serve error (e.g. a bind failure) instead of leaving a zombie process. +// +// serverkit takes primitive inputs only (level, dbURL, isProduction) so it has +// no dependency on any service's config package. +package serverkit diff --git a/services/serverkit/go.mod b/services/serverkit/go.mod new file mode 100644 index 00000000..c915c2d5 --- /dev/null +++ b/services/serverkit/go.mod @@ -0,0 +1,69 @@ +module github.com/ItsThompson/gofin/services/serverkit + +go 1.26 + +require ( + github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 + github.com/ItsThompson/gofin/services/metrics v0.0.0 + github.com/gin-gonic/gin v1.12.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/prometheus/client_golang v1.22.0 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-migrate/migrate/v4 v4.18.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate + +replace github.com/ItsThompson/gofin/services/metrics => ../metrics diff --git a/services/serverkit/go.sum b/services/serverkit/go.sum new file mode 100644 index 00000000..32ff6331 --- /dev/null +++ b/services/serverkit/go.sum @@ -0,0 +1,197 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= +github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/serverkit/grpc.go b/services/serverkit/grpc.go new file mode 100644 index 00000000..279d7a34 --- /dev/null +++ b/services/serverkit/grpc.go @@ -0,0 +1,16 @@ +package serverkit + +import ( + "google.golang.org/grpc" + + "github.com/ItsThompson/gofin/services/metrics" +) + +// NewGRPCServer builds a *grpc.Server preloaded with the shared unary metrics +// interceptor. Services that expose no gRPC surface (gateway, datarights) skip +// this and pass a nil server to Serve. +func NewGRPCServer() *grpc.Server { + return grpc.NewServer( + grpc.UnaryInterceptor(metrics.UnaryServerInterceptor()), + ) +} diff --git a/services/serverkit/grpc_test.go b/services/serverkit/grpc_test.go new file mode 100644 index 00000000..c7cd0b05 --- /dev/null +++ b/services/serverkit/grpc_test.go @@ -0,0 +1,73 @@ +package serverkit_test + +import ( + "context" + "net" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ItsThompson/gofin/services/metrics" + "github.com/ItsThompson/gofin/services/serverkit" +) + +const echoFullMethod = "/serverkit.test.Echo/Ping" + +// echoServiceDesc is a minimal hand-written unary service used to prove the +// shared metrics interceptor NewGRPCServer wires in actually runs. +var echoServiceDesc = grpc.ServiceDesc{ + ServiceName: "serverkit.test.Echo", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{{ + MethodName: "Ping", + Handler: func(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + handler := func(ctx context.Context, _ any) (any, error) { + return &emptypb.Empty{}, nil + } + if interceptor == nil { + return handler(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: echoFullMethod} + return interceptor(ctx, in, info, handler) + }, + }}, +} + +func TestNewGRPCServer_WiresMetricsInterceptor(t *testing.T) { + server := serverkit.NewGRPCServer() + require.NotNil(t, server) + server.RegisterService(&echoServiceDesc, struct{}{}) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(lis) }() + t.Cleanup(func() { + server.GracefulStop() + <-serveErr + }) + + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + before := testutil.ToFloat64(metrics.GRPCRequestsTotal.WithLabelValues(echoFullMethod, "OK")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, conn.Invoke(ctx, echoFullMethod, &emptypb.Empty{}, &emptypb.Empty{})) + + after := testutil.ToFloat64(metrics.GRPCRequestsTotal.WithLabelValues(echoFullMethod, "OK")) + assert.Equal(t, float64(1), after-before, "metrics interceptor should record the unary call") +} diff --git a/services/serverkit/logger.go b/services/serverkit/logger.go new file mode 100644 index 00000000..cf10dea3 --- /dev/null +++ b/services/serverkit/logger.go @@ -0,0 +1,36 @@ +package serverkit + +import ( + "io" + "log/slog" + "os" +) + +// NewLogger builds the service's JSON slog logger and returns it. The level is +// the raw config string ("debug"|"info"|"warn"|"error"); any unrecognized value +// falls back to info. The service name is attached as a "service" attribute on +// every record. Callers own installing it as the default (slog.SetDefault). +func NewLogger(level, service string) *slog.Logger { + return newLogger(os.Stdout, level, service) +} + +// newLogger is the writer-injectable seam behind NewLogger so tests can assert +// the JSON output and the "service" attribute without capturing os.Stdout. +func newLogger(w io.Writer, level, service string) *slog.Logger { + handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)}) + return slog.New(handler).With(slog.String("service", service)) +} + +// parseLevel maps the config level string to a slog.Level, defaulting to info. +func parseLevel(level string) slog.Level { + switch level { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/services/serverkit/logger_internal_test.go b/services/serverkit/logger_internal_test.go new file mode 100644 index 00000000..9f14a72e --- /dev/null +++ b/services/serverkit/logger_internal_test.go @@ -0,0 +1,42 @@ +package serverkit + +import ( + "bytes" + "encoding/json" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewLogger_EmitsServiceAttributeAsJSON(t *testing.T) { + var buf bytes.Buffer + logger := newLogger(&buf, "info", "finance") + + logger.Info("ready", slog.String("port", "8080")) + + var record map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &record)) + + assert.Equal(t, "finance", record["service"]) + assert.Equal(t, "ready", record["msg"]) + assert.Equal(t, "8080", record["port"]) + assert.Equal(t, "INFO", record["level"]) +} + +func TestParseLevel(t *testing.T) { + tests := map[string]slog.Level{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warn": slog.LevelWarn, + "error": slog.LevelError, + "unknown": slog.LevelInfo, + "": slog.LevelInfo, + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + assert.Equal(t, want, parseLevel(input)) + }) + } +} diff --git a/services/serverkit/logger_test.go b/services/serverkit/logger_test.go new file mode 100644 index 00000000..fe7b5cbf --- /dev/null +++ b/services/serverkit/logger_test.go @@ -0,0 +1,37 @@ +package serverkit_test + +import ( + "context" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ItsThompson/gofin/services/serverkit" +) + +func TestNewLogger_LevelFiltering(t *testing.T) { + ctx := context.Background() + + tests := []struct { + level string + wantEnabled slog.Level + wantDisabled slog.Level + }{ + {level: "debug", wantEnabled: slog.LevelDebug, wantDisabled: slog.LevelDebug - 1}, + {level: "info", wantEnabled: slog.LevelInfo, wantDisabled: slog.LevelDebug}, + {level: "warn", wantEnabled: slog.LevelWarn, wantDisabled: slog.LevelInfo}, + {level: "error", wantEnabled: slog.LevelError, wantDisabled: slog.LevelWarn}, + // Unrecognized values fall back to info. + {level: "verbose", wantEnabled: slog.LevelInfo, wantDisabled: slog.LevelDebug}, + {level: "", wantEnabled: slog.LevelInfo, wantDisabled: slog.LevelDebug}, + } + + for _, tc := range tests { + t.Run(tc.level, func(t *testing.T) { + logger := serverkit.NewLogger(tc.level, "svc") + assert.True(t, logger.Enabled(ctx, tc.wantEnabled), "expected %s enabled at %v", tc.level, tc.wantEnabled) + assert.False(t, logger.Enabled(ctx, tc.wantDisabled), "expected %s disabled at %v", tc.level, tc.wantDisabled) + }) + } +} diff --git a/services/serverkit/postgres.go b/services/serverkit/postgres.go new file mode 100644 index 00000000..41dce52e --- /dev/null +++ b/services/serverkit/postgres.go @@ -0,0 +1,36 @@ +package serverkit + +import ( + "context" + "fmt" + "io/fs" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ItsThompson/gofin/services/dbmigrate" +) + +// ConnectPostgres runs embedded migrations, opens a pgx pool, and pings it, +// collapsing the three-step dbmigrate.RunWithFS + pgxpool.New + pool.Ping dance +// every Postgres-backed service repeats. The migrations FS is expected to hold +// the SQL files at its root (the ".subdir" convention used by every service). +// +// On success the caller owns pool.Close() (via defer). On any failure the pool +// is closed before returning, so a failed connect never leaks a pool. +func ConnectPostgres(ctx context.Context, dbURL string, migrations fs.FS) (*pgxpool.Pool, error) { + if err := dbmigrate.RunWithFS(dbURL, migrations, "."); err != nil { + return nil, fmt.Errorf("running migrations: %w", err) + } + + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + return nil, fmt.Errorf("connecting to database: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + + return pool, nil +} diff --git a/services/serverkit/postgres_test.go b/services/serverkit/postgres_test.go new file mode 100644 index 00000000..955a96e4 --- /dev/null +++ b/services/serverkit/postgres_test.go @@ -0,0 +1,33 @@ +package serverkit_test + +import ( + "context" + "fmt" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/serverkit" +) + +// TestConnectPostgres_MigrationFailure_ReturnsErrorNoPool exercises the failure +// boundary without a live database: with nothing listening on the target port, +// the migration step fails and ConnectPostgres must surface a wrapped error and +// no pool. The success path (real migrate + ping) is integration-level. +func TestConnectPostgres_MigrationFailure_ReturnsErrorNoPool(t *testing.T) { + migrations := fstest.MapFS{ + "1_init.up.sql": &fstest.MapFile{Data: []byte("SELECT 1;")}, + "1_init.down.sql": &fstest.MapFile{Data: []byte("SELECT 1;")}, + } + + // A reserved-then-closed address guarantees a refused connection. + dbURL := fmt.Sprintf("postgres://user:pass@%s/db?sslmode=disable", freeAddr(t)) + + pool, err := serverkit.ConnectPostgres(context.Background(), dbURL, migrations) + + require.Error(t, err) + assert.Nil(t, pool) + assert.ErrorContains(t, err, "running migrations") +} diff --git a/services/serverkit/router.go b/services/serverkit/router.go new file mode 100644 index 00000000..e49262c5 --- /dev/null +++ b/services/serverkit/router.go @@ -0,0 +1,32 @@ +package serverkit + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/metrics" +) + +// NewRouter builds a gin.Engine preloaded with Recovery, the shared HTTP +// metrics middleware, the /metrics endpoint, and a GET /health handler that +// reports the service name. Release mode is enabled when isProduction is true. +// +// This is the router for the four API services. The gateway keeps its own +// reverse-proxy router (router.New) and does not use NewRouter. +func NewRouter(service string, isProduction bool) *gin.Engine { + if isProduction { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + router.Use(gin.Recovery()) + router.Use(metrics.HTTPMetrics()) + metrics.Register(router) + + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "service": service}) + }) + + return router +} diff --git a/services/serverkit/router_test.go b/services/serverkit/router_test.go new file mode 100644 index 00000000..921230f0 --- /dev/null +++ b/services/serverkit/router_test.go @@ -0,0 +1,51 @@ +package serverkit_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/serverkit" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestNewRouter_HealthReportsService(t *testing.T) { + router := serverkit.NewRouter("finance", false) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + + require.Equal(t, http.StatusOK, w.Code) + assert.JSONEq(t, `{"status":"ok","service":"finance"}`, w.Body.String()) +} + +func TestNewRouter_ExposesMetricsEndpoint(t *testing.T) { + router := serverkit.NewRouter("auth", false) + + // Drive one request so the HTTP metrics middleware records an observation. + router.GET("/api/ping", func(c *gin.Context) { c.Status(http.StatusOK) }) + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/ping", nil)) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "http_requests_total") + assert.Contains(t, body, `path="/api/ping"`) +} + +func TestNewRouter_ProductionEnablesReleaseMode(t *testing.T) { + t.Cleanup(func() { gin.SetMode(gin.TestMode) }) + + serverkit.NewRouter("expense", true) + + assert.Equal(t, gin.ReleaseMode, gin.Mode()) +} diff --git a/services/serverkit/serve.go b/services/serverkit/serve.go new file mode 100644 index 00000000..1ac2a72d --- /dev/null +++ b/services/serverkit/serve.go @@ -0,0 +1,71 @@ +package serverkit + +import ( + "context" + "errors" + "net" + "net/http" + "sync" + "time" + + "google.golang.org/grpc" +) + +// shutdownTimeout bounds the graceful shutdown of the HTTP server. +const shutdownTimeout = 10 * time.Second + +// Serve runs httpSrv and, when grpcSrv is non-nil, grpcSrv on grpcLis, then +// blocks until ctx is cancelled or a server fails fatally. +// +// On ctx cancellation it performs a bounded graceful shutdown and returns nil. +// If a server fails to serve (e.g. an HTTP bind failure, the zombie-process +// bug this fixes for all services), Serve returns the first such error after +// shutting the other server down, so the caller's run() can exit non-zero +// instead of lingering with no listener. +// +// grpcSrv and grpcLis may both be nil for the HTTP-only path (gateway, +// datarights). http.ErrServerClosed and the gRPC graceful-stop signal are +// treated as clean exits. +func Serve(ctx context.Context, httpSrv *http.Server, grpcSrv *grpc.Server, grpcLis net.Listener) error { + // Buffered so both goroutines can report without blocking even if only the + // first error is consumed by the select below. + fatal := make(chan error, 2) + var wg sync.WaitGroup + + if grpcSrv != nil { + wg.Add(1) + go func() { + defer wg.Done() + // Serve returns nil on GracefulStop and ErrServerStopped only if the + // server was already stopped; neither is a fatal serve error. + if err := grpcSrv.Serve(grpcLis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + fatal <- err + } + }() + } + + wg.Add(1) + go func() { + defer wg.Done() + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + fatal <- err + } + }() + + var serveErr error + select { + case <-ctx.Done(): + case serveErr = <-fatal: + } + + if grpcSrv != nil { + grpcSrv.GracefulStop() + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + + wg.Wait() + return serveErr +} diff --git a/services/serverkit/serve_test.go b/services/serverkit/serve_test.go new file mode 100644 index 00000000..ea75b562 --- /dev/null +++ b/services/serverkit/serve_test.go @@ -0,0 +1,149 @@ +package serverkit_test + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/serverkit" +) + +// freeAddr reserves an ephemeral port, closes it, and returns the address so a +// server under test can bind it. The tiny reserve/rebind window is acceptable +// for local tests. +func freeAddr(t *testing.T) string { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := lis.Addr().String() + require.NoError(t, lis.Close()) + return addr +} + +// waitForDial blocks until a TCP dial to addr succeeds or the deadline passes. +func waitForDial(t *testing.T, addr string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 50*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("nothing listening on %s within deadline", addr) +} + +// awaitServe returns Serve's result, failing the test if it does not return +// within the timeout (i.e. it hung). +func awaitServe(t *testing.T, errCh <-chan error) error { + t.Helper() + select { + case err := <-errCh: + return err + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return within 5s (possible hang)") + return nil + } +} + +func TestServe_NormalCancellation_HTTPAndGRPC_ReturnsNil(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + addr := freeAddr(t) + httpSrv := &http.Server{Addr: addr} + + grpcSrv := serverkit.NewGRPCServer() + grpcLis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + errCh := make(chan error, 1) + go func() { errCh <- serverkit.Serve(ctx, httpSrv, grpcSrv, grpcLis) }() + + // Ensure the HTTP server is actually listening before cancelling, so the + // shutdown path (ListenAndServe -> ErrServerClosed) is genuinely exercised. + waitForDial(t, addr) + + cancel() + + require.NoError(t, awaitServe(t, errCh)) +} + +func TestServe_HTTPBindFailure_ReturnsError(t *testing.T) { + // Pre-occupy the port so the HTTP server's bind fails. + occupied, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = occupied.Close() }() + addr := occupied.Addr().String() + + httpSrv := &http.Server{Addr: addr} + + // A live gRPC server on its own port proves it is also cleaned up when HTTP + // fails: Serve's wg.Wait would hang if GracefulStop were skipped. + grpcSrv := serverkit.NewGRPCServer() + grpcLis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + // ctx is never cancelled: only the bind failure can end Serve. + errCh := make(chan error, 1) + go func() { errCh <- serverkit.Serve(context.Background(), httpSrv, grpcSrv, grpcLis) }() + + err = awaitServe(t, errCh) + require.Error(t, err) + assert.ErrorContains(t, err, "address already in use") +} + +func TestServe_HTTPOnly_NilGRPC_NormalCancellation_ReturnsNil(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + addr := freeAddr(t) + httpSrv := &http.Server{Addr: addr} + + errCh := make(chan error, 1) + go func() { errCh <- serverkit.Serve(ctx, httpSrv, nil, nil) }() + + waitForDial(t, addr) + cancel() + + require.NoError(t, awaitServe(t, errCh)) +} + +func TestServe_HTTPOnly_NilGRPC_BindFailure_ReturnsError(t *testing.T) { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = occupied.Close() }() + + httpSrv := &http.Server{Addr: occupied.Addr().String()} + + errCh := make(chan error, 1) + go func() { errCh <- serverkit.Serve(context.Background(), httpSrv, nil, nil) }() + + err = awaitServe(t, errCh) + require.Error(t, err) + assert.ErrorContains(t, err, "address already in use") +} + +func TestServe_ErrServerClosed_TreatedAsSuccess(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + addr := freeAddr(t) + httpSrv := &http.Server{Addr: addr} + + errCh := make(chan error, 1) + go func() { errCh <- serverkit.Serve(ctx, httpSrv, nil, nil) }() + + // Confirm the server reached the accept loop so its eventual exit is via + // http.ErrServerClosed from Shutdown, not a pre-start short-circuit. + waitForDial(t, addr) + cancel() + + // ErrServerClosed is filtered inside Serve (errors.Is), so the observable + // result is a nil return. + require.NoError(t, awaitServe(t, errCh)) +}