arnaucube
arnaucube/configsPublic Self-hosted repository
configs/server-dockers/jellyfin-news/posters_test.go
server-dockers/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}