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}