test(users): cover config, usecase error paths, and /me 500 path

This commit is contained in:
Efremenko Arhip
2026-07-26 16:15:36 +03:00
parent a3db1a1e67
commit 73196579e8
3 changed files with 246 additions and 0 deletions
+101
View File
@@ -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")
}
}
@@ -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")
}
}
+103
View File
@@ -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 }