package service import ( "context" "fmt" "strings" "testing" "time" "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" ) func newRuntimeCacheForTest(t *testing.T, maxMB int) *RuntimeCacheService { t.Helper() cfg := &config.Config{} cfg.Cache.MemoryMaxSizeMB = maxMB return NewRuntimeCacheService(cfg, zap.NewNop()) } func TestRuntimeCacheEvictsByByteBudget(t *testing.T) { cache := newRuntimeCacheForTest(t, 1) cache.SetObject("large", strings.Repeat("a", 800<<10), time.Minute) cache.SetObject("small", strings.Repeat("b", 300<<10), time.Minute) if cache.bytesUsed > cache.maxBytes { t.Fatalf("cache bytes=%d exceeds max=%d", cache.bytesUsed, cache.maxBytes) } if _, ok := cache.GetObject("large"); ok { t.Fatal("expected oldest large entry to be evicted") } if _, ok := cache.GetObject("small"); !ok { t.Fatal("expected newest small entry to remain cached") } } func TestRuntimeCacheSkipsOversizedObject(t *testing.T) { cache := newRuntimeCacheForTest(t, 1) cache.SetObject("huge", strings.Repeat("x", 2<<20), time.Minute) if _, ok := cache.GetObject("huge"); ok { t.Fatal("oversized object must not be cached") } if cache.bytesUsed != 0 { t.Fatalf("bytesUsed=%d, want 0", cache.bytesUsed) } } func TestRuntimeCachePrefixDeleteReleasesByteBudget(t *testing.T) { cache := newRuntimeCacheForTest(t, 2) for i := 0; i < 4; i++ { cache.SetJSON(context.Background(), fmt.Sprintf("media:%d", i), strings.Repeat("x", 64<<10), time.Minute) } if cache.bytesUsed == 0 { t.Fatal("expected cached bytes") } cache.DeletePrefix(context.Background(), "media:") if cache.bytesUsed != 0 { t.Fatalf("bytesUsed=%d after prefix delete, want 0", cache.bytesUsed) } for i := 0; i < 4; i++ { var out string if cache.GetJSON(context.Background(), fmt.Sprintf("media:%d", i), &out) { t.Fatalf("entry media:%d should have been deleted", i) } } } func TestRuntimeCacheStillEnforcesEntryLimit(t *testing.T) { cache := newRuntimeCacheForTest(t, 1) cache.limit = 3 for i := 0; i < 5; i++ { cache.SetObject(fmt.Sprintf("item-%d", i), i, time.Minute) } if got := cache.entryCountLocked(); got != 3 { t.Fatalf("entry count=%d, want 3", got) } } func TestRuntimeCacheEstimatesMediaRows(t *testing.T) { rows := []model.Media{{ Title: strings.Repeat("t", 1024), Path: "/media/movies/example.mkv", PosterURL: "https://image.example/poster.jpg", BackdropURL: "https://image.example/backdrop.jpg", Overview: strings.Repeat("o", 2048), Genres: "Action,Adventure", }} size := estimateRuntimeCacheObjectSize("media:test", rows) if size < int64(len(rows[0].Title)+len(rows[0].Overview)) { t.Fatalf("estimated size %d is smaller than payload", size) } } func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) { cache := newRuntimeCacheForTest(t, 2) cache.SetObject("old", strings.Repeat("a", 700<<10), time.Minute) cache.SetObject("new", strings.Repeat("b", 700<<10), time.Minute) if cache.bytesUsed <= 1<<20 { t.Fatalf("test setup bytesUsed=%d, want >1MiB", cache.bytesUsed) } cache.SetMaxSizeMB(1) if cache.maxBytes != 1<<20 { t.Fatalf("maxBytes=%d, want 1MiB", cache.maxBytes) } if cache.bytesUsed > cache.maxBytes { t.Fatalf("bytesUsed=%d exceeds maxBytes=%d", cache.bytesUsed, cache.maxBytes) } if _, ok := cache.GetObject("old"); ok { t.Fatal("oldest entry should be evicted after lowering cache limit") } if _, ok := cache.GetObject("new"); !ok { t.Fatal("newest entry should remain after lowering cache limit") } } func TestRuntimeCacheReturnsStaleValueAfterFreshTTL(t *testing.T) { cache := newRuntimeCacheForTest(t, 1) key := "latest:stale" cache.SetJSONWithStale(context.Background(), key, "cached-value", time.Minute, time.Hour) fullKey := cache.key(key) cache.mu.Lock() item := cache.memory[fullKey] item.expiresAt = time.Now().Add(-time.Second) cache.memory[fullKey] = item cache.mu.Unlock() var fresh string if cache.GetJSON(context.Background(), key, &fresh) { t.Fatal("expired fresh entry must not be returned by GetJSON") } var stale string found, isStale := cache.GetJSONStale(context.Background(), key, &stale) if !found || !isStale { t.Fatalf("GetJSONStale found=%t stale=%t, want true/true", found, isStale) } if stale != "cached-value" { t.Fatalf("stale value=%q, want cached-value", stale) } } func TestRuntimeCacheDoesNotReturnOrdinaryEntryAsStale(t *testing.T) { cache := newRuntimeCacheForTest(t, 1) key := "latest:ordinary" cache.SetJSON(context.Background(), key, "cached-value", time.Minute) fullKey := cache.key(key) cache.mu.Lock() item := cache.memory[fullKey] expiredAt := time.Now().Add(-time.Second) item.expiresAt = expiredAt item.staleUntil = expiredAt cache.memory[fullKey] = item cache.mu.Unlock() var out string if found, isStale := cache.GetJSONStale(context.Background(), key, &out); found || isStale { t.Fatalf("ordinary expired entry found=%t stale=%t, want false/false", found, isStale) } } func TestRuntimeCacheStalePrefersFreshRedisValue(t *testing.T) { cache := newRuntimeCacheForTest(t, 1) key := "latest:redis-fresh" cache.SetJSONWithStale(context.Background(), key, "local-stale", time.Minute, time.Hour) fullKey := cache.key(key) cache.mu.Lock() item := cache.memory[fullKey] item.expiresAt = time.Now().Add(-time.Second) cache.memory[fullKey] = item cache.mu.Unlock() cache.redisGet = func(context.Context, string) ([]byte, error) { return []byte(`"redis-fresh"`), nil } var out string found, stale := cache.GetJSONStale(context.Background(), key, &out) if !found || stale { t.Fatalf("GetJSONStale found=%t stale=%t, want true/false", found, stale) } if out != "redis-fresh" { t.Fatalf("value=%q, want redis-fresh", out) } }