arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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}