Finished task 6: Database Clients (Postgres + Redis)

master
oabrivard 1 month ago
parent 34d597b511
commit 7505a4256e

@ -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/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 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-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/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/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/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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 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/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/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/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/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/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/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/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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/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/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/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/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= 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= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

@ -4,11 +4,14 @@ go 1.25.5
require ( require (
github.com/MicahParks/keyfunc/v3 v3.7.0 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/go-playground/validator/v10 v10.25.0
github.com/gofiber/fiber/v3 v3.0.0-beta.3 github.com/gofiber/fiber/v3 v3.0.0-beta.3
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0 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/prometheus/client_golang v1.20.5
github.com/redis/go-redis/v9 v9.7.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
) )

@ -3,8 +3,13 @@ package postgres
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
"strconv"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool"
) )
// Config holds the configuration for the PostgreSQL client. // Config holds the configuration for the PostgreSQL client.
@ -53,46 +58,103 @@ func (c Config) URL() string {
) )
} }
// Client wraps database operations. // HealthCheck performs a stateless health check on PostgreSQL.
// This is a placeholder that should be implemented with actual database client. // Services should initialize and own Ent clients directly. This helper is
type Client struct { // intentionally lightweight and does not expose a long-lived shared DB client.
config Config 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. // ConfigFromEnv creates a Config from environment variables with safe fallbacks.
// Note: Actual implementation should use Ent client from each service. func ConfigFromEnv() Config {
func NewClient(config Config) (*Client, error) { cfg := DefaultConfig()
// This is a placeholder. The actual Ent client should be created
// in each service's infrastructure layer. cfg.Host = getenvString("POSTGRES_HOST", cfg.Host)
return &Client{ cfg.Port = getenvInt("POSTGRES_PORT", cfg.Port)
config: config, cfg.User = getenvString("POSTGRES_USER", cfg.User)
}, nil 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 validateConfig(c Config) error {
func (c *Client) Close() error { switch {
// Placeholder for closing database connection 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 return nil
} }
// Ping checks if the database connection is alive. func getenvString(key string, fallback string) string {
func (c *Client) Ping(ctx context.Context) error { if v := os.Getenv(key); v != "" {
// Placeholder for ping implementation return v
return nil }
return fallback
} }
// HealthCheck performs a health check on the database. func getenvInt(key string, fallback int) int {
func (c *Client) HealthCheck(ctx context.Context) error { v := os.Getenv(key)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) if v == "" {
defer cancel() 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. func getenvDuration(key string, fallback time.Duration) time.Duration {
// This is a placeholder that should be implemented with actual env parsing. v := os.Getenv(key)
func ConfigFromEnv() Config { if v == "" {
// TODO: Implement environment variable parsing return fallback
// Should use os.Getenv or a configuration library }
return DefaultConfig()
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
} }

@ -1,10 +1,11 @@
package postgres package postgres
// Tests for PostgreSQL config defaults and connection string generation. // Tests for PostgreSQL config helpers and health check behavior.
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -23,9 +24,81 @@ func TestConfigDSNAndURL(t *testing.T) {
require.Contains(t, cfg.URL(), "postgresql://") require.Contains(t, cfg.URL(), "postgresql://")
} }
// TestHealthCheck verifies health checks delegate to Ping without error. // TestConfigFromEnvDefaults verifies defaults are used when env vars are absent.
func TestHealthCheck(t *testing.T) { func TestConfigFromEnvDefaults(t *testing.T) {
client, err := NewClient(DefaultConfig()) t.Setenv("POSTGRES_HOST", "")
require.NoError(t, err) t.Setenv("POSTGRES_PORT", "")
require.NoError(t, client.HealthCheck(context.Background())) 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)
} }

@ -3,8 +3,13 @@ package redis
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
"strconv"
"time" "time"
redisv9 "github.com/redis/go-redis/v9"
) )
// Config holds the configuration for the Redis client. // 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. // This is a placeholder that should be replaced with an actual Redis client.
type Client struct { type Client struct {
config Config config Config
client *redisv9.Client
} }
// NewClient creates a new Redis client. // NewClient creates a new Redis client.
// Note: Actual implementation should use go-redis/redis.
func NewClient(config Config) (*Client, error) { func NewClient(config Config) (*Client, error) {
// This is a placeholder. The actual Redis client should be created if err := validateConfig(config); err != nil {
// using github.com/go-redis/redis/v9 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{ return &Client{
config: config, config: config,
client: client,
}, nil }, nil
} }
// Close closes the Redis connection. // Close closes the Redis connection.
func (c *Client) Close() error { func (c *Client) Close() error {
// Placeholder for closing Redis connection return c.client.Close()
return nil
} }
// Ping checks if the Redis connection is alive. // Ping checks if the Redis connection is alive.
func (c *Client) Ping(ctx context.Context) error { func (c *Client) Ping(ctx context.Context) error {
// Placeholder for ping implementation return c.client.Ping(ctx).Err()
return nil
} }
// HealthCheck performs a health check on Redis. // 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. // Set stores a key-value pair with expiration.
func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { func (c *Client) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
// Placeholder for set implementation return c.client.Set(ctx, key, value, expiration).Err()
return fmt.Errorf("not implemented")
} }
// Get retrieves a value by key. // Get retrieves a value by key.
func (c *Client) Get(ctx context.Context, key string) (string, error) { func (c *Client) Get(ctx context.Context, key string) (string, error) {
// Placeholder for get implementation return c.client.Get(ctx, key).Result()
return "", fmt.Errorf("not implemented")
} }
// Delete removes a key. // Delete removes a key.
func (c *Client) Delete(ctx context.Context, keys ...string) error { func (c *Client) Delete(ctx context.Context, keys ...string) error {
// Placeholder for delete implementation return c.client.Del(ctx, keys...).Err()
return fmt.Errorf("not implemented")
} }
// Exists checks if a key exists. // Exists checks if a key exists.
func (c *Client) Exists(ctx context.Context, key string) (bool, error) { func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
// Placeholder for exists implementation n, err := c.client.Exists(ctx, key).Result()
return false, fmt.Errorf("not implemented") if err != nil {
return false, err
}
return n > 0, nil
} }
// Incr increments a counter. // Incr increments a counter.
func (c *Client) Incr(ctx context.Context, key string) (int64, error) { func (c *Client) Incr(ctx context.Context, key string) (int64, error) {
// Placeholder for incr implementation return c.client.Incr(ctx, key).Result()
return 0, fmt.Errorf("not implemented")
} }
// Expire sets expiration on a key. // Expire sets expiration on a key.
func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error { func (c *Client) Expire(ctx context.Context, key string, expiration time.Duration) error {
// Placeholder for expire implementation return c.client.Expire(ctx, key, expiration).Err()
return fmt.Errorf("not implemented")
} }
// ConfigFromEnv creates a Config from environment variables. // ConfigFromEnv creates a Config from environment variables.
func ConfigFromEnv() Config { func ConfigFromEnv() Config {
// TODO: Implement environment variable parsing cfg := DefaultConfig()
return 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
} }

