Files
MeBox/internal/service/runtime_cache_test.go
T
2026-09-14 15:12:42 +08:00

190 lines
5.7 KiB
Go

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