Commit 7e0547cd17
Verified · cmc ci/build: success ci/lint: success ci/test: success
Layout: unified · split
app/atom_test.go added +107
| @@ -0,0 +1,107 @@ | |||
| 1 | package app | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/xml" | ||
| 5 | "net/http/httptest" | ||
| 6 | "net/url" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/krazywarez/devianter" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // atomFeed is the subset of an Atom document the tests check. Decoding through | ||
| 15 | // encoding/xml also proves the output is well-formed XML. | ||
| 16 | type atomFeed struct { | ||
| 17 | XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` | ||
| 18 | ID string `xml:"id"` | ||
| 19 | Updated string `xml:"updated"` | ||
| 20 | Links []struct { | ||
| 21 | Rel string `xml:"rel,attr"` | ||
| 22 | Href string `xml:"href,attr"` | ||
| 23 | } `xml:"link"` | ||
| 24 | Entries []struct { | ||
| 25 | ID string `xml:"id"` | ||
| 26 | Published string `xml:"published"` | ||
| 27 | Updated string `xml:"updated"` | ||
| 28 | Thumbnail struct { | ||
| 29 | URL string `xml:"url,attr"` | ||
| 30 | } `xml:"http://search.yahoo.com/mrss/ group>thumbnail"` | ||
| 31 | } `xml:"entry"` | ||
| 32 | } | ||
| 33 | |||
| 34 | func renderFeed(t *testing.T, devs []devianter.Deviation) atomFeed { | ||
| 35 | t.Helper() | ||
| 36 | rec := httptest.NewRecorder() | ||
| 37 | s := skunkyart{Writer: rec, Host: "http://localhost", Atom: true, _pth: "/dd", Args: url.Values{"atom": {"true"}}} | ||
| 38 | s.DeviationList(devs, true) | ||
| 39 | |||
| 40 | var feed atomFeed | ||
| 41 | if err := xml.Unmarshal(rec.Body.Bytes(), &feed); err != nil { | ||
| 42 | t.Fatalf("feed is not well-formed: %v\n%s", err, rec.Body.String()) | ||
| 43 | } | ||
| 44 | return feed | ||
| 45 | } | ||
| 46 | |||
| 47 | func selfLink(f atomFeed) string { | ||
| 48 | for _, l := range f.Links { | ||
| 49 | if l.Rel == "self" { | ||
| 50 | return l.Href | ||
| 51 | } | ||
| 52 | } | ||
| 53 | return "" | ||
| 54 | } | ||
| 55 | |||
| 56 | // TestAtomFeedHasTheRequiredElements pins what the spec demands and readers | ||
| 57 | // reject a feed without: a feed id and updated time, IRI entry ids, RFC 3339 | ||
| 58 | // timestamps, and the correctly spelled media thumbnail. | ||
| 59 | func TestAtomFeedHasTheRequiredElements(t *testing.T) { | ||
| 60 | proxy, nsfw := CFG.Proxy, CFG.Nsfw | ||
| 61 | CFG.Proxy, CFG.Nsfw = true, true | ||
| 62 | defer func() { CFG.Proxy, CFG.Nsfw = proxy, nsfw }() | ||
| 63 | |||
| 64 | d := *fullviewDeviation() | ||
| 65 | d.ID = 123 | ||
| 66 | d.Title = "T" | ||
| 67 | d.Author.Username = "alice" | ||
| 68 | d.PublishedTime.Time = time.Date(2026, 8, 11, 18, 36, 34, 0, time.UTC) | ||
| 69 | |||
| 70 | feed := renderFeed(t, []devianter.Deviation{d}) | ||
| 71 | |||
| 72 | if !strings.HasPrefix(feed.ID, "http://localhost/dd?") { | ||
| 73 | t.Errorf("feed id %q, want the feed's own URL", feed.ID) | ||
| 74 | } | ||
| 75 | if feed.Updated != "2026-08-11T18:36:34Z" { | ||
| 76 | t.Errorf("feed updated %q, want the newest entry's RFC 3339 time", feed.Updated) | ||
| 77 | } | ||
| 78 | if selfLink(feed) != feed.ID { | ||
| 79 | t.Errorf("self link %q, want %q", selfLink(feed), feed.ID) | ||
| 80 | } | ||
| 81 | if len(feed.Entries) != 1 { | ||
| 82 | t.Fatalf("%d entries, want 1", len(feed.Entries)) | ||
| 83 | } | ||
| 84 | e := feed.Entries[0] | ||
| 85 | if !strings.HasPrefix(e.ID, "http://localhost/post/alice/") { | ||
| 86 | t.Errorf("entry id %q, want the post's URL", e.ID) | ||
| 87 | } | ||
| 88 | if e.Published != "2026-08-11T18:36:34Z" || e.Updated != e.Published { | ||
| 89 | t.Errorf("entry published %q updated %q, want RFC 3339 and equal", e.Published, e.Updated) | ||
| 90 | } | ||
| 91 | if e.Thumbnail.URL == "" { | ||
| 92 | t.Error("entry has no media:thumbnail, want one (the element used to be misspelled)") | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | // TestAtomFeedIsValidWhenEmpty pins that an empty listing still yields a feed | ||
| 97 | // with an updated time, so a reader polling an empty gallery gets a document | ||
| 98 | // rather than an error. | ||
| 99 | func TestAtomFeedIsValidWhenEmpty(t *testing.T) { | ||
| 100 | feed := renderFeed(t, nil) | ||
| 101 | if feed.ID == "" || feed.Updated == "" { | ||
| 102 | t.Errorf("empty feed id %q updated %q, want both set", feed.ID, feed.Updated) | ||
| 103 | } | ||
| 104 | if _, err := time.Parse(time.RFC3339, feed.Updated); err != nil { | ||
| 105 | t.Errorf("empty feed updated %q is not RFC 3339: %v", feed.Updated, err) | ||
| 106 | } | ||
| 107 | } | ||
app/parsers.go +31 −6
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "strconv" | 5 | "strconv" |
| 6 | "strings" | 6 | "strings" |
| 7 | "time" | ||
| 7 | 8 | ||
| 8 | "github.com/krazywarez/devianter" | 9 | "github.com/krazywarez/devianter" |
| 9 | "golang.org/x/net/html" | 10 | "golang.org/x/net/html" |
| @@ -102,6 +103,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | |||
| 102 | } | 103 | } |
| 103 | 104 | ||
| 104 | var list, listContent strings.Builder | 105 | var list, listContent strings.Builder |
| 106 | var newest time.Time // the latest entry, for the feed's own <updated> | ||
| 105 | 107 | ||
| 106 | for i, l := 0, len(devs); i < l; i++ { | 108 | for i, l := 0, len(devs); i < l; i++ { |
| 107 | data := &devs[i] | 109 | data := &devs[i] |
| @@ -115,21 +117,30 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | |||
| 115 | if preview, fullview := esc(ParseMedia(s.Host, data.Media, 320)), esc(ParseMedia(s.Host, data.Media)); true { | 117 | if preview, fullview := esc(ParseMedia(s.Host, data.Media, 320)), esc(ParseMedia(s.Host, data.Media)); true { |
| 116 | if allowAtom && s.Atom { | 118 | if allowAtom && s.Atom { |
| 117 | s.Writer.Header().Add("Content-Type", "application/atom+xml") | 119 | s.Writer.Header().Add("Content-Type", "application/atom+xml") |
| 118 | id := strconv.Itoa(data.ID) | 120 | // The entry id is the post's URL here: Atom wants an IRI, and a |
| 121 | // bare number is not one. Timestamps are RFC 3339, the only form | ||
| 122 | // the format allows. | ||
| 123 | entryURL := esc(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+strconv.Itoa(data.ID))) | ||
| 124 | published := data.PublishedTime.UTC() | ||
| 125 | if published.After(newest) { | ||
| 126 | newest = published | ||
| 127 | } | ||
| 119 | listContent.WriteString(`<entry><author><name>`) | 128 | listContent.WriteString(`<entry><author><name>`) |
| 120 | listContent.WriteString(author) | 129 | listContent.WriteString(author) |
| 121 | listContent.WriteString(`</name></author><title>`) | 130 | listContent.WriteString(`</name></author><title>`) |
| 122 | listContent.WriteString(title) | 131 | listContent.WriteString(title) |
| 123 | listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`) | 132 | listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`) |
| 124 | listContent.WriteString(esc(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id))) | 133 | listContent.WriteString(entryURL) |
| 125 | listContent.WriteString(`"/><id>`) | 134 | listContent.WriteString(`"/><id>`) |
| 126 | listContent.WriteString(id) | 135 | listContent.WriteString(entryURL) |
| 127 | listContent.WriteString(`</id><published>`) | 136 | listContent.WriteString(`</id><published>`) |
| 128 | listContent.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700")) | 137 | listContent.WriteString(published.Format(time.RFC3339)) |
| 129 | listContent.WriteString(`</published>`) | 138 | listContent.WriteString(`</published><updated>`) |
| 139 | listContent.WriteString(published.Format(time.RFC3339)) | ||
| 140 | listContent.WriteString(`</updated>`) | ||
| 130 | listContent.WriteString(`<media:group><media:title>`) | 141 | listContent.WriteString(`<media:group><media:title>`) |
| 131 | listContent.WriteString(title) | 142 | listContent.WriteString(title) |
| 132 | listContent.WriteString(`</media:title><media:thumbinal url="`) | 143 | listContent.WriteString(`</media:title><media:thumbnail url="`) |
| 133 | listContent.WriteString(preview) | 144 | listContent.WriteString(preview) |
| 134 | listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`) | 145 | listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`) |
| 135 | listContent.WriteString(postURL) | 146 | listContent.WriteString(postURL) |
| @@ -185,6 +196,20 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | |||
| 185 | } | 196 | } |
| 186 | list.WriteString(`</title>`) | 197 | list.WriteString(`</title>`) |
| 187 | 198 | ||
| 199 | // Atom requires a feed id and updated time; readers reject a feed | ||
| 200 | // without them. The id is the feed's own URL on this instance. | ||
| 201 | feedURL := esc(s.Host + s._pth + "?" + s.Args.Encode()) | ||
| 202 | if newest.IsZero() { | ||
| 203 | newest = time.Now().UTC() | ||
| 204 | } | ||
| 205 | list.WriteString(`<id>`) | ||
| 206 | list.WriteString(feedURL) | ||
| 207 | list.WriteString(`</id><updated>`) | ||
| 208 | list.WriteString(newest.Format(time.RFC3339)) | ||
| 209 | list.WriteString(`</updated><link rel="self" type="application/atom+xml" href="`) | ||
| 210 | list.WriteString(feedURL) | ||
| 211 | list.WriteString(`"/>`) | ||
| 212 | |||
| 188 | list.WriteString(`<link rel="alternate" href="`) | 213 | list.WriteString(`<link rel="alternate" href="`) |
| 189 | list.WriteString(esc(s.Host)) | 214 | list.WriteString(esc(s.Host)) |
| 190 | list.WriteString(`"/>`) | 215 | list.WriteString(`"/>`) |