Files
silo-server/internal/jellycompat/batch_progress_test.go
T
b246f271cb fix(jellycompat): hide dismissed and superseded entries from Resume (#114)
The jellycompat /UserItems/Resume endpoint served the raw in_progress
list, so Jellyfin clients showed every half-watched episode a profile
ever abandoned, including stale entries from earlier seasons of shows
the viewer had long moved past. The first-party Continue Watching row
already hid those via dismissal filtering and the superseded-episode
check, but that logic was private to internal/sections.

Extract the shared rules into internal/catalog
(ContinueWatchingProgressFilter, HomeDismissalIndex) and apply them to
the compat Resume path through a new UserDataService method,
FilterResumeProgress. The sections fetcher now delegates to the same
code, so both surfaces agree on what "still watching" means.

Resume pagination keeps advancing by raw batch counts so filtering
cannot terminate scans early, and the raw-offset fast path is limited
to StartIndex=0 for Resume because filtered lists make raw offsets
diverge from the visible list. The watched-items view ("completed"
status) stays unfiltered, and the per-series collapse deliberately
stays first-party only to preserve Jellyfin endpoint semantics.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:01:36 -04:00

250 lines
8.1 KiB
Go

package jellycompat
import (
"context"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/config"
)
// mockUserDataService is a counting fake that satisfies UserDataService.
// It records calls to the per-item scalar accessors (IsFavorite, GetProgress)
// and the batch accessors (ListFavoritesByMediaItems, ListProgressByMediaItems)
// so tests can assert that handlers prefer the batch resolver over the
// scalar API. All other methods panic on unexpected use.
type mockUserDataService struct {
isFavoriteCalls int
getProgressCalls int
listFavoritesByMediaItemsCalls int
listProgressByMediaItemsCalls int
addFavoriteCalls int
removeFavoriteCalls int
}
func (m *mockUserDataService) ListFavoritesByMediaItems(_ context.Context, _ *Session, mediaItemIDs []string) (map[string]bool, error) {
m.listFavoritesByMediaItemsCalls++
out := make(map[string]bool, len(mediaItemIDs))
for _, id := range mediaItemIDs {
out[id] = false
}
return out, nil
}
func (m *mockUserDataService) ListProgressByMediaItems(_ context.Context, _ *Session, mediaItemIDs []string) (map[string]*upstreamProgress, error) {
m.listProgressByMediaItemsCalls++
return map[string]*upstreamProgress{}, nil
}
func (m *mockUserDataService) IsFavorite(_ context.Context, _ *Session, _ string) (bool, error) {
m.isFavoriteCalls++
return false, nil
}
func (m *mockUserDataService) GetProgress(_ context.Context, _ *Session, _ string) (*upstreamProgress, error) {
m.getProgressCalls++
return nil, nil
}
func (m *mockUserDataService) AddFavorite(_ context.Context, _ *Session, _ string) error {
m.addFavoriteCalls++
return nil
}
func (m *mockUserDataService) RemoveFavorite(_ context.Context, _ *Session, _ string) error {
m.removeFavoriteCalls++
return nil
}
// Remaining UserDataService methods panic — the test should not exercise them.
func (m *mockUserDataService) ListFavorites(context.Context, *Session, int, int) ([]upstreamListItem, error) {
panic("unused")
}
func (m *mockUserDataService) ListProgress(context.Context, *Session, string, int, int) ([]upstreamProgress, error) {
panic("unused")
}
func (m *mockUserDataService) FilterResumeProgress(_ context.Context, _ *Session, entries []upstreamProgress) ([]upstreamProgress, error) {
return entries, nil
}
func (m *mockUserDataService) MarkPlayed(context.Context, *Session, string) error {
panic("unused")
}
func (m *mockUserDataService) MarkPlayedBatch(context.Context, *Session, []string) error {
panic("unused")
}
func (m *mockUserDataService) MarkUnplayed(context.Context, *Session, string) error {
panic("unused")
}
func (m *mockUserDataService) MarkUnplayedBatch(context.Context, *Session, []string) error {
panic("unused")
}
// stubContentService is a minimal ContentService that returns a fixed
// upstreamItemDetail from GetItemDetail. Other methods panic.
type stubContentService struct {
detail *upstreamItemDetail
}
func (s *stubContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) {
d := *s.detail
if d.ContentID == "" {
d.ContentID = contentID
}
return &d, nil
}
func (s *stubContentService) ListUserLibraries(context.Context, *Session) ([]upstreamUserLibrary, error) {
panic("unused")
}
func (s *stubContentService) BrowseItems(context.Context, *Session, url.Values) (*upstreamBrowseResponse, error) {
panic("unused")
}
func (s *stubContentService) SearchItems(context.Context, *Session, string, []string, int, int, *int) (*upstreamBrowseResponse, error) {
panic("unused")
}
func (s *stubContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
panic("unused")
}
func (s *stubContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
panic("unused")
}
func (s *stubContentService) ListEpisodes(context.Context, *Session, string, int, *int) ([]upstreamEpisode, error) {
panic("unused")
}
func (s *stubContentService) ListEpisodesBySeasonID(context.Context, *Session, string, *int) ([]upstreamEpisode, error) {
panic("unused")
}
func (s *stubContentService) ListItemFilters(context.Context, *Session, url.Values) (*upstreamItemFiltersResponse, error) {
panic("unused")
}
func TestHandleGetUserData_UsesBatchResolveCall(t *testing.T) {
// HandleGetUserData should resolve favorite + progress in a single
// resolveUserStateForContentIDs call (one DB round-trip pair),
// NOT separate IsFavorite + GetProgress scalar calls.
codec := NewResourceIDCodec()
contentID := "movie-1"
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
mockUserData := &mockUserDataService{}
stubContent := &stubContentService{detail: &upstreamItemDetail{
ContentID: contentID,
Type: "movie",
Title: "Test Movie",
}}
h := &UserDataHandler{
content: stubContent,
userData: mockUserData,
codec: codec,
mapper: newMapper(codec, &config.Config{}),
}
req := httptest.NewRequest("GET", "/UserItems/"+encodedID+"/UserData", nil)
// Inject chi route param so handler can resolve "itemId".
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("itemId", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
// Inject an authenticated compat session.
session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
ctx = context.WithValue(ctx, compatSessionKey, session)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleGetUserData(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if mockUserData.isFavoriteCalls != 0 {
t.Errorf("expected zero IsFavorite calls; got %d", mockUserData.isFavoriteCalls)
}
if mockUserData.getProgressCalls != 0 {
t.Errorf("expected zero GetProgress calls; got %d", mockUserData.getProgressCalls)
}
if mockUserData.listFavoritesByMediaItemsCalls != 1 {
t.Errorf("expected one batch favorites call; got %d",
mockUserData.listFavoritesByMediaItemsCalls)
}
if mockUserData.listProgressByMediaItemsCalls != 1 {
t.Errorf("expected one batch progress call; got %d",
mockUserData.listProgressByMediaItemsCalls)
}
}
// TestHandleFavoriteMutation_UsesBatchResolveCall verifies that the favorite
// add/remove mutation handler also uses the batch resolver to fetch the
// updated user-data response, rather than scalar IsFavorite + GetProgress.
func TestHandleFavoriteMutation_UsesBatchResolveCall(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
mockUserData := &mockUserDataService{}
stubContent := &stubContentService{detail: &upstreamItemDetail{
ContentID: contentID,
Type: "movie",
Title: "Test Movie",
}}
h := &UserDataHandler{
content: stubContent,
userData: mockUserData,
codec: codec,
mapper: newMapper(codec, &config.Config{}),
}
req := httptest.NewRequest("POST", "/UserFavoriteItems/"+encodedID, nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("itemId", encodedID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
ctx = context.WithValue(ctx, compatSessionKey, session)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleAddFavorite(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if mockUserData.addFavoriteCalls != 1 {
t.Errorf("expected one AddFavorite call; got %d", mockUserData.addFavoriteCalls)
}
if mockUserData.isFavoriteCalls != 0 {
t.Errorf("expected zero IsFavorite calls; got %d", mockUserData.isFavoriteCalls)
}
if mockUserData.getProgressCalls != 0 {
t.Errorf("expected zero GetProgress calls; got %d", mockUserData.getProgressCalls)
}
if mockUserData.listFavoritesByMediaItemsCalls != 1 {
t.Errorf("expected one batch favorites call; got %d",
mockUserData.listFavoritesByMediaItemsCalls)
}
if mockUserData.listProgressByMediaItemsCalls != 1 {
t.Errorf("expected one batch progress call; got %d",
mockUserData.listProgressByMediaItemsCalls)
}
}