server_test.go
⎇
Raw
1package server
2
3import (
4 "database/sql"
5 "html"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16
17 "vidarchive/internal/config"
18 "vidarchive/internal/database"
19 "vidarchive/internal/handler"
20 "vidarchive/internal/repository"
21 "vidarchive/internal/service"
22 "vidarchive/internal/worker"
23)
24
25// Authentication is mandatory, so the test server is configured with real
26// credentials. The hash is bcrypt over testPassword at the minimum cost,
27// because every test server logs in once.
28const (
29 testUsername = "tester"
30 testPassword = "test-password"
31 testPasswordHash = "$2a$04$6h5pthPxQs2muU4eCZjhReZlMvwf7XP9HUKCEubLMQZsR/84fHs0i"
32)
33
34// testServer is a Server whose Router() signs every request in, so the tests
35// that are not about authentication don't each have to log in. Tests that do
36// exercise the login flow reach past it with srv.Server.Router().
37type testServer struct {
38 *Server
39 session *http.Cookie
40}
41
42func (s testServer) Router() http.Handler {
43 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44 if _, err := r.Cookie("session"); err != nil {
45 r.AddCookie(s.session)
46 }
47 s.Server.Router().ServeHTTP(w, r)
48 })
49}
50
51func setupTestServer(t *testing.T) (testServer, *config.Config, func()) {
52 srv, cfg, _, cleanup := setupTestServerDB(t)
53 return srv, cfg, cleanup
54}
55
56// setupTestServerDB is setupTestServer with the database handle exposed, for
57// tests that need to break the database on purpose.
58func setupTestServerDB(t *testing.T) (testServer, *config.Config, *sql.DB, func()) {
59 t.Helper()
60 dataDir := t.TempDir()
61 t.Setenv("VIDARCHIVE_DATA_DIR", dataDir)
62 t.Setenv("VIDARCHIVE_USERNAME", testUsername)
63 t.Setenv("VIDARCHIVE_PASSWORD_HASH", testPasswordHash)
64
65 cfg := config.New()
66 if err := os.MkdirAll(cfg.LibraryDir, 0o755); err != nil {
67 t.Fatalf("create library dir: %v", err)
68 }
69 if err := os.MkdirAll(cfg.TempDir, 0o755); err != nil {
70 t.Fatalf("create temp dir: %v", err)
71 }
72
73 db, err := database.New(cfg)
74 if err != nil {
75 t.Fatalf("init db: %v", err)
76 }
77
78 presetRepo := repository.NewPresetRepository(db)
79 downloadRepo := repository.NewDownloadRepository(db)
80 settingsRepo := repository.NewSettingsRepository(db)
81 subscriptionRepo := repository.NewSubscriptionRepository(db)
82
83 presetSvc := service.NewPresetService(presetRepo)
84 librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath)
85 settingsSvc := service.NewSettingsService(settingsRepo)
86 subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, cfg)
87 downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, subscriptionSvc, cfg)
88 workerPool := worker.New(downloadSvc, cfg.Workers)
89
90 h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, subscriptionSvc, workerPool)
91 if err != nil {
92 t.Fatalf("init handler: %v", err)
93 }
94
95 srv := New(cfg, h)
96
97 login := postForm(srv.Router(), "/login", url.Values{
98 "username": {testUsername},
99 "password": {testPassword},
100 })
101 session := sessionCookie(login.Result().Cookies())
102 if session == nil {
103 t.Fatal("test login issued no session; check testPasswordHash")
104 }
105
106 cleanup := func() {
107 workerPool.Stop()
108 db.Close()
109 }
110 return testServer{Server: srv, session: session}, cfg, db, cleanup
111}
112
113func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) {
114 t.Helper()
115 itemDir := filepath.Join(libraryDir, relPath)
116 if err := os.MkdirAll(itemDir, 0o755); err != nil {
117 t.Fatalf("create item dir: %v", err)
118 }
119 marker := filepath.Join(itemDir, ".vidarchive-item.toml")
120 if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0o644); err != nil {
121 t.Fatalf("write marker: %v", err)
122 }
123 for filename, content := range files {
124 path := filepath.Join(itemDir, filename)
125 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
126 t.Fatalf("write file %s: %v", filename, err)
127 }
128 }
129}
130
131func TestLibraryEngagementViews(t *testing.T) {
132 srv, cfg, cleanup := setupTestServer(t)
133 defer cleanup()
134
135 createItem(t, cfg.LibraryDir, "engagement", "Engagement", map[string]string{
136 "video.mp4": "dummy video",
137 "info.json": `{"comments":[{"id":"parent","author":"<parent>","text":"safe <comment>","time_text":"today"},{"id":"reply","parent":"parent","author":"<child>","text":"reply","time_text":"today"}],"heatmap":[{"start_time":0,"end_time":10,"value":1}]}`,
138 })
139
140 detail := getWith(srv.Router(), "/library/item/engagement", nil)
141 if detail.Code != http.StatusOK {
142 t.Fatalf("detail status = %d", detail.Code)
143 }
144 body := detail.Body.String()
145 for _, want := range []string{"Playback heatmap", "&lt;parent&gt;", "&lt;child&gt;", "safe &lt;comment&gt;", "replying to <strong>&lt;parent&gt;</strong>", "View all comments"} {
146 if !strings.Contains(body, want) {
147 t.Errorf("detail missing %q", want)
148 }
149 }
150
151 all := getWith(srv.Router(), "/library/comments/engagement", nil)
152 if all.Code != http.StatusOK || !strings.Contains(all.Body.String(), "Engagement comments") {
153 t.Fatalf("comments view status/body unexpected: %d %s", all.Code, all.Body.String())
154 }
155
156 // The full comments view has its own namespace, so an item whose path ends in
157 // "comments" must still be reachable through the item route.
158 createItem(t, cfg.LibraryDir, "playlist/comments", "Nested Comments Item", map[string]string{
159 "video.mp4": "dummy video",
160 })
161 item := getWith(srv.Router(), "/library/item/playlist/comments", nil)
162 if item.Code != http.StatusOK || !strings.Contains(item.Body.String(), "Nested Comments Item") {
163 t.Fatalf("item path ending in comments was shadowed: %d %s", item.Code, item.Body.String())
164 }
165}
166
167func TestCommentsViewMalformedSidecarIsError(t *testing.T) {
168 srv, cfg, cleanup := setupTestServer(t)
169 defer cleanup()
170
171 createItem(t, cfg.LibraryDir, "bad-comments", "Bad comments", map[string]string{
172 "video.mp4": "dummy video",
173 "info.json": `{"comments":[`,
174 })
175 w := getWith(srv.Router(), "/library/comments/bad-comments", nil)
176 if w.Code != http.StatusInternalServerError {
177 t.Fatalf("malformed comments status = %d, want 500", w.Code)
178 }
179 if !strings.Contains(w.Body.String(), "Something went wrong") {
180 t.Fatalf("malformed comments response = %q", w.Body.String())
181 }
182}
183
184func TestCommentsViewEncodedPath(t *testing.T) {
185 srv, cfg, cleanup := setupTestServer(t)
186 defer cleanup()
187
188 const item = "nested/An Item +"
189 createItem(t, cfg.LibraryDir, item, "Encoded comments", map[string]string{
190 "video.mp4": "dummy video",
191 "info.json": `{"comments":[{"author":"author","text":"hello"}]}`,
192 })
193
194 reqURL := "/library/comments/" + (&url.URL{Path: item}).EscapedPath()
195 w := getWith(srv.Router(), reqURL, nil)
196 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Encoded comments") {
197 t.Fatalf("encoded comments route %s: status=%d body=%s", reqURL, w.Code, w.Body.String())
198 }
199}
200
201func TestNestedLibraryItem(t *testing.T) {
202 srv, cfg, cleanup := setupTestServer(t)
203 defer cleanup()
204
205 createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{
206 "My Item [id].mp4": "dummy video",
207 })
208
209 router := srv.Router()
210
211 req := httptest.NewRequest("GET", "/library/item/test/My%20Item%20%5Bid%5D", nil)
212 w := httptest.NewRecorder()
213 router.ServeHTTP(w, req)
214 if w.Code != http.StatusOK {
215 body, _ := io.ReadAll(w.Body)
216 t.Errorf("expected 200, got %d: %s", w.Code, string(body))
217 }
218}
219
220// A directory whose name contains a literal '+' must round-trip: in a URL path
221// '+' is a literal plus (not a space), reachable raw or percent-encoded.
222func TestLiteralPlusInPathSegment(t *testing.T) {
223 srv, cfg, cleanup := setupTestServer(t)
224 defer cleanup()
225
226 createItem(t, cfg.LibraryDir, "C++ Tutorial", "C++ Tutorial", map[string]string{
227 "C++ Tutorial.mp4": "dummy video",
228 })
229
230 router := srv.Router()
231 for _, path := range []string{
232 "/library/item/C++%20Tutorial",
233 "/library/item/C%2B%2B%20Tutorial",
234 } {
235 req := httptest.NewRequest("GET", path, nil)
236 w := httptest.NewRecorder()
237 router.ServeHTTP(w, req)
238 if w.Code != http.StatusOK {
239 body, _ := io.ReadAll(w.Body)
240 t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body))
241 }
242 }
243}
244
245func TestMediaFileQueryDecoding(t *testing.T) {
246 srv, cfg, cleanup := setupTestServer(t)
247 defer cleanup()
248
249 createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{
250 "My Item [id].mp4": "dummy video",
251 "My+Other.mp4": "dummy video plus",
252 })
253
254 router := srv.Router()
255
256 tests := []struct {
257 path string
258 expected int
259 }{
260 {"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK},
261 {"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK},
262 {"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK},
263 {"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound},
264 }
265 for _, tc := range tests {
266 req := httptest.NewRequest("GET", tc.path, nil)
267 w := httptest.NewRecorder()
268 router.ServeHTTP(w, req)
269 if w.Code != tc.expected {
270 body, _ := io.ReadAll(w.Body)
271 t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body))
272 }
273 }
274}
275
276// Subtitle tracks are linked as <item>/subtitles/<lang>, with the language as a
277// trailing path segment. Recognizing only the ?lang= query form makes that fall
278// through to media serving and return 400 "Missing file".
279func TestSubtitleServedByPathSegment(t *testing.T) {
280 srv, cfg, cleanup := setupTestServer(t)
281 defer cleanup()
282
283 // An item whose name contains non-ASCII + spaces, like the reported URL.
284 const item = "ずんだパーリナイ ⧸ なみぐる [ywXQ9SqsaBQ]"
285 createItem(t, cfg.LibraryDir, item, "Vid", map[string]string{
286 "video.mp4": "dummy video",
287 })
288 vtt := "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhi\n"
289 subDir := filepath.Join(cfg.LibraryDir, item, "subtitles")
290 if err := os.MkdirAll(subDir, 0o755); err != nil {
291 t.Fatal(err)
292 }
293 if err := os.WriteFile(filepath.Join(subDir, "eng.vtt"), []byte(vtt), 0o644); err != nil {
294 t.Fatal(err)
295 }
296
297 router := srv.Router()
298 reqURL := "/media/item/" + (&url.URL{Path: item}).EscapedPath() + "/subtitles/eng"
299 req := httptest.NewRequest("GET", reqURL, nil)
300 w := httptest.NewRecorder()
301 router.ServeHTTP(w, req)
302 if w.Code != http.StatusOK {
303 body, _ := io.ReadAll(w.Body)
304 t.Fatalf("expected 200 for %s, got %d: %s", reqURL, w.Code, string(body))
305 }
306 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/vtt") {
307 t.Errorf("expected text/vtt content-type, got %q", ct)
308 }
309 if body, _ := io.ReadAll(w.Body); !strings.Contains(string(body), "WEBVTT") {
310 t.Errorf("expected the .vtt contents, got %q", string(body))
311 }
312
313 // A traversal attempt in the language segment must be rejected, not served.
314 bad := httptest.NewRequest("GET", "/media/item/"+(&url.URL{Path: item}).EscapedPath()+"/subtitles/..%2f..%2fsecret", nil)
315 bw := httptest.NewRecorder()
316 router.ServeHTTP(bw, bad)
317 if bw.Code == http.StatusOK {
318 t.Errorf("traversal in language segment was served (status %d)", bw.Code)
319 }
320}
321
322func TestPathTraversalBlocked(t *testing.T) {
323 srv, cfg, cleanup := setupTestServer(t)
324 defer cleanup()
325
326 outside := filepath.Join(cfg.DataDir, "secret")
327 if err := os.MkdirAll(outside, 0o755); err != nil {
328 t.Fatalf("create outside dir: %v", err)
329 }
330 marker := filepath.Join(outside, ".vidarchive-item.toml")
331 if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0o644); err != nil {
332 t.Fatalf("write marker: %v", err)
333 }
334
335 router := srv.Router()
336
337 req := httptest.NewRequest("GET", "/library/item/../secret", nil)
338 w := httptest.NewRecorder()
339 router.ServeHTTP(w, req)
340 if w.Code != http.StatusNotFound {
341 t.Errorf("expected 404 for path traversal, got %d", w.Code)
342 }
343}
344
345func TestPerFileExistingThumbnailServed(t *testing.T) {
346 srv, cfg, cleanup := setupTestServer(t)
347 defer cleanup()
348
349 // A pre-existing per-file thumbnail (<stem>.thumbnail.webp) is served for the
350 // matching ?file= request without re-extraction.
351 createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
352 "video.mp4": "dummy video",
353 "video.thumbnail.webp": "GENERATED-THUMB",
354 })
355
356 router := srv.Router()
357 req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", nil)
358 w := httptest.NewRecorder()
359 router.ServeHTTP(w, req)
360 if w.Code != http.StatusOK {
361 body, _ := io.ReadAll(w.Body)
362 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
363 }
364 if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" {
365 t.Errorf("expected the existing per-file thumbnail, got %q", string(body))
366 }
367}
368
369func TestAudioThumbnailPlaceholder(t *testing.T) {
370 srv, cfg, cleanup := setupTestServer(t)
371 defer cleanup()
372
373 createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{
374 "song.mp3": "dummy audio",
375 })
376
377 router := srv.Router()
378 req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil)
379 w := httptest.NewRecorder()
380 router.ServeHTTP(w, req)
381 if w.Code != http.StatusOK {
382 body, _ := io.ReadAll(w.Body)
383 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
384 }
385 body, _ := io.ReadAll(w.Body)
386 if len(body) == 0 {
387 t.Errorf("placeholder thumbnail body was empty")
388 }
389}
390
391func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) {
392 srv, cfg, cleanup := setupTestServer(t)
393 defer cleanup()
394
395 // Filenames with spaces are the hard case: the template must emit a query
396 // value the handler decodes back to the exact filename, not one that
397 // double-escapes a space to %2b and decodes to '+'.
398 files := map[string]string{
399 "01 - Color Bars.mp4": "v",
400 "02 - Test Pattern.mp4": "v",
401 }
402 createItem(t, cfg.LibraryDir, "multi", "Multi", files)
403
404 req := httptest.NewRequest("GET", "/library", nil)
405 w := httptest.NewRecorder()
406 srv.Router().ServeHTTP(w, req)
407 body, _ := io.ReadAll(w.Body)
408
409 re := regexp.MustCompile(`thumbnail\?file=([^"]+)`)
410 matches := re.FindAllStringSubmatch(string(body), -1)
411 if len(matches) != len(files) {
412 t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches))
413 }
414 for _, m := range matches {
415 vals, err := url.ParseQuery("file=" + m[1])
416 if err != nil {
417 t.Fatalf("bad query %q: %v", m[1], err)
418 }
419 got := vals.Get("file")
420 if _, ok := files[got]; !ok {
421 t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got)
422 }
423 }
424}
425
426// A folder name containing a space is the double-encoding case: urlEncodePath
427// inside a ?path= query gets re-escaped by html/template (%20 -> %2520), and the
428// link then lands on a directory that does not exist. The decoded ?path must be
429// the real directory, and the breadcrumb must show the readable name.
430func TestNestedFolderLinkRoundTrip(t *testing.T) {
431 srv, cfg, cleanup := setupTestServer(t)
432 defer cleanup()
433
434 createItem(t, cfg.LibraryDir, "subs/playlist test 2/Vid One", "Vid One", map[string]string{
435 "video.mp4": "dummy video",
436 })
437 router := srv.Router()
438
439 // List the parent and pull out the generated folder link.
440 req := httptest.NewRequest("GET", "/library?path=subs", nil)
441 w := httptest.NewRecorder()
442 router.ServeHTTP(w, req)
443 if w.Code != http.StatusOK {
444 t.Fatalf("list /library?path=subs: got %d", w.Code)
445 }
446 body := w.Body.String()
447
448 folderHref := regexp.MustCompile(`href="(/library\?path=[^"]+)" class="folder-item"`).FindStringSubmatch(body)
449 if folderHref == nil {
450 t.Fatalf("no folder link rendered for nested folder; body:\n%s", body)
451 }
452 href := html.UnescapeString(folderHref[1])
453
454 // The link's decoded ?path must be the real directory, not a still-encoded one.
455 u, err := url.Parse(href)
456 if err != nil {
457 t.Fatalf("parse folder href %q: %v", href, err)
458 }
459 if got := u.Query().Get("path"); got != "subs/playlist test 2" {
460 t.Fatalf("folder link path decodes to %q, want %q (double-encoding regression)", got, "subs/playlist test 2")
461 }
462
463 // Follow the link exactly as a browser would. The folder must not be empty,
464 // and the breadcrumb must show the readable name (never the encoded form).
465 req = httptest.NewRequest("GET", href, nil)
466 w = httptest.NewRecorder()
467 router.ServeHTTP(w, req)
468 if w.Code != http.StatusOK {
469 t.Fatalf("follow folder link %q: got %d", href, w.Code)
470 }
471 nested := w.Body.String()
472 if !strings.Contains(nested, "Vid One") {
473 t.Errorf("nested folder rendered empty — item 'Vid One' missing; body:\n%s", nested)
474 }
475 // The breadcrumb must display the readable name, not the percent-encoded form.
476 if !strings.Contains(nested, ">playlist test 2<") {
477 t.Errorf("breadcrumb missing readable folder name 'playlist test 2'")
478 }
479 if strings.Contains(nested, ">playlist%20test%202<") {
480 t.Errorf("breadcrumb displays the encoded name instead of a space (regression)")
481 }
482 // No link may carry a double-encoded path (%2520 == %25 + 20 == re-escaped %20).
483 if strings.Contains(nested, "%2520") {
484 t.Errorf("a link is double-encoded (%%2520) — urlEncodePath inside a ?path= query (regression)")
485 }
486}
487
488func TestListingDoesNotExtractThumbnails(t *testing.T) {
489 srv, cfg, cleanup := setupTestServer(t)
490 defer cleanup()
491
492 createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{
493 "video.mp4": "dummy video",
494 })
495
496 router := srv.Router()
497 req := httptest.NewRequest("GET", "/library", nil)
498 w := httptest.NewRecorder()
499 router.ServeHTTP(w, req)
500 if w.Code != http.StatusOK {
501 t.Fatalf("expected 200, got %d", w.Code)
502 }
503
504 // Rendering the listing must not have created any thumbnail file.
505 entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid"))
506 if err != nil {
507 t.Fatal(err)
508 }
509 for _, e := range entries {
510 if strings.Contains(e.Name(), ".thumbnail.") {
511 t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name())
512 }
513 }
514}
515
516func TestGeneratedThumbnailServedOverIcon(t *testing.T) {
517 srv, cfg, cleanup := setupTestServer(t)
518 defer cleanup()
519
520 createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{
521 "video.mp4": "dummy video",
522 "video.thumbnail.webp": "WEBPDATA",
523 })
524
525 router := srv.Router()
526 req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil)
527 w := httptest.NewRecorder()
528 router.ServeHTTP(w, req)
529 if w.Code != http.StatusOK {
530 t.Fatalf("expected 200, got %d", w.Code)
531 }
532 if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" {
533 t.Errorf("expected generated thumbnail contents, got %q", string(body))
534 }
535}
536
537func TestThumbnailExtractedOnRequest(t *testing.T) {
538 if _, err := exec.LookPath("ffmpeg"); err != nil {
539 t.Skip("ffmpeg not on PATH")
540 }
541 srv, cfg, cleanup := setupTestServer(t)
542 defer cleanup()
543
544 itemDir := filepath.Join(cfg.LibraryDir, "realvid")
545 createItem(t, cfg.LibraryDir, "realvid", "Real", nil)
546 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
547 "-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5",
548 "-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y")
549 if out, err := cmd.CombinedOutput(); err != nil {
550 t.Fatalf("make test video: %v\n%s", err, out)
551 }
552
553 router := srv.Router()
554 req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil)
555 w := httptest.NewRecorder()
556 router.ServeHTTP(w, req)
557 if w.Code != http.StatusOK {
558 t.Fatalf("expected 200, got %d", w.Code)
559 }
560 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
561 t.Errorf("expected image content-type, got %q", ct)
562 }
563 body, _ := io.ReadAll(w.Body)
564 if len(body) == 0 {
565 t.Error("served thumbnail body was empty")
566 }
567
568 // A real thumbnail file should now exist on disk, with no temp leftovers.
569 entries, _ := os.ReadDir(itemDir)
570 var found bool
571 for _, e := range entries {
572 if strings.Contains(e.Name(), ".thumbnail.") {
573 found = true
574 }
575 if strings.Contains(e.Name(), ".tmp") {
576 t.Errorf("leftover temp file %q", e.Name())
577 }
578 }
579 if !found {
580 t.Error("no thumbnail file written to disk after request")
581 }
582}
583
584func TestLibraryPageIsFast(t *testing.T) {
585 srv, cfg, cleanup := setupTestServer(t)
586 defer cleanup()
587
588 for i := 0; i < 50; i++ {
589 createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{
590 "video.mp4": "dummy",
591 })
592 }
593
594 router := srv.Router()
595 req := httptest.NewRequest("GET", "/library", nil)
596 w := httptest.NewRecorder()
597 router.ServeHTTP(w, req)
598 if w.Code != http.StatusOK {
599 body, _ := io.ReadAll(w.Body)
600 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
601 }
602}
603