Files
silo-server/internal/jellycompat/handlers_items_test.go
T
Quick 578100f5ef fix(jellycompat): stabilize signed image tags across restarts
{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
2026-05-26 22:29:49 -04:00

369 lines
14 KiB
Go

package jellycompat
import (
"context"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/config"
)
// countingContentService is a ContentService double that returns a fixed
// episode and series detail and counts calls to GetItemDetail. Other methods
// panic so tests catch unexpected use.
type countingContentService struct {
episodeDetail *upstreamItemDetail
seriesDetail *upstreamItemDetail
getItemDetailCalls int
}
func (s *countingContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) {
s.getItemDetailCalls++
if s.episodeDetail != nil && contentID == s.episodeDetail.ContentID {
d := *s.episodeDetail
return &d, nil
}
if s.seriesDetail != nil && contentID == s.seriesDetail.ContentID {
d := *s.seriesDetail
return &d, nil
}
// Fallback: synthesize a minimal detail to keep tests deterministic if a
// content ID is requested that we did not pre-stage. Tests should still
// observe getItemDetailCalls to detect unexpected lookups.
return &upstreamItemDetail{ContentID: contentID}, nil
}
func (s *countingContentService) ListUserLibraries(context.Context, *Session) ([]upstreamUserLibrary, error) {
panic("unused")
}
func (s *countingContentService) BrowseItems(context.Context, *Session, url.Values) (*upstreamBrowseResponse, error) {
panic("unused")
}
func (s *countingContentService) SearchItems(context.Context, *Session, string, []string, int, int, *int) (*upstreamBrowseResponse, error) {
panic("unused")
}
func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
panic("unused")
}
func (s *countingContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
panic("unused")
}
func (s *countingContentService) ListEpisodes(context.Context, *Session, string, int, *int) ([]upstreamEpisode, error) {
panic("unused")
}
func (s *countingContentService) ListEpisodesBySeasonID(context.Context, *Session, string, *int) ([]upstreamEpisode, error) {
panic("unused")
}
func (s *countingContentService) ListItemFilters(context.Context, *Session, url.Values) (*upstreamItemFiltersResponse, error) {
panic("unused")
}
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
// that episode detail responses fetch parent series image metadata even when
// image URLs are already cached. Cached URLs are not enough to build stable
// signed tags after Jellycompat restarts.
func TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags(t *testing.T) {
codec := NewResourceIDCodec()
episodeContentID := "ep1"
seriesContentID := "series-1"
encodedEpisodeID := codec.EncodeStringID(EncodedIDItem, episodeContentID)
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
contentSvc := &countingContentService{
episodeDetail: &upstreamItemDetail{
ContentID: episodeContentID,
Type: "episode",
Title: "Test Episode",
SeriesID: seriesContentID,
},
seriesDetail: &upstreamItemDetail{
ContentID: seriesContentID,
Type: "series",
Title: "Test Series",
PosterURL: "/img/series-1-poster.jpg",
BackdropURL: "/img/series-1-backdrop.jpg",
},
}
imageCache := NewImageCache(time.Hour, time.Now)
// Pre-populate the cache as a list/browse response would have.
imageCache.RememberSized(encodedSeriesID, "Primary", "/img/series-1-poster.jpg", compatCardImageSize)
imageCache.RememberSized(encodedSeriesID, "Backdrop", "/img/series-1-backdrop.jpg", compatCardImageSize)
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: imageCache,
}
req := httptest.NewRequest("GET", "/Items/"+encodedEpisodeID, nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedEpisodeID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleItem(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.getItemDetailCalls != 2 {
t.Errorf("expected episode and series GetItemDetail calls for stable parent image tags; got %d",
contentSvc.getItemDetailCalls)
}
}
// TestHandleItem_Episode_FallsBackToSeriesDetailOnCacheMiss verifies that when
// the ImageCache has no entry for the parent series, the handler still falls
// back to fetching the series detail.
func TestHandleItem_Episode_FallsBackToSeriesDetailOnCacheMiss(t *testing.T) {
codec := NewResourceIDCodec()
episodeContentID := "ep1"
seriesContentID := "series-1"
encodedEpisodeID := codec.EncodeStringID(EncodedIDItem, episodeContentID)
contentSvc := &countingContentService{
episodeDetail: &upstreamItemDetail{
ContentID: episodeContentID,
Type: "episode",
Title: "Test Episode",
SeriesID: seriesContentID,
},
seriesDetail: &upstreamItemDetail{
ContentID: seriesContentID,
Type: "series",
Title: "Test Series",
PosterURL: "/img/series-1-poster.jpg",
BackdropURL: "/img/series-1-backdrop.jpg",
},
}
// Empty cache — both Primary and Backdrop will miss.
imageCache := NewImageCache(time.Hour, time.Now)
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: imageCache,
}
req := httptest.NewRequest("GET", "/Items/"+encodedEpisodeID, nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedEpisodeID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleItem(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.getItemDetailCalls != 2 {
t.Errorf("cache miss should fall back to series detail fetch; got %d GetItemDetail calls",
contentSvc.getItemDetailCalls)
}
}
// TestHandleItem_Episode_PartialCacheFallsBackToSeriesDetail pins the partial-
// hit fallback: when only one of Primary/Backdrop is cached, the handler must
// fall back to the series-detail fetch so the response carries both URLs
// rather than silently degrading with an empty backdrop tag.
func TestHandleItem_Episode_PartialCacheFallsBackToSeriesDetail(t *testing.T) {
codec := NewResourceIDCodec()
episodeContentID := "ep1"
seriesContentID := "series-1"
encodedEpisodeID := codec.EncodeStringID(EncodedIDItem, episodeContentID)
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
contentSvc := &countingContentService{
episodeDetail: &upstreamItemDetail{
ContentID: episodeContentID,
Type: "episode",
Title: "Test Episode",
SeriesID: seriesContentID,
},
seriesDetail: &upstreamItemDetail{
ContentID: seriesContentID,
Type: "series",
Title: "Test Series",
PosterURL: "/img/series-1-poster.jpg",
BackdropURL: "/img/series-1-backdrop.jpg",
},
}
imageCache := NewImageCache(time.Hour, time.Now)
// Only Primary cached; Backdrop missing — partial hit must fall back.
imageCache.RememberSized(encodedSeriesID, "Primary", "/img/series-1-poster.jpg", compatCardImageSize)
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: imageCache,
}
req := httptest.NewRequest("GET", "/Items/"+encodedEpisodeID, nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", encodedEpisodeID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleItem(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.getItemDetailCalls != 2 {
t.Errorf("partial cache hit (only Primary) must fall back to series detail; got %d GetItemDetail calls",
contentSvc.getItemDetailCalls)
}
}
// TestRememberDetailImages_SeedsMediumWithoutTouchingSmall asserts that detail
// poster/backdrop/logo land in the "medium" route bucket (matching catalog's
// w500/w1920 featured sizing for size="") and do NOT pollute the "small"
// bucket seeded by list paths.
func TestRememberDetailImages_SeedsMediumWithoutTouchingSmall(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-42"
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
imageCache := NewImageCache(time.Hour, time.Now)
listSmallURL := "https://image.tmdb.org/t/p/original/list-small.jpg"
imageCache.RememberSized(encodedID, "Primary", listSmallURL, compatCardImageSize)
h := &ItemsHandler{codec: codec, images: imageCache}
detail := upstreamItemDetail{
ContentID: contentID,
Type: "movie",
PosterURL: "https://s3.example/poster/w500.jpg",
BackdropURL: "https://s3.example/backdrop/w1920.jpg",
LogoURL: "https://s3.example/logo/w500.jpg",
}
h.rememberDetailImages(detail)
if got, ok := imageCache.LookupSized(encodedID, "Primary", "", compatCardImageSize); !ok || got != listSmallURL {
t.Fatalf("small Primary bucket = (%q, %v), want list-seeded URL untouched", got, ok)
}
if got, ok := imageCache.LookupSized(encodedID, "Primary", "", "medium"); !ok || got != detail.PosterURL {
t.Fatalf("medium Primary bucket = (%q, %v), want %q", got, ok, detail.PosterURL)
}
if got, ok := imageCache.LookupSized(encodedID, "Backdrop", "", "medium"); !ok || got != detail.BackdropURL {
t.Fatalf("medium Backdrop bucket = (%q, %v), want %q", got, ok, detail.BackdropURL)
}
if got, ok := imageCache.LookupSized(encodedID, "Logo", "", "medium"); !ok || got != detail.LogoURL {
t.Fatalf("medium Logo bucket = (%q, %v), want %q", got, ok, detail.LogoURL)
}
if _, ok := imageCache.LookupSized(encodedID, "Backdrop", "", "original"); ok {
t.Fatal("original Backdrop bucket must NOT be seeded by detail (would shadow real original asset)")
}
}
// TestRememberDetailImages_SeasonRouteIDUsesEncodedIDSeason asserts that for
// detail.Type=="season" the route bucket is keyed under EncodedIDSeason so
// no-tag image lookups for seasons hit the cache.
func TestRememberDetailImages_SeasonRouteIDUsesEncodedIDSeason(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "season-7"
encodedItemID := codec.EncodeStringID(EncodedIDItem, contentID)
encodedSeasonID := codec.EncodeStringID(EncodedIDSeason, contentID)
imageCache := NewImageCache(time.Hour, time.Now)
h := &ItemsHandler{codec: codec, images: imageCache}
detail := upstreamItemDetail{
ContentID: contentID,
Type: "Season",
PosterURL: "https://s3.example/season-poster/w500.jpg",
}
h.rememberDetailImages(detail)
if got, ok := imageCache.LookupSized(encodedSeasonID, "Primary", "", "medium"); !ok || got != detail.PosterURL {
t.Fatalf("season route lookup via EncodedIDSeason = (%q, %v), want %q", got, ok, detail.PosterURL)
}
// Item-encoded route is also seeded so callers that reach seasons through
// the item route (e.g. some cross-referenced lookups) still resolve.
if got, ok := imageCache.LookupSized(encodedItemID, "Primary", "", "medium"); !ok || got != detail.PosterURL {
t.Fatalf("season route lookup via EncodedIDItem = (%q, %v), want %q", got, ok, detail.PosterURL)
}
}
// TestHandleUpcoming_Unauthorized confirms the endpoint requires a session.
func TestHandleUpcoming_Unauthorized(t *testing.T) {
h := &ItemsHandler{codec: NewResourceIDCodec()}
req := httptest.NewRequest("GET", "/Shows/Upcoming", nil)
rec := httptest.NewRecorder()
h.HandleUpcoming(rec, req)
if rec.Code != 401 {
t.Fatalf("expected 401 without session; got %d, body=%s", rec.Code, rec.Body.String())
}
}
// TestHandleUpcoming_MissingSeriesId_ReturnsEmptyNot404 pins the central
// contract of this endpoint: when no SeriesId/ParentId is supplied we must
// return 200 with an empty result. Returning 404 here re-triggers the
// Android TV fallback to global /Shows/NextUp that this handler exists to
// suppress (see error-report-2026-05-08.md §11).
func TestHandleUpcoming_MissingSeriesId_ReturnsEmptyNot404(t *testing.T) {
codec := NewResourceIDCodec()
h := &ItemsHandler{codec: codec, mapper: newMapper(codec, &config.Config{})}
req := httptest.NewRequest("GET", "/Shows/Upcoming", nil)
ctx := context.WithValue(req.Context(), compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
rec := httptest.NewRecorder()
h.HandleUpcoming(rec, req.WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("expected 200 (not 404) when SeriesId missing; got %d, body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"Items":[]`) {
t.Fatalf("expected empty Items array; got body=%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"TotalRecordCount":0`) {
t.Fatalf("expected TotalRecordCount 0; got body=%s", rec.Body.String())
}
}
// TestHandleUpcoming_InvalidSeriesId_ReturnsEmptyNot404 — same contract for
// undecodable IDs. Decode failure must NOT 404 for the same reason.
func TestHandleUpcoming_InvalidSeriesId_ReturnsEmptyNot404(t *testing.T) {
codec := NewResourceIDCodec()
h := &ItemsHandler{codec: codec, mapper: newMapper(codec, &config.Config{})}
req := httptest.NewRequest("GET", "/Shows/Upcoming?SeriesId=not-a-valid-encoded-id", nil)
ctx := context.WithValue(req.Context(), compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
rec := httptest.NewRecorder()
h.HandleUpcoming(rec, req.WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("expected 200 (not 404) for undecodable SeriesId; got %d, body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"Items":[]`) {
t.Fatalf("expected empty Items array; got body=%s", rec.Body.String())
}
}