181 lines
5.7 KiB
Go
181 lines
5.7 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|