package replay import ( "context" "errors" "net" "testing" "time" "galaxy/gateway/internal/config" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewRedisStore(t *testing.T) { t.Parallel() server := miniredis.RunT(t) tests := []struct { name string sessionCfg config.SessionCacheRedisConfig replayCfg config.ReplayRedisConfig wantErr string }{ { name: "valid config", sessionCfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), DB: 2, }, replayCfg: config.ReplayRedisConfig{ KeyPrefix: "gateway:replay:", ReserveTimeout: 250 * time.Millisecond, }, }, { name: "empty redis addr", replayCfg: config.ReplayRedisConfig{ KeyPrefix: "gateway:replay:", ReserveTimeout: 250 * time.Millisecond, }, wantErr: "redis addr must not be empty", }, { name: "negative redis db", sessionCfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), DB: -1, }, replayCfg: config.ReplayRedisConfig{ KeyPrefix: "gateway:replay:", ReserveTimeout: 250 * time.Millisecond, }, wantErr: "redis db must not be negative", }, { name: "empty replay key prefix", sessionCfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), }, replayCfg: config.ReplayRedisConfig{ ReserveTimeout: 250 * time.Millisecond, }, wantErr: "replay key prefix must not be empty", }, { name: "non-positive reserve timeout", sessionCfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), }, replayCfg: config.ReplayRedisConfig{ KeyPrefix: "gateway:replay:", }, wantErr: "reserve timeout must be positive", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() store, err := NewRedisStore(tt.sessionCfg, tt.replayCfg) if tt.wantErr != "" { require.Error(t, err) require.ErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, store.Close()) }) }) } } func TestRedisStorePing(t *testing.T) { t.Parallel() server := miniredis.RunT(t) store := newTestRedisStore(t, server, config.SessionCacheRedisConfig{}, config.ReplayRedisConfig{}) require.NoError(t, store.Ping(context.Background())) } func TestRedisStoreReserve(t *testing.T) { t.Parallel() tests := []struct { name string sessionCfg config.SessionCacheRedisConfig replayCfg config.ReplayRedisConfig deviceSessionID string requestID string ttl time.Duration secondReserve func(*testing.T, Store) wantErrIs error wantErrText string }{ { name: "first reservation succeeds", deviceSessionID: "device-session-123", requestID: "request-123", ttl: 5 * time.Second, }, { name: "duplicate reservation is rejected", deviceSessionID: "device-session-123", requestID: "request-123", ttl: 5 * time.Second, secondReserve: func(t *testing.T, store Store) { t.Helper() err := store.Reserve(context.Background(), "device-session-123", "request-123", 5*time.Second) require.ErrorIs(t, err, ErrDuplicate) }, }, { name: "same request id in distinct sessions does not collide", deviceSessionID: "device-session-123", requestID: "request-123", ttl: 5 * time.Second, secondReserve: func(t *testing.T, store Store) { t.Helper() require.NoError(t, store.Reserve(context.Background(), "device-session-456", "request-123", 5*time.Second)) }, }, { name: "empty device session id", requestID: "request-123", ttl: 5 * time.Second, wantErrText: "empty device session id", }, { name: "empty request id", deviceSessionID: "device-session-123", ttl: 5 * time.Second, wantErrText: "empty request id", }, { name: "non-positive ttl", deviceSessionID: "device-session-123", requestID: "request-123", wantErrText: "ttl must be positive", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() server := miniredis.RunT(t) store := newTestRedisStore(t, server, tt.sessionCfg, tt.replayCfg) err := store.Reserve(context.Background(), tt.deviceSessionID, tt.requestID, tt.ttl) if tt.wantErrIs != nil || tt.wantErrText != "" { require.Error(t, err) if tt.wantErrIs != nil { require.ErrorIs(t, err, tt.wantErrIs) } if tt.wantErrText != "" { require.ErrorContains(t, err, tt.wantErrText) } return } require.NoError(t, err) if tt.secondReserve != nil { tt.secondReserve(t, store) } }) } } func TestRedisStoreReserveReturnsBackendError(t *testing.T) { t.Parallel() store, err := NewRedisStore( config.SessionCacheRedisConfig{Addr: unusedTCPAddr(t)}, config.ReplayRedisConfig{ KeyPrefix: "gateway:replay:", ReserveTimeout: 100 * time.Millisecond, }, ) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, store.Close()) }) err = store.Reserve(context.Background(), "device-session-123", "request-123", 5*time.Second) require.Error(t, err) assert.False(t, errors.Is(err, ErrDuplicate)) assert.ErrorContains(t, err, "reserve replay request in redis") } func newTestRedisStore(t *testing.T, server *miniredis.Miniredis, sessionCfg config.SessionCacheRedisConfig, replayCfg config.ReplayRedisConfig) *RedisStore { t.Helper() if sessionCfg.Addr == "" { sessionCfg.Addr = server.Addr() } if replayCfg.KeyPrefix == "" { replayCfg.KeyPrefix = "gateway:replay:" } if replayCfg.ReserveTimeout == 0 { replayCfg.ReserveTimeout = 250 * time.Millisecond } store, err := NewRedisStore(sessionCfg, replayCfg) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, store.Close()) }) return store } func unusedTCPAddr(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) addr := listener.Addr().String() require.NoError(t, listener.Close()) return addr }