jellyfin-tools/jellyfin-news/posters_test.go
jellyfin-news/posters_test.goRaw
1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "image"
9 "image/png"
10 "io"
11 "net/http"
12 "net/http/httptest"
13 "strings"
14 "testing"
15)
16
17type searchableSource struct {
18 *fakeSource
19 calls []extraTitle
20}
21
22func (s *searchableSource) FindPosterTitle(_ context.Context, kind, name string, year int) (string, error) {
23 s.calls = append(s.calls, extraTitle{name: name, kind: kind, year: year})
24 if name == "Old Film" {
25 return "old-film", nil
26 }
27 if name == "Old Show" {
28 return "old-show", nil
29 }
30 return "", nil
31}
32
33func TestPosterExtrasUseRecapFormatAndDeduplicate(t *testing.T) {
34 year := 2025
35 source := &searchableSource{fakeSource: &fakeSource{items: []Item{{ID: "new-film", Type: "Movie", Name: "New Film", ProductionYear: &year}}}}
36 a := testApp(source.fakeSource)
37 a.jellyfin = source
38 body := "2026-08-31 - 2026-09-02 jellyfin weekly update:\n\nMovies:\n• New Film\n• Old Film (2022)\n• Missing Film\n\nShows:\n• Old Show — Season 3, episodes 1-12\n• Old Show — Season 4, episode 1"
39 r := httptest.NewRecorder()
40 a.routes().ServeHTTP(r, httptest.NewRequest(http.MethodPost, "/api/posters?period=current-week", strings.NewReader(body)))
41 if r.Code != http.StatusOK {
42 t.Fatalf("response = %d %s", r.Code, r.Body)
43 }
44 var data struct{ Titles []posterTitle }
45 if err := json.Unmarshal(r.Body.Bytes(), &data); err != nil {
46 t.Fatal(err)
47 }
48 if len(data.Titles) != 4 {
49 t.Fatalf("titles = %+v", data.Titles)
50 }
51 want := map[string]string{"New Film (2025)": "/api/poster/new-film", "Old Film (2022)": "/api/poster/old-film", "Missing Film": "", "Old Show": "/api/poster/old-show"}
52 for _, title := range data.Titles {
53 if title.URL != want[title.Name] {
54 t.Fatalf("title = %+v", title)
55 }
56 delete(want, title.Name)
57 }
58 if len(want) != 0 || len(source.calls) != 3 || source.calls[0] != (extraTitle{name: "Old Film", kind: "Movie", year: 2022}) || source.calls[2].name != "Old Show" {
59 t.Fatalf("unmatched titles = %+v, searches = %+v", want, source.calls)
60 }
61}
62
63func TestPosterExtrasRejectMalformedAndOversizedInput(t *testing.T) {
64 for _, body := range []string{"• Film", "Movies:\n• ", "Movies:\n• Film\n•", strings.Repeat("x", 32*1024+1)} {
65 r := httptest.NewRecorder()
66 testApp(&fakeSource{}).routes().ServeHTTP(r, httptest.NewRequest(http.MethodPost, "/api/posters?period=current-week", strings.NewReader(body)))
67 if r.Code != http.StatusBadRequest {
68 t.Fatalf("input %q: response = %d", body[:min(len(body), 30)], r.Code)
69 }
70 }
71}
72
73func TestPosterPeriodsAndGrouping(t *testing.T) {
74 for _, tc := range []struct{ query, start, end string }{
75 {"period=current-week", "2026-08-31", "2026-09-02"},
76 {"period=week&offset=-2", "2026-08-17", "2026-08-24"},
77 {"period=month&offset=-1", "2026-08-01", "2026-09-01"},
78 } {
79 t.Run(tc.query, func(t *testing.T) {
80 source := &fakeSource{items: []Item{
81 {ID: "movie", Type: "Movie", Name: "Film"},
82 {ID: "movie", Type: "Movie", Name: "Film"},
83 {ID: "episode1", Type: "Episode", SeriesID: "series", SeriesName: "Show"},
84 {ID: "episode2", Type: "Episode", SeriesID: "series", SeriesName: "Show"},
85 {ID: "music", Type: "Audio", Name: "Song"},
86 }}
87 r := httptest.NewRecorder()
88 testApp(source).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?"+tc.query, nil))
89 var data struct {
90 Start, End, Title string
91 Titles []posterTitle
92 }
93 if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &data) != nil {
94 t.Fatalf("response = %d %s", r.Code, r.Body)
95 }
96 if data.Start != tc.start || data.End != tc.end || source.start.Format("2006-01-02") != tc.start || source.end.Format("2006-01-02") != tc.end {
97 t.Fatalf("wrong boundaries: %+v, %v to %v", data, source.start, source.end)
98 }
99 if len(data.Titles) != 2 || data.Titles[0].URL != "/api/poster/movie" || data.Titles[1].URL != "/api/poster/series" {
100 t.Fatalf("titles = %+v", data.Titles)
101 }
102 if strings.Contains(tc.query, "month") && data.Title != DefaultLabels().MonthlyUpdate {
103 t.Fatalf("wrong monthly title: %s", data.Title)
104 }
105 })
106 }
107}
108
109func TestPostersInvalidSelectionAndQueryFailure(t *testing.T) {
110 for _, query := range []string{"", "period=years", "period=week", "period=week&offset=0", "period=month&offset=-1201"} {
111 source := &fakeSource{}
112 r := httptest.NewRecorder()
113 testApp(source).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?"+query, nil))
114 if r.Code != 400 || !source.start.IsZero() {
115 t.Fatalf("%s: status %d, queried %v", query, r.Code, source.start)
116 }
117 }
118 r := httptest.NewRecorder()
119 testApp(&fakeSource{err: errors.New("private upstream details")}).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?period=current-week", nil))
120 if r.Code != 502 || strings.Contains(r.Body.String(), "private") {
121 t.Fatalf("response = %d %s", r.Code, r.Body)
122 }
123}
124
125func TestPostersEmptyAndMissingIDs(t *testing.T) {
126 for _, items := range [][]Item{nil, {{Type: "Movie", Name: "No poster"}, {Type: "Episode", SeriesName: "No series ID"}}} {
127 r := httptest.NewRecorder()
128 testApp(&fakeSource{items: items}).routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/posters?period=current-week", nil))
129 var data struct{ Titles []posterTitle }
130 if err := json.Unmarshal(r.Body.Bytes(), &data); err != nil || data.Titles == nil || len(data.Titles) != len(items) {
131 t.Fatalf("response = %s, error = %v", r.Body, err)
132 }
133 for _, title := range data.Titles {
134 if title.URL != "" {
135 t.Fatalf("missing ID should use a placeholder: %+v", title)
136 }
137 }
138 }
139}
140
141func TestPosterProxy(t *testing.T) {
142 var artwork bytes.Buffer
143 if err := png.Encode(&artwork, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil {
144 t.Fatal(err)
145 }
146 for _, tc := range []struct {
147 name string
148 upstream, want int
149 body []byte
150 }{
151 {"image", 200, 200, artwork.Bytes()},
152 {"missing", 404, 404, nil},
153 {"upstream error", 500, 502, []byte("private error")},
154 {"invalid content", 200, 502, []byte("<html>not an image</html>")},
155 {"oversized", 200, 502, make([]byte, (5<<20)+1)},
156 } {
157 t.Run(tc.name, func(t *testing.T) {
158 client, err := New("http://jellyfin.test/base", "secret", "", &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
159 if r.URL.Path != "/base/Items/movie/Images/Primary" || r.Header.Get("Authorization") != `MediaBrowser Token="secret"` || r.Header.Get("X-Emby-Token") != "" || r.URL.Query().Get("maxWidth") != "480" {
160 t.Errorf("wrong poster request path, credentials, or size")
161 }
162 return &http.Response{StatusCode: tc.upstream, Body: io.NopCloser(bytes.NewReader(tc.body)), Header: make(http.Header)}, nil
163 })})
164 if err != nil {
165 t.Fatal(err)
166 }
167 a := testApp(&fakeSource{})
168 a.jellyfin = client
169 r := httptest.NewRecorder()
170 a.routes().ServeHTTP(r, httptest.NewRequest("GET", "/api/poster/movie", nil))
171 if r.Code != tc.want || strings.Contains(r.Body.String(), "private error") {
172 t.Fatalf("unexpected response status %d", r.Code)
173 }
174 if tc.want == 200 && (r.Header().Get("Content-Type") != "image/png" || !bytes.Equal(r.Body.Bytes(), artwork.Bytes())) {
175 t.Fatal("image was not preserved")
176 }
177 if _, err := client.Poster(context.Background(), "../bad"); !errors.Is(err, errNoPoster) {
178 t.Fatalf("invalid ID accepted: %v", err)
179 }
180 })
181 }
182}