Files
summercms/modules/cooler/smoke_test.go
Jakub Zych fd7f204979 feat(19.3-01): add cooler.Cache with otter memory and Redis drivers
- 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
2026-10-15 17:40:23 +02:00

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)
}
}
}