- Cache: Get, Set, Has, Forget, Remember (degrades on cache errors), Increment, Flush with per-entry TTLs under <prefix>cache: - memory driver on otter v2.3.0 bounded by cache.memory.max_entries; Redis driver with SET PX, INCRBY and a Flush limited to SCAN MATCH <prefix>cache:* plus UNLINK - Install publishes the Cache (sharing one Redis client with the rate limiter); FromApp serves jobs and commands without a dial - new docs/services/caching.md, configuration rows, cooler README
161 lines
4.6 KiB
Go
161 lines
4.6 KiB
Go
package cooler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/testcontainers/testcontainers-go"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
)
|
|
|
|
// Smoke tests only; plan 05 of phase 19.3 owns full coverage.
|
|
|
|
func TestOpenRedisRedactsSmoke(t *testing.T) {
|
|
ctx := t.Context()
|
|
_, err := OpenRedis(ctx, "redis://alice:s3cretpw@cache.example.com:6379/0\x7f")
|
|
if err == nil {
|
|
t.Fatal("a URL with a control character must not parse")
|
|
}
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "alice") || strings.Contains(msg, "s3cretpw") {
|
|
t.Fatalf("error leaks credentials: %q", msg)
|
|
}
|
|
if !strings.Contains(msg, RedisURLKey) {
|
|
t.Fatalf("error should name %s: %q", RedisURLKey, msg)
|
|
}
|
|
|
|
c, err := OpenRedis(ctx, "rediss://user:pw@cache.example.com:6380/1")
|
|
if err != nil {
|
|
t.Fatalf("rediss URL: %v", err)
|
|
}
|
|
defer c.Close()
|
|
opt := c.Options()
|
|
if opt.TLSConfig == nil || opt.Addr != "cache.example.com:6380" || opt.DB != 1 {
|
|
t.Fatalf("rediss options = addr %q db %d tls %v", opt.Addr, opt.DB, opt.TLSConfig != nil)
|
|
}
|
|
if !opt.DisableIdentity {
|
|
t.Fatal("OpenRedis must disable the CLIENT SETINFO handshake")
|
|
}
|
|
}
|
|
|
|
// cacheRoundTrip runs the shared smoke sequence against one driver.
|
|
func cacheRoundTrip(t *testing.T, c Cache) {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
if err := c.Set(ctx, "greeting", []byte("hello"), 0); 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 ok, err := c.Has(ctx, "greeting"); err != nil || !ok {
|
|
t.Fatalf("Has = %v %v", ok, err)
|
|
}
|
|
if n, err := c.Increment(ctx, "hits", 2); err != nil || n != 2 {
|
|
t.Fatalf("Increment from missing = %d %v", n, err)
|
|
}
|
|
if n, err := c.Increment(ctx, "hits", 3); err != nil || n != 5 {
|
|
t.Fatalf("Increment existing = %d %v", n, err)
|
|
}
|
|
calls := 0
|
|
fn := func(context.Context) ([]byte, error) { calls++; return []byte("computed"), nil }
|
|
for range 2 {
|
|
v, err := c.Remember(ctx, "remembered", time.Minute, fn)
|
|
if err != nil || string(v) != "computed" {
|
|
t.Fatalf("Remember = %q %v", v, err)
|
|
}
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("Remember called the callback %d times, want 1", calls)
|
|
}
|
|
if err := c.Forget(ctx, "greeting"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ok, _ := c.Has(ctx, "greeting"); ok {
|
|
t.Fatal("Forget left the key")
|
|
}
|
|
if err := c.Set(ctx, "short", []byte("x"), time.Millisecond); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
if _, ok, _ := c.Get(ctx, "short"); ok {
|
|
t.Fatal("a 1 ms TTL did not expire")
|
|
}
|
|
if err := c.Flush(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"hits", "remembered"} {
|
|
if ok, _ := c.Has(ctx, k); ok {
|
|
t.Fatalf("Flush left %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMemoryCacheSmoke(t *testing.T) {
|
|
c, err := NewMemoryCache("acme:", DefaultMaxEntries)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cacheRoundTrip(t, c)
|
|
|
|
// Stored bytes are copies.
|
|
ctx := t.Context()
|
|
in := []byte("abc")
|
|
_ = c.Set(ctx, "copy", in, 0)
|
|
in[0] = 'X'
|
|
out, _, _ := c.Get(ctx, "copy")
|
|
out[1] = 'Y'
|
|
if again, _, _ := c.Get(ctx, "copy"); string(again) != "abc" {
|
|
t.Fatalf("stored value mutated: %q", again)
|
|
}
|
|
if _, err := NewMemoryCache("acme:", 0); err == nil || !strings.Contains(err.Error(), CacheMaxEntriesKey) {
|
|
t.Fatalf("max entries 0: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRedisCacheSmoke(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("requires testcontainers redis")
|
|
}
|
|
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
|
|
defer 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 := OpenRedis(ctx, fmt.Sprintf("redis://%s:%s/0", host, port.Port()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
// A rate-limit counter of this app and another app's cache key survive Flush.
|
|
if err := client.Set(ctx, "acme:rl:login|192.0.2.1", 3, 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := client.Set(ctx, "other:cache:greeting", "theirs", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cacheRoundTrip(t, NewRedisCache(client, "acme:"))
|
|
for _, k := range []string{"acme:rl:login|192.0.2.1", "other:cache:greeting"} {
|
|
if n, err := client.Exists(ctx, k).Result(); err != nil || n != 1 {
|
|
t.Fatalf("Flush removed %q (exists=%d, err=%v)", k, n, err)
|
|
}
|
|
}
|
|
}
|