Files
projax/internal/cache/ttl_test.go
mAi 599d9a5bb0 feat(cache): introduce internal/cache/ TTLCache[V]
Phase 5b slice A. Generic TTL cache that replaces the mechanically
identical dashboardCache + timelineCache in slices B/C.

- TTLCache[V] over map[string]entry[V] with sync.RWMutex.
- Get / Set / Invalidate(key) / InvalidateAll.
- Lazy expiry — a Get past the deadline removes the entry; no sweeper
  goroutine (matches today's behaviour and stays simple at single-user
  scale).
- Nil receiver is safe across all four methods — same defensive shape
  the existing per-package caches use.

Tests cover empty Get, Set+Get, expiry on miss, overwrite,
keyed-Invalidate isolation, InvalidateAll, nil receiver, pointer
payload behaviour, and a -race-flag concurrent-access probe across
8 workers × 200 ops.

No web/mcp wiring yet — slices B/C migrate the callers. Same Go
linker DCE caveat as 5a slice A applies (strings | grep alone won't
fire on this slice).

Task: t-projax-5b-cache
2026-05-22 00:23:50 +02:00

135 lines
3.0 KiB
Go

package cache
import (
"strconv"
"sync"
"testing"
"time"
)
func TestGetEmpty(t *testing.T) {
c := NewTTL[int](50 * time.Millisecond)
if v, ok := c.Get("nope"); ok || v != 0 {
t.Fatalf("empty Get: got (%v, %v), want (0, false)", v, ok)
}
}
func TestSetGet(t *testing.T) {
c := NewTTL[string](50 * time.Millisecond)
c.Set("k", "v")
got, ok := c.Get("k")
if !ok || got != "v" {
t.Fatalf("got (%q, %v), want (v, true)", got, ok)
}
}
func TestGetAfterTTL(t *testing.T) {
c := NewTTL[string](20 * time.Millisecond)
c.Set("k", "v")
time.Sleep(50 * time.Millisecond)
if _, ok := c.Get("k"); ok {
t.Fatalf("expected miss after TTL")
}
// Internal: the expired entry must be removed.
c.mu.RLock()
_, present := c.rows["k"]
c.mu.RUnlock()
if present {
t.Fatalf("expired entry was not removed on miss")
}
}
func TestSetOverwrites(t *testing.T) {
c := NewTTL[int](1 * time.Second)
c.Set("k", 1)
c.Set("k", 2)
got, ok := c.Get("k")
if !ok || got != 2 {
t.Fatalf("got (%d, %v), want (2, true)", got, ok)
}
}
func TestInvalidateOnlyTargetsKey(t *testing.T) {
c := NewTTL[int](1 * time.Second)
c.Set("a", 1)
c.Set("b", 2)
c.Invalidate("a")
if _, ok := c.Get("a"); ok {
t.Fatalf("a should be gone")
}
if v, ok := c.Get("b"); !ok || v != 2 {
t.Fatalf("b should survive Invalidate of a; got (%d, %v)", v, ok)
}
}
func TestInvalidateAll(t *testing.T) {
c := NewTTL[int](1 * time.Second)
c.Set("a", 1)
c.Set("b", 2)
c.InvalidateAll()
if _, ok := c.Get("a"); ok {
t.Fatalf("a should be gone")
}
if _, ok := c.Get("b"); ok {
t.Fatalf("b should be gone")
}
}
func TestNilReceiverSafe(t *testing.T) {
var c *TTLCache[string]
if v, ok := c.Get("k"); ok || v != "" {
t.Errorf("nil Get should miss, got (%q, %v)", v, ok)
}
c.Set("k", "v") // must not panic
c.Invalidate("k")
c.InvalidateAll()
}
func TestPointerPayload(t *testing.T) {
type payload struct{ N int }
c := NewTTL[*payload](1 * time.Second)
if v, ok := c.Get("k"); ok || v != nil {
t.Fatalf("pointer miss should yield (nil, false), got (%v, %v)", v, ok)
}
c.Set("k", &payload{N: 42})
v, ok := c.Get("k")
if !ok || v == nil || v.N != 42 {
t.Fatalf("got (%+v, %v), want non-nil N=42", v, ok)
}
}
// TestRaceCleanConcurrentAccess runs many goroutines Setting / Getting /
// Invalidating overlapping keys. Run with `go test -race ./internal/cache/`
// to catch any unprotected writes — the test body itself only checks
// non-panic + final-count sanity.
func TestRaceCleanConcurrentAccess(t *testing.T) {
c := NewTTL[int](250 * time.Millisecond)
const workers = 8
const ops = 200
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(seed int) {
defer wg.Done()
for i := 0; i < ops; i++ {
key := "k" + strconv.Itoa((seed*ops+i)%5)
switch i % 4 {
case 0:
c.Set(key, i)
case 1:
_, _ = c.Get(key)
case 2:
c.Invalidate(key)
case 3:
if i%50 == 0 {
c.InvalidateAll()
} else {
_, _ = c.Get(key)
}
}
}
}(w)
}
wg.Wait()
}