diff --git a/backend/go.work.sum b/backend/go.work.sum index ecbb44e..6f05271 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -1,14 +1,24 @@ -github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= -github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= -github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= -github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= +github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +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.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= @@ -17,16 +27,19 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/backend/shared/go.mod b/backend/shared/go.mod index 372060d..478ae94 100644 --- a/backend/shared/go.mod +++ b/backend/shared/go.mod @@ -4,11 +4,14 @@ go 1.25.5 require ( github.com/MicahParks/keyfunc/v3 v3.7.0 + github.com/alicebob/miniredis/v2 v2.33.0 github.com/go-playground/validator/v10 v10.25.0 github.com/gofiber/fiber/v3 v3.0.0-beta.3 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 github.com/prometheus/client_golang v1.20.5 + github.com/redis/go-redis/v9 v9.7.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 ) diff --git a/backend/shared/infra/database/postgres/client.go b/backend/shared/infra/database/postgres/client.go index d779aed..dc6c194 100644 --- a/backend/shared/infra/database/postgres/client.go +++ b/backend/shared/infra/database/postgres/client.go @@ -3,8 +3,13 @@ package postgres import ( "context" + "errors" "fmt" + "os" + "strconv" "time" + + "github.com/jackc/pgx/v5/pgxpool" ) // Config holds the configuration for the PostgreSQL client. @@ -53,46 +58,103 @@ func (c Config) URL() string { ) } -// Client wraps database operations. -// This is a placeholder that should be implemented with actual database client. -type Client struct { - config Config +// HealthCheck performs a stateless health check on PostgreSQL. +// Services should initialize and own Ent clients directly. This helper is +// intentionally lightweight and does not expose a long-lived shared DB client. +func HealthCheck(ctx context.Context, config Config) error { + if err := validateConfig(config); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + poolConfig, err := pgxpool.ParseConfig(config.URL()) + if err != nil { + return fmt.Errorf("parse postgres config: %w", err) + } + poolConfig.MaxConns = 1 + poolConfig.MinConns = 0 + poolConfig.MaxConnLifetime = config.ConnMaxLifetime + poolConfig.MaxConnIdleTime = config.ConnMaxIdleTime + + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return fmt.Errorf("create postgres pool: %w", err) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + return fmt.Errorf("ping postgres: %w", err) + } + + return nil } -// NewClient creates a new PostgreSQL client. -// Note: Actual implementation should use Ent client from each service. -func NewClient(config Config) (*Client, error) { - // This is a placeholder. The actual Ent client should be created - // in each service's infrastructure layer. - return &Client{ - config: config, - }, nil +// ConfigFromEnv creates a Config from environment variables with safe fallbacks. +func ConfigFromEnv() Config { + cfg := DefaultConfig() + + cfg.Host = getenvString("POSTGRES_HOST", cfg.Host) + cfg.Port = getenvInt("POSTGRES_PORT", cfg.Port) + cfg.User = getenvString("POSTGRES_USER", cfg.User) + cfg.Password = getenvString("POSTGRES_PASSWORD", cfg.Password) + cfg.Database = getenvString("POSTGRES_DB", cfg.Database) + cfg.SSLMode = getenvString("POSTGRES_SSLMODE", cfg.SSLMode) + + cfg.MaxOpenConns = getenvInt("POSTGRES_MAX_OPEN_CONNS", cfg.MaxOpenConns) + cfg.MaxIdleConns = getenvInt("POSTGRES_MAX_IDLE_CONNS", cfg.MaxIdleConns) + cfg.ConnMaxLifetime = getenvDuration("POSTGRES_CONN_MAX_LIFETIME", cfg.ConnMaxLifetime) + cfg.ConnMaxIdleTime = getenvDuration("POSTGRES_CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime) + + return cfg } -// Close closes the database connection. -func (c *Client) Close() error { - // Placeholder for closing database connection +func validateConfig(c Config) error { + switch { + case c.Host == "": + return errors.New("postgres host is required") + case c.Port <= 0: + return errors.New("postgres port must be greater than 0") + case c.User == "": + return errors.New("postgres user is required") + case c.Database == "": + return errors.New("postgres database is required") + } return nil } -// Ping checks if the database connection is alive. -func (c *Client) Ping(ctx context.Context) error { - // Placeholder for ping implementation - return nil +func getenvString(key string, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback } -// HealthCheck performs a health check on the database. -func (c *Client) HealthCheck(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() +func getenvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } - return c.Ping(ctx) + i, err := strconv.Atoi(v) + if err != nil { + return fallback + } + + return i } -// ConfigFromEnv creates a Config from environment variables. -// This is a placeholder that should be implemented with actual env parsing. -func ConfigFromEnv() Config { - // TODO: Implement environment variable parsing - // Should use os.Getenv or a configuration library - return DefaultConfig() +func getenvDuration(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + + d, err := time.ParseDuration(v) + if err != nil { + return fallback + } + + return d } diff --git a/backend/shared/infra/database/postgres/client_test.go b/backend/shared/infra/database/postgres/client_test.go index 4457985..d5b015c 100644 --- a/backend/shared/infra/database/postgres/client_test.go +++ b/backend/shared/infra/database/postgres/client_test.go @@ -1,10 +1,11 @@ package postgres -// Tests for PostgreSQL config defaults and connection string generation. +// Tests for PostgreSQL config helpers and health check behavior. import ( "context" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -23,9 +24,81 @@ func TestConfigDSNAndURL(t *testing.T) { require.Contains(t, cfg.URL(), "postgresql://") } -// TestHealthCheck verifies health checks delegate to Ping without error. -func TestHealthCheck(t *testing.T) { - client, err := NewClient(DefaultConfig()) - require.NoError(t, err) - require.NoError(t, client.HealthCheck(context.Background())) +// TestConfigFromEnvDefaults verifies defaults are used when env vars are absent. +func TestConfigFromEnvDefaults(t *testing.T) { + t.Setenv("POSTGRES_HOST", "") + t.Setenv("POSTGRES_PORT", "") + t.Setenv("POSTGRES_USER", "") + t.Setenv("POSTGRES_PASSWORD", "") + t.Setenv("POSTGRES_DB", "") + t.Setenv("POSTGRES_SSLMODE", "") + + cfg := ConfigFromEnv() + def := DefaultConfig() + + require.Equal(t, def.Host, cfg.Host) + require.Equal(t, def.Port, cfg.Port) + require.Equal(t, def.User, cfg.User) + require.Equal(t, def.Password, cfg.Password) + require.Equal(t, def.Database, cfg.Database) + require.Equal(t, def.SSLMode, cfg.SSLMode) +} + +// TestConfigFromEnvOverrides verifies valid env vars override defaults. +func TestConfigFromEnvOverrides(t *testing.T) { + t.Setenv("POSTGRES_HOST", "db") + t.Setenv("POSTGRES_PORT", "15432") + t.Setenv("POSTGRES_USER", "user") + t.Setenv("POSTGRES_PASSWORD", "secret") + t.Setenv("POSTGRES_DB", "app") + t.Setenv("POSTGRES_SSLMODE", "require") + t.Setenv("POSTGRES_MAX_OPEN_CONNS", "50") + t.Setenv("POSTGRES_MAX_IDLE_CONNS", "20") + t.Setenv("POSTGRES_CONN_MAX_LIFETIME", "10m") + t.Setenv("POSTGRES_CONN_MAX_IDLE_TIME", "2m") + + cfg := ConfigFromEnv() + require.Equal(t, "db", cfg.Host) + require.Equal(t, 15432, cfg.Port) + require.Equal(t, "user", cfg.User) + require.Equal(t, "secret", cfg.Password) + require.Equal(t, "app", cfg.Database) + require.Equal(t, "require", cfg.SSLMode) + require.Equal(t, 50, cfg.MaxOpenConns) + require.Equal(t, 20, cfg.MaxIdleConns) + require.Equal(t, 10*time.Minute, cfg.ConnMaxLifetime) + require.Equal(t, 2*time.Minute, cfg.ConnMaxIdleTime) +} + +// TestConfigFromEnvInvalidFallsBack verifies invalid numeric/duration values fall back. +func TestConfigFromEnvInvalidFallsBack(t *testing.T) { + def := DefaultConfig() + t.Setenv("POSTGRES_PORT", "not-a-number") + t.Setenv("POSTGRES_MAX_OPEN_CONNS", "x") + t.Setenv("POSTGRES_CONN_MAX_LIFETIME", "invalid") + + cfg := ConfigFromEnv() + require.Equal(t, def.Port, cfg.Port) + require.Equal(t, def.MaxOpenConns, cfg.MaxOpenConns) + require.Equal(t, def.ConnMaxLifetime, cfg.ConnMaxLifetime) +} + +// TestHealthCheckInvalidConfig verifies config validation runs before DB connection. +func TestHealthCheckInvalidConfig(t *testing.T) { + cfg := DefaultConfig() + cfg.Host = "" + + err := HealthCheck(context.Background(), cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "host is required") +} + +// TestHealthCheckConnectionFailure verifies connection issues are surfaced. +func TestHealthCheckConnectionFailure(t *testing.T) { + cfg := DefaultConfig() + cfg.Host = "127.0.0.1" + cfg.Port = 65432 // typically closed + + err := HealthCheck(context.Background(), cfg) + require.Error(t, err) } diff --git a/backend/shared/infra/database/redis/client.go b/backend/shared/infra/database/redis/client.go index c0e3bf0..c5a8396 100644 --- a/backend/shared/infra/database/redis/client.go +++ b/backend/shared/infra/database/redis/client.go @@ -3,8 +3,13 @@ package redis import ( "context" + "errors" "fmt" + "os" + "strconv" "time" + + redisv9 "github.com/redis/go-redis/v9" ) // Config holds the configuration for the Redis client. @@ -44,28 +49,40 @@ func (c Config) Addr() string { // This is a placeholder that should be replaced with an actual Redis client. type Client struct { config Config + client *redisv9.Client } // NewClient creates a new Redis client. -// Note: Actual implementation should use go-redis/redis. func NewClient(config Config) (*Client, error) { - // This is a placeholder. The actual Redis client should be created - // using github.com/go-redis/redis/v9 + if err := validateConfig(config); err != nil { + return nil, err + } + + client := redisv9.NewClient(&redisv9.Options{ + Addr: config.Addr(), + Password: config.Password, + DB: config.DB, + PoolSize: config.PoolSize, + MinIdleConns: config.MinIdleConns, + DialTimeout: config.DialTimeout, + ReadTimeout: config.ReadTimeout, + WriteTimeout: config.WriteTimeout, + }) + return &Client{ config: config, + client: client, }, nil } // Close closes the Redis connection. func (c *Client) Close() error { - // Placeholder for closing Redis connection - return nil + return c.client.Close() } // Ping checks if the Redis connection is alive. func (c *Client) Ping(ctx context.Context) error { - // Placeholder for ping implementation - return nil + return c.client.Ping(ctx).Err() } // HealthCheck performs a health check on Redis. @@ -78,42 +95,104 @@ func (c *Client) HealthCheck(ctx context.Context) error { // Set stores a key-value pair with expiration. func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { - // Placeholder for set implementation - return fmt.Errorf("not implemented") + return c.client.Set(ctx, key, value, expiration).Err() } // Get retrieves a value by key. func (c *Client) Get(ctx context.Context, key string) (string, error) { - // Placeholder for get implementation - return "", fmt.Errorf("not implemented") + return c.client.Get(ctx, key).Result() } // Delete removes a key. func (c *Client) Delete(ctx context.Context, keys ...string) error { - // Placeholder for delete implementation - return fmt.Errorf("not implemented") + return c.client.Del(ctx, keys...).Err() } // Exists checks if a key exists. func (c *Client) Exists(ctx context.Context, key string) (bool, error) { - // Placeholder for exists implementation - return false, fmt.Errorf("not implemented") + n, err := c.client.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return n > 0, nil } // Incr increments a counter. func (c *Client) Incr(ctx context.Context, key string) (int64, error) { - // Placeholder for incr implementation - return 0, fmt.Errorf("not implemented") + return c.client.Incr(ctx, key).Result() } // Expire sets expiration on a key. func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error { - // Placeholder for expire implementation - return fmt.Errorf("not implemented") + return c.client.Expire(ctx, key, expiration).Err() } // ConfigFromEnv creates a Config from environment variables. func ConfigFromEnv() Config { - // TODO: Implement environment variable parsing - return DefaultConfig() + cfg := DefaultConfig() + + cfg.Host = getenvString("REDIS_HOST", cfg.Host) + cfg.Port = getenvInt("REDIS_PORT", cfg.Port) + cfg.Password = getenvString("REDIS_PASSWORD", cfg.Password) + cfg.DB = getenvInt("REDIS_DB", cfg.DB) + cfg.PoolSize = getenvInt("REDIS_POOL_SIZE", cfg.PoolSize) + cfg.MinIdleConns = getenvInt("REDIS_MIN_IDLE_CONNS", cfg.MinIdleConns) + cfg.DialTimeout = getenvDuration("REDIS_DIAL_TIMEOUT", cfg.DialTimeout) + cfg.ReadTimeout = getenvDuration("REDIS_READ_TIMEOUT", cfg.ReadTimeout) + cfg.WriteTimeout = getenvDuration("REDIS_WRITE_TIMEOUT", cfg.WriteTimeout) + + return cfg +} + +func validateConfig(c Config) error { + switch { + case c.Host == "": + return errors.New("redis host is required") + case c.Port <= 0: + return errors.New("redis port must be greater than 0") + case c.PoolSize <= 0: + return errors.New("redis pool size must be greater than 0") + case c.MinIdleConns < 0: + return errors.New("redis min idle conns must be >= 0") + case c.DialTimeout <= 0: + return errors.New("redis dial timeout must be greater than 0") + case c.ReadTimeout <= 0: + return errors.New("redis read timeout must be greater than 0") + case c.WriteTimeout <= 0: + return errors.New("redis write timeout must be greater than 0") + } + return nil +} + +func getenvString(key string, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getenvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + + i, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return i +} + +func getenvDuration(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + + d, err := time.ParseDuration(v) + if err != nil { + return fallback + } + return d } diff --git a/backend/shared/infra/database/redis/client_test.go b/backend/shared/infra/database/redis/client_test.go index fdbf565..97fff9a 100644 --- a/backend/shared/infra/database/redis/client_test.go +++ b/backend/shared/infra/database/redis/client_test.go @@ -1,11 +1,17 @@ package redis -// Tests for Redis config defaults, address formatting, and placeholder methods. +// Tests for Redis config and client behavior. import ( "context" + "errors" + "net" + "strconv" "testing" + "time" + "github.com/alicebob/miniredis/v2" + redisv9 "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) @@ -22,25 +28,136 @@ func TestAddr(t *testing.T) { require.Equal(t, "localhost:6379", cfg.Addr()) } -// TestHealthCheck verifies health checks delegate to Ping without error. -func TestHealthCheck(t *testing.T) { - client, err := NewClient(DefaultConfig()) +func newClientForMiniRedis(t *testing.T) (*Client, *miniredis.Miniredis) { + t.Helper() + + mr := miniredis.RunT(t) + cfg := DefaultConfig() + host, portStr, err := net.SplitHostPort(mr.Addr()) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + cfg.Host = host + cfg.Port = port + + client, err := NewClient(cfg) require.NoError(t, err) + t.Cleanup(func() { + _ = client.Close() + }) + + return client, mr +} + +// TestHealthCheck verifies health checks use the real ping. +func TestHealthCheck(t *testing.T) { + client, _ := newClientForMiniRedis(t) require.NoError(t, client.HealthCheck(context.Background())) } -// TestNotImplemented verifies placeholder Redis methods return errors. -func TestNotImplemented(t *testing.T) { - client, err := NewClient(DefaultConfig()) +// TestSetGet verifies values can be stored and retrieved. +func TestSetGet(t *testing.T) { + client, _ := newClientForMiniRedis(t) + ctx := context.Background() + + require.NoError(t, client.Set(ctx, "k", "v", 0)) + + got, err := client.Get(ctx, "k") + require.NoError(t, err) + require.Equal(t, "v", got) +} + +// TestDeleteExists verifies deletion and existence checks. +func TestDeleteExists(t *testing.T) { + client, _ := newClientForMiniRedis(t) + ctx := context.Background() + + require.NoError(t, client.Set(ctx, "k", "v", 0)) + exists, err := client.Exists(ctx, "k") + require.NoError(t, err) + require.True(t, exists) + + require.NoError(t, client.Delete(ctx, "k")) + exists, err = client.Exists(ctx, "k") + require.NoError(t, err) + require.False(t, exists) +} + +// TestIncr verifies atomic increments. +func TestIncr(t *testing.T) { + client, _ := newClientForMiniRedis(t) + ctx := context.Background() + + n, err := client.Incr(ctx, "counter") + require.NoError(t, err) + require.Equal(t, int64(1), n) + + n, err = client.Incr(ctx, "counter") require.NoError(t, err) + require.Equal(t, int64(2), n) +} + +// TestExpire verifies key expiration behavior. +func TestExpire(t *testing.T) { + client, mr := newClientForMiniRedis(t) + ctx := context.Background() + + require.NoError(t, client.Set(ctx, "temp", "value", 0)) + require.NoError(t, client.Expire(ctx, "temp", time.Second)) - require.Error(t, client.Set(context.Background(), "k", "v", 0)) - _, err = client.Get(context.Background(), "k") + mr.FastForward(2 * time.Second) + + _, err := client.Get(ctx, "temp") require.Error(t, err) - require.Error(t, client.Delete(context.Background(), "k")) - _, err = client.Exists(context.Background(), "k") + require.True(t, errors.Is(err, redisv9.Nil)) +} + +// TestGetNotFound verifies missing keys return redis.Nil. +func TestGetNotFound(t *testing.T) { + client, _ := newClientForMiniRedis(t) + _, err := client.Get(context.Background(), "missing") require.Error(t, err) - _, err = client.Incr(context.Background(), "k") + require.True(t, errors.Is(err, redisv9.Nil)) +} + +// TestConfigFromEnvDefaults verifies default env behavior. +func TestConfigFromEnvDefaults(t *testing.T) { + t.Setenv("REDIS_HOST", "") + t.Setenv("REDIS_PORT", "") + cfg := ConfigFromEnv() + def := DefaultConfig() + require.Equal(t, def.Host, cfg.Host) + require.Equal(t, def.Port, cfg.Port) +} + +// TestConfigFromEnvOverrides verifies valid env overrides. +func TestConfigFromEnvOverrides(t *testing.T) { + t.Setenv("REDIS_HOST", "cache") + t.Setenv("REDIS_PORT", "6380") + t.Setenv("REDIS_PASSWORD", "secret") + t.Setenv("REDIS_DB", "2") + t.Setenv("REDIS_POOL_SIZE", "25") + t.Setenv("REDIS_MIN_IDLE_CONNS", "3") + t.Setenv("REDIS_DIAL_TIMEOUT", "2s") + t.Setenv("REDIS_READ_TIMEOUT", "4s") + t.Setenv("REDIS_WRITE_TIMEOUT", "5s") + + cfg := ConfigFromEnv() + require.Equal(t, "cache", cfg.Host) + require.Equal(t, 6380, cfg.Port) + require.Equal(t, "secret", cfg.Password) + require.Equal(t, 2, cfg.DB) + require.Equal(t, 25, cfg.PoolSize) + require.Equal(t, 3, cfg.MinIdleConns) + require.Equal(t, 2*time.Second, cfg.DialTimeout) + require.Equal(t, 4*time.Second, cfg.ReadTimeout) + require.Equal(t, 5*time.Second, cfg.WriteTimeout) +} + +// TestNewClientInvalidConfig verifies early validation errors. +func TestNewClientInvalidConfig(t *testing.T) { + cfg := DefaultConfig() + cfg.Port = 0 + _, err := NewClient(cfg) require.Error(t, err) - require.Error(t, client.Expire(context.Background(), "k", 0)) }