package fetch import ( "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) const sampleRSS = ` Lectures on Physics Three - The Great Conservation Principles Two - The Relation of Mathematics & Physics One - The Law of Gravitation ` 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, "not a feed") })) 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, `T Only `, 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) } }