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