Files

181 lines
5.7 KiB
Go
Raw Permalink Normal View History

package catalog
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/cache"
)
func newTestGroups(n int) []AudiobookGroup {
groups := make([]AudiobookGroup, n)
for i := range groups {
groups[i] = AudiobookGroup{Name: "author-" + string(rune('a'+i)), ItemCount: i + 1}
}
return groups
}
// The grouped list is the expensive aggregation; paging through it (the client
// fetches every page on each load) must compute it once, not once per page.
func TestAudiobookGroupsCache_ComputesFullListOncePerKey(t *testing.T) {
full := newTestGroups(5)
var fetches int
c := &AudiobookGroupsCache{
cache: cache.NewTTLCache[*groupsCacheEntry](),
ttl: time.Minute,
fetch: func(context.Context, AudiobookGroupsQuery, AccessFilter) ([]AudiobookGroup, int, error) {
fetches++
return full, len(full), nil
},
}
defer c.Close()
q := AudiobookGroupsQuery{LibraryID: 7, GroupBy: AudiobookGroupByAuthor, Sort: "name"}
filter := AccessFilter{UserID: 1, ProfileID: "p1"}
page1, total, err := c.Page(context.Background(), AudiobookGroupsQuery{LibraryID: q.LibraryID, GroupBy: q.GroupBy, Sort: q.Sort, Limit: 2, Offset: 0}, filter)
if err != nil {
t.Fatalf("page1: %v", err)
}
page2, _, err := c.Page(context.Background(), AudiobookGroupsQuery{LibraryID: q.LibraryID, GroupBy: q.GroupBy, Sort: q.Sort, Limit: 2, Offset: 2}, filter)
if err != nil {
t.Fatalf("page2: %v", err)
}
page3, _, err := c.Page(context.Background(), AudiobookGroupsQuery{LibraryID: q.LibraryID, GroupBy: q.GroupBy, Sort: q.Sort, Limit: 2, Offset: 4}, filter)
if err != nil {
t.Fatalf("page3: %v", err)
}
if fetches != 1 {
t.Fatalf("full list computed %d times across 3 pages of one key; want 1", fetches)
}
if total != 5 {
t.Fatalf("total = %d, want 5", total)
}
if len(page1) != 2 || len(page2) != 2 || len(page3) != 1 {
t.Fatalf("page sizes = %d,%d,%d; want 2,2,1", len(page1), len(page2), len(page3))
}
if page1[0].Name != "author-a" || page2[0].Name != "author-c" || page3[0].Name != "author-e" {
t.Fatalf("slice boundaries wrong: %q %q %q", page1[0].Name, page2[0].Name, page3[0].Name)
}
}
// A different viewer must not be served another profile's cached counts.
func TestAudiobookGroupsCache_KeyedByViewer(t *testing.T) {
var fetches int
c := &AudiobookGroupsCache{
cache: cache.NewTTLCache[*groupsCacheEntry](),
ttl: time.Minute,
fetch: func(context.Context, AudiobookGroupsQuery, AccessFilter) ([]AudiobookGroup, int, error) {
fetches++
return newTestGroups(3), 3, nil
},
}
defer c.Close()
q := AudiobookGroupsQuery{LibraryID: 7, GroupBy: AudiobookGroupByAuthor, Sort: "name", Limit: 10}
if _, _, err := c.Page(context.Background(), q, AccessFilter{UserID: 1, ProfileID: "p1"}); err != nil {
t.Fatalf("viewer1: %v", err)
}
if _, _, err := c.Page(context.Background(), q, AccessFilter{UserID: 2, ProfileID: "p2"}); err != nil {
t.Fatalf("viewer2: %v", err)
}
if fetches != 2 {
t.Fatalf("distinct viewers shared a cache entry: fetches=%d, want 2", fetches)
}
}
func TestAudiobookGroupsCache_DeduplicatesConcurrentMisses(t *testing.T) {
var fetches atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
c := &AudiobookGroupsCache{
cache: cache.NewTTLCache[*groupsCacheEntry](),
ttl: time.Minute,
fetch: func(context.Context, AudiobookGroupsQuery, AccessFilter) ([]AudiobookGroup, int, error) {
fetches.Add(1)
startedOnce.Do(func() { close(started) })
<-release
return newTestGroups(4), 4, nil
},
}
defer c.Close()
q := AudiobookGroupsQuery{LibraryID: 7, GroupBy: AudiobookGroupByAuthor, Sort: "name", Limit: 2}
filter := AccessFilter{UserID: 1, ProfileID: "p1"}
const callers = 8
ready := make(chan struct{}, callers)
begin := make(chan struct{})
errs := make(chan error, callers)
var wg sync.WaitGroup
wg.Add(callers)
for range callers {
go func() {
defer wg.Done()
ready <- struct{}{}
<-begin
groups, total, err := c.Page(context.Background(), q, filter)
if err != nil {
errs <- err
return
}
if total != 4 || len(groups) != 2 {
errs <- fmt.Errorf("page result = len %d total %d, want len 2 total 4", len(groups), total)
}
}()
}
for range callers {
<-ready
}
close(begin)
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("timed out waiting for cache fetch to start")
}
time.Sleep(50 * time.Millisecond)
close(release)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent page call failed: %v", err)
}
}
if got := fetches.Load(); got != 1 {
t.Fatalf("concurrent miss fetched %d times; want 1", got)
}
}
func TestAudiobookGroupsCache_KeyIncludesExcludedMediaTypes(t *testing.T) {
var fetches int
c := &AudiobookGroupsCache{
cache: cache.NewTTLCache[*groupsCacheEntry](),
ttl: time.Minute,
fetch: func(context.Context, AudiobookGroupsQuery, AccessFilter) ([]AudiobookGroup, int, error) {
fetches++
return newTestGroups(3), 3, nil
},
}
defer c.Close()
q := AudiobookGroupsQuery{LibraryID: 7, GroupBy: AudiobookGroupByAuthor, Sort: "name", Limit: 10}
if _, _, err := c.Page(context.Background(), q, AccessFilter{UserID: 1, ProfileID: "p1", ExcludedMediaTypes: []string{"podcast"}}); err != nil {
t.Fatalf("podcast excluded: %v", err)
}
if _, _, err := c.Page(context.Background(), q, AccessFilter{UserID: 1, ProfileID: "p1", ExcludedMediaTypes: []string{"audiobook"}}); err != nil {
t.Fatalf("audiobook excluded: %v", err)
}
if _, _, err := c.Page(context.Background(), q, AccessFilter{UserID: 1, ProfileID: "p1", ExcludedMediaTypes: []string{"audiobook"}}); err != nil {
t.Fatalf("audiobook excluded cached: %v", err)
}
if fetches != 2 {
t.Fatalf("excluded media type variants shared a cache entry: fetches=%d, want 2", fetches)
}
}