summaryrefslogtreecommitdiff
path: root/internal/feed/feed_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/feed/feed_test.go')
-rw-r--r--internal/feed/feed_test.go343
1 files changed, 343 insertions, 0 deletions
diff --git a/internal/feed/feed_test.go b/internal/feed/feed_test.go
new file mode 100644
index 0000000..a529e6f
--- /dev/null
+++ b/internal/feed/feed_test.go
@@ -0,0 +1,343 @@
+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 <thing>.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 <EnclosureURL> 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)
+ }
+}