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