Recently Written · git

recentlywritten

my writings (done recently)

git clone https://github.com/equwal/recentlywritten

Log | Files | Refs


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[", "&amp;", "</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 }