688 lines
24 KiB
Go
688 lines
24 KiB
Go
package sections
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
||
|
|
)
|
||
|
|
|
||
|
|
func mediaItems(ids ...string) []*models.MediaItem {
|
||
|
|
items := make([]*models.MediaItem, 0, len(ids))
|
||
|
|
for _, id := range ids {
|
||
|
|
items = append(items, &models.MediaItem{ContentID: id})
|
||
|
|
}
|
||
|
|
return items
|
||
|
|
}
|
||
|
|
|
||
|
|
func itemIDs(items []*models.MediaItem) []string {
|
||
|
|
ids := make([]string, 0, len(items))
|
||
|
|
for _, item := range items {
|
||
|
|
ids = append(ids, item.ContentID)
|
||
|
|
}
|
||
|
|
return ids
|
||
|
|
}
|
||
|
|
|
||
|
|
func staticLoader(items []*models.MediaItem, calls *int64) resolvedListLoader {
|
||
|
|
return func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
if calls != nil {
|
||
|
|
atomic.AddInt64(calls, 1)
|
||
|
|
}
|
||
|
|
return items, len(items), nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheScopeIsolation verifies distinct access scopes hash to
|
||
|
|
// distinct keys and never cross-serve one another's items.
|
||
|
|
func TestResolvedListCacheScopeIsolation(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
resolved := ResolvedSection{
|
||
|
|
ID: "sec-1",
|
||
|
|
SectionType: SectionRecentlyAdded,
|
||
|
|
ItemLimit: 20,
|
||
|
|
}
|
||
|
|
scopeA := catalog.AccessFilter{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "PG-13"}
|
||
|
|
scopeB := catalog.AccessFilter{AllowedLibraryIDs: []int{3}, MaxContentRating: "R"}
|
||
|
|
|
||
|
|
keyA := resolvedListCacheKey(resolved, nil, nil, scopeA)
|
||
|
|
keyB := resolvedListCacheKey(resolved, nil, nil, scopeB)
|
||
|
|
if keyA == keyB {
|
||
|
|
t.Fatalf("expected distinct keys for distinct scopes, got %q for both", keyA)
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Unix(1_700_000_000, 0)
|
||
|
|
itemsA := mediaItems("a1", "a2")
|
||
|
|
itemsB := mediaItems("b1")
|
||
|
|
|
||
|
|
gotA, totalA, err := getOrRefresh(context.Background(), keyA, now, staticLoader(itemsA, nil))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("scope A load: %v", err)
|
||
|
|
}
|
||
|
|
gotB, totalB, err := getOrRefresh(context.Background(), keyB, now, staticLoader(itemsB, nil))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("scope B load: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if got := itemIDs(gotA); len(got) != 2 || got[0] != "a1" || got[1] != "a2" {
|
||
|
|
t.Fatalf("scope A returned %v, want [a1 a2]", got)
|
||
|
|
}
|
||
|
|
if totalA != 2 {
|
||
|
|
t.Fatalf("scope A total = %d, want 2", totalA)
|
||
|
|
}
|
||
|
|
if got := itemIDs(gotB); len(got) != 1 || got[0] != "b1" {
|
||
|
|
t.Fatalf("scope B returned %v, want [b1]", got)
|
||
|
|
}
|
||
|
|
if totalB != 1 {
|
||
|
|
t.Fatalf("scope B total = %d, want 1", totalB)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Re-reading scope A must still yield scope A's items even after scope B was
|
||
|
|
// populated: no cross-serving between scopes.
|
||
|
|
reGotA, _, err := getOrRefresh(context.Background(), keyA, now, func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
t.Fatalf("scope A loader should not run again while entry is fresh")
|
||
|
|
return nil, 0, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("scope A re-read: %v", err)
|
||
|
|
}
|
||
|
|
if got := itemIDs(reGotA); len(got) != 2 || got[0] != "a1" {
|
||
|
|
t.Fatalf("scope A re-read returned %v, want [a1 a2]", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheKeyIgnoresSectionID is the core Approach-2 guarantee: two
|
||
|
|
// sections that share type+config+limit+scope but carry different arbitrary IDs
|
||
|
|
// hash to the SAME key. This is what lets a native "recently added" rail and the
|
||
|
|
// jellyfin-compat /Items/Latest collapse to one shared cache entry.
|
||
|
|
func TestResolvedListCacheKeyIgnoresSectionID(t *testing.T) {
|
||
|
|
cfg := json.RawMessage(`{"filter_type":"movie"}`)
|
||
|
|
scope := catalog.AccessFilter{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "PG-13"}
|
||
|
|
libraryID := 7
|
||
|
|
|
||
|
|
native := ResolvedSection{ID: "home-rail-42", SectionType: SectionRecentlyAdded, ItemLimit: 24, Config: cfg}
|
||
|
|
compat := ResolvedSection{ID: "compat-latest", SectionType: SectionRecentlyAdded, ItemLimit: 24, Config: cfg}
|
||
|
|
|
||
|
|
keyNative := resolvedListCacheKey(native, &libraryID, nil, scope)
|
||
|
|
keyCompat := resolvedListCacheKey(compat, &libraryID, nil, scope)
|
||
|
|
if keyNative != keyCompat {
|
||
|
|
t.Fatalf("expected identical keys regardless of section ID, got %q vs %q", keyNative, keyCompat)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A differing type, config, limit, library, or scope must still split the key
|
||
|
|
// — the ID is the only field that no longer participates.
|
||
|
|
if resolvedListCacheKey(ResolvedSection{ID: "x", SectionType: SectionRecentlyReleased, ItemLimit: 24, Config: cfg}, &libraryID, nil, scope) == keyNative {
|
||
|
|
t.Fatalf("different section type must change the key")
|
||
|
|
}
|
||
|
|
if resolvedListCacheKey(ResolvedSection{ID: "x", SectionType: SectionRecentlyAdded, ItemLimit: 12, Config: cfg}, &libraryID, nil, scope) == keyNative {
|
||
|
|
t.Fatalf("different item limit must change the key")
|
||
|
|
}
|
||
|
|
if resolvedListCacheKey(native, &libraryID, nil, catalog.AccessFilter{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "R"}) == keyNative {
|
||
|
|
t.Fatalf("different max content rating must change the key")
|
||
|
|
}
|
||
|
|
otherLibrary := 8
|
||
|
|
if resolvedListCacheKey(native, &otherLibrary, nil, scope) == keyNative {
|
||
|
|
t.Fatalf("different library must change the key")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheSharedAcrossSurfaces proves the WIN end-to-end through
|
||
|
|
// getOrRefresh: a native recently-added section and the compat /Items/Latest for
|
||
|
|
// the same library+type+limit+scope build the shared list exactly ONCE and reuse
|
||
|
|
// it across both surfaces.
|
||
|
|
func TestResolvedListCacheSharedAcrossSurfaces(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
cfg := json.RawMessage(`{"filter_type":"movie"}`)
|
||
|
|
scope := catalog.AccessFilter{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "PG-13"}
|
||
|
|
libraryID := 7
|
||
|
|
now := time.Unix(1_700_000_000, 0)
|
||
|
|
|
||
|
|
native := ResolvedSection{ID: "home-recent-1", SectionType: SectionRecentlyAdded, ItemLimit: 24, Config: cfg}
|
||
|
|
compat := ResolvedSection{ID: "compat-latest", SectionType: SectionRecentlyAdded, ItemLimit: 24, Config: cfg}
|
||
|
|
|
||
|
|
keyNative := resolvedListCacheKey(native, &libraryID, nil, scope)
|
||
|
|
keyCompat := resolvedListCacheKey(compat, &libraryID, nil, scope)
|
||
|
|
|
||
|
|
var calls int64
|
||
|
|
shared := mediaItems("m1", "m2", "m3")
|
||
|
|
|
||
|
|
// Native surface builds the list first (cold miss → loader runs).
|
||
|
|
gotNative, _, err := getOrRefresh(context.Background(), keyNative, now, staticLoader(shared, &calls))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("native load: %v", err)
|
||
|
|
}
|
||
|
|
// Compat surface hits the SAME entry: the loader must NOT run again.
|
||
|
|
gotCompat, _, err := getOrRefresh(context.Background(), keyCompat, now, func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
t.Fatalf("compat loader must not run: the native entry should be shared")
|
||
|
|
return nil, 0, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("compat load: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if got := atomic.LoadInt64(&calls); got != 1 {
|
||
|
|
t.Fatalf("shared list built %d times, want exactly 1", got)
|
||
|
|
}
|
||
|
|
if a, b := itemIDs(gotNative), itemIDs(gotCompat); len(a) != 3 || len(b) != 3 || a[0] != b[0] || a[2] != b[2] {
|
||
|
|
t.Fatalf("surfaces returned divergent lists: native=%v compat=%v", a, b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheKeyScopeStillIsolatesWithoutID is the SECURITY guarantee:
|
||
|
|
// with the section ID removed from the key, two requests that are identical
|
||
|
|
// except for access scope (library, max content rating, or allowed-content-ids)
|
||
|
|
// must STILL hash to distinct keys so one profile can never be served another's
|
||
|
|
// membership.
|
||
|
|
func TestResolvedListCacheKeyScopeStillIsolatesWithoutID(t *testing.T) {
|
||
|
|
// Deliberately identical section identity to prove scope alone splits the key.
|
||
|
|
resolved := ResolvedSection{ID: "same-id", SectionType: SectionRecentlyAdded, ItemLimit: 24, Config: json.RawMessage(`{"filter_type":"movie"}`)}
|
||
|
|
base := catalog.AccessFilter{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "PG-13"}
|
||
|
|
lib1, lib2 := 7, 8
|
||
|
|
|
||
|
|
baseline := resolvedListCacheKey(resolved, &lib1, nil, base)
|
||
|
|
|
||
|
|
// Different presentation library.
|
||
|
|
if resolvedListCacheKey(resolved, &lib2, nil, base) == baseline {
|
||
|
|
t.Fatalf("different library must isolate the key even with identical section ID")
|
||
|
|
}
|
||
|
|
// Different max content rating.
|
||
|
|
rating := base
|
||
|
|
rating.MaxContentRating = "R"
|
||
|
|
if resolvedListCacheKey(resolved, &lib1, nil, rating) == baseline {
|
||
|
|
t.Fatalf("different max content rating must isolate the key")
|
||
|
|
}
|
||
|
|
// Different allowed-content-ids (content-restricted profile).
|
||
|
|
restricted := base
|
||
|
|
restricted.AllowedContentIDs = []string{"c1", "c2"}
|
||
|
|
if resolvedListCacheKey(resolved, &lib1, nil, restricted) == baseline {
|
||
|
|
t.Fatalf("different allowed-content-ids must isolate the key")
|
||
|
|
}
|
||
|
|
// Different accessible library set.
|
||
|
|
accessible := base
|
||
|
|
accessible.AllowedLibraryIDs = []int{1, 2, 3}
|
||
|
|
if resolvedListCacheKey(resolved, &lib1, nil, accessible) == baseline {
|
||
|
|
t.Fatalf("different accessible library set must isolate the key")
|
||
|
|
}
|
||
|
|
// Different disabled library set.
|
||
|
|
disabled := base
|
||
|
|
disabled.DisabledLibraryIDs = []int{9}
|
||
|
|
if resolvedListCacheKey(resolved, &lib1, nil, disabled) == baseline {
|
||
|
|
t.Fatalf("different disabled library set must isolate the key")
|
||
|
|
}
|
||
|
|
// Different excluded media types.
|
||
|
|
excluded := base
|
||
|
|
excluded.ExcludedMediaTypes = []string{"audiobook"}
|
||
|
|
if resolvedListCacheKey(resolved, &lib1, nil, excluded) == baseline {
|
||
|
|
t.Fatalf("different excluded media types must isolate the key")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheEvictsExpiredEntries verifies expired entries are swept
|
||
|
|
// so the process-global map cannot grow unbounded across never-repeated scopes.
|
||
|
|
func TestResolvedListCacheEvictsExpiredEntries(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
base := time.Unix(1_700_000_000, 0)
|
||
|
|
if _, _, err := getOrRefresh(context.Background(), "scope-A", base, staticLoader(mediaItems("a1"), nil)); err != nil {
|
||
|
|
t.Fatalf("prime scope-A: %v", err)
|
||
|
|
}
|
||
|
|
if _, ok := resolvedListGet("scope-A"); !ok {
|
||
|
|
t.Fatalf("scope-A should be cached right after priming")
|
||
|
|
}
|
||
|
|
|
||
|
|
// A later write, past scope-A's expiry (base+15m) and the prune interval,
|
||
|
|
// must sweep scope-A while installing scope-B.
|
||
|
|
later := base.Add(20 * time.Minute)
|
||
|
|
if _, _, err := getOrRefresh(context.Background(), "scope-B", later, staticLoader(mediaItems("b1"), nil)); err != nil {
|
||
|
|
t.Fatalf("prime scope-B: %v", err)
|
||
|
|
}
|
||
|
|
if _, ok := resolvedListGet("scope-A"); ok {
|
||
|
|
t.Fatalf("expired scope-A should have been evicted")
|
||
|
|
}
|
||
|
|
if _, ok := resolvedListGet("scope-B"); !ok {
|
||
|
|
t.Fatalf("scope-B should be present")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheKeyContentBoundaries verifies the content-scoping access
|
||
|
|
// boundaries (AllowedContentIDs, NamePrefix) that the filter-driven builders
|
||
|
|
// enforce as SQL constraints each produce a distinct key, so a content-restricted
|
||
|
|
// profile can never be served an unrestricted profile's membership.
|
||
|
|
func TestResolvedListCacheKeyContentBoundaries(t *testing.T) {
|
||
|
|
resolved := ResolvedSection{ID: "sec-1", SectionType: SectionGenre, ItemLimit: 20}
|
||
|
|
base := catalog.AccessFilter{AllowedLibraryIDs: []int{1}, MaxContentRating: "PG-13"}
|
||
|
|
|
||
|
|
// nil (unrestricted) vs empty (restrict-to-nothing) vs a concrete allow-list
|
||
|
|
// must all differ, and two different allow-lists must differ.
|
||
|
|
unrestricted := resolvedListCacheKey(resolved, nil, nil, base)
|
||
|
|
|
||
|
|
restrictNone := base
|
||
|
|
restrictNone.AllowedContentIDs = []string{}
|
||
|
|
keyRestrictNone := resolvedListCacheKey(resolved, nil, nil, restrictNone)
|
||
|
|
|
||
|
|
allowX := base
|
||
|
|
allowX.AllowedContentIDs = []string{"c1", "c2"}
|
||
|
|
keyAllowX := resolvedListCacheKey(resolved, nil, nil, allowX)
|
||
|
|
|
||
|
|
allowY := base
|
||
|
|
allowY.AllowedContentIDs = []string{"c3"}
|
||
|
|
keyAllowY := resolvedListCacheKey(resolved, nil, nil, allowY)
|
||
|
|
|
||
|
|
for _, pair := range [][2]string{
|
||
|
|
{unrestricted, keyRestrictNone},
|
||
|
|
{unrestricted, keyAllowX},
|
||
|
|
{keyRestrictNone, keyAllowX},
|
||
|
|
{keyAllowX, keyAllowY},
|
||
|
|
} {
|
||
|
|
if pair[0] == pair[1] {
|
||
|
|
t.Fatalf("expected distinct keys for distinct content scopes, got identical %q", pair[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Order-independence: the same allow-list in a different order shares a key.
|
||
|
|
allowXReordered := base
|
||
|
|
allowXReordered.AllowedContentIDs = []string{"c2", "c1"}
|
||
|
|
if resolvedListCacheKey(resolved, nil, nil, allowXReordered) != keyAllowX {
|
||
|
|
t.Fatalf("allow-list key should be order-independent")
|
||
|
|
}
|
||
|
|
|
||
|
|
// NamePrefix is an access boundary too.
|
||
|
|
prefixed := base
|
||
|
|
prefixed.NamePrefix = "The "
|
||
|
|
if resolvedListCacheKey(resolved, nil, nil, prefixed) == unrestricted {
|
||
|
|
t.Fatalf("NamePrefix must change the cache key")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestHashSectionConfigCanonicalizes verifies configs that are semantically
|
||
|
|
// identical but differ in whitespace or field order hash the same, so native
|
||
|
|
// and jellycompat fetches of the same rail share a cache entry. It fails if the
|
||
|
|
// canonicalization step is removed.
|
||
|
|
func TestHashSectionConfigCanonicalizes(t *testing.T) {
|
||
|
|
a := hashSectionConfig(json.RawMessage(`{"sort":"added","order":"desc"}`))
|
||
|
|
reordered := hashSectionConfig(json.RawMessage(`{"order":"desc","sort":"added"}`))
|
||
|
|
spaced := hashSectionConfig(json.RawMessage("{ \"sort\": \"added\" ,\n\"order\": \"desc\" }"))
|
||
|
|
if a != reordered {
|
||
|
|
t.Fatalf("field-order should not change the config hash: %q vs %q", a, reordered)
|
||
|
|
}
|
||
|
|
if a != spaced {
|
||
|
|
t.Fatalf("whitespace should not change the config hash: %q vs %q", a, spaced)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A genuinely different config must still hash differently.
|
||
|
|
if a == hashSectionConfig(json.RawMessage(`{"sort":"title","order":"desc"}`)) {
|
||
|
|
t.Fatalf("distinct configs must hash differently")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Invalid JSON falls back to raw bytes rather than collapsing together.
|
||
|
|
if hashSectionConfig(json.RawMessage(`not json`)) == hashSectionConfig(json.RawMessage(`also not`)) {
|
||
|
|
t.Fatalf("distinct non-JSON configs must hash differently")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheDoesNotCacheEmpty verifies a transiently empty result is
|
||
|
|
// not frozen for the TTL: the loader runs again on the next request.
|
||
|
|
func TestResolvedListCacheDoesNotCacheEmpty(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
key := "empty-not-cached"
|
||
|
|
now := time.Unix(1_700_000_000, 0)
|
||
|
|
|
||
|
|
var calls int64
|
||
|
|
emptyLoader := func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
atomic.AddInt64(&calls, 1)
|
||
|
|
return nil, 0, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, _, err := getOrRefresh(context.Background(), key, now, emptyLoader); err != nil {
|
||
|
|
t.Fatalf("first empty load: %v", err)
|
||
|
|
}
|
||
|
|
// A second read in the same fresh window must rebuild (empty was not cached).
|
||
|
|
got, _, err := getOrRefresh(context.Background(), key, now, staticLoader(mediaItems("now-has-content"), &calls))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("second load: %v", err)
|
||
|
|
}
|
||
|
|
if ids := itemIDs(got); len(ids) != 1 || ids[0] != "now-has-content" {
|
||
|
|
t.Fatalf("second load returned %v, want [now-has-content]", ids)
|
||
|
|
}
|
||
|
|
if got := atomic.LoadInt64(&calls); got != 2 {
|
||
|
|
t.Fatalf("loader invoked %d times, want 2 (empty result was not cached)", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheDefensiveCopy verifies a caller mutating the returned
|
||
|
|
// slice cannot corrupt the cached entry.
|
||
|
|
func TestResolvedListCacheDefensiveCopy(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
key := "defensive-copy"
|
||
|
|
now := time.Unix(1_700_000_000, 0)
|
||
|
|
|
||
|
|
got, _, err := getOrRefresh(context.Background(), key, now, staticLoader(mediaItems("x1", "x2"), nil))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("initial load: %v", err)
|
||
|
|
}
|
||
|
|
got[0] = &models.MediaItem{ContentID: "tampered"}
|
||
|
|
|
||
|
|
reGot, _, err := getOrRefresh(context.Background(), key, now, func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
t.Fatalf("loader should not run while entry is fresh")
|
||
|
|
return nil, 0, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("re-read: %v", err)
|
||
|
|
}
|
||
|
|
if reGot[0].ContentID != "x1" {
|
||
|
|
t.Fatalf("cached entry was corrupted by caller mutation: got %q", reGot[0].ContentID)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestIsCacheableSectionType covers the whitelist, the user-collection
|
||
|
|
// exclusion, and the explicit exclusions. Cacheability is derived from
|
||
|
|
// userAgnosticSectionFetcher, so this doubles as a pin on that table.
|
||
|
|
func TestIsCacheableSectionType(t *testing.T) {
|
||
|
|
f := &Fetcher{}
|
||
|
|
cacheable := []SectionType{
|
||
|
|
SectionRecentlyAdded,
|
||
|
|
SectionRecentlyReleased,
|
||
|
|
SectionGenre,
|
||
|
|
SectionCustomFilter,
|
||
|
|
SectionCriticallyAcclaimed,
|
||
|
|
SectionAwardWinners,
|
||
|
|
SectionFormatShowcase,
|
||
|
|
SectionSeasonalThemed,
|
||
|
|
SectionMoodCollection,
|
||
|
|
SectionTrendingOnServer,
|
||
|
|
SectionNewToLibrary,
|
||
|
|
SectionMostWatched,
|
||
|
|
SectionTrendingDiscover,
|
||
|
|
SectionAdminCuratedList,
|
||
|
|
}
|
||
|
|
for _, st := range cacheable {
|
||
|
|
if !f.isCacheableSectionType(ResolvedSection{SectionType: st}) {
|
||
|
|
t.Errorf("expected %s to be cacheable", st)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
notCacheable := []SectionType{
|
||
|
|
SectionRandom,
|
||
|
|
SectionContinueWatching,
|
||
|
|
SectionNextUp,
|
||
|
|
SectionNextInSeries,
|
||
|
|
SectionRecommendedForYou,
|
||
|
|
SectionBecauseYouWatched,
|
||
|
|
SectionSimilarUsersLiked,
|
||
|
|
SectionTasteMatch,
|
||
|
|
SectionHiddenGems,
|
||
|
|
SectionForgottenFavorites,
|
||
|
|
SectionProfileActivityFeed,
|
||
|
|
SectionEditorialSpotlight,
|
||
|
|
}
|
||
|
|
for _, st := range notCacheable {
|
||
|
|
if f.isCacheableSectionType(ResolvedSection{SectionType: st}) {
|
||
|
|
t.Errorf("expected %s to NOT be cacheable", st)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestIsCacheableSectionTypePersonalizedFilter verifies genre / custom_filter
|
||
|
|
// sections whose QueryDefinition carries a personalized (per-profile) rule or
|
||
|
|
// sort are NOT cacheable — their membership differs per profile and the shared
|
||
|
|
// cache key excludes userID/profileID — while non-personalized definitions of
|
||
|
|
// the same types stay cacheable.
|
||
|
|
func TestIsCacheableSectionTypePersonalizedFilter(t *testing.T) {
|
||
|
|
f := &Fetcher{}
|
||
|
|
nonPersonalized := mustJSON(t, catalog.QueryDefinition{
|
||
|
|
Match: "all",
|
||
|
|
Groups: []catalog.QueryGroup{
|
||
|
|
{Match: "all", Rules: []catalog.QueryRule{
|
||
|
|
{Field: "genre", Op: "contains", Value: "Action"},
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
personalizedRule := mustJSON(t, catalog.QueryDefinition{
|
||
|
|
Match: "all",
|
||
|
|
Groups: []catalog.QueryGroup{
|
||
|
|
{Match: "all", Rules: []catalog.QueryRule{
|
||
|
|
{Field: "genre", Op: "contains", Value: "Action"},
|
||
|
|
{Field: "in_watchlist", Op: "is", Value: true},
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
personalizedSort := mustJSON(t, catalog.QueryDefinition{
|
||
|
|
Match: "all",
|
||
|
|
Groups: []catalog.QueryGroup{
|
||
|
|
{Match: "all", Rules: []catalog.QueryRule{
|
||
|
|
{Field: "watched", Op: "is", Value: false},
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Sort: catalog.QuerySort{Field: "progress", Order: "desc"},
|
||
|
|
})
|
||
|
|
|
||
|
|
for _, st := range []SectionType{SectionCustomFilter, SectionGenre} {
|
||
|
|
if !f.isCacheableSectionType(ResolvedSection{SectionType: st, Config: nonPersonalized}) {
|
||
|
|
t.Errorf("%s with a non-personalized definition should be cacheable", st)
|
||
|
|
}
|
||
|
|
if f.isCacheableSectionType(ResolvedSection{SectionType: st, Config: personalizedRule}) {
|
||
|
|
t.Errorf("%s with a personalized rule (in_watchlist) must NOT be cacheable", st)
|
||
|
|
}
|
||
|
|
if f.isCacheableSectionType(ResolvedSection{SectionType: st, Config: personalizedSort}) {
|
||
|
|
t.Errorf("%s with a personalized sort/rule (watched/progress) must NOT be cacheable", st)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheCollectionUserExclusion verifies library collections are
|
||
|
|
// cacheable while user (profile-scoped) collections are not.
|
||
|
|
func TestResolvedListCacheCollectionUserExclusion(t *testing.T) {
|
||
|
|
f := &Fetcher{}
|
||
|
|
libraryCollection := ResolvedSection{
|
||
|
|
SectionType: SectionCollection,
|
||
|
|
Config: mustJSON(t, SectionCollectionConfig{LibraryCollectionID: "lib-coll-1"}),
|
||
|
|
}
|
||
|
|
if !f.isCacheableSectionType(libraryCollection) {
|
||
|
|
t.Fatalf("library collection should be cacheable")
|
||
|
|
}
|
||
|
|
|
||
|
|
userCollection := ResolvedSection{
|
||
|
|
SectionType: SectionCollection,
|
||
|
|
Config: mustJSON(t, SectionCollectionConfig{UserCollectionID: "user-coll-1"}),
|
||
|
|
}
|
||
|
|
if f.isCacheableSectionType(userCollection) {
|
||
|
|
t.Fatalf("user collection must NOT be cacheable (profile-scoped)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheRefreshAhead verifies an entry past refreshAfter but
|
||
|
|
// before expiry serves the current value without blocking on a slow loader, and
|
||
|
|
// eventually swaps in the refreshed value.
|
||
|
|
func TestResolvedListCacheRefreshAhead(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
key := "refresh-ahead"
|
||
|
|
base := time.Unix(1_700_000_000, 0)
|
||
|
|
|
||
|
|
// Prime a cold entry at base.
|
||
|
|
if _, _, err := getOrRefresh(context.Background(), key, base, staticLoader(mediaItems("old"), nil)); err != nil {
|
||
|
|
t.Fatalf("priming entry: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Slow refresh loader: blocks until released, then returns the fresh list
|
||
|
|
// and signals completion.
|
||
|
|
release := make(chan struct{})
|
||
|
|
loaderReturned := make(chan struct{})
|
||
|
|
slowLoader := func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
<-release
|
||
|
|
close(loaderReturned)
|
||
|
|
return mediaItems("new"), 1, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Now is past refreshAfter (base+12m) but before expiry (base+15m): serve
|
||
|
|
// stale and trigger the async rebuild. This call must not block on the slow
|
||
|
|
// loader.
|
||
|
|
within := base.Add(13 * time.Minute)
|
||
|
|
done := make(chan struct{})
|
||
|
|
var served []*models.MediaItem
|
||
|
|
go func() {
|
||
|
|
items, _, err := getOrRefresh(context.Background(), key, within, slowLoader)
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("refresh-ahead read: %v", err)
|
||
|
|
}
|
||
|
|
served = items
|
||
|
|
close(done)
|
||
|
|
}()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case <-done:
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatalf("refresh-ahead read blocked on the slow loader")
|
||
|
|
}
|
||
|
|
if got := itemIDs(served); len(got) != 1 || got[0] != "old" {
|
||
|
|
t.Fatalf("refresh-ahead served %v, want [old] (the cached value)", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Release the background rebuild and wait for it to finish.
|
||
|
|
close(release)
|
||
|
|
select {
|
||
|
|
case <-loaderReturned:
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatalf("background rebuild loader never completed")
|
||
|
|
}
|
||
|
|
|
||
|
|
// The refreshed value swaps in. Poll (the set happens just after the loader
|
||
|
|
// returns) using the same within-window clock so the entry is served fresh.
|
||
|
|
if !waitFor(2*time.Second, func() bool {
|
||
|
|
items, _, err := getOrRefresh(context.Background(), key, within, func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
return mediaItems("unexpected"), 1, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
ids := itemIDs(items)
|
||
|
|
return len(ids) == 1 && ids[0] == "new"
|
||
|
|
}) {
|
||
|
|
t.Fatalf("refreshed value never swapped in")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolvedListCacheStampede verifies N concurrent cold requests for the
|
||
|
|
// same key invoke the loader exactly once.
|
||
|
|
func TestResolvedListCacheStampede(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
key := "stampede"
|
||
|
|
now := time.Unix(1_700_000_000, 0)
|
||
|
|
|
||
|
|
const n = 50
|
||
|
|
var calls int64
|
||
|
|
release := make(chan struct{})
|
||
|
|
loader := func(context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
atomic.AddInt64(&calls, 1)
|
||
|
|
// Block until every goroutine has entered singleflight so the collapse
|
||
|
|
// window is guaranteed to overlap.
|
||
|
|
<-release
|
||
|
|
return mediaItems("shared"), 1, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
var started sync.WaitGroup
|
||
|
|
var finished sync.WaitGroup
|
||
|
|
started.Add(n)
|
||
|
|
finished.Add(n)
|
||
|
|
for i := 0; i < n; i++ {
|
||
|
|
go func() {
|
||
|
|
defer finished.Done()
|
||
|
|
started.Done()
|
||
|
|
items, _, err := getOrRefresh(context.Background(), key, now, loader)
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("stampede read: %v", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if ids := itemIDs(items); len(ids) != 1 || ids[0] != "shared" {
|
||
|
|
t.Errorf("stampede read returned %v, want [shared]", ids)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Give every goroutine time to reach the singleflight flight before letting
|
||
|
|
// the single in-flight loader complete.
|
||
|
|
started.Wait()
|
||
|
|
time.Sleep(50 * time.Millisecond)
|
||
|
|
close(release)
|
||
|
|
finished.Wait()
|
||
|
|
|
||
|
|
if got := atomic.LoadInt64(&calls); got != 1 {
|
||
|
|
t.Fatalf("loader invoked %d times, want exactly 1", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||
|
|
t.Helper()
|
||
|
|
b, err := json.Marshal(v)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("marshaling config: %v", err)
|
||
|
|
}
|
||
|
|
return b
|
||
|
|
}
|
||
|
|
|
||
|
|
// waitFor polls cond until it returns true or timeout elapses. Used instead of
|
||
|
|
// a fixed sleep to observe the async rebuild swap deterministically.
|
||
|
|
func waitFor(timeout time.Duration, cond func() bool) bool {
|
||
|
|
deadline := time.Now().Add(timeout)
|
||
|
|
for time.Now().Before(deadline) {
|
||
|
|
if cond() {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
time.Sleep(time.Millisecond)
|
||
|
|
}
|
||
|
|
return cond()
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestBlockingRebuildDetachedFromLeaderCancellation verifies a cold-miss
|
||
|
|
// blocking rebuild survives the initiating request's cancellation: singleflight
|
||
|
|
// shares one build across every collapsed waiter, so the leader's client
|
||
|
|
// disconnecting must not fail all the other requests riding on the flight.
|
||
|
|
func TestBlockingRebuildDetachedFromLeaderCancellation(t *testing.T) {
|
||
|
|
resetResolvedListCacheForTest()
|
||
|
|
defer resetResolvedListCacheForTest()
|
||
|
|
|
||
|
|
// A context canceled BEFORE the build starts: without detachment the loader
|
||
|
|
// (which honors ctx like the real fetchers do) would fail immediately.
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
cancel()
|
||
|
|
|
||
|
|
loader := func(loadCtx context.Context) ([]*models.MediaItem, int, error) {
|
||
|
|
if err := loadCtx.Err(); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
return mediaItems("x1"), 1, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Unix(1_700_000_000, 0)
|
||
|
|
items, total, err := getOrRefresh(ctx, "detach-key", now, loader)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("blocking rebuild must run detached from the leader's cancellation, got %v", err)
|
||
|
|
}
|
||
|
|
if total != 1 || len(items) != 1 || items[0].ContentID != "x1" {
|
||
|
|
t.Fatalf("unexpected result: items=%v total=%d", itemIDs(items), total)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The successful detached build must have been cached for later requests.
|
||
|
|
if _, ok := resolvedListGet("detach-key"); !ok {
|
||
|
|
t.Fatal("detached rebuild did not cache its result")
|
||
|
|
}
|
||
|
|
}
|