arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-news/main_test.go
jellyfin-news/main_test.goRaw
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/http"
  7	"net/http/httptest"
  8	"strings"
  9	"testing"
 10	"time"
 11)
 12
 13type fakeSource struct {
 14	start time.Time
 15	end   time.Time
 16	items []Item
 17	err   error
 18}
 19
 20func (f *fakeSource) AddedBetween(_ context.Context, start, end time.Time) ([]Item, error) {
 21	f.start, f.end = start, end
 22	return f.items, f.err
 23}
 24
 25func testApp(source *fakeSource) *app {
 26	return &app{
 27		weekStart: weekStart{day: time.Monday},
 28		jellyfin:  source,
 29		location:  time.UTC,
 30		labels:    DefaultLabels(),
 31		now: func() time.Time {
 32			return time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
 33		},
 34	}
 35}
 36
 37func TestCurrentWeek(t *testing.T) {
 38	source := &fakeSource{}
 39	request := httptest.NewRequest(http.MethodGet, "/api/current-week", nil)
 40	response := httptest.NewRecorder()
 41	testApp(source).routes().ServeHTTP(response, request)
 42
 43	if response.Code != http.StatusOK {
 44		t.Fatalf("status = %d, body = %q", response.Code, response.Body.String())
 45	}
 46	if got := response.Body.String(); !strings.HasPrefix(got, "2026-08-31 - 2026-09-02 jellyfin weekly update:") {
 47		t.Fatalf("body = %q", got)
 48	}
 49	wantStart := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
 50	if !source.start.Equal(wantStart) || !source.end.Equal(testApp(source).now()) {
 51		t.Fatalf("range = %v to %v", source.start, source.end)
 52	}
 53}
 54
 55func TestStartOfWeekUsesMondayMidnight(t *testing.T) {
 56	location := time.FixedZone("test", 2*60*60)
 57	now := time.Date(2026, 9, 6, 23, 45, 0, 0, location) // Sunday
 58	want := time.Date(2026, 8, 31, 0, 0, 0, 0, location)
 59	if got := (weekStart{day: time.Monday}).start(now); !got.Equal(want) {
 60		t.Fatalf("startOfWeek() = %v, want %v", got, want)
 61	}
 62}
 63
 64func TestSelectedWeek(t *testing.T) {
 65	source := &fakeSource{}
 66	request := httptest.NewRequest(http.MethodGet, "/api/week?offset=-2", nil)
 67	response := httptest.NewRecorder()
 68	testApp(source).routes().ServeHTTP(response, request)
 69
 70	if response.Code != http.StatusOK {
 71		t.Fatalf("status = %d, body = %q", response.Code, response.Body.String())
 72	}
 73	if got := response.Body.String(); !strings.HasPrefix(got, "2026-08-17 - 2026-08-24 jellyfin weekly update:") {
 74		t.Fatalf("body = %q", got)
 75	}
 76}
 77
 78func TestSelectedWeekRejectsInvalidOffset(t *testing.T) {
 79	request := httptest.NewRequest(http.MethodGet, "/api/week?offset=0", nil)
 80	response := httptest.NewRecorder()
 81	testApp(&fakeSource{}).routes().ServeHTTP(response, request)
 82	if response.Code != http.StatusBadRequest {
 83		t.Fatalf("status = %d", response.Code)
 84	}
 85}
 86
 87func TestSelectedMonth(t *testing.T) {
 88	source := &fakeSource{}
 89	request := httptest.NewRequest(http.MethodGet, "/api/month?offset=-2", nil)
 90	response := httptest.NewRecorder()
 91	testApp(source).routes().ServeHTTP(response, request)
 92
 93	if response.Code != http.StatusOK {
 94		t.Fatalf("status = %d, body = %q", response.Code, response.Body.String())
 95	}
 96	if got := response.Body.String(); !strings.HasPrefix(got, "2026-07 jellyfin monthly update:") {
 97		t.Fatalf("body = %q", got)
 98	}
 99	if source.start.Month() != time.July || source.end.Month() != time.August {
100		t.Fatalf("range = %v to %v", source.start, source.end)
101	}
102}
103
104func TestSelectedMonthRejectsInvalidOffset(t *testing.T) {
105	request := httptest.NewRequest(http.MethodGet, "/api/month?offset=0", nil)
106	response := httptest.NewRecorder()
107	testApp(&fakeSource{}).routes().ServeHTTP(response, request)
108	if response.Code != http.StatusBadRequest {
109		t.Fatalf("status = %d", response.Code)
110	}
111}
112
113func TestIndex(t *testing.T) {
114	request := httptest.NewRequest(http.MethodGet, "/", nil)
115	response := httptest.NewRecorder()
116	testApp(&fakeSource{}).routes().ServeHTTP(response, request)
117	if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Jellyfin updates") {
118		t.Fatalf("status = %d, body = %q", response.Code, response.Body.String())
119	}
120}
121
122type fakeWatched struct {
123	start, end time.Time
124	err        error
125}
126
127func (f *fakeWatched) WatchedBetween(_ context.Context, start, end time.Time) ([]WatchedTitle, error) {
128	f.start, f.end = start, end
129	return []WatchedTitle{{ID: "movie", Name: "Example", Type: "Movie", Users: 2}}, f.err
130}
131
132func TestWatchedPeriods(t *testing.T) {
133	for _, tc := range []struct{ query, start, end string }{
134		{"period=current-week", "2026-08-31T00:00:00Z", "2026-09-02T12:00:00Z"},
135		{"period=week&offset=-2", "2026-08-17T00:00:00Z", "2026-08-24T00:00:00Z"},
136		{"period=month&offset=-1", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"},
137	} {
138		t.Run(tc.query, func(t *testing.T) {
139			a := testApp(&fakeSource{})
140			source := &fakeWatched{}
141			a.jellystat = source
142			response := httptest.NewRecorder()
143			a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/api/watched?"+tc.query, nil))
144			if response.Code != 200 || !strings.Contains(response.Body.String(), "Example (2 users)") {
145				t.Fatalf("response: %d %s", response.Code, response.Body.String())
146			}
147			if source.start.Format(time.RFC3339) != tc.start || source.end.Format(time.RFC3339) != tc.end {
148				t.Fatalf("range: %v to %v", source.start, source.end)
149			}
150			if response.Header().Get("Cache-Control") != "no-store" {
151				t.Fatal("missing no-store")
152			}
153		})
154	}
155}
156
157func TestWatchedErrors(t *testing.T) {
158	for _, tc := range []struct {
159		query  string
160		source watchedSource
161		status int
162	}{
163		{"period=current-week", nil, 503},
164		{"period=week&offset=0", nil, 400},
165		{"period=invalid", nil, 400},
166		{"period=current-week", &fakeWatched{err: fmt.Errorf("upstream failure")}, 502},
167	} {
168		a := testApp(&fakeSource{})
169		a.jellystat = tc.source
170		response := httptest.NewRecorder()
171		a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/api/watched?"+tc.query, nil))
172		if response.Code != tc.status {
173			t.Fatalf("status = %d, want %d", response.Code, tc.status)
174		}
175	}
176}
177
178func TestWatchedButtonEscapesTranslation(t *testing.T) {
179	a := testApp(&fakeSource{})
180	a.labels.WatchedButton = "<Watch & copy>"
181	response := httptest.NewRecorder()
182	a.routes().ServeHTTP(response, httptest.NewRequest("GET", "/", nil))
183	if !strings.Contains(response.Body.String(), "&lt;Watch &amp; copy&gt;") {
184		t.Fatal("button translation not escaped")
185	}
186}
187
188func TestWeekStartSettings(t *testing.T) {
189	for _, tc := range []struct {
190		day, clock string
191		want       weekStart
192		valid      bool
193	}{
194		{"", "", weekStart{day: time.Monday}, true},
195		{"sUnDaY", "20:00", weekStart{day: time.Sunday, hour: 20}, true},
196		{"Friday", "19:30", weekStart{day: time.Friday, hour: 19, minute: 30}, true},
197		{"Funday", "20:00", weekStart{}, false},
198		{"Sunday", "24:00", weekStart{}, false},
199		{"Sunday", "20:60", weekStart{}, false},
200		{"Sunday", "8:00", weekStart{}, false},
201	} {
202		t.Run(tc.day+tc.clock, func(t *testing.T) {
203			t.Setenv("WEEK_START_DAY", tc.day)
204			t.Setenv("WEEK_START_TIME", tc.clock)
205			got, err := loadWeekStart()
206			if (err == nil) != tc.valid {
207				t.Fatalf("error = %v", err)
208			}
209			if tc.valid && got != tc.want {
210				t.Fatalf("got %+v, want %+v", got, tc.want)
211			}
212		})
213	}
214}
215
216func TestSundayEveningWeekBoundary(t *testing.T) {
217	location, err := time.LoadLocation("Europe/London")
218	if err != nil {
219		t.Fatal(err)
220	}
221	boundary := weekStart{day: time.Sunday, hour: 20, minute: 30}
222	for _, tc := range []struct{ now, want string }{
223		{"2026-09-06T20:29:59+01:00", "2026-08-30T20:30:00+01:00"},
224		{"2026-09-06T20:30:00+01:00", "2026-09-06T20:30:00+01:00"},
225		{"2026-09-07T01:00:00+01:00", "2026-09-06T20:30:00+01:00"},
226		{"2026-03-29T19:00:00+01:00", "2026-03-22T20:30:00Z"},
227		{"2026-10-25T19:00:00Z", "2026-10-18T20:30:00+01:00"},
228	} {
229		now, _ := time.Parse(time.RFC3339, tc.now)
230		if got := boundary.start(now.In(location)).Format(time.RFC3339); got != tc.want {
231			t.Fatalf("at %s got %s, want %s", tc.now, got, tc.want)
232		}
233	}
234}
235
236func TestCustomWeekBothReportTypes(t *testing.T) {
237	for _, period := range []string{"current-week", "week", "month"} {
238		for _, watched := range []bool{false, true} {
239			added := &fakeSource{}
240			viewed := &fakeWatched{}
241			a := testApp(added)
242			a.jellystat = viewed
243			a.weekStart = weekStart{day: time.Sunday, hour: 20}
244			path := "/api/" + period + "?offset=-1"
245			if watched {
246				path = "/api/watched?period=" + period + "&offset=-1"
247			}
248			response := httptest.NewRecorder()
249			a.routes().ServeHTTP(response, httptest.NewRequest("GET", path, nil))
250			if response.Code != 200 {
251				t.Fatalf("%s: %d", path, response.Code)
252			}
253			start, end := added.start, added.end
254			if watched {
255				start, end = viewed.start, viewed.end
256			}
257			wantStart, wantEnd := "2026-08-30T20:00:00Z", "2026-09-02T12:00:00Z"
258			if period == "week" {
259				wantStart, wantEnd = "2026-08-23T20:00:00Z", "2026-08-30T20:00:00Z"
260			}
261			if period == "month" {
262				wantStart, wantEnd = "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"
263			}
264			if start.Format(time.RFC3339) != wantStart || end.Format(time.RFC3339) != wantEnd {
265				t.Fatalf("%s: %v to %v", path, start, end)
266			}
267		}
268	}
269}