Files
silo-server/internal/jellycompat/batch_loaders_test.go
T

277 lines
9.0 KiB
Go

package jellycompat
import (
"context"
"errors"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type stubLibraryMembershipChecker struct {
membership map[string]bool
err error
}
func (s stubLibraryMembershipChecker) GetItemsInLibrary(context.Context, []string, int) (map[string]bool, error) {
if s.err != nil {
return nil, s.err
}
return s.membership, nil
}
// countingItemRepo is an in-memory itemRepoForBatchLoader fake. It records
// invocation counts so tests can assert that the compatPool() == nil fallback
// uses the batched GetByIDsWithAccess instead of a per-item EnsureAccessible
// loop (audit 2026-05-01 §3.3).
type countingItemRepo struct {
itemsByID map[string]*models.MediaItem
getByIDsCalls int
getByIDsWithAccessCalls int
getItemsInLibraryCalls int
libraryMembership map[int]map[string]bool
getByIDsWithAccessAccess catalog.AccessFilter
getByIDsWithAccessIDs []string
}
func (r *countingItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
r.getByIDsCalls++
out := make([]*models.MediaItem, 0, len(contentIDs))
for _, id := range contentIDs {
if it, ok := r.itemsByID[id]; ok {
out = append(out, it)
}
}
return out, nil
}
func (r *countingItemRepo) GetByIDsWithAccess(_ context.Context, contentIDs []string, access catalog.AccessFilter) ([]*models.MediaItem, error) {
r.getByIDsWithAccessCalls++
r.getByIDsWithAccessAccess = access
r.getByIDsWithAccessIDs = append([]string(nil), contentIDs...)
out := make([]*models.MediaItem, 0, len(contentIDs))
for _, id := range contentIDs {
if it, ok := r.itemsByID[id]; ok {
out = append(out, it)
}
}
return out, nil
}
func (r *countingItemRepo) GetItemsInLibrary(_ context.Context, contentIDs []string, libraryID int) (map[string]bool, error) {
r.getItemsInLibraryCalls++
result := make(map[string]bool, len(contentIDs))
allowed, ok := r.libraryMembership[libraryID]
if !ok {
return result, nil
}
for _, id := range contentIDs {
if allowed[id] {
result[id] = true
}
}
return result, nil
}
// countingEpisodeRepo is an in-memory episodeRepoForBatchLoader fake.
type countingEpisodeRepo struct {
episodesByID map[string]*models.Episode
getByIDsCalls int
}
func (r *countingEpisodeRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.Episode, error) {
r.getByIDsCalls++
out := make([]*models.Episode, 0, len(contentIDs))
for _, id := range contentIDs {
if ep, ok := r.episodesByID[id]; ok {
out = append(out, ep)
}
}
return out, nil
}
func (r *countingEpisodeRepo) ListBySeason(context.Context, string, int) ([]*models.Episode, error) {
return nil, errors.New("ListBySeason not used in fallback test")
}
func (r *countingEpisodeRepo) ListBySeries(context.Context, string) ([]*models.Episode, error) {
return nil, errors.New("ListBySeries not used in fallback test")
}
func TestFilterContentIDsForLibrary_AppliesMembershipAndPreservesOrder(t *testing.T) {
libraryID := 7
filtered, err := filterContentIDsForLibrary(
context.Background(),
stubLibraryMembershipChecker{membership: map[string]bool{"episode-2": true, "movie-1": true}},
[]string{"movie-1", "episode-2", "movie-1", "", "episode-3"},
&libraryID,
)
if err != nil {
t.Fatalf("filterContentIDsForLibrary returned error: %v", err)
}
want := []string{"movie-1", "episode-2"}
if !reflect.DeepEqual(filtered, want) {
t.Fatalf("filterContentIDsForLibrary = %v, want %v", filtered, want)
}
}
func TestFilterContentIDsForLibrary_PropagatesMembershipErrors(t *testing.T) {
libraryID := 7
wantErr := errors.New("boom")
_, err := filterContentIDsForLibrary(
context.Background(),
stubLibraryMembershipChecker{err: wantErr},
[]string{"movie-1"},
&libraryID,
)
if !errors.Is(err, wantErr) {
t.Fatalf("filterContentIDsForLibrary error = %v, want %v", err, wantErr)
}
}
// TestFetchCompatItemsByContentIDsFallback_UsesBatchedAccessQuery pins the
// audit fix: when compatPool() returns nil (e.g. browseRepo is unset in a
// DB-less test config), the fallback must push library/rating gating into
// itemRepo.GetByIDsWithAccess instead of fetching items then looping
// EnsureAccessible per item (audit 2026-05-01 §3.3, Pattern C).
func TestFetchCompatItemsByContentIDsFallback_UsesBatchedAccessQuery(t *testing.T) {
repo := &countingItemRepo{
itemsByID: map[string]*models.MediaItem{
"a": {ContentID: "a", Type: "movie", Title: "A"},
"b": {ContentID: "b", Type: "movie", Title: "B"},
},
}
h := &ItemsHandler{
itemRepo: repo,
// No accessFilter resolver: resolveAccessFilter returns a zero filter.
}
got, err := h.fetchCompatItemsByContentIDsFallback(
context.Background(),
&Session{},
[]string{"a", "b"},
nil,
)
if err != nil {
t.Fatalf("fetchCompatItemsByContentIDsFallback returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 items in result; got %d (%v)", len(got), got)
}
if repo.getByIDsWithAccessCalls != 1 {
t.Errorf("expected exactly 1 batched GetByIDsWithAccess call; got %d", repo.getByIDsWithAccessCalls)
}
if repo.getByIDsCalls != 0 {
t.Errorf("expected zero plain GetByIDs calls in the fallback; got %d", repo.getByIDsCalls)
}
if !reflect.DeepEqual(repo.getByIDsWithAccessIDs, []string{"a", "b"}) {
t.Errorf("expected GetByIDsWithAccess to receive both content IDs; got %v", repo.getByIDsWithAccessIDs)
}
}
// TestFetchCompatItemsByContentIDsFallback_NarrowsAccessToLibraryArg verifies
// that the libraryID argument is pushed into access.AllowedLibraryIDs so
// GetByIDsWithAccess can gate it in a single SQL statement instead of pre-
// filtering with GetItemsInLibrary then re-checking via EnsureAccessible.
func TestFetchCompatItemsByContentIDsFallback_NarrowsAccessToLibraryArg(t *testing.T) {
repo := &countingItemRepo{
itemsByID: map[string]*models.MediaItem{
"a": {ContentID: "a", Type: "movie", Title: "A"},
},
}
h := &ItemsHandler{itemRepo: repo}
libraryID := 7
if _, err := h.fetchCompatItemsByContentIDsFallback(
context.Background(),
&Session{},
[]string{"a"},
&libraryID,
); err != nil {
t.Fatalf("fetchCompatItemsByContentIDsFallback returned error: %v", err)
}
if repo.getByIDsWithAccessCalls != 1 {
t.Fatalf("expected exactly 1 GetByIDsWithAccess call; got %d", repo.getByIDsWithAccessCalls)
}
if !reflect.DeepEqual(repo.getByIDsWithAccessAccess.AllowedLibraryIDs, []int{libraryID}) {
t.Errorf("expected libraryID to be pushed into access.AllowedLibraryIDs; got %v", repo.getByIDsWithAccessAccess.AllowedLibraryIDs)
}
}
// TestFetchCompatItemsByContentIDsFallback_LibraryOutsideAllowlistShortCircuits
// confirms that when the caller-supplied libraryID is not in the access
// allowlist, the fallback returns an empty result without hitting the
// repository.
func TestFetchCompatItemsByContentIDsFallback_LibraryOutsideAllowlistShortCircuits(t *testing.T) {
repo := &countingItemRepo{itemsByID: map[string]*models.MediaItem{}}
h := &ItemsHandler{
itemRepo: repo,
accessFilter: func(context.Context, int, string) catalog.AccessFilter {
return catalog.AccessFilter{AllowedLibraryIDs: []int{1, 2}}
},
}
disallowed := 99
got, err := h.fetchCompatItemsByContentIDsFallback(
context.Background(),
&Session{},
[]string{"a"},
&disallowed,
)
if err != nil {
t.Fatalf("fetchCompatItemsByContentIDsFallback returned error: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty result when libraryID is outside the access allowlist; got %v", got)
}
if repo.getByIDsWithAccessCalls != 0 {
t.Errorf("expected zero GetByIDsWithAccess calls; got %d", repo.getByIDsWithAccessCalls)
}
}
// TestFetchCompatEpisodeTargetsByContentIDsFallback_UsesBatchedSeriesAccess
// pins the episode-fallback fix: series-level access checks must be batched
// through itemRepo.GetByIDsWithAccess instead of iterating EnsureAccessible
// per series (audit 2026-05-01 §3.3, Pattern C).
func TestFetchCompatEpisodeTargetsByContentIDsFallback_UsesBatchedSeriesAccess(t *testing.T) {
itemRepo := &countingItemRepo{
itemsByID: map[string]*models.MediaItem{
"series-1": {ContentID: "series-1", Type: "series", Title: "Show"},
},
}
episodeRepo := &countingEpisodeRepo{
episodesByID: map[string]*models.Episode{
"ep-1": {ContentID: "ep-1", SeriesID: "series-1", Title: "Pilot"},
"ep-2": {ContentID: "ep-2", SeriesID: "series-1", Title: "Two"},
},
}
h := &ItemsHandler{
itemRepo: itemRepo,
episodeRepo: episodeRepo,
}
got, err := h.fetchCompatEpisodeTargetsByContentIDsFallback(
context.Background(),
&Session{},
[]string{"ep-1", "ep-2"},
nil,
)
if err != nil {
t.Fatalf("fetchCompatEpisodeTargetsByContentIDsFallback returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 episodes in result; got %d (%v)", len(got), got)
}
if itemRepo.getByIDsWithAccessCalls != 1 {
t.Errorf("expected exactly 1 batched GetByIDsWithAccess for series access; got %d", itemRepo.getByIDsWithAccessCalls)
}
if itemRepo.getByIDsCalls != 0 {
t.Errorf("expected zero plain GetByIDs calls in the episode fallback; got %d", itemRepo.getByIDsCalls)
}
}