Files
goclaw/internal/cache/redis_test.go
T
viettranx 0d3230b2bf feat(cache): add build-tag-gated Redis cache backend
Add optional Redis cache support via `go build -tags redis`, following
the same paired-stub pattern as OTel and Tailscale. The Cache[V] interface
is unchanged; Redis and in-memory implementations are injected at startup
without altering usage logic.

- Add RedisCache[V] implementation with JSON serialization, fail-open on errors
- Add gateway_redis.go / gateway_redis_noop.go paired wiring files
- Refactor GroupWriterCache and ContextFileInterceptor to accept injected caches
- Add GOCLAW_REDIS_DSN env var, docker-compose.redis.yml overlay
- Update Dockerfile and GitHub Actions with ENABLE_REDIS build arg
- Add Redis variant to CI matrix (5 variants: latest, otel, tsnet, redis, full)
2026-03-07 19:27:24 +07:00

143 lines
3.1 KiB
Go

//go:build redis
package cache
import (
"context"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
func testRedisClient(t *testing.T) *redis.Client {
t.Helper()
dsn := os.Getenv("REDIS_TEST_DSN")
if dsn == "" {
dsn = "redis://localhost:6379/15" // use DB 15 for tests
}
client, err := NewRedisClient(dsn)
if err != nil {
t.Skipf("Redis not available: %v", err)
}
t.Cleanup(func() {
client.FlushDB(context.Background())
client.Close()
})
return client
}
func TestRedisCache_GetSet(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[string](client, "test:getset")
ctx := context.Background()
// miss
_, ok := c.Get(ctx, "k1")
if ok {
t.Fatal("expected miss")
}
// hit
c.Set(ctx, "k1", "hello", time.Minute)
v, ok := c.Get(ctx, "k1")
if !ok || v != "hello" {
t.Fatalf("expected hello, got %q ok=%v", v, ok)
}
}
func TestRedisCache_TTLExpiry(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[int](client, "test:ttl")
ctx := context.Background()
c.Set(ctx, "k1", 42, 100*time.Millisecond)
v, ok := c.Get(ctx, "k1")
if !ok || v != 42 {
t.Fatalf("expected 42, got %d ok=%v", v, ok)
}
time.Sleep(150 * time.Millisecond)
_, ok = c.Get(ctx, "k1")
if ok {
t.Fatal("expected miss after TTL")
}
}
func TestRedisCache_Delete(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[string](client, "test:del")
ctx := context.Background()
c.Set(ctx, "k1", "v1", time.Minute)
c.Delete(ctx, "k1")
_, ok := c.Get(ctx, "k1")
if ok {
t.Fatal("expected miss after delete")
}
}
func TestRedisCache_DeleteByPrefix(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[string](client, "test:prefix")
ctx := context.Background()
c.Set(ctx, "agent:1:a", "v1", time.Minute)
c.Set(ctx, "agent:1:b", "v2", time.Minute)
c.Set(ctx, "agent:2:a", "v3", time.Minute)
c.DeleteByPrefix(ctx, "agent:1:")
if _, ok := c.Get(ctx, "agent:1:a"); ok {
t.Fatal("agent:1:a should be deleted")
}
if _, ok := c.Get(ctx, "agent:1:b"); ok {
t.Fatal("agent:1:b should be deleted")
}
if _, ok := c.Get(ctx, "agent:2:a"); !ok {
t.Fatal("agent:2:a should still exist")
}
}
func TestRedisCache_Clear(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[int](client, "test:clear")
ctx := context.Background()
c.Set(ctx, "a", 1, time.Minute)
c.Set(ctx, "b", 2, time.Minute)
c.Clear(ctx)
if _, ok := c.Get(ctx, "a"); ok {
t.Fatal("expected miss after clear")
}
if _, ok := c.Get(ctx, "b"); ok {
t.Fatal("expected miss after clear")
}
}
// TestRedisCache_StructRoundtrip verifies JSON serialization of complex types.
type testStruct struct {
Name string `json:"name"`
Age int `json:"age"`
}
func TestRedisCache_StructRoundtrip(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[[]testStruct](client, "test:struct")
ctx := context.Background()
data := []testStruct{{Name: "Alice", Age: 30}, {Name: "Bob", Age: 25}}
c.Set(ctx, "users", data, time.Minute)
got, ok := c.Get(ctx, "users")
if !ok {
t.Fatal("expected hit")
}
if len(got) != 2 || got[0].Name != "Alice" || got[1].Age != 25 {
t.Fatalf("roundtrip mismatch: %+v", got)
}
}