package feed import ( "encoding/xml" "os" "path/filepath" "strings" "testing" "time" ) func writeFiles(t *testing.T, names ...string) string { t.Helper() dir := t.TempDir() for i, n := range names { // Distinct sizes so enclosure length is verifiably per-file. body := strings.Repeat("x", 100+i) if err := os.WriteFile(filepath.Join(dir, n), []byte(body), 0o644); err != nil { t.Fatalf("writing %s: %v", n, err) } } return dir } func build(t *testing.T, dir string, opts ...func(*Options)) *RSS { t.Helper() o := Options{ Dir: dir, BaseURL: "https://example.com/audio/", Title: "Personal Physics Lectures", Epoch: time.Date(2020, time.March, 1, 12, 0, 0, 0, time.UTC), } for _, f := range opts { f(&o) } r, err := Build(o, nil) if err != nil { t.Fatalf("Build: %v", err) } return r } func TestParseName(t *testing.T) { cases := []struct { in string track int title string }{ {"01 - The Law of Gravitation.mp3", 1, "The Law of Gravitation"}, {"07 - Seeking New Laws.mp3", 7, "Seeking New Laws"}, {"10 - Probability & Uncertainty.mp3", 10, "Probability & Uncertainty"}, {"3 - Short Prefix.mp3", 3, "Short Prefix"}, {"04 – En Dash Separator.mp3", 4, "En Dash Separator"}, {"No Number Here.mp3", 0, "No Number Here"}, {" Padded Name .mp3", 0, "Padded Name"}, } for _, c := range cases { track, title := ParseName(c.in) if track != c.track || title != c.title { t.Errorf("ParseName(%q) = (%d, %q), want (%d, %q)", c.in, track, title, c.track, c.title) } } } // Filenames routinely contain spaces and ampersands. Pasting them into the // enclosure URL unescaped produces both an invalid URL and invalid XML — the // single most likely way a hand-rolled feed fails to load at all. func TestEnclosureURL_EscapesFilenames(t *testing.T) { got, err := EnclosureURL("https://example.com/audio/", "10 - Probability & Uncertainty.mp3", "") if err != nil { t.Fatalf("EnclosureURL: %v", err) } if strings.ContainsAny(got, " ") { t.Errorf("URL still contains a raw space: %q", got) } for _, want := range []string{"%20", "&"} { _ = want } if !strings.Contains(got, "10%20-%20Probability") { t.Errorf("spaces not percent-encoded: %q", got) } if !strings.HasPrefix(got, "https://example.com/audio/") { t.Errorf("base URL mangled: %q", got) } } func TestEnclosureURL_HandlesMissingTrailingSlash(t *testing.T) { got, err := EnclosureURL("https://example.com/audio", "01 - One.mp3", "") if err != nil { t.Fatalf("EnclosureURL: %v", err) } if !strings.Contains(got, "/audio/01") { t.Errorf("expected a separator to be inserted, got %q", got) } } func TestEnclosureURL_RejectsBadBase(t *testing.T) { for _, bad := range []string{"", "not-a-url", "/just/a/path"} { if _, err := EnclosureURL(bad, "a.mp3", ""); err == nil { t.Errorf("EnclosureURL(%q) should have failed", bad) } } } // Zero-padding is a convention, not a guarantee. Ordering has to come from the // parsed track number, or a series with ten or more parts plays 1, 10, 2. func TestBuild_OrdersByTrackNumberNotFilename(t *testing.T) { dir := writeFiles(t, "1 - One.mp3", "10 - Ten.mp3", "2 - Two.mp3", ) r := build(t, dir) var titles []string for _, it := range r.Channel.Items { titles = append(titles, it.Title) } want := []string{"One", "Two", "Ten"} for i := range want { if titles[i] != want[i] { t.Fatalf("order = %v, want %v", titles, want) } } } // Files with no track number should not displace the numbered sequence. func TestBuild_UntrackedFilesSortLast(t *testing.T) { dir := writeFiles(t, "Intro.mp3", "01 - One.mp3", "02 - Two.mp3") r := build(t, dir) last := r.Channel.Items[len(r.Channel.Items)-1] if last.Title != "Intro" { t.Errorf("last item = %q, want the untracked Intro", last.Title) } } // Anything that is not audio must be ignored, including the .part files an // interrupted download leaves behind and any cover art. func TestBuild_IgnoresNonAudio(t *testing.T) { dir := writeFiles(t, "01 - One.mp3", "cover.jpg", "02 - Two.mp3.part", "notes.txt", ) r := build(t, dir) if len(r.Channel.Items) != 1 { var got []string for _, i := range r.Channel.Items { got = append(got, i.Title) } t.Errorf("got %d items %v, want only the mp3", len(r.Channel.Items), got) } } func TestBuild_EnclosureLengthIsTheFileSize(t *testing.T) { dir := writeFiles(t, "01 - One.mp3") info, err := os.Stat(filepath.Join(dir, "01 - One.mp3")) if err != nil { t.Fatal(err) } r := build(t, dir) if got := r.Channel.Items[0].Enclosure.Length; got != info.Size() { t.Errorf("Length = %d, want %d", got, info.Size()) } } // Every item needs a distinct, increasing date or clients are free to order // the series however they like. func TestBuild_PubDatesAreDistinctAndAscending(t *testing.T) { dir := writeFiles(t, "01 - One.mp3", "02 - Two.mp3", "03 - Three.mp3") r := build(t, dir) var prev time.Time for i, it := range r.Channel.Items { got, err := time.Parse(time.RFC1123Z, it.PubDate) if err != nil { t.Fatalf("item %d has an unparseable pubDate %q: %v", i, it.PubDate, err) } if i > 0 && !got.After(prev) { t.Errorf("item %d pubDate %v is not after the previous %v", i, got, prev) } prev = got } } func TestBuild_EpisodeNumbersFollowTracks(t *testing.T) { dir := writeFiles(t, "01 - One.mp3", "02 - Two.mp3") r := build(t, dir) for i, want := range []int{1, 2} { if r.Channel.Items[i].Episode != want { t.Errorf("item %d episode = %d, want %d", i, r.Channel.Items[i].Episode, want) } } } // A lecture course must be presented oldest-first; the default for a podcast // is newest-first, which would start the listener at the final lecture. func TestBuild_MarkedAsSerial(t *testing.T) { r := build(t, writeFiles(t, "01 - One.mp3")) if r.Channel.ITunesType != "serial" { t.Errorf("itunes:type = %q, want serial", r.Channel.ITunesType) } } func TestBuild_RequiresATitle(t *testing.T) { _, err := Build(Options{Dir: t.TempDir(), BaseURL: "https://e.com/"}, nil) if err == nil { t.Error("expected an error when no feed title is given") } } // A missing directory must be an error, not an empty feed that looks like a // working setup with nothing in it. func TestBuild_MissingDirectoryIsAnError(t *testing.T) { _, err := Build(Options{ Dir: filepath.Join(t.TempDir(), "nope"), BaseURL: "https://e.com/", Title: "T", }, nil) if err == nil { t.Error("expected an error for a missing directory") } } func TestBuild_ChannelHasRequiredElements(t *testing.T) { r := build(t, writeFiles(t, "01 - One.mp3")) if r.Channel.Title == "" { t.Error("channel title is empty") } if r.Channel.Description == "" { t.Error("channel description is empty — many clients reject a feed without one") } if r.Channel.Link == "" { t.Error("channel link is empty") } } // ID3 tags, when present, are better than a filename guess. func TestBuild_PrefersTagsOverFilenames(t *testing.T) { dir := writeFiles(t, "01 - filename title.mp3") r, err := Build(Options{ Dir: dir, BaseURL: "https://example.com/a/", Title: "T", }, func(string) (string, int, bool) { return "Tagged Title", 5, true }) if err != nil { t.Fatalf("Build: %v", err) } if r.Channel.Items[0].Title != "Tagged Title" { t.Errorf("title = %q, want the tag value", r.Channel.Items[0].Title) } if r.Channel.Items[0].Episode != 5 { t.Errorf("episode = %d, want the tag track", r.Channel.Items[0].Episode) } } // A tag reader that finds nothing must not blank out the filename-derived title. func TestBuild_FallsBackWhenTagsAreAbsent(t *testing.T) { dir := writeFiles(t, "01 - Filename Title.mp3") r, err := Build(Options{Dir: dir, BaseURL: "https://e.com/a/", Title: "T"}, func(string) (string, int, bool) { return "", 0, false }) if err != nil { t.Fatalf("Build: %v", err) } if r.Channel.Items[0].Title != "Filename Title" { t.Errorf("title = %q, want the filename fallback", r.Channel.Items[0].Title) } } // The whole document must be well-formed and round-trip through a parser — // this is what a podcast client does first, and the thing most likely to fail // on unescaped content. func TestMarshal_ProducesParseableXML(t *testing.T) { dir := writeFiles(t, "10 - Probability & Uncertainty.mp3", "01 - One .mp3") r := build(t, dir) out, err := Marshal(r) if err != nil { t.Fatalf("Marshal: %v", err) } if !strings.HasPrefix(string(out), xml.Header) { t.Error("missing XML declaration") } var parsed struct { Channel struct { Title string `xml:"title"` Items []struct { Title string `xml:"title"` Enclosure struct { URL string `xml:"url,attr"` Length int64 `xml:"length,attr"` } `xml:"enclosure"` } `xml:"item"` } `xml:"channel"` } if err := xml.Unmarshal(out, &parsed); err != nil { t.Fatalf("generated feed does not parse: %v\n%s", err, out) } if len(parsed.Channel.Items) != 2 { t.Fatalf("parsed %d items, want 2", len(parsed.Channel.Items)) } for _, it := range parsed.Channel.Items { if it.Enclosure.URL == "" { t.Error("an item has no enclosure URL") } if it.Enclosure.Length == 0 { t.Error("an item has a zero enclosure length") } } } // The original hand-rolled struct had a field with no xml tag, which the // marshaller emitted as a stray element. Guard against any // element leaking in that a reader would not expect. func TestMarshal_NoStrayElements(t *testing.T) { out, err := Marshal(build(t, writeFiles(t, "01 - One.mp3"))) if err != nil { t.Fatal(err) } if strings.Contains(string(out), "EnclosureURL") { t.Error("stray EnclosureURL element in output") } if strings.Contains(string(out), "XMLName") { t.Error("stray XMLName element in output") } } func TestMarshal_GUIDIsNotAPermalink(t *testing.T) { out, _ := Marshal(build(t, writeFiles(t, "01 - One.mp3"))) if !strings.Contains(string(out), `isPermaLink="false"`) { t.Error(`guid should carry isPermaLink="false"; it is not a URL`) } } func TestBuild_EmptyDirectoryProducesAValidEmptyFeed(t *testing.T) { r := build(t, t.TempDir()) if len(r.Channel.Items) != 0 { t.Errorf("got %d items, want 0", len(r.Channel.Items)) } if _, err := Marshal(r); err != nil { t.Errorf("an empty feed should still marshal: %v", err) } }