From 479bfd0acc8c30a574e563cec598c85c3cde0d8c Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:31:10 +0100 Subject: [PATCH 01/66] refactor(finance): remove dead CreatePeriod service method - REST handler uses CreatePeriodWithProRata; the CreatePeriod service method had no callers and duplicated its validation block - delete the method and its ~50 LOC divergent validation copy --- services/finance/internal/service/finance.go | 50 -------------------- 1 file changed, 50 deletions(-) diff --git a/services/finance/internal/service/finance.go b/services/finance/internal/service/finance.go index fe1f9efe..175e263f 100644 --- a/services/finance/internal/service/finance.go +++ b/services/finance/internal/service/finance.go @@ -225,56 +225,6 @@ func (s *FinanceService) GetCurrentPeriod(ctx context.Context, userID string, ye 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, - } - } - - 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 -} - // ListPeriods returns all budget periods for a user, ordered by year/month descending. func (s *FinanceService) ListPeriods(ctx context.Context, userID string) ([]*model.BudgetPeriod, error) { periods, err := s.repo.ListPeriods(ctx, userID) From 822a1b6de5b984ea3c832ec04ec8b28e19fcac84 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:31:13 +0100 Subject: [PATCH 02/66] refactor(finance): dedup yearMonth to package-level type - dashboard.go declared a func-local yearMonth identical in shape to the package-level one in prorata.go - drop the local definition; both call sites now share one type --- services/finance/internal/service/dashboard.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/services/finance/internal/service/dashboard.go b/services/finance/internal/service/dashboard.go index edbe048d..272ffd34 100644 --- a/services/finance/internal/service/dashboard.go +++ b/services/finance/internal/service/dashboard.go @@ -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++ { From e9fc6607d4f4a12bc6a816de9e1cdddfb21082ce Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:31:15 +0100 Subject: [PATCH 03/66] refactor(finance): drop redundant gRPC Unimplemented stubs - remove 12 explicit Unimplemented RPC stubs; the embedded UnimplementedFinanceServiceServer already returns codes.Unimplemented - refresh the stale struct doc comment to match --- services/finance/internal/handler/grpc.go | 55 ++--------------------- 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/services/finance/internal/handler/grpc.go b/services/finance/internal/handler/grpc.go index 45f36a22..c3d5ea5b 100644 --- a/services/finance/internal/handler/grpc.go +++ b/services/finance/internal/handler/grpc.go @@ -12,9 +12,9 @@ import ( 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 @@ -77,27 +77,6 @@ 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 { @@ -175,34 +154,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 == "" { From c053a6c16de14c9dc4e0de3a5286a734298e15b5 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:32:27 +0100 Subject: [PATCH 04/66] feat(pgutil): add shared pgx UUID/no-rows/unique-violation helpers Create the services/pgutil leaf module with three specific helpers that every Postgres-backed service currently re-implements inline: - ParseUUID: string -> pgtype.UUID, standardizing the wrap message fmt.Errorf("parsing UUID: %w", err) across 38 call sites. - IsNoRows: errors.Is(err, pgx.ErrNoRows) (19 sites, incl. datarights' non-idiomatic == comparisons). - IsUniqueViolation: errors.As on *pgconn.PgError, SQLSTATE 23505, returning the constraint name (3 sites). Specific over generic (no ScanOne[T]/NilOnNoRows[T]): sqlc row types differ per query. Pins pgx to v5.9.2 to match the workspace and avoid a workspace-wide MVS bump. Call-site migration and formatUUID deletion are handled by the per-service tickets (#7/#8/#10). --- services/go.work | 1 + services/pgutil/go.mod | 16 +++++ services/pgutil/go.sum | 25 +++++++ services/pgutil/pgutil.go | 45 +++++++++++++ services/pgutil/pgutil_test.go | 118 +++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 services/pgutil/go.mod create mode 100644 services/pgutil/go.sum create mode 100644 services/pgutil/pgutil.go create mode 100644 services/pgutil/pgutil_test.go diff --git a/services/go.work b/services/go.work index 0dbf95e8..06e2608d 100644 --- a/services/go.work +++ b/services/go.work @@ -11,4 +11,5 @@ use ( ./healthcheck ./metrics ./perf + ./pgutil ) 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..76f462f4 --- /dev/null +++ b/services/pgutil/pgutil_test.go @@ -0,0 +1,118 @@ +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 use %w so callers can errors.Is/As the underlying cause. + if errors.Unwrap(err) == nil { + t.Error("ParseUUID error does not wrap the underlying cause (%w expected)") + } +} + +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) + } + }) + } +} From de7cf9905b8c756245538f8216ef0a09465b0edc Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:33:39 +0100 Subject: [PATCH 05/66] feat(apierr): add shared API error contract module Single-source the API error contract that is duplicated across the four API services: the typed Error, the shared code constants, and the HTTP wire mapping. - Error{Code,Message,Status,Fields} with Unauthorized/NotFound/Validation/ Conflict/Internal constructors. - Respond(c, err) classifies via errors.As so %w-wrapped typed errors keep their status/code; unknown errors fall through to 500 INTERNAL_SERVER_ERROR. - Exported wire type APIError adopts the initialism (US-HARDEN-04); JSON shape {code,message,fields?} is byte-identical to what services emit today. Kept generic: no RateLimitError special-casing, no retryAfter wire field. Module + 100%-covered unit tests only; per-service migration is #7-#11. --- services/apierr/codes.go | 16 +++++ services/apierr/error.go | 40 +++++++++++ services/apierr/error_test.go | 59 +++++++++++++++++ services/apierr/go.mod | 44 ++++++++++++ services/apierr/go.sum | 91 +++++++++++++++++++++++++ services/apierr/respond.go | 38 +++++++++++ services/apierr/respond_test.go | 114 ++++++++++++++++++++++++++++++++ services/go.work | 1 + 8 files changed, 403 insertions(+) create mode 100644 services/apierr/codes.go create mode 100644 services/apierr/error.go create mode 100644 services/apierr/error_test.go create mode 100644 services/apierr/go.mod create mode 100644 services/apierr/go.sum create mode 100644 services/apierr/respond.go create mode 100644 services/apierr/respond_test.go 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..30c43288 --- /dev/null +++ b/services/apierr/error_test.go @@ -0,0 +1,59 @@ +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 + }{ + {"unauthorized", apierr.Unauthorized("no auth"), apierr.CodeUnauthorized, http.StatusUnauthorized}, + {"not found", apierr.NotFound("gone"), apierr.CodeNotFound, http.StatusNotFound}, + {"validation", apierr.Validation("bad", nil), apierr.CodeValidation, http.StatusBadRequest}, + {"conflict", apierr.Conflict("DUPLICATE_TAG", "dup"), "DUPLICATE_TAG", http.StatusConflict}, + {"internal", apierr.Internal("boom"), apierr.CodeInternal, http.StatusInternalServerError}, + } + + 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) + }) + } +} + +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..88f4b8b4 --- /dev/null +++ b/services/apierr/respond.go @@ -0,0 +1,38 @@ +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 fall through to 500 CodeInternal. +func Respond(c *gin.Context, err error) { + var apiErr *Error + if errors.As(err, &apiErr) { + 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..11144ec7 --- /dev/null +++ b/services/apierr/respond_test.go @@ -0,0 +1,114 @@ +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"]) +} diff --git a/services/go.work b/services/go.work index 0dbf95e8..b722d299 100644 --- a/services/go.work +++ b/services/go.work @@ -2,6 +2,7 @@ go 1.26 use ( ./access + ./apierr ./auth ./datarights ./dbmigrate From 3ee545daed6dbd2cb5e0010322e05390b1f2a17b Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:33:48 +0100 Subject: [PATCH 06/66] refactor(auth): drop redundant gRPC Unimplemented stubs - remove 7 generic Unimplemented RPC stubs served by the embedded UnimplementedAuthServiceServer - keep explicit Register/Login overrides that redirect to REST endpoints - drop the companion TestGRPCStubs_ReturnUnimplemented and its helper --- services/auth/internal/handler/grpc.go | 35 +++------------------ services/auth/internal/handler/grpc_test.go | 34 -------------------- 2 files changed, 4 insertions(+), 65 deletions(-) diff --git a/services/auth/internal/handler/grpc.go b/services/auth/internal/handler/grpc.go index 6ff040c5..774844f1 100644 --- a/services/auth/internal/handler/grpc.go +++ b/services/auth/internal/handler/grpc.go @@ -11,9 +11,10 @@ import ( 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 @@ -56,26 +57,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 == "" { @@ -106,14 +87,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 == "" { diff --git a/services/auth/internal/handler/grpc_test.go b/services/auth/internal/handler/grpc_test.go index b329473f..d2944ebb 100644 --- a/services/auth/internal/handler/grpc_test.go +++ b/services/auth/internal/handler/grpc_test.go @@ -72,40 +72,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() From 43bb69330624d033fd4cddbd454689555da48947 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:33:50 +0100 Subject: [PATCH 07/66] refactor(auth): remove unused RefreshTokenTTL accessor - only jwt_test.go called RefreshTokenTTL(); rest.go hardcodes 7*24h - keep the refreshTokenTTL field; drop the accessor and its test --- services/auth/internal/service/jwt.go | 5 ----- services/auth/internal/service/jwt_test.go | 5 ----- 2 files changed, 10 deletions(-) diff --git a/services/auth/internal/service/jwt.go b/services/auth/internal/service/jwt.go index 6e463cac..c807488d 100644 --- a/services/auth/internal/service/jwt.go +++ b/services/auth/internal/service/jwt.go @@ -120,8 +120,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()) -} From 25c8e9f66d318a86c74bb0e53f74c978eb0a0102 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:33:50 +0100 Subject: [PATCH 08/66] feat(httpx): add request guards module Consolidate the two request guards duplicated across HTTP handlers into a shared module that depends on apierr (the only new module depending on another new module). - RequireUserID(c) reads the trusted gateway-injected X-User-ID header; writes a 401 apierr.Error and returns ok=false when missing/empty. - BindJSON[T](c, dst) binds the body, writing a 400 apierr.Error with validator field detail on failure; returns false. Generic so call sites stay one line. Module + 100%-covered unit tests only; handler migration is #7-#11. --- services/go.work | 1 + services/httpx/bind.go | 38 +++++++++++ services/httpx/go.mod | 46 +++++++++++++ services/httpx/go.sum | 91 ++++++++++++++++++++++++++ services/httpx/httpx_test.go | 121 +++++++++++++++++++++++++++++++++++ services/httpx/userid.go | 22 +++++++ 6 files changed, 319 insertions(+) create mode 100644 services/httpx/bind.go create mode 100644 services/httpx/go.mod create mode 100644 services/httpx/go.sum create mode 100644 services/httpx/httpx_test.go create mode 100644 services/httpx/userid.go diff --git a/services/go.work b/services/go.work index b722d299..fba46adb 100644 --- a/services/go.work +++ b/services/go.work @@ -10,6 +10,7 @@ use ( ./finance ./gateway ./healthcheck + ./httpx ./metrics ./perf ) 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 +} From 167cf8bdb09f4532187976dc658798159a09ab73 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:34:30 +0100 Subject: [PATCH 09/66] refactor(expense): drop dead SchemaInitializer and SQLResult.Columns - SchemaInitializer interface had no referents; concrete InitSchema stays - SQLResult.Columns was never set or read --- services/expense/internal/repository/repository.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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. From a6bd4653db9f239a45ebf774937ad9f5392d6ce0 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:35:03 +0100 Subject: [PATCH 10/66] refactor(metrics): remove dead DB collector symbols and their tests Delete DBQueryDuration, ActiveConnections, ObserveQuery, and SetActiveConnections from the shared metrics module. ActiveConnections was the only one that emitted a series, polluting every service's /metrics with a permanent active_connections 0 gauge. The other three were dead-but-invisible. Removes their companion assertions and the two tests that referenced only these symbols. --- services/metrics/metrics.go | 38 ++------------------------------ services/metrics/metrics_test.go | 31 -------------------------- 2 files changed, 2 insertions(+), 67 deletions(-) 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..41eeb04b 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,8 +50,6 @@ 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") @@ -133,34 +130,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) From d2b6b85afc7699320eb283d2ed978dacadf4a323 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:35:06 +0100 Subject: [PATCH 11/66] test(metrics): cover UnaryServerInterceptor success and error outcomes Invoke the interceptor with a stub handler and assert grpc_requests_total is recorded for both an OK and a NotFound status outcome, verifying the handler runs and its response/error propagate. Exercises the interceptor behaviorally rather than calling .Inc()/.Observe() on collectors directly (US-HARDEN-03). --- services/metrics/interceptor_test.go | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 services/metrics/interceptor_test.go 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") +} From dbafe776a0a30d4b5cb12bbc875821341e4a1777 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:35:58 +0100 Subject: [PATCH 12/66] refactor(dbmigrate): drop filesystem Run variant and source/file driver Remove the dead Run function (only its own tests called it; all 3 consumers use RunWithFS via source/iofs) along with the source/file blank import, which drops that driver from the auth, finance, and datarights binaries. RunWithFS and the rawConnectionURL/ensureSchema helpers remain. Deletes the Run-only test file; RunWithFS coverage lives in dbmigrate_embed_test.go. --- services/dbmigrate/dbmigrate.go | 51 ------------------- services/dbmigrate/dbmigrate_test.go | 75 ---------------------------- 2 files changed, 126 deletions(-) delete mode 100644 services/dbmigrate/dbmigrate_test.go 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") - } -} From 50f2fdb90e9ea5ab2d9c1491791b5fbbc7919b8b Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:36:25 +0100 Subject: [PATCH 13/66] refactor(gateway): drop write-only TokenValidationResult.Username - setIdentityHeaders reads only UserID/Role/AssumedBy; Username was set in grpc_validator.go but never read - remove the field, its single write, and companion test references --- services/gateway/cmd/grpc_validator.go | 1 - services/gateway/cmd/grpc_validator_test.go | 12 ++++++------ services/gateway/internal/access/validator.go | 1 - services/gateway/internal/router/router_test.go | 2 -- 4 files changed, 6 insertions(+), 10 deletions(-) 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/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/router/router_test.go b/services/gateway/internal/router/router_test.go index 17d9eb24..2bdf4fc8 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -41,7 +41,6 @@ func adminValidator() *mockValidator { result: &access.TokenValidationResult{ UserID: "admin-1", Role: "admin", - Username: "admin", }, } } @@ -51,7 +50,6 @@ func userValidator() *mockValidator { result: &access.TokenValidationResult{ UserID: "user-1", Role: "user", - Username: "alice", }, } } From 3922d901735b6a863534923c1a31f07ddc1e0b1f Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:36:54 +0100 Subject: [PATCH 14/66] refactor(access): remove dead Classifies helper Delete the test-only Classifies function and its inaccurate "services use it" doc-comment. Its behavioral coverage is preserved by the resolver suite: every-entry-is-classified is subsumed by TestRegistry_EveryEntryResolvesToItsAccess, and the unknown-route deny guardrail folds into TestResolve_FallsBackToDeny (adding the deep unknown-service-path case). Registry and RoutesFor are retained (both live internally via BindRoutes and coverage.go). --- services/access/registry.go | 13 ----------- services/access/registry_test.go | 39 -------------------------------- services/access/resolver_test.go | 1 + 3 files changed, 1 insertion(+), 52 deletions(-) diff --git a/services/access/registry.go b/services/access/registry.go index b19cb6c0..5d461b37 100644 --- a/services/access/registry.go +++ b/services/access/registry.go @@ -96,16 +96,3 @@ func RoutesFor(service string) []Route { } 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..e6b5e2f0 100644 --- a/services/access/registry_test.go +++ b/services/access/registry_test.go @@ -25,17 +25,6 @@ func TestRegistry_EveryEntryResolvesToItsAccess(t *testing.T) { } } -// 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. @@ -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_test.go b/services/access/resolver_test.go index 0c07308f..43ee3016 100644 --- a/services/access/resolver_test.go +++ b/services/access/resolver_test.go @@ -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"}, } From 8e45dfdf864f45acbe1ff46a83da918d27a875f1 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:37:15 +0100 Subject: [PATCH 15/66] test(pgutil): reword ParseUUID wrap assertion to drop literal %w token --- services/pgutil/pgutil_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/pgutil/pgutil_test.go b/services/pgutil/pgutil_test.go index 76f462f4..306c62d8 100644 --- a/services/pgutil/pgutil_test.go +++ b/services/pgutil/pgutil_test.go @@ -41,9 +41,10 @@ func TestParseUUID_Invalid(t *testing.T) { if !strings.HasPrefix(err.Error(), "parsing UUID: ") { t.Errorf("error = %q, want %q prefix", err.Error(), "parsing UUID: ") } - // The wrap must use %w so callers can errors.Is/As the underlying cause. + // 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 (%w expected)") + t.Error("ParseUUID error does not wrap the underlying cause") } } From 0f3ad311b10c8a127db49b8ffcac94474bf5e81d Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:38:23 +0100 Subject: [PATCH 16/66] refactor(datarights): drop unused GRPCPort config - datarights runs no gRPC server; GRPCPort and its GRPC_PORT parsing were never read --- services/datarights/internal/config/config.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/datarights/internal/config/config.go b/services/datarights/internal/config/config.go index 13c7085a..6ae9c385 100644 --- a/services/datarights/internal/config/config.go +++ b/services/datarights/internal/config/config.go @@ -13,7 +13,6 @@ type Config struct { LogLevel string Environment string RESTPort string - GRPCPort string AuthServiceAddr string ExpenseServiceAddr string FinanceServiceAddr string @@ -48,11 +47,6 @@ func Load() (*Config, error) { restPort = "8084" } - grpcPort := os.Getenv("GRPC_PORT") - if grpcPort == "" { - grpcPort = "9084" - } - authServiceAddr := os.Getenv("AUTH_SERVICE_ADDR") if authServiceAddr == "" { authServiceAddr = "auth-service:9081" @@ -110,7 +104,6 @@ func Load() (*Config, error) { LogLevel: logLevel, Environment: environment, RESTPort: restPort, - GRPCPort: grpcPort, AuthServiceAddr: authServiceAddr, ExpenseServiceAddr: expenseServiceAddr, FinanceServiceAddr: financeServiceAddr, From 8b452429b28620e236c9bef14a6ae7429b793f8b Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:38:26 +0100 Subject: [PATCH 17/66] refactor(datarights): remove empty CreateExportRequest - the empty struct was referenced nowhere; export uses the header user ID --- services/datarights/internal/model/requests.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/services/datarights/internal/model/requests.go b/services/datarights/internal/model/requests.go index 01abc66f..07ac5fd8 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"` From 2ecfa859adbb25d067d2ed50936aec679a8ea105 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:38:28 +0100 Subject: [PATCH 18/66] refactor(datarights): drop unused BrandColors E/D/S fields - Essentials/Desires/Savings were parsed from brand.json but never copied into templateData, so the template never used them - drop the fields and their test fixture assignments --- services/datarights/internal/email/email_test.go | 3 --- services/datarights/internal/email/tokens.go | 3 --- 2 files changed, 6 deletions(-) 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/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. From 6e65a379bc5e28e101269511cd7cc38d37bebfcf Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:38:42 +0100 Subject: [PATCH 19/66] refactor(perf): delete unused AssertMaxAllocs alloc-bound helper Remove allocs.go and allocs_test.go: AssertMaxAllocs had zero callers (self-admittedly reserved for a future path). CallCounter (counter.go), live in finance and datarights, is retained. Updates the package doc and README so they no longer document the removed helper; the arch-stable alloc-bound strategy now points at testing.AllocsPerRun directly. --- services/perf/README.md | 21 ++-------- services/perf/allocs.go | 41 ------------------- services/perf/allocs_test.go | 77 ------------------------------------ services/perf/counter.go | 5 +-- 4 files changed, 6 insertions(+), 138 deletions(-) delete mode 100644 services/perf/allocs.go delete mode 100644 services/perf/allocs_test.go 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" From 24317ef78cfbaedcf4fa4e9755655bd8b769ca60 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:40:07 +0100 Subject: [PATCH 20/66] fix(apierr): fall back to 500 when Error.Status is unset Respond now treats a classified *Error with a non-positive Status the same as an unknown error (500 INTERNAL_SERVER_ERROR) instead of emitting WriteHeader(0). Unreachable via the five constructors, but hardens Respond for the varied *apierr.Error construction coming in the per-service migrations (#7-#11), keeping Respond total. Also assert Message in the constructor table test. --- services/apierr/error_test.go | 20 +++++++++++--------- services/apierr/respond.go | 6 ++++-- services/apierr/respond_test.go | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/services/apierr/error_test.go b/services/apierr/error_test.go index 30c43288..d655ab51 100644 --- a/services/apierr/error_test.go +++ b/services/apierr/error_test.go @@ -14,16 +14,17 @@ import ( func TestConstructors_SetCodeAndStatus(t *testing.T) { cases := []struct { - name string - err *apierr.Error - wantCode string - wantStatus int + name string + err *apierr.Error + wantCode string + wantStatus int + wantMessage string }{ - {"unauthorized", apierr.Unauthorized("no auth"), apierr.CodeUnauthorized, http.StatusUnauthorized}, - {"not found", apierr.NotFound("gone"), apierr.CodeNotFound, http.StatusNotFound}, - {"validation", apierr.Validation("bad", nil), apierr.CodeValidation, http.StatusBadRequest}, - {"conflict", apierr.Conflict("DUPLICATE_TAG", "dup"), "DUPLICATE_TAG", http.StatusConflict}, - {"internal", apierr.Internal("boom"), apierr.CodeInternal, http.StatusInternalServerError}, + {"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 { @@ -31,6 +32,7 @@ func TestConstructors_SetCodeAndStatus(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) }) } } diff --git a/services/apierr/respond.go b/services/apierr/respond.go index 88f4b8b4..4e13ce34 100644 --- a/services/apierr/respond.go +++ b/services/apierr/respond.go @@ -19,10 +19,12 @@ type APIError struct { // 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 fall through to 500 CodeInternal. +// 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) { + if errors.As(err, &apiErr) && apiErr.Status > 0 { c.JSON(apiErr.Status, APIError{ Code: apiErr.Code, Message: apiErr.Message, diff --git a/services/apierr/respond_test.go b/services/apierr/respond_test.go index 11144ec7..afd4d5d9 100644 --- a/services/apierr/respond_test.go +++ b/services/apierr/respond_test.go @@ -112,3 +112,17 @@ func TestRespond_UnknownErrorYields500Internal(t *testing.T) { 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"]) +} From 29dbd7dcf145f02fdafec61805527d5bfbb92e7c Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:40:45 +0100 Subject: [PATCH 21/66] feat(serverkit): add shared bootstrap and serve/shutdown lifecycle Introduce the in-process shared module services/serverkit that owns the bootstrap spine and serve/shutdown lifecycle the five Go services hand-write today. Wires the module into services/go.work and mirrors the existing shared-module dependency pattern (requires dbmigrate, metrics). Exposes NewLogger, ConnectPostgres, NewRouter, NewGRPCServer, and Serve, all taking primitive inputs so serverkit has no dependency on any service config package. Serve fixes the zombie-on-bind-failure bug in one place for all services: it runs the HTTP and (optional) gRPC servers in goroutines that push their first fatal error onto a buffered channel, selects between ctx cancellation (graceful shutdown, return nil) and a fatal serve error (graceful shutdown, return the error so run() can exit non-zero), and bounds shutdown to 10s. http.ErrServerClosed and the gRPC graceful-stop signal are treated as clean exits via errors.Is. Serve accepts a nil gRPC server and listener for the HTTP-only path. This ticket builds the module only; migrating the services onto it is separate. --- services/go.work | 1 + services/serverkit/doc.go | 10 ++ services/serverkit/go.mod | 69 ++++++++++++ services/serverkit/go.sum | 197 +++++++++++++++++++++++++++++++++ services/serverkit/grpc.go | 16 +++ services/serverkit/logger.go | 36 ++++++ services/serverkit/postgres.go | 36 ++++++ services/serverkit/router.go | 32 ++++++ services/serverkit/serve.go | 71 ++++++++++++ 9 files changed, 468 insertions(+) create mode 100644 services/serverkit/doc.go create mode 100644 services/serverkit/go.mod create mode 100644 services/serverkit/go.sum create mode 100644 services/serverkit/grpc.go create mode 100644 services/serverkit/logger.go create mode 100644 services/serverkit/postgres.go create mode 100644 services/serverkit/router.go create mode 100644 services/serverkit/serve.go diff --git a/services/go.work b/services/go.work index 0dbf95e8..a77aab41 100644 --- a/services/go.work +++ b/services/go.work @@ -11,4 +11,5 @@ use ( ./healthcheck ./metrics ./perf + ./serverkit ) 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/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/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/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/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 +} From 120191c78f9a6519797a224644f0ed4423d333e4 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:40:55 +0100 Subject: [PATCH 22/66] test(serverkit): cover bootstrap helpers and Serve lifecycle Add high-density unit tests for the new module. Densest coverage on Serve: normal ctx cancellation returns nil (HTTP+gRPC and HTTP-only), an HTTP bind failure returns a non-nil error without hanging (port pre-occupied), and http.ErrServerClosed during graceful shutdown is treated as success, all asserted via the Serve return value with a timeout guard against hangs. NewLogger level filtering is tested through the public logger; the JSON output and service attribute via a writer-injectable seam. NewRouter is covered for /health, /metrics, and release mode. NewGRPCServer registers a minimal unary service and asserts the shared metrics interceptor records the call. ConnectPostgres covers the migration-failure boundary (no live DB). --- services/serverkit/grpc_test.go | 73 ++++++++++ services/serverkit/logger_internal_test.go | 42 ++++++ services/serverkit/logger_test.go | 37 +++++ services/serverkit/postgres_test.go | 33 +++++ services/serverkit/router_test.go | 51 +++++++ services/serverkit/serve_test.go | 149 +++++++++++++++++++++ 6 files changed, 385 insertions(+) create mode 100644 services/serverkit/grpc_test.go create mode 100644 services/serverkit/logger_internal_test.go create mode 100644 services/serverkit/logger_test.go create mode 100644 services/serverkit/postgres_test.go create mode 100644 services/serverkit/router_test.go create mode 100644 services/serverkit/serve_test.go 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_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_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_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_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)) +} From 41fd35febeb8f031fd99508f436cd0506aa9e9d9 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 20:46:03 +0100 Subject: [PATCH 23/66] test(metrics): assert active_connections series is absent from /metrics Lock in the pollutant-gauge removal with a NotContains guard so a future edit that reintroduces the gauge fails the endpoint test. --- services/metrics/metrics_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/metrics/metrics_test.go b/services/metrics/metrics_test.go index 41eeb04b..a245da35 100644 --- a/services/metrics/metrics_test.go +++ b/services/metrics/metrics_test.go @@ -53,6 +53,8 @@ func TestMetricsEndpoint_ReturnsPrometheusFormat(t *testing.T) { 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) { From 6b7e43a17fbe1c4ad23d6fb2ea036a0c6ebbe0e8 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:09:28 +0100 Subject: [PATCH 24/66] refactor(finance): adopt serverkit bootstrap in main - Replace inlined logger/DB-connect/router/gRPC-server/serve/shutdown in run() with serverkit helpers; return serverkit.Serve so a REST bind failure propagates and the process exits non-zero (fixes C5 for finance) - Source the --healthcheck probe port from config.ResolveRESTPort so it always matches the listener's REST_PORT/default (US-PLATFORM-05) - Add serverkit require + replace ../serverkit to finance/go.mod --- services/finance/cmd/main.go | 97 +++------------------- services/finance/go.mod | 3 + services/finance/internal/config/config.go | 20 ++++- 3 files changed, 32 insertions(+), 88 deletions(-) diff --git a/services/finance/cmd/main.go b/services/finance/cmd/main.go index 3e5ebcdd..b53b476f 100644 --- a/services/finance/cmd/main.go +++ b/services/finance/cmd/main.go @@ -9,10 +9,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" @@ -22,16 +19,15 @@ import ( "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" + "github.com/ItsThompson/gofin/services/serverkit" expensepb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) 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 +47,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, @@ -108,10 +80,8 @@ func run() error { ) financeSvc := service.NewFinanceService(repo, txBeginner, logger).WithExpenseClient(expenseClient) - // 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 +90,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 +100,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..4518aa4d 100644 --- a/services/finance/go.mod +++ b/services/finance/go.mod @@ -9,6 +9,7 @@ require ( github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 github.com/ItsThompson/gofin/services/perf 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,6 +31,8 @@ replace github.com/ItsThompson/gofin/services/expense => ../expense replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate +replace github.com/ItsThompson/gofin/services/serverkit => ../serverkit + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // 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 == "" { From a0c11612c1db3a6f2a9db05e3c4c5a4e69fb3620 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:14:12 +0100 Subject: [PATCH 25/66] refactor(finance): fold expense client and clock into constructor - NewFinanceService now takes expenseClient and nowFunc directly; delete the mutate-but-say-copy WithExpenseClient/WithNowFunc fluent builders (US-HARDEN-01) - Route GetPeriodSummary's time.Now() through the injected nowFunc so dashboard pacing is deterministic in tests - Drop the expenseClient nil-guards in DeleteTag and pro-rata now that the client is always injected (removes the dashboard nil-deref contradiction by construction) - Update main.go and all service/handler test constructors to the folded signature --- services/finance/cmd/main.go | 3 +- .../finance/internal/handler/grpc_test.go | 10 ++--- .../finance/internal/handler/rest_test.go | 6 +-- .../internal/service/alluserdata_test.go | 2 +- .../finance/internal/service/dashboard.go | 2 +- .../internal/service/dashboard_test.go | 2 +- .../internal/service/deleteuserdata_test.go | 10 ++--- services/finance/internal/service/finance.go | 42 ++++++++----------- .../finance/internal/service/period_test.go | 15 +++---- services/finance/internal/service/prorata.go | 8 ---- .../finance/internal/service/prorata_test.go | 9 ++-- services/finance/internal/service/tag_test.go | 13 ++++-- 12 files changed, 53 insertions(+), 69 deletions(-) diff --git a/services/finance/cmd/main.go b/services/finance/cmd/main.go index b53b476f..29426b9b 100644 --- a/services/finance/cmd/main.go +++ b/services/finance/cmd/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "syscall" + "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -78,7 +79,7 @@ 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) // Build the gRPC server and pre-bind its listener so a bind failure surfaces. grpcServer := serverkit.NewGRPCServer() diff --git a/services/finance/internal/handler/grpc_test.go b/services/finance/internal/handler/grpc_test.go index 4a48d993..b590e5c1 100644 --- a/services/finance/internal/handler/grpc_test.go +++ b/services/finance/internal/handler/grpc_test.go @@ -21,7 +21,7 @@ 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) } @@ -140,7 +140,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 +164,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 +201,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 +227,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_test.go b/services/finance/internal/handler/rest_test.go index 9fd859f9..309dbea3 100644 --- a/services/finance/internal/handler/rest_test.go +++ b/services/finance/internal/handler/rest_test.go @@ -210,7 +210,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() @@ -878,7 +878,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() @@ -1339,7 +1339,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() 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 272ffd34..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. 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 175e263f..9b745962 100644 --- a/services/finance/internal/service/finance.go +++ b/services/finance/internal/service/finance.go @@ -38,34 +38,29 @@ 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 @@ -401,12 +396,9 @@ func (s *FinanceService) DeleteTag(ctx context.Context, userID, tagID string) er return &ServiceError{Code: model.ErrDefaultTag, Message: "Default tags cannot be deleted, only renamed", Status: 403} } - 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) diff --git a/services/finance/internal/service/period_test.go b/services/finance/internal/service/period_test.go index d734ca7b..254d9e9d 100644 --- a/services/finance/internal/service/period_test.go +++ b/services/finance/internal/service/period_test.go @@ -39,8 +39,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 +73,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) @@ -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) @@ -168,8 +165,7 @@ func TestUpdatePeriod_NegativeBudget(t *testing.T) { 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) @@ -192,8 +188,7 @@ func TestUpdatePeriod_NotFound(t *testing.T) { 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..a1004e63 100644 --- a/services/finance/internal/service/prorata.go +++ b/services/finance/internal/service/prorata.go @@ -77,10 +77,6 @@ func (s *FinanceService) CreateProRataExpense(ctx context.Context, userID string 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") - } - installments := CalculateInstallments(req.TotalAmount, req.Months) proRataGroup := uuid.New().String() @@ -196,10 +192,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 diff --git a/services/finance/internal/service/prorata_test.go b/services/finance/internal/service/prorata_test.go index cdd26da7..040b8468 100644 --- a/services/finance/internal/service/prorata_test.go +++ b/services/finance/internal/service/prorata_test.go @@ -135,8 +135,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 +183,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) @@ -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) diff --git a/services/finance/internal/service/tag_test.go b/services/finance/internal/service/tag_test.go index e88bef16..58b77c85 100644 --- a/services/finance/internal/service/tag_test.go +++ b/services/finance/internal/service/tag_test.go @@ -216,12 +216,19 @@ 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) } func makeTag(id, name string, isDefault bool) *model.Tag { From 540915b14fe0e3fd5f935b1fd81a7a3bb4821672 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:22:35 +0100 Subject: [PATCH 26/66] feat(datarights): detect no-rows via pgutil.IsNoRows in job repos --- services/datarights/go.mod | 3 +++ services/datarights/internal/repository/postgres.go | 8 ++++---- .../datarights/internal/repository/postgres_deletion.go | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/services/datarights/go.mod b/services/datarights/go.mod index 165b67ec..e0b060fb 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -11,6 +11,7 @@ require ( github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 github.com/ItsThompson/gofin/services/perf v0.0.0 + github.com/ItsThompson/gofin/services/pgutil 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 @@ -35,6 +36,8 @@ replace github.com/ItsThompson/gofin/services/metrics => ../metrics replace github.com/ItsThompson/gofin/services/perf => ../perf +replace github.com/ItsThompson/gofin/services/pgutil => ../pgutil + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect 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) From e108830ac75825055671b9b9ae97f3ef29658ba5 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:22:56 +0100 Subject: [PATCH 27/66] fix(expense): remove advertised-but-ignored list filters (C1) GetExpensesRequest carried Sort/Type/TagID/DateFrom/DateTo and the REST handler parsed them, but neither the service nor the immudb repository ever read them (the query hardcodes user_id + period + status='active' ORDER BY expense_date DESC). Clients received unfiltered, unsorted data with no error. The frontend does all filtering/sorting client-side and never sends these params, so deleting the fields and the param parsing is behavior-preserving: the response for a given ?year&month&page&pageSize is byte-identical. --- services/expense/internal/handler/rest.go | 5 ----- services/expense/internal/model/requests.go | 18 ++++++++---------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/services/expense/internal/handler/rest.go b/services/expense/internal/handler/rest.go index 3695af47..fc874187 100644 --- a/services/expense/internal/handler/rest.go +++ b/services/expense/internal/handler/rest.go @@ -143,11 +143,6 @@ 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) diff --git a/services/expense/internal/model/requests.go b/services/expense/internal/model/requests.go index c5d78b61..b000f70d 100644 --- a/services/expense/internal/model/requests.go +++ b/services/expense/internal/model/requests.go @@ -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. From de273a8060b9ccc47153c70b2d9c7c07123e9272 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:23:20 +0100 Subject: [PATCH 28/66] refactor(access): rename Access type to Level US-HARDEN-04: the access.Access type stuttered with its package name. Rename the type to access.Level (the field Route.Access keeps its name, so Registry literals are unchanged) and converge the gateway's shared-access import on the sharedaccess alias, ending the shadow with the gateway's own internal access package. --- services/access/access.go | 10 +++++----- services/access/registry.go | 2 +- services/access/resolver.go | 2 +- services/access/resolver_test.go | 2 +- services/gateway/internal/access/control.go | 20 +++++++++---------- .../gateway/internal/access/control_test.go | 4 ++-- 6 files changed, 20 insertions(+), 20 deletions(-) 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/registry.go b/services/access/registry.go index 5d461b37..10970494 100644 --- a/services/access/registry.go +++ b/services/access/registry.go @@ -20,7 +20,7 @@ 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 diff --git a/services/access/resolver.go b/services/access/resolver.go index b46fcea3..4df77e23 100644 --- a/services/access/resolver.go +++ b/services/access/resolver.go @@ -19,7 +19,7 @@ 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] diff --git a/services/access/resolver_test.go b/services/access/resolver_test.go index 43ee3016..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}, diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index 9c3aecd9..2bcf49bb 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -10,7 +10,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/ItsThompson/gofin/services/access" + sharedaccess "github.com/ItsThompson/gofin/services/access" ) // Identity headers the gateway sets for downstream services after a successful @@ -52,16 +52,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 +105,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 +135,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 diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index a0f037a0..b27441b4 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}, From 15c50ead52b45143d051475bac6c5ec5dbe9bbe3 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:24:58 +0100 Subject: [PATCH 29/66] fix(expense): guard rowToExpense against short rows (C4) rowToExpense indexed values[0..16] with no length guard, so a column-list or mapper drift, an immudb SDK change, or schema drift would panic with an index-out-of-range (gin recovers to a 500). Validate the value count against the expected column count before indexing and return a wrapped error on a short row. All five call sites propagate the error. Adds a unit test that exercises the short-row path through GetExpenseByID. --- .../expense/internal/repository/immudb.go | 43 +++++++++++++++---- .../internal/repository/immudb_test.go | 18 ++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) 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") +} From b72d7942808ed92817ece4f34facdeb05329e923 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:25:18 +0100 Subject: [PATCH 30/66] feat(datarights): adopt serverkit bootstrap, serve lifecycle, and probe port --- services/datarights/cmd/main.go | 82 +++---------------- services/datarights/go.mod | 3 + services/datarights/internal/config/config.go | 20 ++++- 3 files changed, 31 insertions(+), 74 deletions(-) diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index 3ec23f41..aa3a86fa 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" @@ -27,16 +24,15 @@ import ( _ "github.com/ItsThompson/gofin/services/datarights/internal/metrics" // register Prometheus metrics "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 @@ -188,26 +165,8 @@ func run() error { service.WithExportRepo(repo), ) - // 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,34 +177,17 @@ 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. diff --git a/services/datarights/go.mod b/services/datarights/go.mod index e0b060fb..cf261e92 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -12,6 +12,7 @@ require ( github.com/ItsThompson/gofin/services/metrics 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 @@ -38,6 +39,8 @@ 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/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect diff --git a/services/datarights/internal/config/config.go b/services/datarights/internal/config/config.go index 6ae9c385..f981172c 100644 --- a/services/datarights/internal/config/config.go +++ b/services/datarights/internal/config/config.go @@ -25,6 +25,21 @@ type Config struct { BrandTokensPath 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 +} + // Load reads configuration from environment variables and returns a Config. func Load() (*Config, error) { dbURL := os.Getenv("DATARIGHTS_DB_URL") @@ -42,10 +57,7 @@ func Load() (*Config, error) { environment = "development" } - restPort := os.Getenv("REST_PORT") - if restPort == "" { - restPort = "8084" - } + restPort := RESTPort() authServiceAddr := os.Getenv("AUTH_SERVICE_ADDR") if authServiceAddr == "" { From 31a9cb6caa7672e5f7ef1c3cd366979dffabbb4d Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:25:48 +0100 Subject: [PATCH 31/66] refactor(access): unexport Registry/ProxyPrefixes, inject prefixes into router.New US-HARDEN-02 (preventive): access.Registry and access.ProxyPrefixes were exported mutable package-level vars that any caller could reassign, and a gateway test reassigned ProxyPrefixes at startup. Unexport both (registry, proxyPrefixes) so they are immutable-after-init; the resolver and RoutesFor read the internal registry unchanged (per-request read behavior preserved). Expose Prefixes() returning a defensive copy, and inject the prefix inventory into router.New so the fail-fast wiring test constructs a bad prefix list instead of mutating a shared global. --- services/access/proxy.go | 16 ++++++++++++++-- services/access/proxy_test.go | 8 ++++---- services/access/registry.go | 12 ++++++------ services/access/registry_test.go | 10 +++++----- services/access/resolver.go | 4 ++-- services/gateway/cmd/main.go | 4 +++- services/gateway/internal/router/router.go | 12 ++++++++---- services/gateway/internal/router/router_test.go | 14 ++++++-------- 8 files changed, 48 insertions(+), 32 deletions(-) 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 10970494..bae45512 100644 --- a/services/access/registry.go +++ b/services/access/registry.go @@ -23,17 +23,17 @@ type Route struct { 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,7 +89,7 @@ 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) } diff --git a/services/access/registry_test.go b/services/access/registry_test.go index e6b5e2f0..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,7 +18,7 @@ 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) } @@ -42,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)) } } diff --git a/services/access/resolver.go b/services/access/resolver.go index 4df77e23..7e273d3e 100644 --- a/services/access/resolver.go +++ b/services/access/resolver.go @@ -21,8 +21,8 @@ import "strings" // prefix matching guarded against (commit ca37e4c) impossible by construction. 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/gateway/cmd/main.go b/services/gateway/cmd/main.go index b2b129d2..b081eeba 100644 --- a/services/gateway/cmd/main.go +++ b/services/gateway/cmd/main.go @@ -17,6 +17,8 @@ import ( "github.com/ItsThompson/gofin/services/gateway/internal/config" "github.com/ItsThompson/gofin/services/gateway/internal/router" "github.com/ItsThompson/gofin/services/healthcheck" + + sharedaccess "github.com/ItsThompson/gofin/services/access" ) func main() { @@ -97,7 +99,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{ 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 2bdf4fc8..286ebd2d 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -94,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). @@ -315,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() @@ -338,5 +336,5 @@ func TestRouter_New_PanicsOnPrefixWithNoProxy(t *testing.T) { ExpenseREST: u, FinanceREST: u, DatarightsREST: u, - }, newSilentLogger(), false) + }, badPrefixes, newSilentLogger(), false) } From a6790b45383241fd2c402a829a7a7ff82054c713 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:26:38 +0100 Subject: [PATCH 32/66] fix(expense): make dev in-memory client UPDATE loud (C3) The local in-memory immudb stub fell through to a silent empty result on UPDATE, so a local CorrectExpense left the original row active (double-counting in GetExpensesForPeriod) and anonymize was a silent no-op. Return an explicit "unsupported in local stub" error for UPDATE so the gap is loud in local dev. Production (real immudb, immudb_prod.go) is unaffected. Adds a package-main unit test for the new explicit-error path. --- services/expense/cmd/immudb_local.go | 8 ++++++ services/expense/cmd/immudb_local_test.go | 30 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 services/expense/cmd/immudb_local_test.go diff --git a/services/expense/cmd/immudb_local.go b/services/expense/cmd/immudb_local.go index 240733c0..b94d3196 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 } diff --git a/services/expense/cmd/immudb_local_test.go b/services/expense/cmd/immudb_local_test.go new file mode 100644 index 00000000..71341007 --- /dev/null +++ b/services/expense/cmd/immudb_local_test.go @@ -0,0 +1,30 @@ +//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") +} From 1fd5f319313869beb5c93a485d18a188fcc2fe4f Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:40:52 +0100 Subject: [PATCH 33/66] refactor(finance): migrate error contract to apierr and httpx - Replace local ApiError/ServiceError/handleError with apierr: services build *apierr.Error, handlers call apierr.Respond; keep finance-specific codes (PERIOD_NOT_FOUND, DUPLICATE_TAG, TAG_IN_USE, DEFAULT_TAG, PERIOD_LOCKED) as local constants - Populate Fields on every VALIDATION_ERROR site so multi-field failures emit field-level detail (C6); ValidateEDSSplit now returns an *apierr.Error naming each offending percentage - Replace X-User-ID and JSON-bind guards with httpx.RequireUserID/httpx.BindJSON; convert the 6 gRPC handler type-assertions to errors.As so %w-wrapped typed errors still classify (C7) - respondError logs unexpected 500s before apierr.Respond to preserve observability; add apierr+httpx require/replace to go.mod --- services/finance/go.mod | 6 + services/finance/internal/handler/grpc.go | 56 ++-- .../finance/internal/handler/grpc_test.go | 22 ++ services/finance/internal/handler/rest.go | 262 ++++++------------ .../finance/internal/handler/rest_test.go | 37 +-- services/finance/internal/model/errors.go | 27 +- services/finance/internal/service/finance.go | 187 +++++++------ .../finance/internal/service/finance_test.go | 44 ++- .../finance/internal/service/period_test.go | 22 +- services/finance/internal/service/prorata.go | 41 +-- .../finance/internal/service/prorata_test.go | 11 +- services/finance/internal/service/tag_test.go | 46 +-- 12 files changed, 356 insertions(+), 405 deletions(-) diff --git a/services/finance/go.mod b/services/finance/go.mod index 4518aa4d..46b476bd 100644 --- a/services/finance/go.mod +++ b/services/finance/go.mod @@ -4,9 +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/dbmigrate 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/httpx v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 github.com/ItsThompson/gofin/services/perf v0.0.0 github.com/ItsThompson/gofin/services/serverkit v0.0.0 @@ -33,6 +35,10 @@ 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 + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect diff --git a/services/finance/internal/handler/grpc.go b/services/finance/internal/handler/grpc.go index c3d5ea5b..366c891d 100644 --- a/services/finance/internal/handler/grpc.go +++ b/services/finance/internal/handler/grpc.go @@ -2,11 +2,13 @@ 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" @@ -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") } @@ -80,8 +84,9 @@ func (h *GRPCHandler) CompleteOnboarding(ctx context.Context, req *pb.CompleteOn 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") } @@ -102,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") @@ -119,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") @@ -138,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") diff --git a/services/finance/internal/handler/grpc_test.go b/services/finance/internal/handler/grpc_test.go index b590e5c1..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" @@ -25,6 +26,27 @@ func setupGRPCHandler(repo *mockFinanceRepository) *GRPCHandler { 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) 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 309dbea3..bb9e5124 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" @@ -306,9 +307,9 @@ 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%") } @@ -416,9 +417,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 +508,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 +558,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 +702,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 +793,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%") } @@ -941,7 +942,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 +1195,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 +1295,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 +1318,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)") @@ -1406,7 +1407,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 +1429,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/service/finance.go b/services/finance/internal/service/finance.go index 9b745962..1ba18c3a 100644 --- a/services/finance/internal/service/finance.go +++ b/services/finance/internal/service/finance.go @@ -5,12 +5,14 @@ import ( "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" ) @@ -61,42 +63,70 @@ func NewFinanceService( } } -// 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) @@ -147,11 +177,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 } @@ -159,20 +185,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{ @@ -196,14 +214,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) @@ -211,10 +227,10 @@ 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, + Status: http.StatusNotFound, } } return period, nil @@ -232,20 +248,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. @@ -254,20 +262,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, } } @@ -283,11 +287,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", @@ -341,17 +341,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} + return nil, duplicateTagError(name) } return nil, fmt.Errorf("creating tag: %w", err) } @@ -362,38 +359,56 @@ 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} + 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, + } } expenseCount, err := s.expenseClient.CountExpensesByTag(ctx, userID, tagID) @@ -414,7 +429,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 { 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 254d9e9d..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" ) @@ -89,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") @@ -115,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) } @@ -135,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%") } @@ -156,9 +154,8 @@ 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") } @@ -179,9 +176,8 @@ 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) } diff --git a/services/finance/internal/service/prorata.go b/services/finance/internal/service/prorata.go index a1004e63..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,26 +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} + return nil, apierr.Validation("Expense date is required", map[string]string{"expenseDate": "required"}) } installments := CalculateInstallments(req.TotalAmount, req.Months) @@ -133,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) } @@ -250,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 040b8468..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" ) @@ -229,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) }) } @@ -255,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 58b77c85..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" ) @@ -231,6 +232,16 @@ func newTagTestServiceNow(repo *mockRepo, txBeg *mockTxBeg, expClient *mockExpCl 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 { return &model.Tag{ ID: id, @@ -322,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) } @@ -338,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") } @@ -353,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 --- @@ -385,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 --- @@ -423,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") @@ -446,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)") @@ -469,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)") } @@ -491,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)") @@ -509,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) } From b7cd9478c62cfc1e2b56f25d9277f640ee08c5a1 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:41:09 +0100 Subject: [PATCH 34/66] fix(expense): remove empty-scope gRPC read RPCs and dev cross-user bypass (C2) GetCorrectionHistory and GetProRataGroup gRPC handlers hardcoded an empty ("") user scope (the proto requests carry no user_id), so in production they returned nothing by construction, and in local dev the in-memory client's empty-userID select-by-id bypass let them read cross-user data. Neither RPC has any consumer: the frontend fetches correction-history and pro-rata over REST (which passes the real header userID), and no in-workspace gRPC client invokes them. - Remove both RPC declarations from expense.proto and regenerate expensepb, so the generated client/server interfaces and service descriptor no longer expose them (removes the UnimplementedExpenseServiceServer stubs too). - Remove the two handler methods from grpc.go. - Remove the dev in-memory client's userID == "" select-by-id OR-bypass so it can never return another user's row. - Add a gRPC guard test asserting the two RPCs are not registered, and a dev-client test asserting select-by-id is strictly user-scoped. The request/response message types are retained (still referenced by an out-of-service gRPC client mock); removing them would require editing a module outside this ticket's scope. Regeneration also re-syncs a stale doc comment on StreamAllUserExpenses in the generated interfaces. The REST /api/expenses/:id/history and /api/expenses/prorata/:groupId endpoints are unchanged and still pass the real header userID. --- services/expense/cmd/immudb_local.go | 6 +- services/expense/cmd/immudb_local_test.go | 34 +++++++ services/expense/internal/handler/grpc.go | 45 +-------- .../expense/internal/handler/grpc_test.go | 27 ++++++ services/expense/proto/expense.proto | 14 ++- .../expense/proto/expensepb/expense.pb.go | 28 +++--- .../proto/expensepb/expense_grpc.pb.go | 94 ++++--------------- 7 files changed, 104 insertions(+), 144 deletions(-) diff --git a/services/expense/cmd/immudb_local.go b/services/expense/cmd/immudb_local.go index b94d3196..fdc417e3 100644 --- a/services/expense/cmd/immudb_local.go +++ b/services/expense/cmd/immudb_local.go @@ -89,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 index 71341007..a77405d9 100644 --- a/services/expense/cmd/immudb_local_test.go +++ b/services/expense/cmd/immudb_local_test.go @@ -28,3 +28,37 @@ func TestInMemoryClient_UpdateReturnsExplicitError(t *testing.T) { 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/internal/handler/grpc.go b/services/expense/internal/handler/grpc.go index 79447c56..2c3e57f4 100644 --- a/services/expense/internal/handler/grpc.go +++ b/services/expense/internal/handler/grpc.go @@ -13,9 +13,8 @@ import ( 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 +90,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 +107,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 { diff --git a/services/expense/internal/handler/grpc_test.go b/services/expense/internal/handler/grpc_test.go index c21bf65f..2365b332 100644 --- a/services/expense/internal/handler/grpc_test.go +++ b/services/expense/internal/handler/grpc_test.go @@ -23,6 +23,33 @@ func newTestGRPCHandler(repo *mockExpenseRepository) *GRPCHandler { 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") +} + // --- AnonymizeAllUserExpenses gRPC handler tests --- func TestGRPC_AnonymizeAllUserExpenses_Success(t *testing.T) { 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{ { From 75488a36c7296bf500b5fb5c47465bb4d53ac6df Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:43:42 +0100 Subject: [PATCH 35/66] refactor(finance): adopt pgutil in postgres repo - Replace the 28 pgtype.UUID{}.Scan blocks with pgutil.ParseUUID and the 7 no-rows checks with pgutil.IsNoRows - Delete the local formatUUID copy; format UUIDs via uuid.UUID(b).String() at the dbXToModel call sites - Replace isDuplicateKeyError with pgutil.IsUniqueViolation at the tag create/update sites; add pgutil require/replace and tidy dbmigrate/metrics to indirect (now reached via serverkit) --- services/finance/go.mod | 7 +- .../finance/internal/repository/postgres.go | 209 +++++++++--------- services/finance/internal/service/finance.go | 16 +- 3 files changed, 110 insertions(+), 122 deletions(-) diff --git a/services/finance/go.mod b/services/finance/go.mod index 46b476bd..38759d47 100644 --- a/services/finance/go.mod +++ b/services/finance/go.mod @@ -5,12 +5,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/dbmigrate 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/httpx v0.0.0 - github.com/ItsThompson/gofin/services/metrics 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 @@ -39,7 +38,11 @@ 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/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/finance.go b/services/finance/internal/service/finance.go index 1ba18c3a..b0192dd2 100644 --- a/services/finance/internal/service/finance.go +++ b/services/finance/internal/service/finance.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "fmt" "log/slog" "net/http" @@ -10,11 +9,10 @@ import ( "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. @@ -347,7 +345,7 @@ func (s *FinanceService) CreateTag(ctx context.Context, userID string, req *mode tag, err := s.repo.CreateTag(ctx, userID, name, false) if err != nil { - if isDuplicateKeyError(err) { + if _, ok := pgutil.IsUniqueViolation(err); ok { return nil, duplicateTagError(name) } return nil, fmt.Errorf("creating tag: %w", err) @@ -365,7 +363,7 @@ func (s *FinanceService) UpdateTag(ctx context.Context, userID, tagID string, re tag, err := s.repo.UpdateTag(ctx, tagID, userID, name) if err != nil { - if isDuplicateKeyError(err) { + if _, ok := pgutil.IsUniqueViolation(err); ok { return nil, duplicateTagError(name) } return nil, fmt.Errorf("updating tag: %w", err) @@ -439,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 -} From 6199a1cc95d33f48a90e36c7872541775f7b102b Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:48:09 +0100 Subject: [PATCH 36/66] feat(datarights): route errors through apierr and guards through httpx --- services/datarights/go.mod | 6 +++ .../datarights/internal/handler/deletion.go | 45 +++-------------- .../internal/handler/deletion_test.go | 39 ++++++++------- .../datarights/internal/handler/respond.go | 23 +++++++++ services/datarights/internal/handler/rest.go | 50 ++++--------------- .../datarights/internal/handler/rest_test.go | 13 ++--- .../datarights/internal/model/requests.go | 25 +++------- .../datarights/internal/service/deletion.go | 27 +++------- .../internal/service/deletion_test.go | 37 +++++++------- .../datarights/internal/service/export.go | 18 +------ .../internal/service/export_test.go | 11 ++-- 11 files changed, 118 insertions(+), 176 deletions(-) create mode 100644 services/datarights/internal/handler/respond.go diff --git a/services/datarights/go.mod b/services/datarights/go.mod index cf261e92..ca0ac661 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -4,11 +4,13 @@ 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/httpx v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 github.com/ItsThompson/gofin/services/perf v0.0.0 github.com/ItsThompson/gofin/services/pgutil v0.0.0 @@ -23,6 +25,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 @@ -33,6 +37,8 @@ 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 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..f931f1dd 100644 --- a/services/datarights/internal/handler/deletion_test.go +++ b/services/datarights/internal/handler/deletion_test.go @@ -20,6 +20,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" @@ -338,7 +339,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 +363,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 +388,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 +418,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 +444,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 +463,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 +483,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 +505,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 +535,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 +591,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 +609,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..39e6184a 100644 --- a/services/datarights/internal/handler/rest.go +++ b/services/datarights/internal/handler/rest.go @@ -11,6 +11,7 @@ import ( 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,12 +60,8 @@ 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 } @@ -84,7 +81,7 @@ func (h *RESTHandler) CreateExport(c *gin.Context) { }) return } - h.handleError(c, err) + respondError(c, h.logger, err) return } @@ -105,12 +102,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 +124,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 +134,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 +143,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..3a9194cf 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" @@ -248,10 +249,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 +266,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 +315,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/model/requests.go b/services/datarights/internal/model/requests.go index 07ac5fd8..05b35970 100644 --- a/services/datarights/internal/model/requests.go +++ b/services/datarights/internal/model/requests.go @@ -23,24 +23,15 @@ type RateLimitedResponse struct { 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/service/deletion.go b/services/datarights/internal/service/deletion.go index fe4c77aa..17d2e99b 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" @@ -92,11 +93,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 +154,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 +173,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 +183,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 +205,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, @@ -222,7 +215,7 @@ func (s *DeletionService) checkProtectedUsername(ctx context.Context, userID str } if isProtectedUsername(resp.GetUsername()) { - return &ServiceError{ + return &apierr.Error{ Code: model.ErrProtectedUser, Message: "Cannot delete a protected user account", Status: http.StatusForbidden, @@ -244,11 +237,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_test.go b/services/datarights/internal/service/deletion_test.go index bd93d1da..f212e25a 100644 --- a/services/datarights/internal/service/deletion_test.go +++ b/services/datarights/internal/service/deletion_test.go @@ -16,6 +16,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" @@ -265,8 +266,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 +316,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 +337,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 +360,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 +380,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 +429,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 +456,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 +620,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) } From 48f4d2140de74a73620db2bc468b64133a7fb443 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:49:08 +0100 Subject: [PATCH 37/66] refactor(gateway): adopt serverkit NewLogger and Serve lifecycle US-PLATFORM-01/02: run() now builds its logger via serverkit.NewLogger and delegates the serve/shutdown lifecycle to serverkit.Serve(ctx, httpServer, nil, nil) (no gRPC server). This fixes the C5 zombie bug for the gateway: a REST bind failure now propagates out of run() so the process exits non-zero instead of lingering with no listener (previously swallowed in a goroutine that only logged). Gateway keeps its own router.New reverse proxy and does not use serverkit.NewRouter or ConnectPostgres. Adds serverkit (and its transitive dbmigrate) require+replace to go.mod for GOWORK=off Docker builds. --- services/gateway/cmd/main.go | 58 +++++++++++--------------------- services/gateway/cmd/run_test.go | 52 ++++++++++++++++++++++++++++ services/gateway/go.mod | 16 +++++++++ services/gateway/go.sum | 54 +++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 39 deletions(-) create mode 100644 services/gateway/cmd/run_test.go diff --git a/services/gateway/cmd/main.go b/services/gateway/cmd/main.go index b081eeba..aea52220 100644 --- a/services/gateway/cmd/main.go +++ b/services/gateway/cmd/main.go @@ -9,16 +9,15 @@ 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" - - sharedaccess "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/serverkit" ) func main() { @@ -42,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. @@ -107,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..6f04749b 100644 --- a/services/gateway/go.mod +++ b/services/gateway/go.mod @@ -10,6 +10,7 @@ require ( 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 @@ -21,11 +22,16 @@ replace github.com/ItsThompson/gofin/services/access => ../access 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 +46,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 +72,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= From 6f1f621294a8bb583c12ec1ca2ff6e1c499e3591 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:49:57 +0100 Subject: [PATCH 38/66] chore(auth): wire serverkit/apierr/httpx/pgutil into go.mod Add require + replace directives for the four shared platform modules so the auth service can consume them. No code imports them yet; the per-concern migrations follow. --- services/auth/go.mod | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 From 458850b3cbb56b34bfaa81d2863466366c9f19ab Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:50:04 +0100 Subject: [PATCH 39/66] refactor(auth): replace pgx boilerplate with pgutil helpers Route the 10 UUID-parse sites through pgutil.ParseUUID, the 7 no-rows checks through pgutil.IsNoRows, and the 2 unique-violation checks through pgutil.IsUniqueViolation. Delete the local formatUUID copy in favor of uuid.UUID(b).String() at its two call sites. --- .../internal/repository/blacklist_postgres.go | 19 +++--- services/auth/internal/repository/postgres.go | 68 ++++++++----------- 2 files changed, 37 insertions(+), 50 deletions(-) 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 } From 606450c3046070a9bb84cef4f303e5ab5b404ba8 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:50:14 +0100 Subject: [PATCH 40/66] feat(auth): hoist JWT/cleanup/cookie config and name role constants Read the JWT access TTL (default 15m), refresh TTL (default 7d), cleanup interval (default 5m) and timeout (default 30s) from Config, defaulting to the JWTService's documented DefaultAccessTokenTTL / DefaultRefreshTokenTTL so the defaults are single-sourced. NewJWTService gains WithAccessTTL/WithRefreshTTL options. Expose config.RESTPort() so the --healthcheck probe and the listener share one port default (US-PLATFORM-05). Add named RoleUser/RoleAdmin constants. Config tests cover the new env-driven values and duration validation. --- services/auth/internal/config/config.go | 108 +++++++++++++++---- services/auth/internal/config/config_test.go | 80 ++++++++++++++ services/auth/internal/model/user.go | 6 ++ services/auth/internal/service/jwt.go | 37 ++++++- 4 files changed, 204 insertions(+), 27 deletions(-) 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..8366a0ed 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,12 +148,89 @@ func TestIsProduction(t *testing.T) { assert.True(t, (&Config{Environment: "staging"}).IsProduction()) } +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 unsets all config env vars so tests are isolated. 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 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/service/jwt.go b/services/auth/internal/service/jwt.go index c807488d..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. From 509178454a361e20c6dc7cd03cd1e8c0c5f23205 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:50:30 +0100 Subject: [PATCH 41/66] refactor(auth): route errors via apierr+httpx, adopt serverkit lifecycle Replace the local ApiError struct, AuthError typed error, and handleError with the shared apierr package: service methods construct *apierr.Error (or apierr constructors) and handlers call apierr.Respond, logging unexpected 500s first so observability is preserved. Replace the 5 X-User-ID guards and JSON-bind guards with httpx.RequireUserID/BindJSON, which now surfaces field-level validation detail on the wire (C6). Convert the gRPC handler type-assertions to errors.As via isMissingUser so a %w-wrapped typed error still maps to codes.NotFound (C7). run() now uses serverkit NewLogger/ConnectPostgres/NewGRPCServer/NewRouter and returns serverkit.Serve, fixing the zombie-on-bind-failure bug (C5), and sources cookie max-ages + cleanup cadence from config. UpdateProfile's own-record-missing path now returns 401 to match the four-method majority (C8); AssumeIdentity/RestoreIdentity 404s are unchanged. Service-specific codes stay local; tests assert the apierr.Respond output instead of the removed AuthError shape. --- services/auth/cmd/main.go | 104 ++------- services/auth/internal/handler/grpc.go | 15 +- services/auth/internal/handler/grpc_test.go | 21 ++ .../handler/refresh_integration_test.go | 2 +- .../internal/handler/registration_test.go | 2 +- services/auth/internal/handler/rest.go | 169 +++++--------- services/auth/internal/handler/rest_test.go | 77 +++++-- services/auth/internal/model/errors.go | 26 +-- services/auth/internal/service/auth.go | 180 ++++----------- services/auth/internal/service/auth_test.go | 216 ++++++++++-------- 10 files changed, 344 insertions(+), 468 deletions(-) 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/internal/handler/grpc.go b/services/auth/internal/handler/grpc.go index 774844f1..81285ab6 100644 --- a/services/auth/internal/handler/grpc.go +++ b/services/auth/internal/handler/grpc.go @@ -2,11 +2,14 @@ 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" ) @@ -29,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 { @@ -65,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", @@ -99,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 d2944ebb..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() 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/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") } - From 26a32205445fe5cd26678df4dfb16f606aba753d Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:51:30 +0100 Subject: [PATCH 42/66] feat(datarights): move rate-limit retry timing to Retry-After header (C9) --- .../__tests__/ExportDataSection.test.tsx | 2 -- .../finance/src/features/settings/types.ts | 7 ------- services/datarights/internal/handler/rest.go | 17 ++++++++++----- .../datarights/internal/handler/rest_test.go | 21 +++++++++++-------- .../datarights/internal/model/requests.go | 7 ------- 5 files changed, 24 insertions(+), 30 deletions(-) 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/datarights/internal/handler/rest.go b/services/datarights/internal/handler/rest.go index 39e6184a..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,6 +9,7 @@ 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" @@ -67,17 +69,22 @@ func (h *RESTHandler) CreateExport(c *gin.Context) { 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 } diff --git a/services/datarights/internal/handler/rest_test.go b/services/datarights/internal/handler/rest_test.go index 3a9194cf..3e8b6f92 100644 --- a/services/datarights/internal/handler/rest_test.go +++ b/services/datarights/internal/handler/rest_test.go @@ -192,14 +192,17 @@ 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) { @@ -226,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) { diff --git a/services/datarights/internal/model/requests.go b/services/datarights/internal/model/requests.go index 05b35970..32a52e45 100644 --- a/services/datarights/internal/model/requests.go +++ b/services/datarights/internal/model/requests.go @@ -16,13 +16,6 @@ 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"` -} - // 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. From 17853d54b7f237558282d086ae40125564bf67ee Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:51:58 +0100 Subject: [PATCH 43/66] refactor(gateway): route API errors through apierr US-ERRTAX-01: the gateway's middleware aborts (401/403/503) and the reverse proxy's 502 body previously hand-built {code,message} literals. Route the middleware errors through apierr.Respond (+ c.Abort to halt the chain, since Respond only writes) and encode the proxy's BAD_GATEWAY body via the shared apierr.APIError struct (the ErrorHandler has no gin.Context, so Respond does not apply there). SERVICE_UNAVAILABLE and BAD_GATEWAY are gateway-specific codes constructed inline; FORBIDDEN uses apierr.CodeForbidden. Wire shapes are byte-identical (pinned by a new exact-JSON test). --- services/gateway/go.mod | 3 ++ services/gateway/internal/access/control.go | 33 +++++++++------- .../gateway/internal/access/control_test.go | 39 +++++++++++++++++++ services/gateway/internal/proxy/proxy.go | 12 +++++- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/services/gateway/go.mod b/services/gateway/go.mod index 6f04749b..32c2e60b 100644 --- a/services/gateway/go.mod +++ b/services/gateway/go.mod @@ -7,6 +7,7 @@ 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 @@ -20,6 +21,8 @@ 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 diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index 2bcf49bb..b19631fb 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -11,6 +11,7 @@ import ( "google.golang.org/grpc/status" sharedaccess "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/apierr" ) // Identity headers the gateway sets for downstream services after a successful @@ -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 b27441b4..ccb07b27 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -500,6 +500,45 @@ 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 shape 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. +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.JSONEq(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.JSONEq(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.JSONEq(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/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. From e5b26002be31f3981bc442b2a04099bbf5cfbed9 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:52:51 +0100 Subject: [PATCH 44/66] refactor(gateway): unify healthcheck probe port with config US-PLATFORM-05: the --healthcheck branch hardcoded healthcheck.Run("8080"), duplicating the config PORT default, so a PORT override desynced the probe from the listener. Add config.DefaultPort + config.ResolvePort() as the single source; Load and the --healthcheck branch both call ResolvePort, so an override moves the listener and the probe together. --- services/gateway/cmd/main.go | 2 +- services/gateway/internal/config/config.go | 23 +++++++-- .../gateway/internal/config/config_test.go | 49 +++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/services/gateway/cmd/main.go b/services/gateway/cmd/main.go index aea52220..cac6e215 100644 --- a/services/gateway/cmd/main.go +++ b/services/gateway/cmd/main.go @@ -22,7 +22,7 @@ import ( func main() { if healthcheck.ShouldRun(os.Args) { - os.Exit(healthcheck.Run("8080")) + os.Exit(healthcheck.Run(config.ResolvePort())) } if err := run(); err != nil { 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. From 83687dabfde235e68164b08669ee5b8d9195037d Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:53:17 +0100 Subject: [PATCH 45/66] docs(gateway): drop stale AssertMaxAllocs mention in perf baseline Ticket 1 removed perf.AssertMaxAllocs; the validate baseline note still named it. Reword to 'no allocation-bound assertion is made for it' so the doc cites no deleted symbol. Meaning (the guard is behavioral, not an allocation count) is unchanged. --- services/gateway/perf/baseline/validate.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 51235b3a154f4bbb178f321f8806726cc96858e3 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:54:25 +0100 Subject: [PATCH 46/66] feat(datarights): hoist protected-username list to config with test --- services/datarights/cmd/main.go | 1 + services/datarights/internal/config/config.go | 28 ++++ .../datarights/internal/config/config_test.go | 136 ++++++++++++++++++ .../internal/handler/deletion_test.go | 5 +- .../datarights/internal/service/deletion.go | 21 ++- .../internal/service/deletion_test.go | 5 +- .../internal/service/protected_usernames.go | 14 +- 7 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 services/datarights/internal/config/config_test.go diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index aa3a86fa..b67b4449 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -163,6 +163,7 @@ func run() error { service.WithDeletionEngine(deletionEngine), service.WithAuthClient(authClient), service.WithExportRepo(repo), + service.WithProtectedUsernames(cfg.ProtectedUsernames), ) // Build the shared router (Recovery, HTTP metrics, /metrics, GET /health). diff --git a/services/datarights/internal/config/config.go b/services/datarights/internal/config/config.go index f981172c..ae6d4688 100644 --- a/services/datarights/internal/config/config.go +++ b/services/datarights/internal/config/config.go @@ -4,9 +4,16 @@ 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 @@ -23,6 +30,7 @@ type Config struct { EmailFrom string EmailEnabled bool BrandTokensPath string + ProtectedUsernames []string } // DefaultRESTPort is the datarights REST listener port used when REST_PORT is @@ -40,6 +48,23 @@ func RESTPort() string { 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. func Load() (*Config, error) { dbURL := os.Getenv("DATARIGHTS_DB_URL") @@ -111,6 +136,8 @@ func Load() (*Config, error) { brandTokensPath = "/app/tokens/brand.json" } + protectedUsernames := parseProtectedUsernames(os.Getenv("PROTECTED_USERNAMES")) + return &Config{ DBUrl: dbURL, LogLevel: logLevel, @@ -126,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/handler/deletion_test.go b/services/datarights/internal/handler/deletion_test.go index f931f1dd..7b88b922 100644 --- a/services/datarights/internal/handler/deletion_test.go +++ b/services/datarights/internal/handler/deletion_test.go @@ -22,6 +22,7 @@ import ( "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" @@ -214,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)) } diff --git a/services/datarights/internal/service/deletion.go b/services/datarights/internal/service/deletion.go index 17d2e99b..6ce6eac7 100644 --- a/services/datarights/internal/service/deletion.go +++ b/services/datarights/internal/service/deletion.go @@ -24,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. @@ -71,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: @@ -214,7 +223,7 @@ func (s *DeletionService) checkProtectedUsername(ctx context.Context, userID str return fmt.Errorf("fetching user for protected check: %w", err) } - if isProtectedUsername(resp.GetUsername()) { + if isProtectedUsername(s.protectedUsernames, resp.GetUsername()) { return &apierr.Error{ Code: model.ErrProtectedUser, Message: "Cannot delete a protected user account", diff --git a/services/datarights/internal/service/deletion_test.go b/services/datarights/internal/service/deletion_test.go index f212e25a..d30f4dc9 100644 --- a/services/datarights/internal/service/deletion_test.go +++ b/services/datarights/internal/service/deletion_test.go @@ -18,6 +18,7 @@ import ( "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" ) @@ -228,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)) } 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 } } From 614afd8bd92c5ede9facef5a62907203a7c328c7 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:54:53 +0100 Subject: [PATCH 47/66] style(gateway): gofmt router_test validator fixtures The UserID/Role struct-literal alignment went stale when Ticket 2 removed the Username field from these fixtures. gofmt-clean it (the pinned linter set has no gofmt check, so it was not caught). --- services/gateway/internal/router/router_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/gateway/internal/router/router_test.go b/services/gateway/internal/router/router_test.go index 286ebd2d..ffbc2a09 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -39,8 +39,8 @@ func validCookie() *http.Cookie { func adminValidator() *mockValidator { return &mockValidator{ result: &access.TokenValidationResult{ - UserID: "admin-1", - Role: "admin", + UserID: "admin-1", + Role: "admin", }, } } @@ -48,8 +48,8 @@ func adminValidator() *mockValidator { func userValidator() *mockValidator { return &mockValidator{ result: &access.TokenValidationResult{ - UserID: "user-1", - Role: "user", + UserID: "user-1", + Role: "user", }, } } From c035742a19b1cd101bc45b90877d6b15c31b00ac Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 21:59:16 +0100 Subject: [PATCH 48/66] test(auth): clarify clearEnv comment on empty-vs-unset env handling --- services/auth/internal/config/config_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/auth/internal/config/config_test.go b/services/auth/internal/config/config_test.go index 8366a0ed..3be6dc58 100644 --- a/services/auth/internal/config/config_test.go +++ b/services/auth/internal/config/config_test.go @@ -223,7 +223,10 @@ func TestRESTPort(t *testing.T) { assert.Equal(t, "9090", RESTPort()) } -// clearEnv unsets all config env vars so tests are isolated. +// 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{ @@ -233,8 +236,6 @@ func clearEnv(t *testing.T) { "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 } } From c341bf94dc7e186b980c4f33f6ff9df52540aca5 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:04:26 +0100 Subject: [PATCH 49/66] test(gateway): assert exact wire bytes for apierr error bodies Tighten the error-contract test from assert.JSONEq (semantic) to assert.Equal on the raw body, pinning the byte-for-byte claim: field order (code, message) and the absence of a trailing newline are now part of the guarded contract. --- services/gateway/internal/access/control_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index ccb07b27..036cb81c 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -502,9 +502,11 @@ 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 shape clients already receive, so the migration is not a +// {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. +// (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) @@ -513,7 +515,7 @@ func TestAccessControl_ErrorBodies_MatchApierrWireShape(t *testing.T) { engine.ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.JSONEq(t, `{"code":"UNAUTHORIZED","message":"Authentication required"}`, rec.Body.String()) + assert.Equal(t, `{"code":"UNAUTHORIZED","message":"Authentication required"}`, rec.Body.String()) }) t.Run("403 denied unclassified route", func(t *testing.T) { @@ -523,7 +525,7 @@ func TestAccessControl_ErrorBodies_MatchApierrWireShape(t *testing.T) { engine.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) - assert.JSONEq(t, `{"code":"FORBIDDEN","message":"Access denied"}`, rec.Body.String()) + assert.Equal(t, `{"code":"FORBIDDEN","message":"Access denied"}`, rec.Body.String()) }) t.Run("503 auth dependency timeout", func(t *testing.T) { @@ -535,7 +537,7 @@ func TestAccessControl_ErrorBodies_MatchApierrWireShape(t *testing.T) { engine.ServeHTTP(rec, req) assert.Equal(t, http.StatusServiceUnavailable, rec.Code) - assert.JSONEq(t, `{"code":"SERVICE_UNAVAILABLE","message":"Authentication service unavailable"}`, rec.Body.String()) + assert.Equal(t, `{"code":"SERVICE_UNAVAILABLE","message":"Authentication service unavailable"}`, rec.Body.String()) }) } From da606117180c8c76c5efa0ffcfae43620e45d682 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:16:40 +0100 Subject: [PATCH 50/66] chore(datarights): tidy go.mod indirect deps and rename rate-limit test --- services/datarights/go.mod | 4 ++-- services/datarights/internal/handler/rest_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/datarights/go.mod b/services/datarights/go.mod index ca0ac661..ec036483 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -6,12 +6,10 @@ 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/httpx v0.0.0 - github.com/ItsThompson/gofin/services/metrics 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 @@ -48,6 +46,8 @@ 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/handler/rest_test.go b/services/datarights/internal/handler/rest_test.go index 3e8b6f92..ae369db8 100644 --- a/services/datarights/internal/handler/rest_test.go +++ b/services/datarights/internal/handler/rest_test.go @@ -205,7 +205,7 @@ func TestCreateExport_RateLimited_Returns429(t *testing.T) { 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) From b953a5d38b9558d5d51820966080558cf3fea54e Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:18:00 +0100 Subject: [PATCH 51/66] style(finance): gofmt-sort cmd/main.go import group - Order the third import group by path so expensepb/pb sit in place instead of appended at the end --- services/finance/cmd/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/finance/cmd/main.go b/services/finance/cmd/main.go index 29426b9b..0703a09b 100644 --- a/services/finance/cmd/main.go +++ b/services/finance/cmd/main.go @@ -14,16 +14,16 @@ import ( "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" + pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" "github.com/ItsThompson/gofin/services/healthcheck" "github.com/ItsThompson/gofin/services/serverkit" - expensepb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" - pb "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) func main() { From b5df7feb6b6f4024ea527c5d93f3af7225c0d41d Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:18:02 +0100 Subject: [PATCH 52/66] test(finance): assert onboarding emits field-level validation detail - Decode fields:{...} from a multi-field VALIDATION_ERROR response body to pin the C6 contract end-to-end (service Fields -> apierr wire) --- .../finance/internal/handler/rest_test.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/services/finance/internal/handler/rest_test.go b/services/finance/internal/handler/rest_test.go index bb9e5124..8c505a6f 100644 --- a/services/finance/internal/handler/rest_test.go +++ b/services/finance/internal/handler/rest_test.go @@ -313,6 +313,34 @@ func TestCompleteOnboardingHandler_InvalidSplit(t *testing.T) { 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) From b6f7c9ae45dff5b3479c46dec22e636459ef1941 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:45:55 +0100 Subject: [PATCH 53/66] refactor(expense): adopt serverkit bootstrap in main --- services/expense/cmd/main.go | 82 +++---------------- services/expense/go.mod | 17 +++- services/expense/go.sum | 46 ++++++++++- services/expense/internal/config/config.go | 20 ++++- .../expense/internal/config/config_test.go | 12 +++ 5 files changed, 102 insertions(+), 75 deletions(-) diff --git a/services/expense/cmd/main.go b/services/expense/cmd/main.go index 09da8c89..1e70613a 100644 --- a/services/expense/cmd/main.go +++ b/services/expense/cmd/main.go @@ -9,23 +9,19 @@ import ( "os" "os/signal" "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 +41,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) @@ -76,10 +63,8 @@ func run() error { // Build dependency graph expenseSvc := service.NewExpenseService(repo, 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 +73,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 +83,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..f732b001 100644 --- a/services/expense/go.mod +++ b/services/expense/go.mod @@ -6,6 +6,7 @@ require ( github.com/ItsThompson/gofin/services/access 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/codenotary/immudb v1.11.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 @@ -20,7 +21,12 @@ 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 + 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 +45,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 +79,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 +90,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") From d75a4fd70619c9e3ae833bc5509a4f07d19b1d8e Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:48:25 +0100 Subject: [PATCH 54/66] refactor(expense): fold clock into the service constructor --- services/expense/cmd/main.go | 3 +- .../expense/internal/handler/grpc_test.go | 3 +- .../expense/internal/handler/rest_test.go | 4 +-- services/expense/internal/service/expense.go | 18 +++++------- .../expense/internal/service/expense_test.go | 7 ++--- .../expense/internal/service/stream_test.go | 2 +- .../internal/service/suggestions_test.go | 28 +++++-------------- 7 files changed, 24 insertions(+), 41 deletions(-) diff --git a/services/expense/cmd/main.go b/services/expense/cmd/main.go index 1e70613a..46f5677a 100644 --- a/services/expense/cmd/main.go +++ b/services/expense/cmd/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/ItsThompson/gofin/services/expense/internal/config" "github.com/ItsThompson/gofin/services/expense/internal/handler" @@ -61,7 +62,7 @@ func run() error { } // Build dependency graph - expenseSvc := service.NewExpenseService(repo, logger) + expenseSvc := service.NewExpenseService(repo, time.Now, logger) // Build the gRPC server and pre-bind its listener so a bind failure surfaces. grpcServer := serverkit.NewGRPCServer() diff --git a/services/expense/internal/handler/grpc_test.go b/services/expense/internal/handler/grpc_test.go index 2365b332..39a8521f 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" @@ -19,7 +20,7 @@ import ( 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) } diff --git a/services/expense/internal/handler/rest_test.go b/services/expense/internal/handler/rest_test.go index e46e021a..ce92fd5a 100644 --- a/services/expense/internal/handler/rest_test.go +++ b/services/expense/internal/handler/rest_test.go @@ -104,7 +104,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) @@ -426,7 +426,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) diff --git a/services/expense/internal/service/expense.go b/services/expense/internal/service/expense.go index 1e2cf59b..2b9001a7 100644 --- a/services/expense/internal/service/expense.go +++ b/services/expense/internal/service/expense.go @@ -23,25 +23,21 @@ 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 { @@ -61,7 +57,7 @@ func (s *ExpenseService) CreateExpense(ctx context.Context, userID string, req * 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, @@ -253,7 +249,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) diff --git a/services/expense/internal/service/expense_test.go b/services/expense/internal/service/expense_test.go index bb7122a0..1dac544f 100644 --- a/services/expense/internal/service/expense_test.go +++ b/services/expense/internal/service/expense_test.go @@ -98,7 +98,7 @@ 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) } func validCreateRequest() *model.CreateExpenseRequest { @@ -436,9 +436,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) { diff --git a/services/expense/internal/service/stream_test.go b/services/expense/internal/service/stream_test.go index bbf6a896..36bffbf7 100644 --- a/services/expense/internal/service/stream_test.go +++ b/services/expense/internal/service/stream_test.go @@ -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_test.go b/services/expense/internal/service/suggestions_test.go index c6a51272..2ca82e4b 100644 --- a/services/expense/internal/service/suggestions_test.go +++ b/services/expense/internal/service/suggestions_test.go @@ -15,9 +15,7 @@ import ( 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 +41,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 +58,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 +78,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 +96,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 +116,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 +148,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"}, From ce13c1e3261b5bffb7eefc6128bfae509e62cde6 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 22:59:13 +0100 Subject: [PATCH 55/66] feat(datarights): add jobrunner Pool and StatusStore seam Extract the bounded-worker-pool lifecycle shared by the export and deletion engines into a new internal package. Pool owns the semaphore, the running transition, the timeout-derived context, and the background-context fail-on-timeout; engines inject their own Execute strategy and a StatusStore. Sociable tests use a real Pool with a fake StatusStore and stub execute (no mocking of the semaphore or goroutine) and cover: the running transition, success -> CompleteJob, strategy error -> FailJob under a background context, the timeout path, MaxConcurrent bounding, and live ActiveJobs/QueuedJobs. --- .../datarights/internal/jobrunner/pool.go | 116 ++++++++ .../internal/jobrunner/pool_test.go | 267 ++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 services/datarights/internal/jobrunner/pool.go create mode 100644 services/datarights/internal/jobrunner/pool_test.go diff --git a/services/datarights/internal/jobrunner/pool.go b/services/datarights/internal/jobrunner/pool.go new file mode 100644 index 00000000..30be6629 --- /dev/null +++ b/services/datarights/internal/jobrunner/pool.go @@ -0,0 +1,116 @@ +// 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 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) { + go p.run(jobID, userID) +} + +func (p *Pool) run(jobID, userID string) { + p.queued.Add(1) + 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) +} From af6bcaaebfd4562767cd3bc5b4c958f0af19b0fe Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 23:02:06 +0100 Subject: [PATCH 56/66] refactor(expense): migrate error contract to apierr and httpx --- services/expense/go.mod | 6 + services/expense/internal/handler/grpc.go | 29 ++-- .../expense/internal/handler/grpc_test.go | 26 ++++ services/expense/internal/handler/rest.go | 131 ++++++------------ .../expense/internal/handler/rest_test.go | 21 +-- .../internal/handler/suggestions_test.go | 13 +- services/expense/internal/model/errors.go | 20 +-- services/expense/internal/model/requests.go | 2 +- services/expense/internal/service/expense.go | 115 +++------------ .../expense/internal/service/expense_test.go | 86 ++++++------ .../expense/internal/service/stream_test.go | 6 +- .../expense/internal/service/suggestions.go | 7 +- .../internal/service/suggestions_test.go | 6 +- 13 files changed, 184 insertions(+), 284 deletions(-) diff --git a/services/expense/go.mod b/services/expense/go.mod index f732b001..9b3cfdd2 100644 --- a/services/expense/go.mod +++ b/services/expense/go.mod @@ -4,7 +4,9 @@ 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 @@ -25,6 +27,10 @@ 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 diff --git a/services/expense/internal/handler/grpc.go b/services/expense/internal/handler/grpc.go index 2c3e57f4..f42ad343 100644 --- a/services/expense/internal/handler/grpc.go +++ b/services/expense/internal/handler/grpc.go @@ -8,6 +8,7 @@ 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" @@ -125,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 / @@ -174,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 39a8521f..84a729b5 100644 --- a/services/expense/internal/handler/grpc_test.go +++ b/services/expense/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/expense/internal/service" pb "github.com/ItsThompson/gofin/services/expense/proto/expensepb" ) @@ -51,6 +52,31 @@ func TestGRPC_RemovedReadRPCsAreNotRegistered(t *testing.T) { 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 fc874187..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 } @@ -145,7 +127,7 @@ func (h *RESTHandler) GetExpenses(c *gin.Context) { PageSize: int32(pageSize), }) if svcErr != nil { - h.handleError(c, svcErr) + h.respondError(c, svcErr) return } @@ -154,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 } @@ -181,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 } @@ -202,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 } @@ -223,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 } @@ -278,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 } @@ -291,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 } @@ -302,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 } @@ -315,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 ce92fd5a..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" @@ -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 --- @@ -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 b000f70d..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"` diff --git a/services/expense/internal/service/expense.go b/services/expense/internal/service/expense.go index 2b9001a7..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" @@ -38,19 +40,6 @@ func NewExpenseService( } } -// 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 { @@ -99,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 @@ -141,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) @@ -153,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 @@ -166,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) @@ -186,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 { @@ -203,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 @@ -224,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, } } @@ -273,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) @@ -285,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 @@ -298,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) @@ -328,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 @@ -400,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 { @@ -420,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 == "" { @@ -442,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 == "" { @@ -484,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 1dac544f..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" ) @@ -101,6 +102,15 @@ func newTestService(repo *mockExpenseRepository) *ExpenseService { 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 { return &model.CreateExpenseRequest{ Name: "Grocery shopping", @@ -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 --- @@ -501,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) } @@ -533,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) } @@ -549,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) } @@ -562,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) { @@ -592,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]) }) } @@ -649,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) { @@ -661,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 --- @@ -692,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 --- @@ -734,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 36bffbf7..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) { 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 2ca82e4b..82ed88e3 100644 --- a/services/expense/internal/service/suggestions_test.go +++ b/services/expense/internal/service/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" ) @@ -197,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) }) } From 5a2c2b0c78581ca6ceca0359bb24ef288b4f7d16 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 23:02:15 +0100 Subject: [PATCH 57/66] refactor(datarights): de-stutter deletion engine/provider/registry names Rename DeletionEngine -> Engine, DeletionProvider -> Provider, DeletionProviderRegistry -> Registry, and their constructors (NewDeletionEngine -> NewEngine, NewDeletionProviderRegistry -> NewRegistry). The package is internal, so no external module references the old names. Pure mechanical rename; behavior is unchanged (the pool extraction follows). --- services/datarights/cmd/main.go | 6 +-- .../datarights/internal/deletion/engine.go | 28 +++++------ .../internal/deletion/engine_test.go | 48 +++++++++---------- .../datarights/internal/deletion/provider.go | 4 +- .../internal/deletion/providers/auth.go | 4 +- .../internal/deletion/providers/expense.go | 4 +- .../internal/deletion/providers/finance.go | 4 +- .../deletion/providers/integration_test.go | 10 ++-- .../datarights/internal/deletion/registry.go | 16 +++---- .../internal/service/deletion_submitter.go | 2 +- 10 files changed, 63 insertions(+), 63 deletions(-) diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index b67b4449..1228c317 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -143,12 +143,12 @@ func run() error { // Set up deletion engine with provider registry deletionRepo := repository.NewPostgresDeletionJobRepository(pool) - deletionRegistry := deletion.NewDeletionProviderRegistry() + deletionRegistry := deletion.NewRegistry() deletionRegistry.Register(deletionproviders.NewFinanceDeletionProvider(financeClient)) deletionRegistry.Register(deletionproviders.NewExpenseDeletionProvider(expenseClient)) deletionRegistry.Register(deletionproviders.NewAuthDeletionProvider(authClient)) - deletionEngine := deletion.NewDeletionEngine( + deletionEngine := deletion.NewEngine( deletionRegistry, deletionRepo, cfg.MaxConcurrent, @@ -225,7 +225,7 @@ func recoverJobs(ctx context.Context, repo repository.JobRepository, eng *engine } // recoverDeletionJobs re-submits any non-terminal deletion jobs on startup. -func recoverDeletionJobs(ctx context.Context, repo repository.DeletionJobRepository, eng *deletion.DeletionEngine, logger *slog.Logger) { +func recoverDeletionJobs(ctx context.Context, repo repository.DeletionJobRepository, eng *deletion.Engine, logger *slog.Logger) { jobs, err := repo.GetNonTerminalJobs(ctx) if err != nil { logger.Error("failed to query recoverable deletion jobs", slog.String("error", err.Error())) diff --git a/services/datarights/internal/deletion/engine.go b/services/datarights/internal/deletion/engine.go index fb5bafb3..ca847477 100644 --- a/services/datarights/internal/deletion/engine.go +++ b/services/datarights/internal/deletion/engine.go @@ -15,26 +15,26 @@ const ( backoffSecond = 2 * time.Second ) -// DeletionEngine manages a bounded pool of deletion workers. +// Engine 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 +type Engine struct { + registry *Registry repo repository.DeletionJobRepository sem chan struct{} 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 { + return &Engine{ registry: registry, repo: repo, sem: make(chan struct{}, maxConcurrent), @@ -44,18 +44,18 @@ func NewDeletionEngine( } // ActiveJobs returns the number of currently executing deletion jobs. -func (e *DeletionEngine) ActiveJobs() int { +func (e *Engine) ActiveJobs() int { return len(e.sem) } // MaxConcurrent returns the pool capacity. -func (e *DeletionEngine) MaxConcurrent() int { +func (e *Engine) 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) { +func (e *Engine) Submit(jobID, userID string) { go func() { // Acquire semaphore slot (blocks if pool is full) e.sem <- struct{}{} @@ -70,7 +70,7 @@ func (e *DeletionEngine) Submit(jobID, userID string) { // 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) { +func (e *Engine) execute(ctx context.Context, jobID, userID string) { e.logger.Info("deletion job starting", slog.String("job_id", jobID), slog.String("user_id", userID), @@ -120,7 +120,7 @@ func (e *DeletionEngine) execute(ctx context.Context, jobID, userID string) { // 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 @@ -172,7 +172,7 @@ func (e *DeletionEngine) executeProvider(ctx context.Context, provider DeletionP } // failJob marks a job as failed using a background context (in case the original expired). -func (e *DeletionEngine) failJob(jobID, userID, errMsg string) { +func (e *Engine) failJob(jobID, userID, errMsg string) { e.logger.Error("deletion job failed", slog.String("job_id", jobID), slog.String("user_id", userID), 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/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 index 204e6baa..f9e2565e 100644 --- a/services/datarights/internal/deletion/providers/auth.go +++ b/services/datarights/internal/deletion/providers/auth.go @@ -7,8 +7,8 @@ import ( "github.com/ItsThompson/gofin/services/datarights/internal/deletion" ) -// Compile-time check that AuthDeletionProvider implements DeletionProvider. -var _ deletion.DeletionProvider = (*AuthDeletionProvider)(nil) +// Compile-time check that AuthDeletionProvider implements deletion.Provider. +var _ deletion.Provider = (*AuthDeletionProvider)(nil) // AuthDeletionProvider deletes all auth-related data for a user via gRPC. // Must be registered last: user cannot authenticate after this provider runs. diff --git a/services/datarights/internal/deletion/providers/expense.go b/services/datarights/internal/deletion/providers/expense.go index bf2ec2d1..1869cee5 100644 --- a/services/datarights/internal/deletion/providers/expense.go +++ b/services/datarights/internal/deletion/providers/expense.go @@ -7,8 +7,8 @@ import ( "github.com/ItsThompson/gofin/services/expense/proto/expensepb" ) -// Compile-time check that ExpenseDeletionProvider implements DeletionProvider. -var _ deletion.DeletionProvider = (*ExpenseDeletionProvider)(nil) +// Compile-time check that ExpenseDeletionProvider implements deletion.Provider. +var _ deletion.Provider = (*ExpenseDeletionProvider)(nil) // ExpenseDeletionProvider anonymizes all expense data for a user via gRPC. type ExpenseDeletionProvider struct { diff --git a/services/datarights/internal/deletion/providers/finance.go b/services/datarights/internal/deletion/providers/finance.go index 6021ff40..1ea0bd04 100644 --- a/services/datarights/internal/deletion/providers/finance.go +++ b/services/datarights/internal/deletion/providers/finance.go @@ -7,8 +7,8 @@ import ( "github.com/ItsThompson/gofin/services/finance/proto/financepb" ) -// Compile-time check that FinanceDeletionProvider implements DeletionProvider. -var _ deletion.DeletionProvider = (*FinanceDeletionProvider)(nil) +// Compile-time check that FinanceDeletionProvider implements deletion.Provider. +var _ deletion.Provider = (*FinanceDeletionProvider)(nil) // FinanceDeletionProvider deletes all financial data for a user via gRPC. type FinanceDeletionProvider struct { diff --git a/services/datarights/internal/deletion/providers/integration_test.go b/services/datarights/internal/deletion/providers/integration_test.go index 60b01426..d4d3bcec 100644 --- a/services/datarights/internal/deletion/providers/integration_test.go +++ b/services/datarights/internal/deletion/providers/integration_test.go @@ -76,7 +76,7 @@ func (m *integrationMockRepo) GetNonTerminalJobs(_ context.Context) ([]model.Rec // --------------------------------------------------------------------------- type orderTrackingProvider struct { - inner deletion.DeletionProvider + inner deletion.Provider mu *sync.Mutex callLog *[]string calledAt *[]time.Time @@ -117,7 +117,7 @@ func TestIntegration_AllProviders_CalledInOrder_JobCompletes(t *testing.T) { wrappedAuth := &orderTrackingProvider{inner: authProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} // Register in correct order: finance → expense → auth - registry := deletion.NewDeletionProviderRegistry() + registry := deletion.NewRegistry() registry.Register(wrappedFinance) registry.Register(wrappedExpense) registry.Register(wrappedAuth) @@ -125,7 +125,7 @@ func TestIntegration_AllProviders_CalledInOrder_JobCompletes(t *testing.T) { repo := &integrationMockRepo{} logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - engine := deletion.NewDeletionEngine(registry, repo, 2, 30*time.Second, logger) + engine := deletion.NewEngine(registry, repo, 2, 30*time.Second, logger) // Submit a job engine.Submit("job-1", "user-42") @@ -179,7 +179,7 @@ func TestIntegration_ProviderFailure_JobFails_RemainingSkipped(t *testing.T) { wrappedExpense := &orderTrackingProvider{inner: expenseProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} wrappedAuth := &orderTrackingProvider{inner: authProv, mu: &mu, callLog: &callLog, calledAt: &calledAt} - registry := deletion.NewDeletionProviderRegistry() + registry := deletion.NewRegistry() registry.Register(wrappedFinance) registry.Register(wrappedExpense) registry.Register(wrappedAuth) @@ -187,7 +187,7 @@ func TestIntegration_ProviderFailure_JobFails_RemainingSkipped(t *testing.T) { repo := &integrationMockRepo{} logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - engine := deletion.NewDeletionEngine(registry, repo, 2, 30*time.Second, logger) + engine := deletion.NewEngine(registry, repo, 2, 30*time.Second, logger) engine.Submit("job-2", "user-99") 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/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) From 9988e0e98186ea7f1ae08ae4650ada0e8f081143 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 23:03:30 +0100 Subject: [PATCH 58/66] refactor(datarights): run deletion engine on jobrunner.Pool The deletion Engine now composes a jobrunner.Pool and injects its sequential-retry-with-backoff loop as the Execute strategy. The pool owns the semaphore, running transition, completion, and background-context failure; the deletion job repo satisfies jobrunner.StatusStore directly and is handed to the pool as the store. Removes the engine's now-dead private semaphore, ActiveJobs/MaxConcurrent copies, and failJob helper. --- .../datarights/internal/deletion/engine.go | 110 +++++------------- 1 file changed, 28 insertions(+), 82 deletions(-) diff --git a/services/datarights/internal/deletion/engine.go b/services/datarights/internal/deletion/engine.go index ca847477..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,15 +16,15 @@ const ( backoffSecond = 2 * time.Second ) -// Engine 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). +// 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 - repo repository.DeletionJobRepository - sem chan struct{} + pool *jobrunner.Pool logger *slog.Logger - timeout time.Duration } // NewEngine creates a deletion engine with bounded concurrency. @@ -34,87 +35,52 @@ func NewEngine( timeout time.Duration, logger *slog.Logger, ) *Engine { - return &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 *Engine) ActiveJobs() int { - return len(e.sem) -} - -// MaxConcurrent returns the pool capacity. -func (e *Engine) MaxConcurrent() int { - return cap(e.sem) -} - -// Submit enqueues a deletion job for async processing. -// Non-blocking: spawns a goroutine that blocks on the semaphore. +// 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) { - 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) - }() + e.pool.Submit(jobID, userID) } -// execute runs the full deletion flow for a single job. -// Called inside a goroutine after acquiring a semaphore slot. -func (e *Engine) 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. @@ -170,23 +136,3 @@ func (e *Engine) executeProvider(ctx context.Context, provider Provider, jobID, return maxRetries, lastErr } - -// failJob marks a job as failed using a background context (in case the original expired). -func (e *Engine) 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()), - ) - } -} From 74e72e733155c69ef837e493e8e4466496fb3614 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 23:07:21 +0100 Subject: [PATCH 59/66] refactor(datarights): run export engine on jobrunner.Pool with live pool gauges The export Engine now composes a jobrunner.Pool and injects its errgroup fan-out + zip + email closure as the Execute strategy. Because export's CompleteJob persists file_size_bytes and Submit carries userEmail (neither fits the generic pool signatures), the engine adapts its repo behind the StatusStore seam via a small statusStore type plus a per-job state map. The pool now owns the semaphore, so export_pool_active_jobs and export_pool_queued_jobs become GaugeFuncs that read pool.ActiveJobs()/ QueuedJobs() live at scrape time (wired via metrics.SetPoolStats from main), preserving the Grafana dashboard and stuck-pool alert. Job business metrics stay inside the injected strategy. --- services/datarights/cmd/main.go | 6 +- services/datarights/internal/engine/engine.go | 185 ++++++++++-------- .../internal/engine/engine_bench_test.go | 4 +- .../datarights/internal/metrics/metrics.go | 40 +++- .../internal/metrics/metrics_test.go | 52 +++-- 5 files changed, 181 insertions(+), 106 deletions(-) diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index 1228c317..25a3ed17 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -21,7 +21,7 @@ import ( "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/repository" "github.com/ItsThompson/gofin/services/datarights/internal/service" "github.com/ItsThompson/gofin/services/expense/proto/expensepb" @@ -135,6 +135,10 @@ func run() error { exportEngine := engine.NewEngine(newExportProviders, financeClient, repo, emailSender, cfg.MaxConcurrent, cfg.ExportTimeout, logger) + // 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 emailResolver := service.NewAuthUserEmailResolver(authClient) recoverJobs(ctx, repo, exportEngine, emailResolver, logger) diff --git a/services/datarights/internal/engine/engine.go b/services/datarights/internal/engine/engine.go index f4caa12b..1100d171 100644 --- a/services/datarights/internal/engine/engine.go +++ b/services/datarights/internal/engine/engine.go @@ -6,11 +6,13 @@ 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" @@ -22,15 +24,31 @@ import ( // finance client is supplied per job so each job gets its own memoized instance. type ProviderFactory func(finance financepb.FinanceServiceClient) []DataProvider -// Engine manages a bounded pool of export workers. +// 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 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 @@ -45,54 +63,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{} +} + +func (e *Engine) takeState(jobID string) *jobState { + if v, ok := e.jobs.LoadAndDelete(jobID); ok { + return v.(*jobState) + } + return &jobState{} +} - // No longer queued, now active - exportmetrics.ExportPoolQueuedJobs.Dec() - exportmetrics.ExportPoolActiveJobs.Inc() +// 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 +} - defer func() { - <-e.sem - exportmetrics.ExportPoolActiveJobs.Dec() - }() +func (s statusStore) CompleteJob(ctx context.Context, jobID string) error { + return s.e.repo.CompleteJob(ctx, jobID, s.e.takeState(jobID).fileSize) +} - ctx, cancel := context.WithTimeout(context.Background(), e.timeout) - defer cancel() +func (s statusStore) FailJob(ctx context.Context, jobID, reason string) error { + s.e.jobs.Delete(jobID) + return s.e.repo.FailJob(ctx, jobID, reason) +} - e.execute(ctx, jobID, userID, userEmail) - }() +// 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 } -// execute runs the full export flow for a single job. -func (e *Engine) execute(ctx context.Context, jobID, userID, userEmail string) { +// 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,19 +155,6 @@ 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 - } - // 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. @@ -178,21 +219,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 +245,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 +256,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 +267,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 +282,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..5b4abfa8 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 @@ -36,6 +36,6 @@ func BenchmarkEngineCollectionFanout(b *testing.B) { 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/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) { From 259f94c87090d235a0ff58846da7e65ddafb6dbc Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 23:08:36 +0100 Subject: [PATCH 60/66] refactor(datarights): share startup job-recovery skeleton recoverJobs and recoverDeletionJobs collapsed into one generic helper that owns the query -> empty-check -> per-job submit skeleton; each engine passes a submit closure. Export retains its per-job email-resolve step; recovery on startup still re-submits non-terminal jobs. --- services/datarights/cmd/main.go | 91 +++++++++++++++++---------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index 25a3ed17..cf1e0bf6 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -22,6 +22,7 @@ import ( "github.com/ItsThompson/gofin/services/datarights/internal/engine/providers" "github.com/ItsThompson/gofin/services/datarights/internal/handler" 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/expense/proto/expensepb" @@ -139,9 +140,25 @@ func run() error { // (scraped by the Grafana dashboard and the stuck-pool alert). exportmetrics.SetPoolStats(exportEngine.ActiveJobs, exportEngine.QueuedJobs) - // Startup recovery: re-submit non-terminal export jobs + // 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)) @@ -161,7 +178,13 @@ 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), @@ -195,44 +218,23 @@ func run() error { 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.Engine, 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 } @@ -240,14 +242,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) } } From 8e2c2953ecdc2351c1a0f58f9ee03cd420840639 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 11 Jul 2026 23:26:36 +0100 Subject: [PATCH 61/66] refactor(datarights): mark jobs queued in Submit for precise pool telemetry Move the queued-counter increment from the worker goroutine into Submit so a just-submitted job is counted as queued the instant Submit returns, rather than only once its goroutine schedules. The decrement stays after slot acquisition; still atomic and race-clean. --- services/datarights/internal/jobrunner/pool.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/datarights/internal/jobrunner/pool.go b/services/datarights/internal/jobrunner/pool.go index 30be6629..e821a925 100644 --- a/services/datarights/internal/jobrunner/pool.go +++ b/services/datarights/internal/jobrunner/pool.go @@ -60,14 +60,15 @@ func New(maxConcurrent int, timeout time.Duration, store StatusStore, execute Ex } } -// Submit is non-blocking: it spawns a worker goroutine that acquires a slot, -// transitions the job to running, runs execute, then completes or fails it. +// 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.queued.Add(1) p.sem <- struct{}{} // acquire slot (blocks if the pool is full) p.queued.Add(-1) defer func() { <-p.sem }() From 9d7ab747ed4f33641162a11ed274a9c52c28f6f7 Mon Sep 17 00:00:00 2001 From: thompson Date: Sun, 12 Jul 2026 00:11:34 +0100 Subject: [PATCH 62/66] refactor(datarights): collapse deletion providers into NewFuncProvider Replace the three near-identical deletion/providers/{auth,expense,finance}.go structs (each wrapping one gRPC delete call) with deletion.NewFuncProvider(name, fn). Registration in main.go now pairs a name with an idempotent delete closure, preserving execution order (finance, expense, auth). The deletion engine's retry/backoff execution strategy is unchanged; only provider construction changes. Engine-level order/retry/fail-fast coverage stays in engine_test.go; NewFuncProvider gets focused constructor + order/idempotency tests. --- services/datarights/cmd/main.go | 21 +- .../internal/deletion/func_provider.go | 27 +++ .../internal/deletion/func_provider_test.go | 65 ++++++ .../internal/deletion/providers/auth.go | 35 --- .../internal/deletion/providers/auth_test.go | 38 --- .../internal/deletion/providers/expense.go | 34 --- .../deletion/providers/expense_test.go | 38 --- .../internal/deletion/providers/finance.go | 34 --- .../deletion/providers/finance_test.go | 38 --- .../deletion/providers/integration_test.go | 217 ------------------ .../deletion/providers/mock_clients_test.go | 185 --------------- 11 files changed, 109 insertions(+), 623 deletions(-) create mode 100644 services/datarights/internal/deletion/func_provider.go create mode 100644 services/datarights/internal/deletion/func_provider_test.go delete mode 100644 services/datarights/internal/deletion/providers/auth.go delete mode 100644 services/datarights/internal/deletion/providers/auth_test.go delete mode 100644 services/datarights/internal/deletion/providers/expense.go delete mode 100644 services/datarights/internal/deletion/providers/expense_test.go delete mode 100644 services/datarights/internal/deletion/providers/finance.go delete mode 100644 services/datarights/internal/deletion/providers/finance_test.go delete mode 100644 services/datarights/internal/deletion/providers/integration_test.go delete mode 100644 services/datarights/internal/deletion/providers/mock_clients_test.go diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index cf1e0bf6..8b3d3765 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -16,7 +16,6 @@ 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" @@ -164,10 +163,24 @@ func run() error { // Set up deletion engine with provider registry deletionRepo := repository.NewPostgresDeletionJobRepository(pool) + + // 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(deletionproviders.NewFinanceDeletionProvider(financeClient)) - deletionRegistry.Register(deletionproviders.NewExpenseDeletionProvider(expenseClient)) - deletionRegistry.Register(deletionproviders.NewAuthDeletionProvider(authClient)) + 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, 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/providers/auth.go b/services/datarights/internal/deletion/providers/auth.go deleted file mode 100644 index f9e2565e..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 deletion.Provider. -var _ deletion.Provider = (*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 1869cee5..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 deletion.Provider. -var _ deletion.Provider = (*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 1ea0bd04..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 deletion.Provider. -var _ deletion.Provider = (*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 d4d3bcec..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.Provider - 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.NewRegistry() - registry.Register(wrappedFinance) - registry.Register(wrappedExpense) - registry.Register(wrappedAuth) - - repo := &integrationMockRepo{} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - - engine := deletion.NewEngine(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.NewRegistry() - registry.Register(wrappedFinance) - registry.Register(wrappedExpense) - registry.Register(wrappedAuth) - - repo := &integrationMockRepo{} - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - - engine := deletion.NewEngine(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 -} From b5d61433648c51e3041b0d4559237a51cc87aaae Mon Sep 17 00:00:00 2001 From: thompson Date: Sun, 12 Jul 2026 00:24:55 +0100 Subject: [PATCH 63/66] refactor(datarights): fetch export finance data once, drop MemoizedFinanceClient The export engine now fetches GetAllUserData once per job in execute and hands the resolved response to the provider factory. The finance-backed providers (tags, budget_periods, default_settings) become pure response -> rows mappers, and the expenses provider takes a pre-derived tag map (new BuildTagMap) instead of self-fetching it. The auth-backed profile and stream-backed expenses providers still self-fetch their own sources. finance_cache.go / MemoizedFinanceClient are removed: the single-fetch guarantee is now structural, asserted by TestExport_DedupesFinanceCalls (GetAllUserData exactly once, ListTags zero), replacing the deleted MemoizedFinanceClient unit tests. --- services/datarights/cmd/main.go | 21 ++-- services/datarights/internal/engine/engine.go | 33 +++-- .../internal/engine/engine_bench_test.go | 2 +- .../internal/engine/engine_fanout_test.go | 10 +- .../datarights/internal/engine/engine_test.go | 33 +++-- .../internal/engine/export_dedup_test.go | 19 +-- .../internal/engine/export_helpers_test.go | 24 ++-- .../engine/export_measurement_test.go | 26 ++-- .../internal/engine/finance_cache.go | 46 ------- .../internal/engine/finance_cache_test.go | 74 ----------- .../engine/providers/budget_periods.go | 25 ++-- .../engine/providers/budget_periods_test.go | 102 +++++++--------- .../engine/providers/default_settings.go | 27 ++-- .../engine/providers/default_settings_test.go | 58 +++------ .../internal/engine/providers/expenses.go | 44 ++----- .../engine/providers/expenses_test.go | 115 +++++------------- .../engine/providers/mock_clients_test.go | 74 ----------- .../internal/engine/providers/tagmap.go | 17 +++ .../internal/engine/providers/tagmap_test.go | 33 +++++ .../internal/engine/providers/tags.go | 24 ++-- .../internal/engine/providers/tags_test.go | 72 ++++------- 21 files changed, 301 insertions(+), 578 deletions(-) delete mode 100644 services/datarights/internal/engine/finance_cache.go delete mode 100644 services/datarights/internal/engine/finance_cache_test.go create mode 100644 services/datarights/internal/engine/providers/tagmap.go create mode 100644 services/datarights/internal/engine/providers/tagmap_test.go diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index 8b3d3765..0ffbb062 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -111,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), } } diff --git a/services/datarights/internal/engine/engine.go b/services/datarights/internal/engine/engine.go index 1100d171..c6e426a6 100644 --- a/services/datarights/internal/engine/engine.go +++ b/services/datarights/internal/engine/engine.go @@ -18,11 +18,13 @@ import ( "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 @@ -52,8 +54,8 @@ type Engine struct { } // 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, @@ -155,11 +157,18 @@ func (e *Engine) runExport(ctx context.Context, jobID, userID, userEmail string) slog.String("method", "engine.execute"), ) - // 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) + // 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) + } + 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 diff --git a/services/datarights/internal/engine/engine_bench_test.go b/services/datarights/internal/engine/engine_bench_test.go index 5b4abfa8..e2ab20e8 100644 --- a/services/datarights/internal/engine/engine_bench_test.go +++ b/services/datarights/internal/engine/engine_bench_test.go @@ -31,7 +31,7 @@ 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() 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) From 4132a8b4d47b8a927014896244d383a4789935a9 Mon Sep 17 00:00:00 2001 From: thompson Date: Sun, 12 Jul 2026 00:25:33 +0100 Subject: [PATCH 64/66] refactor(datarights): dedup email templateData against BrandTokens templateData now uses BrandColors/BrandTypography/BrandSpacing directly instead of three parallel template* structs that mirrored them field by field, and buildTemplateData assigns the token groups wholesale. The templates access the same field names, so rendering is unchanged (covered by the existing HTML/text rendering tests). No reference remains to the Essentials/Desires/Savings colors removed in the dead-code sweep. --- services/datarights/internal/email/resend.go | 58 ++++---------------- 1 file changed, 10 insertions(+), 48 deletions(-) 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, } From d29428ba9eda5f4062844a6f313d7d17b48778e2 Mon Sep 17 00:00:00 2001 From: thompson Date: Sun, 12 Jul 2026 00:34:55 +0100 Subject: [PATCH 65/66] docs(datarights): update export perf baseline for structural single-fetch The dedup-target note named a per-job MemoizedFinanceClient serving GetAllUserData at most once. That wrapper is gone: the export engine now fetches GetAllUserData exactly once in execute and passes the response to pure-mapper providers, so the guarantee is structural (one call site) and the assertion is exactly-once, not at-most-once. Doc-only. --- services/datarights/perf/baseline/export.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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). From 08df1a0f8232c9114d23b845144ca36c1e8393fb Mon Sep 17 00:00:00 2001 From: thompson Date: Sun, 12 Jul 2026 01:01:00 +0100 Subject: [PATCH 66/66] fix(docker): copy shared modules into service builds - Add serverkit/apierr/httpx/pgutil (and dbmigrate for gateway/expense) COPY lines to each service Dockerfile so GOWORK=off go mod download can read the epic's new replaced modules' go.mod. - Unblocks the e2e Docker build and CD image build, which failed with "reading ../apierr/go.mod: no such file or directory". - Verified by reproducing each Dockerfile's build context and running GOWORK=off download+build for all 5 services. --- services/auth/Dockerfile | 8 ++++++++ services/datarights/Dockerfile | 8 ++++++++ services/expense/Dockerfile | 8 ++++++++ services/finance/Dockerfile | 8 ++++++++ services/gateway/Dockerfile | 10 +++++++++- 5 files changed, 41 insertions(+), 1 deletion(-) 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/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/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/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/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 \