jellyfin-tools/jellyfin-year-review/internal/artwork/jellyfin_test.go
jellyfin-year-review/internal/artwork/jellyfin_test.goRaw
1package artwork
2
3import (
4 "bytes"
5 "context"
6 "image"
7 "image/color"
8 "image/jpeg"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "reflect"
14 "strings"
15 "testing"
16 "time"
17
18 "reelreview/internal/model"
19 "reelreview/internal/source"
20)
21
22const movieID = "11111111111111111111111111111111"
23const seriesID = "22222222222222222222222222222222"
24const unusedID = "33333333333333333333333333333333"
25
26func picture(t *testing.T, red uint8) []byte {
27 t.Helper()
28 var b bytes.Buffer
29 img := image.NewRGBA(image.Rect(0, 0, 2, 3))
30 for y := 0; y < 3; y++ {
31 for x := 0; x < 2; x++ {
32 img.Set(x, y, color.RGBA{R: red, A: 255})
33 }
34 }
35 if e := jpeg.Encode(&b, img, nil); e != nil {
36 t.Fatal(e)
37 }
38 return b.Bytes()
39}
40func jellyfinClient(t *testing.T, server *httptest.Server, base string) *source.Jellyfin {
41 t.Helper()
42 j, e := source.NewJellyfin(server.URL + base)
43 if e != nil {
44 t.Fatal(e)
45 }
46 return j
47}
48func TestJellyfinPostersCacheAndOnlyReviewedTitles(t *testing.T) {
49 imageBytes := picture(t, 180)
50 calls := 0
51 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 calls++
53 if !strings.Contains(r.Header.Get("Authorization"), `Token="operator-only-key"`) || !strings.Contains(r.Header.Get("Authorization"), `DeviceId="generator"`) {
54 t.Error("missing generator credential/identity")
55 }
56 if r.URL.Query().Get("format") != "Jpg" || r.URL.Query().Get("maxWidth") != "500" || r.URL.Query().Get("maxHeight") != "750" || strings.Contains(r.URL.RawQuery, "operator-only-key") {
57 t.Error("unsafe artwork query")
58 }
59 if r.URL.Path != "/jellyfin/Items/"+movieID+"/Images/Primary" && r.URL.Path != "/jellyfin/Items/"+seriesID+"/Images/Primary" {
60 t.Error("unnecessary or incorrect poster request")
61 }
62 w.Write(imageBytes)
63 }))
64 defer api.Close()
65 c := New(jellyfinClient(t, api, "/jellyfin"), "operator-only-key", t.TempDir())
66 items := []model.Item{{ID: movieID, Type: "Movie", ImageTag: "movie-v1"}, {ID: seriesID, Type: "Series", ImageTag: "show-v1"}, {ID: unusedID, Type: "Movie", ImageTag: "unwatched"}}
67 reviews := []model.Review{{Movies: []model.Content{{ID: movieID, Title: "Film", Poster: "https://untrusted.invalid/image"}}, Shows: []model.Content{{ID: seriesID, Title: "Show"}}, Abandoned: []model.Content{{ID: movieID}}}, {Movies: []model.Content{{ID: movieID}}}}
68 assets, warnings := c.Resolve(context.Background(), items, reviews)
69 want := "/art/" + digest(imageBytes) + ".jpg"
70 if calls != 2 || len(assets) != 1 || len(warnings) != 0 || reviews[0].Movies[0].Poster != want || reviews[0].Shows[0].Poster != want || reviews[0].Abandoned[0].Poster != want || reviews[1].Movies[0].Poster != want {
71 t.Fatalf("poster resolution/cache failed: %d calls, %d assets, %v", calls, len(assets), warnings)
72 }
73 api.Close()
74 // A fixed-tag cached image remains available when the source is unreachable.
75 cached, warnings := c.Resolve(context.Background(), items, reviews)
76 if !reflect.DeepEqual(cached, assets) || len(warnings) != 0 {
77 t.Fatal("successful posters were not cached")
78 }
79}
80func TestImageTagChangesUntaggedRefreshAndCorruptCache(t *testing.T) {
81 calls := 0
82 red := uint8(0)
83 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; w.Write(picture(t, red)) }))
84 defer api.Close()
85 cache := t.TempDir()
86 c := New(jellyfinClient(t, api, ""), "key", cache)
87 first, e := c.fetch(context.Background(), movieID, "v1")
88 if e != nil {
89 t.Fatal(e)
90 }
91 red = 255
92 second, e := c.fetch(context.Background(), movieID, "v2")
93 if e != nil || bytes.Equal(first, second) || calls != 2 {
94 t.Fatal("changed image tag retained old artwork")
95 }
96 for i := 0; i < 2; i++ {
97 if _, e = c.fetch(context.Background(), movieID, ""); e != nil {
98 t.Fatal(e)
99 }
100 }
101 if calls != 4 {
102 t.Fatal("untagged image was cached indefinitely")
103 }
104 files, e := os.ReadDir(cache)
105 if e != nil {
106 t.Fatal(e)
107 }
108 for _, f := range files {
109 if e = os.WriteFile(filepath.Join(cache, f.Name()), []byte("corrupt"), 0600); e != nil {
110 t.Fatal(e)
111 }
112 }
113 if _, e = c.fetch(context.Background(), movieID, "v2"); e != nil || calls != 5 {
114 t.Fatal("corrupt cache was not recovered")
115 }
116}
117func TestMissingInvalidAndUnconfiguredPosters(t *testing.T) {
118 for _, status := range []int{404, 401, 200} {
119 t.Run(http.StatusText(status), func(t *testing.T) {
120 calls := 0
121 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122 calls++
123 w.WriteHeader(status)
124 w.Write([]byte("private upstream error"))
125 }))
126 defer api.Close()
127 c := New(jellyfinClient(t, api, ""), "key", t.TempDir())
128 reviews := []model.Review{{Movies: []model.Content{{ID: movieID, Poster: "/old.jpg"}}}}
129 assets, warnings := c.Resolve(context.Background(), []model.Item{{ID: movieID, Type: "Movie"}}, reviews)
130 if calls != 1 || len(assets) != 0 || len(warnings) != 1 || reviews[0].Movies[0].Poster != "" || strings.Contains(warnings[0], "private upstream") {
131 t.Fatal("failed artwork leaked upstream details or retained old reference")
132 }
133 })
134 }
135 reviews := []model.Review{{Movies: []model.Content{{ID: movieID, Poster: "https://untrusted.invalid"}}}}
136 assets, warnings := New(nil, "", t.TempDir()).Resolve(context.Background(), nil, reviews)
137 if len(assets) != 0 || len(warnings) != 1 || reviews[0].Movies[0].Poster != "" {
138 t.Fatal("unconfigured artwork did not use placeholders")
139 }
140 if _, warnings = New(nil, "", t.TempDir()).Resolve(context.Background(), nil, nil); len(warnings) != 0 {
141 t.Fatal("empty history needs no artwork")
142 }
143}
144func TestRejectsUnsafeIDsRedirectsAndOversizedImages(t *testing.T) {
145 foreignCalls := 0
146 foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { foreignCalls++; w.Write(picture(t, 10)) }))
147 defer foreign.Close()
148 calls := 0
149 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; http.Redirect(w, r, foreign.URL+"/poster", 302) }))
150 defer api.Close()
151 c := New(jellyfinClient(t, api, ""), "operator-key", t.TempDir())
152 if _, e := c.fetch(context.Background(), "../Users/Me", ""); e == nil || calls != 0 {
153 t.Fatal("unsafe item path reached Jellyfin")
154 }
155 if _, e := c.fetch(context.Background(), movieID, ""); e == nil || calls != 1 || foreignCalls != 0 {
156 t.Fatal("redirect followed with operator credentials")
157 }
158 var large bytes.Buffer
159 if e := jpeg.Encode(&large, image.NewGray(image.Rect(0, 0, 4097, 1)), nil); e != nil {
160 t.Fatal(e)
161 }
162 if validJPEG(large.Bytes()) || validJPEG(bytes.Repeat([]byte{0}, maxImageBytes+1)) {
163 t.Fatal("image bounds not enforced")
164 }
165 valid := picture(t, 10)
166 if validJPEG(valid[:len(valid)-10]) {
167 t.Fatal("truncated JPEG accepted")
168 }
169}
170func TestRetriesBoundedAndCancellation(t *testing.T) {
171 for _, status := range []int{429, 503} {
172 t.Run(http.StatusText(status), func(t *testing.T) {
173 calls := 0
174 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175 calls++
176 w.Header().Set("Retry-After", "600")
177 w.WriteHeader(status)
178 }))
179 defer api.Close()
180 c := New(jellyfinClient(t, api, ""), "key", t.TempDir())
181 delays := []time.Duration{}
182 c.wait = func(ctx context.Context, d time.Duration) error { delays = append(delays, d); return nil }
183 if _, e := c.fetch(context.Background(), movieID, ""); e == nil || calls != 3 || !reflect.DeepEqual(delays, []time.Duration{3 * time.Second, 3 * time.Second}) {
184 t.Fatal("unbounded retry behavior")
185 }
186 ctx, cancel := context.WithCancel(context.Background())
187 cancel()
188 if _, e := c.fetch(ctx, movieID, ""); e == nil || calls != 3 {
189 t.Fatal("canceled request reached source")
190 }
191 })
192 }
193 if e := waitRetry(canceledContext(), time.Hour); e == nil {
194 t.Fatal("backoff ignored cancellation")
195 }
196}
197func canceledContext() context.Context {
198 ctx, cancel := context.WithCancel(context.Background())
199 cancel()
200 return ctx
201}