From 73196579e8b4cc8b98cbf1a7fa8399e9c87ef077 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 16:15:36 +0300 Subject: [PATCH] test(users): cover config, usecase error paths, and /me 500 path --- users/internal/infra/config_test.go | 101 ++++++++++++++++++ users/internal/transport/http/server_test.go | 42 ++++++++ users/internal/usecase/errorpaths_test.go | 103 +++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 users/internal/infra/config_test.go create mode 100644 users/internal/usecase/errorpaths_test.go diff --git a/users/internal/infra/config_test.go b/users/internal/infra/config_test.go new file mode 100644 index 0000000..57840b2 --- /dev/null +++ b/users/internal/infra/config_test.go @@ -0,0 +1,101 @@ +package infra + +import ( + "testing" + "time" +) + +const validSecret = "a-secret-that-is-at-least-32-bytes!!" + +// setBaseEnv wires the minimum valid environment. ENV_FILE points at a path that +// does not exist so a developer's stray .env never leaks into the test. +func setBaseEnv(t *testing.T) { + t.Helper() + t.Setenv("ENV_FILE", "/nonexistent/.env") + t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable") + t.Setenv("JWT_SECRET", validSecret) +} + +func TestLoadConfigDefaults(t *testing.T) { + setBaseEnv(t) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Addr != ":8081" { + t.Errorf("Addr default = %q, want :8081", cfg.Addr) + } + if cfg.TokenTTL != 24*time.Hour { + t.Errorf("TokenTTL default = %v, want 24h", cfg.TokenTTL) + } + if cfg.JWTSecret != validSecret { + t.Errorf("JWTSecret = %q", cfg.JWTSecret) + } +} + +func TestLoadConfigRequiresDatabaseURL(t *testing.T) { + setBaseEnv(t) + t.Setenv("DATABASE_URL", "") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error when DATABASE_URL is empty") + } +} + +func TestLoadConfigRequiresJWTSecret(t *testing.T) { + setBaseEnv(t) + t.Setenv("JWT_SECRET", "") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error when JWT_SECRET is empty") + } +} + +func TestLoadConfigRejectsShortJWTSecret(t *testing.T) { + setBaseEnv(t) + t.Setenv("JWT_SECRET", "too-short") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error when JWT_SECRET is under 32 bytes") + } +} + +func TestLoadConfigOverrides(t *testing.T) { + setBaseEnv(t) + t.Setenv("USERSERVICE_ADDR", ":9000") + t.Setenv("JWT_TTL", "1h") + t.Setenv("BCRYPT_COST", "6") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Addr != ":9000" { + t.Errorf("Addr = %q, want :9000", cfg.Addr) + } + if cfg.TokenTTL != time.Hour { + t.Errorf("TokenTTL = %v, want 1h", cfg.TokenTTL) + } + if cfg.BcryptCost != 6 { + t.Errorf("BcryptCost = %d, want 6", cfg.BcryptCost) + } +} + +func TestLoadConfigRejectsMalformedDuration(t *testing.T) { + setBaseEnv(t) + t.Setenv("JWT_TTL", "not-a-duration") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error for malformed JWT_TTL") + } +} + +func TestLoadConfigRejectsMalformedInt(t *testing.T) { + setBaseEnv(t) + t.Setenv("BCRYPT_COST", "abc") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error for malformed BCRYPT_COST") + } +} diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go index f74eb0f..a81a128 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -2,7 +2,9 @@ package http_test import ( "bytes" + "context" "encoding/json" + "errors" "io" "log/slog" "net/http" @@ -10,7 +12,10 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" "github.com/emil28092005/SciMesh/users/internal/memstore" apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http" "github.com/emil28092005/SciMesh/users/internal/usecase" @@ -165,3 +170,40 @@ func TestHealth(t *testing.T) { t.Errorf("health: got %d", rec.Code) } } + +// failingUsers is a UserRepository whose reads fail with an unexpected (non- +// sentinel) error, so the handler must map it to 500 and not leak internals. +type failingUsers struct{ usecase.UserRepository } + +func (failingUsers) GetByID(context.Context, uuid.UUID) (*domain.User, error) { + return nil, errors.New("db exploded") +} + +func TestMeInternalError(t *testing.T) { + hasher := auth.NewHasher(4) + clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)} + issuer := auth.NewIssuer(secret, time.Hour, nil) + + users := failingUsers{UserRepository: memstore.NewUserRepo()} + uc := apihttp.UseCases{ + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + Users: users, + } + h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer) + + // A structurally valid token for a caller the failing repo can't load. + token, err := issuer.Issue(uuid.New(), "user") + if err != nil { + t.Fatal(err) + } + + rec := do(t, h, http.MethodGet, "/me", token, nil) + if rec.Code != http.StatusInternalServerError { + t.Errorf("got %d, want 500", rec.Code) + } + // The body must not disclose the underlying error. + if bytes.Contains(rec.Body.Bytes(), []byte("db exploded")) { + t.Error("internal error leaked to the client") + } +} diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go new file mode 100644 index 0000000..d25f56d --- /dev/null +++ b/users/internal/usecase/errorpaths_test.go @@ -0,0 +1,103 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// These stubs let a test inject failures the happy-path memstore never produces, +// so the use cases' error branches are exercised too. + +var errBoom = errors.New("boom") + +type stubRepo struct { + getByEmail func() (*domain.User, error) + insert func() error +} + +func (s stubRepo) Insert(context.Context, *domain.User) error { return s.insert() } +func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) { + return s.getByEmail() +} +func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) { + return nil, usecase.ErrUserNotFound +} + +type stubHasher struct { + hashErr error + compareErr error +} + +func (s stubHasher) Hash(string) (string, error) { + if s.hashErr != nil { + return "", s.hashErr + } + return "hashed", nil +} +func (s stubHasher) Compare(string, string) error { return s.compareErr } + +type stubIssuer struct{ err error } + +func (s stubIssuer) Issue(uuid.UUID, domain.Role) (string, error) { + if s.err != nil { + return "", s.err + } + return "token", nil +} + +func TestRegisterPropagatesHasherError(t *testing.T) { + clk := stubClock{time.Now()} + reg := usecase.NewRegister(stubRepo{}, stubHasher{hashErr: errBoom}, clk) + + _, err := reg.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +func TestRegisterPropagatesInsertError(t *testing.T) { + clk := stubClock{time.Now()} + repo := stubRepo{insert: func() error { return errBoom }} + reg := usecase.NewRegister(repo, stubHasher{}, clk) + + _, err := reg.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +func TestLoginPropagatesRepoError(t *testing.T) { + // A non-ErrUserNotFound repo error must surface as-is, not be masked as + // ErrInvalidCredentials. + repo := stubRepo{getByEmail: func() (*domain.User, error) { return nil, errBoom }} + login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{}) + + _, _, err := login.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +func TestLoginPropagatesIssuerError(t *testing.T) { + repo := stubRepo{getByEmail: func() (*domain.User, error) { + return &domain.User{ID: uuid.New(), Email: "a@b.com", Role: domain.RoleUser}, nil + }} + // Hasher accepts the password (nil compareErr) so we reach token issuance. + login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{err: errBoom}) + + _, _, err := login.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +type stubClock struct{ t time.Time } + +func (c stubClock) Now() time.Time { return c.t }