internal/store/postgres/channel_row_cache_test.go
Rama main · réplica de lectura del repositorio original.
package postgres
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelRowCacheNilDisabled(t *testing.T) {
if c := NewChannelRowCache(0); c != nil {
t.Fatalf("max<=0 should return nil (disabled), got %v", c)
}
if c := NewChannelRowCache(-5); c != nil {
t.Fatalf("negative capacity should return nil (disabled), got %v", c)
}
// All operations on a nil cache must be safe (no-op).
var nilCache *ChannelRowCache
nilCache.put(domain.Channel{ID: 1})
nilCache.delete(1)
nilCache.flush()
if _, ok := nilCache.get(1); ok {
t.Fatalf("get on a nil cache must return ok=false")
}
}
func TestChannelRowCachePutGetDeleteFlush(t *testing.T) {
c := NewChannelRowCache(16)
ch := domain.Channel{ID: 42, Title: "hello", AccessHash: 7}
if _, ok := c.get(42); ok {
t.Fatalf("empty cache should not hit")
}
c.put(ch)
got, ok := c.get(42)
if !ok || got.ID != 42 || got.Title != "hello" || got.AccessHash != 7 {
t.Fatalf("put/get round-trip failed: %+v ok=%v", got, ok)
}
c.delete(42)
if _, ok := c.get(42); ok {
t.Fatalf("should not hit after delete")
}
c.put(ch)
c.flush()
if _, ok := c.get(42); ok {
t.Fatalf("should not hit after flush")
}
// id==0 is neither stored nor looked up.
c.put(domain.Channel{ID: 0, Title: "zero"})
if _, ok := c.get(0); ok {
t.Fatalf("id==0 should not be cached/hit")
}
}
func TestChannelRowCacheReturnsIsolatedClone(t *testing.T) {
c := NewChannelRowCache(8)
orig := domain.Channel{
ID: 9,
PhotoStripped: []byte{1, 2, 3},
}
orig.ReactionPolicy.Emoticons = []string{"👍"}
orig.ReactionPolicy.CustomEmojiIDs = []int64{100}
c.put(orig)
// Mutating the underlying slice of the put argument must not pollute the cache (put must keep its own copy).
orig.PhotoStripped[0] = 99
orig.ReactionPolicy.Emoticons[0] = "💩"
orig.ReactionPolicy.CustomEmojiIDs[0] = 999
got, ok := c.get(9)
if !ok {
t.Fatalf("should hit")
}
if got.PhotoStripped[0] != 1 || got.ReactionPolicy.Emoticons[0] != "👍" || got.ReactionPolicy.CustomEmojiIDs[0] != 100 {
t.Fatalf("cache polluted by later mutation of the put argument: %+v", got)
}
// Mutating the slice returned by get must not pollute the cache either (get must return a copy).
got.PhotoStripped[0] = 77
got.ReactionPolicy.Emoticons[0] = "🔥"
got.ReactionPolicy.CustomEmojiIDs[0] = 777
again, _ := c.get(9)
if again.PhotoStripped[0] != 1 || again.ReactionPolicy.Emoticons[0] != "👍" || again.ReactionPolicy.CustomEmojiIDs[0] != 100 {
t.Fatalf("cache polluted by mutation of the get return value: %+v", again)
}
}
func TestChannelRowCacheCapEviction(t *testing.T) {
c := NewChannelRowCache(2)
c.put(domain.Channel{ID: 1})
c.put(domain.Channel{ID: 2})
// A third new id exceeds cap=2: LRU evicts the oldest single entry id1 rather than clearing the whole table.
c.put(domain.Channel{ID: 3})
if _, ok := c.get(1); ok {
t.Fatalf("after overflow the oldest entry id1 should be evicted")
}
if _, ok := c.get(2); !ok {
t.Fatalf("per-entry LRU eviction should keep the next-newest entry id2, proving it is not a whole-table flush")
}
if _, ok := c.get(3); !ok {
t.Fatalf("the newest written entry id3 should be present")
}
// Updating an existing id takes effect in place and does not trigger eviction.
c.put(domain.Channel{ID: 3, Title: "v2"})
if got, ok := c.get(3); !ok || got.Title != "v2" {
t.Fatalf("update of an existing id should take effect in place: %+v ok=%v", got, ok)
}
}
func TestChannelRowCacheSingleflightsColdLoad(t *testing.T) {
c := NewChannelRowCache(16)
var calls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
load := func() (domain.Channel, error) {
calls.Add(1)
once.Do(func() { close(started) })
<-release
return domain.Channel{ID: 42, Title: "loaded"}, nil
}
const goroutines = 16
var wg sync.WaitGroup
errs := make(chan error, goroutines)
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
ch, err := c.getOrLoad(context.Background(), 42, load)
if err != nil {
errs <- err
return
}
if ch.ID != 42 || ch.Title != "loaded" {
errs <- fmt.Errorf("channel = %+v, want loaded 42", ch)
}
}()
}
<-started
time.Sleep(20 * time.Millisecond)
close(release)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("load calls = %d, want 1", got)
}
if _, err := c.getOrLoad(context.Background(), 42, load); err != nil {
t.Fatalf("cached getOrLoad: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("cache hit called load again: calls=%d", got)
}
}
func TestChannelRowCacheBatchesMissesAndNegativeRows(t *testing.T) {
c := NewChannelRowCache(8)
loads := 0
load := func(_ context.Context, ids []int64) (map[int64]domain.Channel, error) {
loads++
out := make(map[int64]domain.Channel, len(ids))
for _, id := range ids {
if id != 2 {
out[id] = domain.Channel{ID: id, Title: "channel"}
}
}
return out, nil
}
for i := 0; i < 2; i++ {
got, err := c.getOrLoadBatch(context.Background(), []int64{1, 2, 3}, load)
if err != nil {
t.Fatal(err)
}
if got[1].ID != 1 || got[2].ID != 0 || got[3].ID != 3 {
t.Fatalf("batch result = %+v", got)
}
}
if loads != 1 {
t.Fatalf("batch loads = %d, want 1", loads)
}
}