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)
}
}