configs/server-dockers/jellyfin-news/main_test.go
server-dockers/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(), "<Watch & copy>") {
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}