package server
import (
"database/sql"
"html"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"vidarchive/internal/config"
"vidarchive/internal/database"
"vidarchive/internal/handler"
"vidarchive/internal/repository"
"vidarchive/internal/service"
"vidarchive/internal/worker"
)
func setupTestServer(t *testing.T) (*Server, *config.Config, func()) {
srv, cfg, _, cleanup := setupTestServerDB(t)
return srv, cfg, cleanup
}
// setupTestServerDB is setupTestServer with the database handle exposed, for
// tests that need to break the database on purpose.
func setupTestServerDB(t *testing.T) (*Server, *config.Config, *sql.DB, func()) {
t.Helper()
dataDir := t.TempDir()
t.Setenv("VIDARCHIVE_DATA_DIR", dataDir)
cfg := config.New()
if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil {
t.Fatalf("create library dir: %v", err)
}
if err := os.MkdirAll(cfg.TempDir, 0755); err != nil {
t.Fatalf("create temp dir: %v", err)
}
db, err := database.New(cfg)
if err != nil {
t.Fatalf("init db: %v", err)
}
presetRepo := repository.NewPresetRepository(db)
downloadRepo := repository.NewDownloadRepository(db)
settingsRepo := repository.NewSettingsRepository(db)
subscriptionRepo := repository.NewSubscriptionRepository(db)
presetSvc := service.NewPresetService(presetRepo)
librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath)
settingsSvc := service.NewSettingsService(settingsRepo)
subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, cfg)
downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, subscriptionSvc, cfg)
workerPool := worker.New(downloadSvc, cfg.Workers)
h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, subscriptionSvc, workerPool)
if err != nil {
t.Fatalf("init handler: %v", err)
}
srv := New(cfg, h)
cleanup := func() {
workerPool.Stop()
db.Close()
}
return srv, cfg, db, cleanup
}
func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) {
t.Helper()
itemDir := filepath.Join(libraryDir, relPath)
if err := os.MkdirAll(itemDir, 0755); err != nil {
t.Fatalf("create item dir: %v", err)
}
marker := filepath.Join(itemDir, ".vidarchive-item.toml")
if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
for filename, content := range files {
path := filepath.Join(itemDir, filename)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write file %s: %v", filename, err)
}
}
}
func TestLibraryEngagementViews(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "engagement", "Engagement", map[string]string{
"video.mp4": "dummy video",
"info.json": `{"comments":[{"id":"parent","author":"","text":"safe ","time_text":"today"},{"id":"reply","parent":"parent","author":"","text":"reply","time_text":"today"}],"heatmap":[{"start_time":0,"end_time":10,"value":1}]}`,
})
detail := getWith(srv.Router(), "/library/item/engagement", nil)
if detail.Code != http.StatusOK {
t.Fatalf("detail status = %d", detail.Code)
}
body := detail.Body.String()
for _, want := range []string{"Playback heatmap", "<parent>", "<child>", "safe <comment>", "replying to <parent>", "View all comments"} {
if !strings.Contains(body, want) {
t.Errorf("detail missing %q", want)
}
}
all := getWith(srv.Router(), "/library/comments/engagement", nil)
if all.Code != http.StatusOK || !strings.Contains(all.Body.String(), "Engagement comments") {
t.Fatalf("comments view status/body unexpected: %d %s", all.Code, all.Body.String())
}
// A real item whose path ends in "comments" must remain reachable through
// the item route now that the full comments view has its own namespace.
createItem(t, cfg.LibraryDir, "playlist/comments", "Nested Comments Item", map[string]string{
"video.mp4": "dummy video",
})
item := getWith(srv.Router(), "/library/item/playlist/comments", nil)
if item.Code != http.StatusOK || !strings.Contains(item.Body.String(), "Nested Comments Item") {
t.Fatalf("item path ending in comments was shadowed: %d %s", item.Code, item.Body.String())
}
}
func TestCommentsViewMalformedSidecarIsError(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "bad-comments", "Bad comments", map[string]string{
"video.mp4": "dummy video",
"info.json": `{"comments":[`,
})
w := getWith(srv.Router(), "/library/comments/bad-comments", nil)
if w.Code != http.StatusInternalServerError {
t.Fatalf("malformed comments status = %d, want 500", w.Code)
}
if !strings.Contains(w.Body.String(), "Something went wrong") {
t.Fatalf("malformed comments response = %q", w.Body.String())
}
}
func TestCommentsViewEncodedPath(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
const item = "nested/An Item +"
createItem(t, cfg.LibraryDir, item, "Encoded comments", map[string]string{
"video.mp4": "dummy video",
"info.json": `{"comments":[{"author":"author","text":"hello"}]}`,
})
reqURL := "/library/comments/" + (&url.URL{Path: item}).EscapedPath()
w := getWith(srv.Router(), reqURL, nil)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Encoded comments") {
t.Fatalf("encoded comments route %s: status=%d body=%s", reqURL, w.Code, w.Body.String())
}
}
func TestNestedLibraryItem(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{
"My Item [id].mp4": "dummy video",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/library/item/test/My%20Item%20%5Bid%5D", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Errorf("expected 200, got %d: %s", w.Code, string(body))
}
}
// A directory whose name contains a literal '+' must round-trip: in a URL path
// '+' is a literal plus (not a space), reachable raw or percent-encoded.
func TestLiteralPlusInPathSegment(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "C++ Tutorial", "C++ Tutorial", map[string]string{
"C++ Tutorial.mp4": "dummy video",
})
router := srv.Router()
for _, path := range []string{
"/library/item/C++%20Tutorial",
"/library/item/C%2B%2B%20Tutorial",
} {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body))
}
}
}
func TestMediaFileQueryDecoding(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{
"My Item [id].mp4": "dummy video",
"My+Other.mp4": "dummy video plus",
})
router := srv.Router()
tests := []struct {
path string
expected int
}{
{"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK},
{"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK},
{"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK},
{"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound},
}
for _, tc := range tests {
req := httptest.NewRequest("GET", tc.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tc.expected {
body, _ := io.ReadAll(w.Body)
t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body))
}
}
}
// TestSubtitleServedByPathSegment guards the regression where subtitle tracks
// are linked as - /subtitles/ (language as a trailing path segment),
// but the handler only recognized the "/subtitles" suffix with a ?lang= query.
// The path form fell through to media serving and returned 400 "Missing file".
func TestSubtitleServedByPathSegment(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
// An item whose name contains non-ASCII + spaces, like the reported URL.
const item = "ずんだパーリナイ ⧸ なみぐる [ywXQ9SqsaBQ]"
createItem(t, cfg.LibraryDir, item, "Vid", map[string]string{
"video.mp4": "dummy video",
})
vtt := "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhi\n"
subDir := filepath.Join(cfg.LibraryDir, item, "subtitles")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subDir, "eng.vtt"), []byte(vtt), 0644); err != nil {
t.Fatal(err)
}
router := srv.Router()
reqURL := "/media/item/" + (&url.URL{Path: item}).EscapedPath() + "/subtitles/eng"
req := httptest.NewRequest("GET", reqURL, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200 for %s, got %d: %s", reqURL, w.Code, string(body))
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/vtt") {
t.Errorf("expected text/vtt content-type, got %q", ct)
}
if body, _ := io.ReadAll(w.Body); !strings.Contains(string(body), "WEBVTT") {
t.Errorf("expected the .vtt contents, got %q", string(body))
}
// A traversal attempt in the language segment must be rejected, not served.
bad := httptest.NewRequest("GET", "/media/item/"+(&url.URL{Path: item}).EscapedPath()+"/subtitles/..%2f..%2fsecret", nil)
bw := httptest.NewRecorder()
router.ServeHTTP(bw, bad)
if bw.Code == http.StatusOK {
t.Errorf("traversal in language segment was served (status %d)", bw.Code)
}
}
func TestPathTraversalBlocked(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
outside := filepath.Join(cfg.DataDir, "secret")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatalf("create outside dir: %v", err)
}
marker := filepath.Join(outside, ".vidarchive-item.toml")
if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
router := srv.Router()
req := httptest.NewRequest("GET", "/library/item/../secret", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 for path traversal, got %d", w.Code)
}
}
func TestPerFileExistingThumbnailServed(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
// A pre-existing per-file thumbnail (.thumbnail.webp) is served for the
// matching ?file= request without re-extraction.
createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
"video.mp4": "dummy video",
"video.thumbnail.webp": "GENERATED-THUMB",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
}
if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" {
t.Errorf("expected the existing per-file thumbnail, got %q", string(body))
}
}
func TestAudioThumbnailPlaceholder(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{
"song.mp3": "dummy audio",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
}
body, _ := io.ReadAll(w.Body)
if len(body) == 0 {
t.Errorf("placeholder thumbnail body was empty")
}
}
func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
// Filenames with spaces are the case that broke: the template must emit a
// query value that the handler decodes back to the exact filename (the bug
// was double-escaping spaces to %2b, which decodes to '+').
files := map[string]string{
"01 - Color Bars.mp4": "v",
"02 - Test Pattern.mp4": "v",
}
createItem(t, cfg.LibraryDir, "multi", "Multi", files)
req := httptest.NewRequest("GET", "/library", nil)
w := httptest.NewRecorder()
srv.Router().ServeHTTP(w, req)
body, _ := io.ReadAll(w.Body)
re := regexp.MustCompile(`thumbnail\?file=([^"]+)`)
matches := re.FindAllStringSubmatch(string(body), -1)
if len(matches) != len(files) {
t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches))
}
for _, m := range matches {
vals, err := url.ParseQuery("file=" + m[1])
if err != nil {
t.Fatalf("bad query %q: %v", m[1], err)
}
got := vals.Get("file")
if _, ok := files[got]; !ok {
t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got)
}
}
}
// TestNestedFolderLinkRoundTrip guards the double-encoding regression: a folder
// whose name contains a space was linked with urlEncodePath *inside* a ?path=
// query, which html/template then re-escaped (%20 -> %2520). Clicking the link
// landed on a path the server decoded to "playlist%20test%202" — a directory
// that doesn't exist — so the folder rendered empty and the breadcrumb showed
// the literal "%20". The link must round-trip: its decoded ?path must be the
// real directory, the item inside must render, and the breadcrumb must show the
// human-readable name.
func TestNestedFolderLinkRoundTrip(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "subs/playlist test 2/Vid One", "Vid One", map[string]string{
"video.mp4": "dummy video",
})
router := srv.Router()
// List the parent and pull out the generated folder link.
req := httptest.NewRequest("GET", "/library?path=subs", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("list /library?path=subs: got %d", w.Code)
}
body := w.Body.String()
folderHref := regexp.MustCompile(`href="(/library\?path=[^"]+)" class="folder-item"`).FindStringSubmatch(body)
if folderHref == nil {
t.Fatalf("no folder link rendered for nested folder; body:\n%s", body)
}
href := html.UnescapeString(folderHref[1])
// The link's decoded ?path must be the real directory, not a still-encoded one.
u, err := url.Parse(href)
if err != nil {
t.Fatalf("parse folder href %q: %v", href, err)
}
if got := u.Query().Get("path"); got != "subs/playlist test 2" {
t.Fatalf("folder link path decodes to %q, want %q (double-encoding regression)", got, "subs/playlist test 2")
}
// Follow the link exactly as a browser would. The folder must not be empty,
// and the breadcrumb must show the readable name (never the encoded form).
req = httptest.NewRequest("GET", href, nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("follow folder link %q: got %d", href, w.Code)
}
nested := w.Body.String()
if !strings.Contains(nested, "Vid One") {
t.Errorf("nested folder rendered empty — item 'Vid One' missing; body:\n%s", nested)
}
// The breadcrumb must display the readable name, not the percent-encoded form.
if !strings.Contains(nested, ">playlist test 2<") {
t.Errorf("breadcrumb missing readable folder name 'playlist test 2'")
}
if strings.Contains(nested, ">playlist%20test%202<") {
t.Errorf("breadcrumb displays the encoded name instead of a space (regression)")
}
// No link may carry a double-encoded path (%2520 == %25 + 20 == re-escaped %20).
if strings.Contains(nested, "%2520") {
t.Errorf("a link is double-encoded (%%2520) — urlEncodePath inside a ?path= query (regression)")
}
}
func TestListingDoesNotExtractThumbnails(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{
"video.mp4": "dummy video",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Rendering the listing must not have created any thumbnail file.
entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid"))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".thumbnail.") {
t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name())
}
}
}
func TestGeneratedThumbnailServedOverIcon(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{
"video.mp4": "dummy video",
"video.thumbnail.webp": "WEBPDATA",
})
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" {
t.Errorf("expected generated thumbnail contents, got %q", string(body))
}
}
func TestThumbnailExtractedOnRequest(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not on PATH")
}
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
itemDir := filepath.Join(cfg.LibraryDir, "realvid")
createItem(t, cfg.LibraryDir, "realvid", "Real", nil)
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5",
"-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("make test video: %v\n%s", err, out)
}
router := srv.Router()
req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
t.Errorf("expected image content-type, got %q", ct)
}
body, _ := io.ReadAll(w.Body)
if len(body) == 0 {
t.Error("served thumbnail body was empty")
}
// A real thumbnail file should now exist on disk, with no temp leftovers.
entries, _ := os.ReadDir(itemDir)
var found bool
for _, e := range entries {
if strings.Contains(e.Name(), ".thumbnail.") {
found = true
}
if strings.Contains(e.Name(), ".tmp") {
t.Errorf("leftover temp file %q", e.Name())
}
}
if !found {
t.Error("no thumbnail file written to disk after request")
}
}
func TestLibraryPageIsFast(t *testing.T) {
srv, cfg, cleanup := setupTestServer(t)
defer cleanup()
for i := 0; i < 50; i++ {
createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{
"video.mp4": "dummy",
})
}
router := srv.Router()
req := httptest.NewRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
body, _ := io.ReadAll(w.Body)
t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
}
}