accounts/feed_test.go (5016 bytes)
1 package main 2 3 import ( 4 "bytes" 5 "encoding/xml" 6 "errors" 7 "io" 8 "slices" 9 "strings" 10 "testing" 11 12 "pgregory.net/rapid" 13 ) 14 15 // wellFormed reads each token of doc with a strict XML parser. 16 func wellFormed(doc []byte) error { 17 d := xml.NewDecoder(bytes.NewReader(doc)) 18 for { 19 _, err := d.Token() 20 if errors.Is(err, io.EOF) { 21 return nil 22 } 23 if err != nil { 24 return err 25 } 26 } 27 } 28 29 // anyText draws any string, also strings that are not valid UTF-8 and 30 // strings with characters that XML 1.0 does not permit. 31 func anyText() *rapid.Generator[string] { 32 return rapid.OneOf( 33 rapid.String(), 34 rapid.Map(rapid.SliceOf(rapid.Byte()), func(b []byte) string { return string(b) }), 35 rapid.SampledFrom([]string{"]]>", "<![CDATA[", "&", "</item>", "\x00", "\r\n", "\uFFFE"}), 36 ) 37 } 38 39 // xmlText draws strings of characters that XML 1.0 permits. 40 func xmlText() *rapid.Generator[string] { 41 return rapid.StringOf(rapid.Rune().Filter(func(r rune) bool { 42 return r == '\t' || r == '\n' || r == '\r' || 43 r >= 0x20 && r <= 0xD7FF || r >= 0xE000 && r <= 0xFFFD || r >= 0x10000 && r <= 0x10FFFF 44 })) 45 } 46 47 func drawChannel(t *rapid.T, text *rapid.Generator[string]) channel { 48 ch := channel{Title: text.Draw(t, "title"), Link: text.Draw(t, "link"), 49 Description: text.Draw(t, "desc"), Language: "en-us"} 50 for range rapid.IntRange(0, 3).Draw(t, "items") { 51 ch.Items = append(ch.Items, item{ 52 Title: text.Draw(t, "item title"), 53 Link: text.Draw(t, "item link"), 54 GUID: guid{IsPermaLink: "true", Value: text.Draw(t, "guid")}, 55 PubDate: text.Draw(t, "date"), 56 Categories: rapid.SliceOfN(text, 0, 2).Draw(t, "tags"), 57 Description: text.Draw(t, "body"), 58 }) 59 } 60 return ch 61 } 62 63 func TestFeedIsWellFormedProperty(t *testing.T) { 64 rapid.Check(t, func(t *rapid.T) { 65 var b bytes.Buffer 66 if err := writeFeed(&b, drawChannel(t, anyText())); err != nil { 67 t.Fatal(err) 68 } 69 if err := wellFormed(b.Bytes()); err != nil { 70 t.Fatalf("feed is not well-formed: %v\n%s", err, b.String()) 71 } 72 }) 73 } 74 75 // TestFeedRoundTripProperty checks the escaping: an XML parser gives back 76 // each text exactly. 77 func TestFeedRoundTripProperty(t *testing.T) { 78 rapid.Check(t, func(t *rapid.T) { 79 want := drawChannel(t, xmlText()) 80 var b bytes.Buffer 81 if err := writeFeed(&b, want); err != nil { 82 t.Fatal(err) 83 } 84 var got rss 85 if err := xml.Unmarshal(b.Bytes(), &got); err != nil { 86 t.Fatalf("unmarshal: %v\n%s", err, b.String()) 87 } 88 g := got.Channel 89 if g.Title != want.Title || g.Link != want.Link || g.Description != want.Description || 90 len(g.Items) != len(want.Items) { 91 t.Fatalf("channel: got %+v, want %+v", g, want) 92 } 93 for i, w := range want.Items { 94 gi := g.Items[i] 95 if gi.Title != w.Title || gi.Link != w.Link || gi.GUID != w.GUID || 96 gi.PubDate != w.PubDate || gi.Description != w.Description || 97 !slices.Equal(gi.Categories, w.Categories) { 98 t.Fatalf("item %d: got %+v, want %+v", i, gi, w) 99 } 100 } 101 }) 102 } 103 104 func TestBuildFeedFiltersAndLimits(t *testing.T) { 105 var posts []Post 106 for i := range 30 { 107 tag := "articles" 108 if i%2 == 1 { 109 tag = "projects" 110 } 111 posts = append(posts, Post{Date: "2026-09-01", Slug: "p" + string(rune('a'+i)), Tags: []string{tag}, Title: "T"}) 112 } 113 posts = append(posts, Post{Date: "bad", Slug: "untagged", Title: "U"}) 114 115 ch := buildFeed("https://example.com", posts, map[string]bool{"projects": true}, func(string) string { return "" }) 116 if len(ch.Items) != 16 { // 15 articles and the untagged post 117 t.Fatalf("got %d items, want 16", len(ch.Items)) 118 } 119 for _, it := range ch.Items { 120 if slices.Contains(it.Categories, "projects") { 121 t.Errorf("muted tag in feed: %+v", it) 122 } 123 } 124 if got := ch.Items[0].PubDate; got != "Tue, 01 Sep 2026 00:00:00 +0000" { 125 t.Errorf("pubDate = %q", got) 126 } 127 if got := ch.Items[15].PubDate; got != "" { 128 t.Errorf("pubDate for a bad date = %q, want empty", got) 129 } 130 131 ch = buildFeed("https://example.com", posts, nil, func(string) string { return "" }) 132 if len(ch.Items) != feedSize { 133 t.Fatalf("got %d items, want %d", len(ch.Items), feedSize) 134 } 135 } 136 137 func TestParseIndex(t *testing.T) { 138 in := "2026-09-22\trebind\tprojects\tRebind: a tool\r\n\n2025-01-01\tx\tlisp, unix\tA \"b\" & c\n" 139 posts, err := parseIndex(strings.NewReader(in)) 140 if err != nil { 141 t.Fatal(err) 142 } 143 want := []Post{ 144 {Date: "2026-09-22", Slug: "rebind", Tags: []string{"projects"}, Title: "Rebind: a tool"}, 145 {Date: "2025-01-01", Slug: "x", Tags: []string{"lisp", "unix"}, Title: `A "b" & c`}, 146 } 147 if len(posts) != len(want) { 148 t.Fatalf("got %+v", posts) 149 } 150 for i := range want { 151 if posts[i].Slug != want[i].Slug || posts[i].Title != want[i].Title || 152 posts[i].Date != want[i].Date || !slices.Equal(posts[i].Tags, want[i].Tags) { 153 t.Errorf("post %d = %+v, want %+v", i, posts[i], want[i]) 154 } 155 } 156 157 for _, bad := range []string{"a\t../etc/passwd\tx\tT\n", "a\t.hidden\tx\tT\n", "only\tthree\tfields\n"} { 158 if _, err := parseIndex(strings.NewReader(bad)); err == nil { 159 t.Errorf("parseIndex(%q) gave no error", bad) 160 } 161 } 162 }