package cooler import ( "context" "errors" "net" "strconv" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) // closedAddr returns a localhost address nothing listens on. func closedAddr(t *testing.T) string { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } addr := ln.Addr().String() _ = ln.Close() return addr } // miniClient starts a miniredis and returns it with a client from OpenRedis. func miniClient(t *testing.T) (*miniredis.Miniredis, *redis.Client) { t.Helper() mr := miniredis.RunT(t) client, err := OpenRedis(t.Context(), "redis://"+mr.Addr()+"/0") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = client.Close() }) return mr, client } func TestRedisCache(t *testing.T) { ctx := t.Context() mr, client := miniClient(t) c := NewRedisCache(client, "acme:") if v, ok, err := c.Get(ctx, "missing"); v != nil || ok || err != nil { t.Fatalf("Get(missing) = %q %v %v", v, ok, err) } if ok, err := c.Has(ctx, "missing"); ok || err != nil { t.Fatalf("Has(missing) = %v %v", ok, err) } // Keys live under cache:; a TTL is stored with PX. if err := c.Set(ctx, "greeting", []byte("hello"), 10*time.Second); err != nil { t.Fatal(err) } if got, err := mr.Get("acme:cache:greeting"); err != nil || got != "hello" { t.Fatalf("raw acme:cache:greeting = %q %v", got, err) } if ttl := mr.TTL("acme:cache:greeting"); ttl != 10*time.Second { t.Fatalf("TTL = %s, want 10s", ttl) } if v, ok, err := c.Get(ctx, "greeting"); err != nil || !ok || string(v) != "hello" { t.Fatalf("Get = %q %v %v", v, ok, err) } mr.FastForward(9 * time.Second) if ok, _ := c.Has(ctx, "greeting"); !ok { t.Fatal("the key expired before its TTL") } mr.FastForward(2 * time.Second) if ok, _ := c.Has(ctx, "greeting"); ok { t.Fatal("the key outlived its TTL") } // A sub-millisecond TTL is rounded up to 1 ms, not stored forever. if err := c.Set(ctx, "tiny", []byte("x"), time.Microsecond); err != nil { t.Fatal(err) } if ttl := mr.TTL("acme:cache:tiny"); ttl != time.Millisecond { t.Fatalf("sub-ms TTL stored as %s, want 1ms", ttl) } // ttl 0 and a negative ttl store without expiry; a nil value stores empty. for _, ttl := range []time.Duration{0, -time.Second} { if err := c.Set(ctx, "forever", nil, ttl); err != nil { t.Fatal(err) } if got := mr.TTL("acme:cache:forever"); got != 0 { t.Fatalf("ttl %s stored TTL %s, want none", ttl, got) } if v, ok, err := c.Get(ctx, "forever"); err != nil || !ok || len(v) != 0 { t.Fatalf("Get(forever) = %q %v %v", v, ok, err) } } // Increment: INCRBY from missing has no TTL, an existing key keeps its TTL. if n, err := c.Increment(ctx, "hits", 4); err != nil || n != 4 { t.Fatalf("Increment(missing) = %d %v", n, err) } if ttl := mr.TTL("acme:cache:hits"); ttl != 0 { t.Fatalf("Increment(missing) set TTL %s", ttl) } if err := c.Set(ctx, "views", []byte("10"), time.Minute); err != nil { t.Fatal(err) } if n, err := c.Increment(ctx, "views", 5); err != nil || n != 15 { t.Fatalf("Increment(existing) = %d %v", n, err) } if ttl := mr.TTL("acme:cache:views"); ttl != time.Minute { t.Fatalf("Increment changed the TTL to %s", ttl) } _ = c.Set(ctx, "word", []byte("abc"), 0) if _, err := c.Increment(ctx, "word", 1); err == nil { t.Fatal("Increment on a non-integer must fail") } // Remember stores with the TTL and calls the callback once. calls := 0 fn := func(context.Context) ([]byte, error) { calls++; return []byte("computed"), nil } for range 3 { if v, err := c.Remember(ctx, "remembered", 30*time.Second, fn); err != nil || string(v) != "computed" { t.Fatalf("Remember = %q %v", v, err) } } if calls != 1 || mr.TTL("acme:cache:remembered") != 30*time.Second { t.Fatalf("Remember: %d calls, TTL %s", calls, mr.TTL("acme:cache:remembered")) } // Forget removes only that key. if err := c.Forget(ctx, "views"); err != nil { t.Fatal(err) } if mr.Exists("acme:cache:views") || !mr.Exists("acme:cache:hits") { t.Fatal("Forget removed the wrong keys") } // A cache without a client answers errNilClient on every method. var none Cache = &redisCache{} if _, _, err := none.Get(ctx, "k"); !errors.Is(err, errNilClient) { t.Fatalf("Get without client = %v", err) } if err := none.Set(ctx, "k", nil, 0); !errors.Is(err, errNilClient) { t.Fatalf("Set without client = %v", err) } if _, err := none.Has(ctx, "k"); !errors.Is(err, errNilClient) { t.Fatalf("Has without client = %v", err) } if err := none.Forget(ctx, "k"); !errors.Is(err, errNilClient) { t.Fatalf("Forget without client = %v", err) } if _, err := none.Increment(ctx, "k", 1); !errors.Is(err, errNilClient) { t.Fatalf("Increment without client = %v", err) } if err := none.Flush(ctx); !errors.Is(err, errNilClient) { t.Fatalf("Flush without client = %v", err) } } func TestRedisFlushOnlyPrefix(t *testing.T) { ctx := t.Context() mr, client := miniClient(t) // A prefix with glob characters is matched literally. const prefix = "a*[b]?:" c := NewRedisCache(client, prefix) for i := range 20 { if err := c.Set(ctx, "k"+strconv.Itoa(i), []byte("v"), 0); err != nil { t.Fatal(err) } } survivors := map[string]string{ prefix + "rl:login|192.0.2.1": "3", // this app's rate-limit counter "other:cache:greeting": "theirs", // another app's cache "aXbY:cache:k1": "glob", // matches the prefix as a pattern "a*[b]?:cachex": "near", // shares the prefix, not the cache: part "plain": "x", } for k, v := range survivors { if err := mr.Set(k, v); err != nil { t.Fatal(err) } } if err := c.Flush(ctx); err != nil { t.Fatal(err) } for i := range 20 { if mr.Exists(prefix + "cache:k" + strconv.Itoa(i)) { t.Fatalf("Flush left %scache:k%d", prefix, i) } } for k, v := range survivors { if got, err := mr.Get(k); err != nil || got != v { t.Fatalf("Flush removed or changed %q: %q %v", k, got, err) } } if err := c.Flush(ctx); err != nil { t.Fatalf("Flush on an empty cache: %v", err) } if got := escapeGlob(`a*b?[c]\`); got != `a\*b\?\[c\]\\` { t.Fatalf("escapeGlob = %q", got) } }