Files
khosra/internal/web/asset_test.go
T
Claude Opus 5andbdeshi 9100ce4876 notice content changes and rebuild without a restart
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.
2026-07-31 14:00:53 +06:00

105 lines
3.9 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func assetHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{
// A directory bundle owns the files beside it.
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n{{< gallery >}}\n")},
// A Bengali variant, so /bn/ is a language prefix at all: the engine treats a leading segment as a
// language only when some bundle is written in it (content-model.md).
"content/art/monsoon/index.bn.md": {Data: []byte("---\ntitle: বর্ষা\n---\nx\n")},
"content/art/monsoon/10-first.jpg": {Data: []byte("\xff\xd8\xff-not-really-a-jpeg")},
"content/art/monsoon/_notes.md": {Data: []byte("a fragment\n")},
"content/art/monsoon/notes.md": {Data: []byte("---\ntitle: Notes\n---\nx\n")},
// A single-file bundle has no directory of its own.
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\nx\n")},
"content/posts/loose.jpg": {Data: []byte("bytes")},
// A series landing page, whose directory also holds child bundles.
"content/comics/series/_index.md": {Data: []byte("---\ntitle: Series\n---\nx\n")},
"content/comics/series/cover.png": {Data: []byte("png")},
"content/comics/series/one.md": {Data: []byte("---\ntitle: One\n---\nx\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
}
func TestABundlesOwnFilesAreServed(t *testing.T) {
h := assetHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/art/monsoon/10-first.jpg", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200 — this is what a relative src in a body resolves to", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
t.Errorf("content-type = %q, want image/jpeg from the extension", ct)
}
// A landing page's directory works the same way.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/series/cover.png", nil))
if rec.Code != http.StatusOK {
t.Errorf("a series landing page owns its files too: got %d", rec.Code)
}
// Under a language prefix as well: an image is not translated.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/art/monsoon/10-first.jpg", nil))
if rec.Code != http.StatusOK {
t.Errorf("prefixed request for the same file: got %d", rec.Code)
}
}
func TestMarkdownInsideABundleIsNeverAnAsset(t *testing.T) {
// Serving these raw would publish fragments an author never addressed, and hand out the source of a
// bundle that has its own rendered URL.
h := assetHandler(t)
for _, path := range []string{"/art/monsoon/_notes.md", "/art/monsoon/notes.md"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
}
}
}
func TestASingleFileBundleOwnsNoDirectory(t *testing.T) {
// Its neighbours belong to the section, not to it — so nothing is served under its slash-terminated URL.
h := assetHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/plain/loose.jpg", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
}
func TestAnAssetRequestCannotWanderOffItsBundle(t *testing.T) {
h := assetHandler(t)
for _, path := range []string{
"/art/monsoon/../../posts/loose.jpg",
"/art/nonexistent/10-first.jpg",
"/art/monsoon/missing.jpg",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %s = 200, want a miss:\n%s", path, rec.Body.String())
}
}
}