summaryrefslogtreecommitdiff
path: root/internal/fetch/direct_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/fetch/direct_test.go')
-rw-r--r--internal/fetch/direct_test.go242
1 files changed, 242 insertions, 0 deletions
diff --git a/internal/fetch/direct_test.go b/internal/fetch/direct_test.go
new file mode 100644
index 0000000..86b9e31
--- /dev/null
+++ b/internal/fetch/direct_test.go
@@ -0,0 +1,242 @@
+package fetch
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const sampleRSS = `<?xml version="1.0"?>
+<rss version="2.0">
+ <channel>
+ <title>Lectures on Physics</title>
+ <item>
+ <title>Three - The Great Conservation Principles</title>
+ <enclosure url="%s/three.mp3" type="audio/mpeg" length="10"/>
+ </item>
+ <item>
+ <title>Two - The Relation of Mathematics &amp; Physics</title>
+ <enclosure url="%s/two.mp3" type="audio/mpeg" length="10"/>
+ </item>
+ <item>
+ <title>One - The Law of Gravitation</title>
+ <enclosure url="%s/one.mp3" type="audio/mpeg" length="10"/>
+ </item>
+ </channel>
+</rss>`
+
+func rssServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, ".mp3") {
+ fmt.Fprintf(w, "audio for %s", r.URL.Path)
+ return
+ }
+ w.Header().Set("Content-Type", "application/rss+xml")
+ fmt.Fprintf(w, sampleRSS, srv.URL, srv.URL, srv.URL)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestDiscoverDirectAudioURL(t *testing.T) {
+ sources, _, err := Discover(context.Background(), "https://example.org/talks/01%20-%20Intro.mp3", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sources) != 1 {
+ t.Fatalf("got %d sources", len(sources))
+ }
+ if sources[0].Name != "01 - Intro.mp3" {
+ t.Errorf("name %q — the URL escape should be decoded", sources[0].Name)
+ }
+}
+
+func TestDiscoverRSSEnclosures(t *testing.T) {
+ srv := rssServer(t)
+ sources, title, err := Discover(context.Background(), srv.URL+"/feed.xml", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if title != "Lectures on Physics" {
+ t.Errorf("title %q", title)
+ }
+ if len(sources) != 3 {
+ t.Fatalf("got %d sources", len(sources))
+ }
+}
+
+// RSS is newest-first by convention, so the last item is episode 1. Numbering
+// has to run in reverse or a mirrored course plays backwards.
+func TestDiscoverNumbersRSSInListeningOrder(t *testing.T) {
+ srv := rssServer(t)
+ sources, _, err := Discover(context.Background(), srv.URL+"/feed.xml", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ byTrack := map[int]string{}
+ for _, s := range sources {
+ byTrack[s.Track] = s.Name
+ }
+ if !strings.Contains(byTrack[1], "One - The Law of Gravitation") {
+ t.Errorf("track 1 is %q, want the feed's last item", byTrack[1])
+ }
+ if !strings.Contains(byTrack[3], "Three - The Great Conservation") {
+ t.Errorf("track 3 is %q, want the feed's first item", byTrack[3])
+ }
+ if !strings.HasPrefix(byTrack[1], "01 - ") {
+ t.Errorf("track 1 name %q is not zero-padded", byTrack[1])
+ }
+}
+
+func TestDiscoverRejectsNonFeed(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, "<html><body>not a feed</body></html>")
+ }))
+ defer srv.Close()
+
+ if _, _, err := Discover(context.Background(), srv.URL, nil); err == nil {
+ t.Error("expected an error for a page that is not a feed")
+ }
+}
+
+func TestDiscoverRejectsBadURL(t *testing.T) {
+ for _, u := range []string{"", "not-a-url", "/just/a/path"} {
+ if _, _, err := Discover(context.Background(), u, nil); err == nil {
+ t.Errorf("%q was accepted", u)
+ }
+ }
+}
+
+func TestDownloadWritesFiles(t *testing.T) {
+ srv := rssServer(t)
+ dir := t.TempDir()
+
+ sources, _, err := Discover(context.Background(), srv.URL+"/feed.xml", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Download(context.Background(), sources, dir, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ entries, _ := os.ReadDir(dir)
+ if len(entries) != 3 {
+ t.Fatalf("got %d files", len(entries))
+ }
+ for _, e := range entries {
+ if strings.HasSuffix(e.Name(), ".part") {
+ t.Errorf("partial file %q left behind", e.Name())
+ }
+ }
+}
+
+// Re-running must not re-download; this is what makes a long series resumable.
+func TestDownloadSkipsExisting(t *testing.T) {
+ var hits int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ fmt.Fprint(w, "audio")
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ sources := []Source{{URL: srv.URL + "/a.mp3", Name: "01 - A.mp3", Track: 1}}
+
+ for i := 0; i < 3; i++ {
+ if err := Download(context.Background(), sources, dir, nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if hits != 1 {
+ t.Errorf("downloaded %d times, want 1", hits)
+ }
+}
+
+// A failed download must not leave something the feed generator would treat as
+// a complete episode.
+func TestDownloadLeavesNothingOnFailure(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "gone", http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ err := Download(context.Background(), []Source{{URL: srv.URL + "/a.mp3", Name: "01 - A.mp3"}}, dir, nil, nil)
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+ t.Errorf("left %d file(s) behind after a failed download", len(entries))
+ }
+}
+
+func TestSanitiseRemovesPathCharacters(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"A/B: The Sequel", "AB The Sequel"},
+ {"What? Why!", "What Why!"},
+ {" spaced out ", "spaced out"},
+ // Separators are dropped and the leading dots trimmed, so a name that
+ // tried to traverse comes out as an ordinary filename.
+ {"../../etc/passwd", "etcpasswd"},
+ {"", "episode"},
+ {"...", "episode"},
+ }
+ for _, c := range cases {
+ if got := sanitise(c.in); got != c.want {
+ t.Errorf("sanitise(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// A sanitised name must never be able to escape the download directory.
+func TestSanitisedNamesStayInDirectory(t *testing.T) {
+ dir := t.TempDir()
+ for _, in := range []string{"../escape", "../../escape", "/absolute", "a/b/c"} {
+ joined := filepath.Join(dir, sanitise(in)+".mp3")
+ if !strings.HasPrefix(joined, dir+string(filepath.Separator)) {
+ t.Errorf("sanitise(%q) produced a path outside the directory: %s", in, joined)
+ }
+ }
+}
+
+func TestExtOfPrefersURLThenMIME(t *testing.T) {
+ sources, _, err := Discover(context.Background(), "https://example.org/a/file.opus", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Ext(sources[0].Name) != ".opus" {
+ t.Errorf("name %q", sources[0].Name)
+ }
+}
+
+// An enclosure URL with no useful extension has to fall back to the declared
+// MIME type, or the file lands as .mp3 and no player reads its tags.
+func TestExtOfFallsBackToMIMEType(t *testing.T) {
+ var srv *httptest.Server
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/stream") {
+ fmt.Fprint(w, "audio")
+ return
+ }
+ fmt.Fprintf(w, `<?xml version="1.0"?><rss version="2.0"><channel><title>T</title>
+ <item><title>Only</title><enclosure url="%s/stream?id=7" type="audio/mp4"/></item>
+ </channel></rss>`, srv.URL)
+ }))
+ defer srv.Close()
+
+ sources, _, err := Discover(context.Background(), srv.URL+"/feed.xml", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Ext(sources[0].Name) != ".m4a" {
+ t.Errorf("name %q, want a .m4a extension from the audio/mp4 type", sources[0].Name)
+ }
+}