package cooler_test import ( "context" "fmt" "strconv" "testing" "time" "git.golem15.com/golem15/summercms/modules/cooler" "github.com/redis/go-redis/v9" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) // realRedis starts redis:7-alpine for the test and returns a client from // OpenRedis. It skips under -short. func realRedis(t *testing.T) *redis.Client { t.Helper() if testing.Short() { t.Skip("requires testcontainers redis") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) ctr, err := testcontainers.Run(ctx, "redis:7-alpine", testcontainers.WithExposedPorts("6379/tcp"), testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")), ) if err != nil { t.Fatalf("redis: testcontainers: %v", err) } t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) host, err := ctr.Host(ctx) if err != nil { t.Fatal(err) } port, err := ctr.MappedPort(ctx, "6379/tcp") if err != nil { t.Fatal(err) } client, err := cooler.OpenRedis(ctx, fmt.Sprintf("redis://%s:%s/0", host, port.Port())) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = client.Close() }) if err := client.Ping(ctx).Err(); err != nil { t.Fatalf("redis ping: %v", err) } return client } func TestRateLimitParityDocker(t *testing.T) { client := realRedis(t) store := cooler.NewRateLimitStore(client, "acme:", nil) runParity(t, parityStore{name: "redis", store: store, advance: sleepFor}) if got := countAdmitted(cooler.NewRateLimitStore(client, "burst:", nil), 50, 10); got != 10 { t.Fatalf("real Redis admitted %d of 50, want 10", got) } ctx := t.Context() if v, err := client.Get(ctx, "burst:rl:burst").Result(); err != nil || v != "10" { t.Fatalf("burst counter = %q %v, want 10", v, err) } if ttl, err := client.PTTL(ctx, "burst:rl:burst").Result(); err != nil || ttl <= 0 || ttl > time.Minute { t.Fatalf("burst window TTL = %s %v", ttl, err) } } func TestRedisCacheDocker(t *testing.T) { client := realRedis(t) ctx := t.Context() c := cooler.NewRedisCache(client, "acme:") if err := c.Set(ctx, "greeting", []byte("hello"), 150*time.Millisecond); err != nil { t.Fatal(err) } if v, ok, err := c.Get(ctx, "greeting"); err != nil || !ok || string(v) != "hello" { t.Fatalf("Get = %q %v %v", v, ok, err) } if ttl, _ := client.PTTL(ctx, "acme:cache:greeting").Result(); ttl <= 0 || ttl > 150*time.Millisecond { t.Fatalf("PTTL = %s, want (0, 150ms]", ttl) } time.Sleep(250 * time.Millisecond) if ok, _ := c.Has(ctx, "greeting"); ok { t.Fatal("the key outlived its TTL") } if err := c.Set(ctx, "views", []byte("1"), time.Minute); err != nil { t.Fatal(err) } if n, err := c.Increment(ctx, "views", 2); err != nil || n != 3 { t.Fatalf("Increment = %d %v", n, err) } if ttl, _ := client.PTTL(ctx, "acme:cache:views").Result(); ttl <= 50*time.Second { t.Fatalf("Increment dropped the TTL: %s", ttl) } calls := 0 for range 2 { v, err := c.Remember(ctx, "remembered", time.Minute, func(context.Context) ([]byte, error) { calls++; return []byte("computed"), nil }) if err != nil || string(v) != "computed" { t.Fatalf("Remember = %q %v", v, err) } } if calls != 1 { t.Fatalf("Remember called the callback %d times", calls) } // Flush walks SCAN in batches: more keys than one batch, and the keys // outside cache: survive. pipe := client.Pipeline() for i := range 1200 { pipe.Set(ctx, "acme:cache:bulk"+strconv.Itoa(i), "v", 0) } survivors := []string{"acme:rl:login|192.0.2.1", "other:cache:greeting", "acme:cachex"} for _, k := range survivors { pipe.Set(ctx, k, "keep", 0) } if _, err := pipe.Exec(ctx); err != nil { t.Fatal(err) } if err := c.Flush(ctx); err != nil { t.Fatal(err) } left, err := client.Keys(ctx, "acme:cache:*").Result() if err != nil || len(left) != 0 { t.Fatalf("Flush left %d cache keys (%v)", len(left), err) } for _, k := range survivors { if n, err := client.Exists(ctx, k).Result(); err != nil || n != 1 { t.Fatalf("Flush removed %q", k) } } }