arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/artwork/tvmaze_test.go
jellyfin-year-review/internal/artwork/tvmaze_test.goRaw
  1package artwork
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"image"
  8	"image/png"
  9	"net/http"
 10	"net/http/httptest"
 11	"reflect"
 12	"strings"
 13	"testing"
 14	"time"
 15
 16	"reelreview/internal/model"
 17)
 18
 19type tvmazeTransport func(*http.Request) (*http.Response, error)
 20
 21func (f tvmazeTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
 22
 23// Keep synthetic tests on a private transport while validating real API hostnames.
 24func fakeTVmaze(t *testing.T, c *Client, handler http.HandlerFunc) {
 25	t.Helper()
 26	c.tvmaze.wait = func(context.Context, time.Duration) error { return nil }
 27	c.tvmaze.http.Transport = tvmazeTransport(func(r *http.Request) (*http.Response, error) {
 28		if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || strings.Contains(r.URL.String(), c.Key) {
 29			t.Error("private credentials reached TVmaze")
 30		}
 31		if r.Header.Get("User-Agent") != "Jellyfin-Year-Review/1.0" {
 32			t.Error("missing application identity")
 33		}
 34		w := httptest.NewRecorder()
 35		handler(w, r)
 36		return w.Result(), nil
 37	})
 38}
 39
 40func TestTVmazeFallbackProviderLookupCacheAndJellyfinPriority(t *testing.T) {
 41	jellyfinRecovered := false
 42	jellyfinImage := picture(t, 25)
 43	api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 44		if jellyfinRecovered {
 45			w.Write(jellyfinImage)
 46		} else {
 47			w.WriteHeader(404)
 48		}
 49	}))
 50	defer api.Close()
 51	c := New(jellyfinClient(t, api, ""), "secret-jellyfin-key", t.TempDir())
 52	calls := []string{}
 53	fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) {
 54		calls = append(calls, r.URL.Host+r.URL.Path)
 55		switch r.URL.Path {
 56		case "/lookup/shows":
 57			if r.URL.Query().Get("thetvdb") != "123" {
 58				t.Error("missing Jellyfin provider ID")
 59			}
 60			w.Header().Set("Location", "https://api.tvmaze.com/shows/42")
 61			w.WriteHeader(302)
 62		case "/shows/42":
 63			w.Write([]byte(`{"id":42,"name":"Frieren","image":{"medium":"https://static.tvmaze.com/poster.png"}}`))
 64		case "/poster.png":
 65			png.Encode(w, image.NewRGBA(image.Rect(0, 0, 2, 3)))
 66		default:
 67			t.Error("unexpected TVmaze request")
 68			w.WriteHeader(404)
 69		}
 70	})
 71	items := []model.Item{{ID: seriesID, Title: "Frieren", Type: "Series", ProviderIDs: map[string]string{"Tvdb": "123"}}, {ID: movieID, Title: "Frieren", Type: "Movie"}}
 72	reviews := []model.Review{{Shows: []model.Content{{ID: seriesID, Title: "Frieren"}}, Movies: []model.Content{{ID: movieID, Title: "Frieren"}}}}
 73	assets, warnings := c.Resolve(context.Background(), items, reviews)
 74	show := reviews[0].Shows[0]
 75	if len(calls) != 3 || len(assets) != 1 || len(warnings) != 1 || show.PosterSource != "https://www.tvmaze.com/shows/42" || !strings.HasPrefix(show.Poster, "/art/") || reviews[0].Movies[0].Poster != "" {
 76		t.Fatalf("fallback not published correctly: calls=%d assets=%d warnings=%v", len(calls), len(assets), warnings)
 77	}
 78	for _, b := range assets {
 79		if !validJPEG(b) {
 80			t.Error("TVmaze PNG was not converted to a valid local JPEG")
 81		}
 82	}
 83	cached, _ := c.Resolve(context.Background(), items, reviews)
 84	if len(calls) != 3 || !reflect.DeepEqual(assets, cached) {
 85		t.Fatal("successful fallback was not cached")
 86	}
 87	jellyfinRecovered = true
 88	_, warnings = c.Resolve(context.Background(), items, reviews)
 89	if len(calls) != 3 || len(warnings) != 0 || reviews[0].Shows[0].PosterSource != "" || reviews[0].Shows[0].Poster != "/art/"+digest(jellyfinImage)+".jpg" {
 90		t.Fatal("Jellyfin did not take priority over cached fallback")
 91	}
 92}
 93
 94func TestTVmazeTitleAliasAndYearMatching(t *testing.T) {
 95	cases := []struct {
 96		name, title, results, aliases string
 97		year, want                    int
 98	}{
 99		{"title", "A Show!", `[{"show":{"id":1,"name":"a show","premiered":"2023-01-01","image":{"medium":"https://static.tvmaze.com/1.jpg"}}}]`, `[]`, 2023, 1},
100		{"alias", "Frieren: Beyond Journey's End", `[{"show":{"id":2,"name":"Sousou no Frieren","premiered":"2023-09-29","image":{"medium":"https://static.tvmaze.com/2.jpg"}}}]`, `[{"name":"Frieren: Beyond Journey's End"}]`, 2023, 2},
101		{"wrong year", "A Show", `[{"show":{"id":1,"name":"A Show","premiered":"2000-01-01","image":{"medium":"https://static.tvmaze.com/1.jpg"}}}]`, `[]`, 2023, 0},
102		{"ambiguous", "A Show", `[{"show":{"id":1,"name":"A Show","image":{}}},{"show":{"id":2,"name":"A Show","image":{}}}]`, `[]`, 0, 0},
103		{"no image", "A Show", `[{"show":{"id":1,"name":"A Show","image":null}}]`, `[]`, 0, 0},
104		{"no match", "A Show", `[]`, `[]`, 0, 0},
105		{"fuzzy mismatch", "A Show", `[{"show":{"id":1,"name":"A Different Show","image":{}}}]`, `[{"name":"Another Show"}]`, 0, 0},
106	}
107	for _, tc := range cases {
108		t.Run(tc.name, func(t *testing.T) {
109			c := New(nil, "private-key", t.TempDir())
110			fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) {
111				if r.URL.Path == "/search/shows" {
112					if r.URL.Query().Get("q") != tc.title {
113						t.Error("incorrect title search")
114					}
115					w.Write([]byte(tc.results))
116				} else {
117					w.Write([]byte(tc.aliases))
118				}
119			})
120			show, e := c.tvmaze.match(context.Background(), model.Item{Title: tc.title, Year: tc.year})
121			if tc.want == 0 && e == nil || tc.want != 0 && (e != nil || show.ID != tc.want) {
122				t.Fatalf("unexpected match: id=%d error=%v", show.ID, e)
123			}
124		})
125	}
126}
127
128func TestTVmazeRejectsUnsafeImagesAndInvalidResponses(t *testing.T) {
129	for _, target := range []string{"https://private.invalid/secret", "http://static.tvmaze.com/image", "https://static.tvmaze.com/image"} {
130		t.Run(target, func(t *testing.T) {
131			c := New(nil, "private-key", t.TempDir())
132			fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) {
133				if r.URL.Path == "/search/shows" {
134					json.NewEncoder(w).Encode([]any{map[string]any{"show": map[string]any{"id": 1, "name": "Show", "image": map[string]string{"medium": target}}}})
135				} else if r.URL.Host == "static.tvmaze.com" {
136					w.Write([]byte("private upstream error"))
137				} else {
138					t.Error("unexpected request to foreign host")
139				}
140			})
141			_, _, e := c.fallback(context.Background(), model.Item{Title: "Show", Type: "Series"})
142			if e == nil || strings.Contains(e.Error(), "private upstream") || strings.Contains(e.Error(), "private-key") {
143				t.Fatal("unsafe or invalid fallback accepted")
144			}
145		})
146	}
147}
148
149func TestTVmazeRetryRedirectBoundsAndCancellation(t *testing.T) {
150	c := New(nil, "private-key", t.TempDir())
151	calls := 0
152	fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) {
153		calls++
154		w.Header().Set("Retry-After", "600")
155		w.WriteHeader(429)
156	})
157	_, e := c.tvmaze.get(context.Background(), "https://api.tvmaze.com/search/shows?q=Show", 1<<20)
158	if e == nil || calls != 3 {
159		t.Fatal("TVmaze retries were not bounded")
160	}
161	calls = 0
162	fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) {
163		calls++
164		w.Header().Set("Location", "https://private.invalid/secret")
165		w.WriteHeader(302)
166	})
167	_, e = c.tvmaze.get(context.Background(), "https://api.tvmaze.com/lookup/shows?thetvdb=1", 1<<20)
168	if e == nil || calls != 1 {
169		t.Fatal("unsafe redirect followed")
170	}
171	_, e = c.tvmaze.get(canceledContext(), "https://api.tvmaze.com/search/shows?q=Show", 1<<20)
172	if e == nil || calls != 1 {
173		t.Fatal("canceled request reached TVmaze")
174	}
175	fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) { w.Write(bytes.Repeat([]byte("x"), 20)) })
176	if _, e := c.tvmaze.get(context.Background(), "https://api.tvmaze.com/search/shows?q=Show", 10); e == nil {
177		t.Fatal("oversized response accepted")
178	}
179}
180
181func TestTVmazeFailuresKeepPlaceholderAndAreNotCached(t *testing.T) {
182	api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) }))
183	defer api.Close()
184	c := New(jellyfinClient(t, api, ""), "private-key", t.TempDir())
185	available := false
186	searches := 0
187	fakeTVmaze(t, c, func(w http.ResponseWriter, r *http.Request) {
188		if r.URL.Path == "/search/shows" {
189			searches++
190			if available {
191				w.Write([]byte(`[{"show":{"id":42,"name":"Show","image":{"medium":"https://static.tvmaze.com/image.jpg"}}}]`))
192			} else {
193				w.Write([]byte(`[]`))
194			}
195		} else {
196			w.Write(picture(t, 10))
197		}
198	})
199	items := []model.Item{{ID: seriesID, Title: "Show", Type: "Series"}}
200	reviews := []model.Review{{Shows: []model.Content{{ID: seriesID, Title: "Show", Poster: "https://untrusted.invalid", PosterSource: "https://untrusted.invalid"}}}}
201	assets, warnings := c.Resolve(context.Background(), items, reviews)
202	if len(assets) != 0 || len(warnings) != 1 || reviews[0].Shows[0].Poster != "" || reviews[0].Shows[0].PosterSource != "" {
203		t.Fatal("failed fallback did not clear unsafe references")
204	}
205	available = true
206	assets, warnings = c.Resolve(context.Background(), items, reviews)
207	if searches != 2 || len(assets) != 1 || len(warnings) != 0 || reviews[0].Shows[0].PosterSource != "https://www.tvmaze.com/shows/42" {
208		t.Fatal("failed fallback was permanently cached")
209	}
210}