@ -1,11 +1,17 @@
package redis package redis
// Tests for Redis config defaults, address formatting, and placeholder methods. // Tests for Redis config and client behavior.
import ( import (
"context" "context"
"errors"
"net"
"strconv"
"testing" "testing"
"time"
"github.com/alicebob/miniredis/v2"
redisv9 "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -22,25 +28,136 @@ func TestAddr(t *testing.T) {
require.Equal(t, "localhost:6379", cfg.Addr()) require.Equal(t, "localhost:6379", cfg.Addr())
} }
// TestHealthCheck verifies health checks delegate to Ping without error. func newClientForMiniRedis(t *testing.T) (*Client, *miniredis.Miniredis) {
func TestHealthCheck(t *testing.T) { t.Helper()
client, err := NewClient(DefaultConfig())
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) 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())) require.NoError(t, client.HealthCheck(context.Background()))
} }
// TestNotImplemented verifies placeholder Redis methods return errors. // TestSetGet verifies values can be stored and retrieved.
func TestNotImplemented(t *testing.T) { func TestSetGet(t *testing.T) {
client, err := NewClient(DefaultConfig()) 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.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)) mr.FastForward(2 * time.Second)
_, err = client.Get(context.Background(), "k")
_, err := client.Get(ctx, "temp")
require.Error(t, err) require.Error(t, err)
require.Error(t, client.Delete(context.Background(), "k")) require.True(t, errors.Is(err, redisv9.Nil))
_, err = client.Exists(context.Background(), "k") }
// 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) 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, err)
require.Error(t, client.Expire(context.Background(), "k", 0))
} }

Loading…
Cancel
Save