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

246 lines
7.9 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) 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)
}
}