- 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
201 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|