Files
summercms/modules/cooler/redis_test.go
Jakub Zych 78ba385793 test(19.3-05): cooler caches, rate-limit parity, Install and after-commit purge
- miniredis v2.40.0 as a test-only dependency (approved at the Task 4 check)
- memory and Redis cache suites, Flush limited to <prefix>cache:
- one parity table through surf.MemoryStore, miniredis and real Redis
- Install, FromApp and site_cache validation; Postgres after-commit purge
- gate: cooler names in the go and docker stages
2026-10-15 19:49:34 +02:00

201 lines
6.1 KiB
Go

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 <prefix>cache:<key>; 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)
}
}