package service import ( "database/sql" "os" "path/filepath" "strings" "testing" "vidarchive/internal/config" "vidarchive/internal/models" ) func TestSanitizeDirName(t *testing.T) { tests := []struct{ in, want string }{ {"normal title", "normal title"}, {"a/b:c*d?e\"fh|i", "a-b-c-d-e-f-g-h-i"}, {" trimmed ", "trimmed"}, {"", "untitled"}, {"///", "---"}, } for _, tc := range tests { if got := sanitizeDirName(tc.in); got != tc.want { t.Errorf("sanitizeDirName(%q) = %q, want %q", tc.in, got, tc.want) } } } func TestUniqueDir(t *testing.T) { base := t.TempDir() svc := &DownloadService{} first := svc.uniqueDir(base, "item") if filepath.Base(first) != "item" { t.Errorf("first uniqueDir = %q, want .../item", first) } if err := os.MkdirAll(first, 0755); err != nil { t.Fatal(err) } second := svc.uniqueDir(base, "item") if filepath.Base(second) != "item-1" { t.Errorf("second uniqueDir = %q, want .../item-1", second) } if err := os.MkdirAll(second, 0755); err != nil { t.Fatal(err) } third := svc.uniqueDir(base, "item") if filepath.Base(third) != "item-2" { t.Errorf("third uniqueDir = %q, want .../item-2", third) } } func TestDeriveItemName(t *testing.T) { svc := &DownloadService{} itemDir := t.TempDir() // info.json title wins and is sanitized. infoPath := filepath.Join(itemDir, "info.json") if err := os.WriteFile(infoPath, []byte(`{"title":"Cool: Video"}`), 0644); err != nil { t.Fatal(err) } bigName := "big.mp4" if err := os.WriteFile(filepath.Join(itemDir, bigName), []byte("xxxxxxxxxx"), 0644); err != nil { t.Fatal(err) } media := []os.DirEntry{dirEntry(t, itemDir, bigName)} if got := svc.deriveItemName(itemDir, infoPath, media); got != "Cool- Video" { t.Errorf("deriveItemName with info.json = %q, want %q", got, "Cool- Video") } // Without info.json, falls back to the largest media file's stem. small := "small.mp4" if err := os.WriteFile(filepath.Join(itemDir, small), []byte("x"), 0644); err != nil { t.Fatal(err) } media = []os.DirEntry{dirEntry(t, itemDir, small), dirEntry(t, itemDir, bigName)} if got := svc.deriveItemName(itemDir, "", media); got != "big" { t.Errorf("deriveItemName fallback = %q, want %q (largest file stem)", got, "big") } } func dirEntry(t *testing.T, dir, name string) os.DirEntry { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, e := range entries { if e.Name() == name { return e } } t.Fatalf("dir entry %q not found", name) return nil } func TestParseFormatList(t *testing.T) { output := `[info] Available formats: ID EXT RESOLUTION FPS 18 mp4 640x360 30 137 mp4 1920x1080 60 140 m4a audio only ` formats := parseFormatList(output) if len(formats) != 3 { t.Fatalf("expected 3 formats, got %d: %+v", len(formats), formats) } if formats[0].ID != "18" || formats[0].Ext != "mp4" { t.Errorf("format[0] = %+v", formats[0]) } if formats[1].Resolution != "1920x1080" { t.Errorf("format[1] resolution = %q, want 1920x1080", formats[1].Resolution) } } // TestImportItemDir exercises the full import: media + info.json + subtitles get // sorted into a named item directory with a marker. Uses ffmpeg to produce real // media so the mimetype-based classification in importItemDir matches. func TestImportItemDir(t *testing.T) { requireFFmpeg(t) libDir := t.TempDir() svc := &DownloadService{cfg: &config.Config{LibraryDir: libDir}} src := t.TempDir() makeTestVideo(t, filepath.Join(src, "raw.mp4")) if err := os.WriteFile(filepath.Join(src, "info.json"), []byte(`{"title":"My Clip"}`), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(src, "raw.en.srt"), []byte("1\n00:00:00,000 --> 00:00:01,000\nhi\n"), 0644); err != nil { t.Fatal(err) } if err := svc.importItemDir("https://example.com/v", src, libDir); err != nil { t.Fatalf("importItemDir: %v", err) } targetDir := filepath.Join(libDir, "My Clip") if _, err := os.Stat(filepath.Join(targetDir, itemMarkerName)); err != nil { t.Errorf("marker not created: %v", err) } if _, err := os.Stat(filepath.Join(targetDir, "info.json")); err != nil { t.Errorf("info.json not moved: %v", err) } if _, err := os.Stat(filepath.Join(targetDir, "raw.mp4")); err != nil { t.Errorf("media not moved: %v", err) } if _, err := os.Stat(filepath.Join(targetDir, subtitlesDirName, "raw.en.srt")); err != nil { t.Errorf("subtitle not moved into subtitles dir: %v", err) } // The marker should record the source URL. markerData, _ := os.ReadFile(filepath.Join(targetDir, itemMarkerName)) if want := "https://example.com/v"; !strings.Contains(string(markerData), want) { t.Errorf("marker missing source url %q: %s", want, markerData) } } func TestImportDownloadedItemsRejectsOutputTraversal(t *testing.T) { libDir := t.TempDir() svc := &DownloadService{cfg: &config.Config{LibraryDir: libDir}} // A temp download dir with one item subdir. tempDir := t.TempDir() itemDir := filepath.Join(tempDir, "item-00001") if err := os.MkdirAll(itemDir, 0755); err != nil { t.Fatal(err) } d := &models.Download{ URL: "u", OutputDir: sql.NullString{String: "../escape", Valid: true}, } if err := svc.importDownloadedItems(d, tempDir); err == nil { t.Error("expected path-traversal output dir to be rejected") } }