package cache import ( "fmt" "sync" "testing" "time" ) // clock lets the TTL tests run without sleeping. type clock struct { mu sync.Mutex t time.Time } func (c *clock) now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.t } func (c *clock) advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.t = c.t.Add(d) } func newTestCache[K comparable, V any](t *testing.T, max int, ttl time.Duration) (*Cache[K, V], *clock) { t.Helper() c := New[K, V](max, ttl) clk := &clock{t: time.Unix(1_700_000_000, 0)} c.now = clk.now return c, clk } func TestGetPut(t *testing.T) { c, _ := newTestCache[string, int](t, 4, time.Minute) if _, ok := c.Get("missing"); ok { t.Error("empty cache returned a hit") } c.Put("a", 1) if v, ok := c.Get("a"); !ok || v != 1 { t.Errorf("Get(a) = %v, %v", v, ok) } c.Put("a", 2) if v, _ := c.Get("a"); v != 2 { t.Errorf("Put did not overwrite: %v", v) } if c.Len() != 1 { t.Errorf("Len = %d, want 1", c.Len()) } c.Delete("a") if _, ok := c.Get("a"); ok { t.Error("deleted key still present") } } func TestTTLExpiry(t *testing.T) { c, clk := newTestCache[string, int](t, 4, time.Minute) c.Put("a", 1) clk.advance(59 * time.Second) if _, ok := c.Get("a"); !ok { t.Error("entry expired early") } clk.advance(time.Second) if _, ok := c.Get("a"); ok { t.Error("entry outlived its TTL") } if c.Len() != 0 { t.Errorf("expired entry not dropped: Len = %d", c.Len()) } } // The bound is the whole point: the rate limiter's key space is whatever // addresses show up, so an unbounded map would be a memory DoS. func TestEvictsLeastRecentlyUsed(t *testing.T) { c, _ := newTestCache[string, int](t, 3, time.Minute) c.Put("a", 1) c.Put("b", 2) c.Put("c", 3) // Touching "a" makes "b" the least recently used. if _, ok := c.Get("a"); !ok { t.Fatal("a missing") } c.Put("d", 4) if c.Len() != 3 { t.Errorf("Len = %d, want 3", c.Len()) } if _, ok := c.Get("b"); ok { t.Error("b should have been evicted") } for _, k := range []string{"a", "c", "d"} { if _, ok := c.Get(k); !ok { t.Errorf("%s should have survived", k) } } } func TestEvictionKeepsListConsistent(t *testing.T) { c, _ := newTestCache[int, int](t, 8, time.Minute) for i := 0; i < 1000; i++ { c.Put(i, i) if i%3 == 0 { c.Get(i / 2) } if i%7 == 0 { c.Delete(i - 1) } if c.Len() > 8 { t.Fatalf("cap exceeded at i=%d: Len = %d", i, c.Len()) } } // Every surviving node must be reachable from both ends, or a later // eviction would corrupt the list rather than free anything. c.mu.Lock() defer c.mu.Unlock() forward := 0 for n := c.head; n != nil; n = n.next { forward++ if forward > 100 { t.Fatal("forward walk did not terminate (cycle in the list)") } } backward := 0 for n := c.tail; n != nil; n = n.prev { backward++ if backward > 100 { t.Fatal("backward walk did not terminate (cycle in the list)") } } if forward != len(c.m) || backward != len(c.m) { t.Errorf("list holds %d/%d nodes, map holds %d", forward, backward, len(c.m)) } } func TestGetOrCreate(t *testing.T) { c, clk := newTestCache[string, *int](t, 4, time.Minute) calls := 0 newVal := func() *int { calls++ v := calls return &v } first := c.GetOrCreate("a", newVal) second := c.GetOrCreate("a", newVal) if first != second { t.Error("GetOrCreate must return the same value for a live entry") } if calls != 1 { t.Errorf("newVal called %d times, want 1", calls) } clk.advance(2 * time.Minute) third := c.GetOrCreate("a", newVal) if third == first { t.Error("an expired entry must be replaced") } if calls != 2 { t.Errorf("newVal called %d times, want 2", calls) } } // Two callers racing on the same key must end up sharing one value — the rate // limiter relies on this to avoid handing every request its own token bucket. func TestGetOrCreateIsAtomic(t *testing.T) { c := New[string, *int](64, time.Minute) const goroutines = 32 var wg sync.WaitGroup got := make([]*int, goroutines) start := make(chan struct{}) for i := 0; i < goroutines; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start got[i] = c.GetOrCreate("shared", func() *int { v := 0; return &v }) }(i) } close(start) wg.Wait() for i := 1; i < goroutines; i++ { if got[i] != got[0] { t.Fatalf("goroutine %d got a different value", i) } } } func TestConcurrentUse(t *testing.T) { c := New[string, int](16, time.Minute) var wg sync.WaitGroup for g := 0; g < 8; g++ { wg.Add(1) go func(g int) { defer wg.Done() for i := 0; i < 500; i++ { k := fmt.Sprintf("k%d", i%40) c.Put(k, i) c.Get(k) if i%10 == 0 { c.Delete(k) } c.GetOrCreate(k, func() int { return g }) } }(g) } wg.Wait() if c.Len() > 16 { t.Errorf("Len = %d, want <= 16", c.Len()) } } func TestNewClampsMax(t *testing.T) { c := New[string, int](0, time.Minute) c.Put("a", 1) c.Put("b", 2) if c.Len() != 1 { t.Errorf("Len = %d, want 1", c.Len()) } }