Polling lives in internal/ext/watch, per the human's call to keep core under its ceiling rather than raise it a second time — which is what ADR-0041 said a second raise would mean. It is a poller, deletable without trace, and core stayed at 2671/2800. A settled change calls the same `rebuilder` that startup calls, because a reload path that differs from the startup path is a reload path that drifts. The index is an atomic.Pointer swapped whole, so a request reads the site that was current when it arrived instead of one being rebuilt underneath it — the alternative, mutating in place, is a data race with every in-flight request. Names, sizes and modification times, not contents: reading every file to detect a change costs more than the rebuild it triggers. Editor droppings are excluded, because saving in vim writes a swap file, a backup and the number 4913, and each would otherwise look like a change. A change must hold still for a moment first, since one save is often several operations. Verified against the running binary: a page 404s, the file appears, and five seconds later it serves — one "site root changed" in the log. Then three droppings written at once produced no rebuild at all. Two warnings fired and were fixed rather than silenced: `runServe` gave up the rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to `record`, where three exclusions read as a list instead of as nesting. The Dockerfile ships the binary alone. The site root arrives as a volume and is never copied in — it is somebody's content repository with its own history (ADR-0011), so the image is the same for every site.
155 lines
5.6 KiB
Go
155 lines
5.6 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"khosra/internal/content"
|
|
"khosra/internal/render"
|
|
)
|
|
|
|
func feedHandler(t *testing.T, settings content.Settings) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{
|
|
"content/posts/newer.md": {Data: []byte("---\ntitle: Newer\ndate: 2026-03-08\ntags: [monsoon]\n---\nx\n")},
|
|
"content/posts/older.md": {Data: []byte("---\ntitle: Older\ndate: 2026-02-01\n---\nx\n")},
|
|
"content/posts/newer.bn.md": {Data: []byte("---\ntitle: নতুন\ndate: 2026-03-08\n---\nx\n")},
|
|
"content/comics/strip.md": {Data: []byte("---\ntitle: Strip\ndate: 2026-01-15\ntags: [monsoon]\n---\nx\n")},
|
|
"content/pages/colophon.md": {Data: []byte("---\ntitle: Colophon\n---\nno date at all\n")},
|
|
"content/posts/renamed.md": {Data: []byte("---\ntitle: Renamed\ndate: 2026-03-01\nslug: ekti\n---\nx\n")},
|
|
}
|
|
bundles, err := content.Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err := render.New(nil, settings, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
|
}
|
|
|
|
func fetchFeed(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, atom) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
var doc atom
|
|
if rec.Code == http.StatusOK {
|
|
if err := xml.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
|
t.Fatalf("GET %s did not produce parseable XML: %v\n%s", path, err, rec.Body.String())
|
|
}
|
|
}
|
|
return rec, doc
|
|
}
|
|
|
|
func TestTheFeedCarriesDatedBundlesNewestFirst(t *testing.T) {
|
|
h := feedHandler(t, content.Settings{Base: "https://khosra.example", Title: "Khosra"})
|
|
rec, doc := fetchFeed(t, h, "/feed.xml")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") {
|
|
t.Errorf("content-type = %q", ct)
|
|
}
|
|
var titles []string
|
|
for _, e := range doc.Entries {
|
|
titles = append(titles, e.Title)
|
|
}
|
|
want := []string{"Newer", "Renamed", "Older", "Strip"}
|
|
if strings.Join(titles, ",") != strings.Join(want, ",") {
|
|
t.Errorf("entries = %v, want %v (newest first, every section)", titles, want)
|
|
}
|
|
// Undated bundles are not feed items, and drop out because they have no date rather than by declaration.
|
|
if strings.Contains(rec.Body.String(), "Colophon") {
|
|
t.Error("an undated bundle must not appear in a feed")
|
|
}
|
|
// Identity has to mean something in a reader that has never seen the site.
|
|
for _, e := range doc.Entries {
|
|
if !strings.HasPrefix(e.ID, "https://khosra.example/") {
|
|
t.Errorf("entry id %q is not absolute", e.ID)
|
|
}
|
|
}
|
|
// A slugged bundle appears at its address, not its key.
|
|
if !strings.Contains(rec.Body.String(), "https://khosra.example/posts/ekti/") {
|
|
t.Errorf("a renamed bundle must be linked at its route:\n%s", rec.Body.String())
|
|
}
|
|
if doc.Title != "Khosra" {
|
|
t.Errorf("feed title = %q", doc.Title)
|
|
}
|
|
// Asserted on the bytes, because a namespaced attribute does not round-trip through the same struct tag
|
|
// that marshals it — and the bytes are what a reader sees.
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `xml:lang="en"`) {
|
|
t.Errorf("the feed should declare its language:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "example//") {
|
|
t.Errorf("a doubled slash makes every identity wrong:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestFeedsNarrowBySectionTagAndLanguage(t *testing.T) {
|
|
h := feedHandler(t, content.Settings{Base: "https://khosra.example", Title: "Khosra"})
|
|
for path, want := range map[string][]string{
|
|
"/comics/feed.xml": {"Strip"},
|
|
"/tags/monsoon/feed.xml": {"Newer", "Strip"},
|
|
"/posts/tags/monsoon/feed.xml": {"Newer"},
|
|
"/bn/feed.xml": {"নতুন", "Renamed", "Older", "Strip"}, // bn where it exists, fallback elsewhere
|
|
} {
|
|
rec, doc := fetchFeed(t, h, path)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("GET %s = %d, want 200", path, rec.Code)
|
|
continue
|
|
}
|
|
var titles []string
|
|
for _, e := range doc.Entries {
|
|
titles = append(titles, e.Title)
|
|
}
|
|
if strings.Join(titles, ",") != strings.Join(want, ",") {
|
|
t.Errorf("GET %s = %v, want %v", path, titles, want)
|
|
}
|
|
}
|
|
// The self link names the feed that was actually asked for.
|
|
_, doc := fetchFeed(t, h, "/comics/feed.xml")
|
|
var self string
|
|
for _, l := range doc.Links {
|
|
if l.Rel == "self" {
|
|
self = l.Href
|
|
}
|
|
}
|
|
if self != "https://khosra.example/comics/feed.xml" {
|
|
t.Errorf("self link = %q", self)
|
|
}
|
|
}
|
|
|
|
func TestAFeedNeedsABaseAndSomethingToCarry(t *testing.T) {
|
|
// Without a base an entry's identity would be a path no reader can resolve, so the file does not exist.
|
|
h := feedHandler(t, content.Settings{})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/feed.xml", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("no base: got %d, want 404", rec.Code)
|
|
}
|
|
h = feedHandler(t, content.Settings{Base: "https://khosra.example"})
|
|
for _, path := range []string{"/pages/feed.xml", "/tags/nothing/feed.xml", "/nosuch/feed.xml"} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("GET %s = %d, want 404: a feed for nothing is nothing", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAFeedPathIsAFileNotAPage(t *testing.T) {
|
|
// No trailing-slash canonicalisation: /feed.xml/ is not the feed, and /feed.xml must not redirect.
|
|
h := feedHandler(t, content.Settings{Base: "https://khosra.example"})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/feed.xml", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("got %d, want 200 without a redirect", rec.Code)
|
|
}
|
|
}
|