A pass over the content at startup writes three widths per picture into a cache outside the site root, named by the source's content hash and the width (ADR-0042). Idempotent by construction: a rerun stats and skips, an edited picture takes a new name, and nothing stale can be served under an old one. Restarting the evidence site made 0 derivatives the second time, as it should. Ahead of the request rather than during it, because resampling is felt and there is no page cache yet to hide it. Outside the site root, because the engine reads that directory and must not leave generated files in somebody's content git — a lost cache costs one startup pass and no correctness. Markup now carries the original as src, the derivatives as srcset closed by the original at its own width, and width/height from the original — which retires most of the latent row about the output floor; only a gallery's alt is still empty, and a filename cannot supply that. Two things the work itself decided: `Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items had one consumer, so widening it beat adding a second list beside it. "A browser can show it" and "we can resample it" are different questions, and conflating them nearly deleted content: an SVG has no decoder here, so a single predicate would have dropped SVGs from galleries silently. Undecodable and unsupported pictures are now rendered as they are, without a size or a srcset.
443 lines
15 KiB
Go
443 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"khosra/internal/content"
|
|
"khosra/internal/render"
|
|
)
|
|
|
|
func testHandler(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nAbout me.\n")},
|
|
"content/posts/hello/index.md": {Data: []byte("---\ntitle: Hello\n---\nFirst post.\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(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
|
}
|
|
|
|
func TestServeBundleAtItsPermalink(t *testing.T) {
|
|
h := testHandler(t)
|
|
for _, path := range []string{"/pages/about/", "/posts/hello/"} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200", path, rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Errorf("GET %s content-type = %q", path, ct)
|
|
}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about/", nil))
|
|
if body := rec.Body.String(); !strings.Contains(body, "<h1>About</h1>") || !strings.Contains(body, "About me.") {
|
|
t.Errorf("body did not render the bundle:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestUnknownPathsAre404(t *testing.T) {
|
|
h := testHandler(t)
|
|
for _, path := range []string{"/", "/nope/", "/pages/nope", "/pages/about/deeper/"} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func multilingualHandler(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nEnglish.\n")},
|
|
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nবাংলা।\n")},
|
|
"content/pages/now.md": {Data: []byte("---\ntitle: Now\n---\nOnly English.\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(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
|
}
|
|
|
|
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
|
|
h := multilingualHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/pages/about/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "বাংলা।") || !strings.Contains(body, `lang="bn"`) {
|
|
t.Errorf("did not serve the Bengali variant:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `rel="canonical" href="/bn/pages/about/"`) {
|
|
t.Error("canonical should name the variant actually served")
|
|
}
|
|
if !strings.Contains(body, `hreflang="en" href="/pages/about/"`) {
|
|
t.Error("hreflang should list the English variant at the root form")
|
|
}
|
|
}
|
|
|
|
func TestMissingVariantFallsBackAndSaysSo(t *testing.T) {
|
|
h := multilingualHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/pages/now/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200: the fallback chain must not 404 (ADR-0009)", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Only English.") {
|
|
t.Error("expected the English body as fallback")
|
|
}
|
|
if !strings.Contains(body, `rel="canonical" href="/pages/now/"`) {
|
|
t.Error("canonical must point at the variant served, not the URL requested")
|
|
}
|
|
}
|
|
|
|
func aliasHandler(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{
|
|
"content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [posts/old-name]\n---\nMoved here.\n")},
|
|
"content/posts/new-name.bn.md": {Data: []byte("---\ntitle: নতুন\n---\nএখানে।\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(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
|
}
|
|
|
|
func TestAliasRedirectsToCanonical(t *testing.T) {
|
|
h := aliasHandler(t)
|
|
for path, want := range map[string]string{
|
|
"/posts/old-name/": "/posts/new-name/",
|
|
"/bn/posts/old-name/": "/bn/posts/new-name/",
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != http.StatusMovedPermanently {
|
|
t.Errorf("GET %s = %d, want 301", path, rec.Code)
|
|
continue
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != want {
|
|
t.Errorf("GET %s → %q, want %q", path, loc, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnknownPathIsStill404NotAnAliasProbe(t *testing.T) {
|
|
h := aliasHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/never-existed/", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("got %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func listingHandler(t *testing.T, n int) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{}
|
|
for i := 1; i <= n; i++ {
|
|
name := fmt.Sprintf("content/posts/post-%02d.md", i)
|
|
body := fmt.Sprintf("---\ntitle: Post %02d\ndate: 2026-01-%02d\n---\nBody %d.\n", i, i, i)
|
|
fsys[name] = &fstest.MapFile{Data: []byte(body)}
|
|
}
|
|
fsys["content/pages/about.md"] = &fstest.MapFile{Data: []byte("---\ntitle: About\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(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
|
}
|
|
|
|
func TestSectionIndexListsNewestFirst(t *testing.T) {
|
|
h := listingHandler(t, 3)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
first, third := strings.Index(body, "Post 03"), strings.Index(body, "Post 01")
|
|
if first < 0 || third < 0 || first > third {
|
|
t.Errorf("newest should come first:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "About") {
|
|
t.Error("a section listing must not leak another section's bundles")
|
|
}
|
|
}
|
|
|
|
func TestPaginationSplitsAndLinks(t *testing.T) {
|
|
h := listingHandler(t, content.PerPage+2)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
|
|
body := rec.Body.String()
|
|
if strings.Count(body, "<li>") != content.PerPage {
|
|
t.Errorf("page one holds %d entries, want %d", strings.Count(body, "<li>"), content.PerPage)
|
|
}
|
|
if !strings.Contains(body, `rel="next" href="/posts/page/2/"`) || strings.Contains(body, `rel="prev"`) {
|
|
t.Errorf("page one should link next and not prev:\n%s", body)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/page/2/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("page two = %d, want 200", rec.Code)
|
|
}
|
|
body = rec.Body.String()
|
|
if strings.Count(body, "<li>") != 2 {
|
|
t.Errorf("page two holds %d entries, want 2", strings.Count(body, "<li>"))
|
|
}
|
|
if !strings.Contains(body, `rel="prev" href="/posts/"`) {
|
|
t.Errorf("page two should link back to the bare listing URL:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestPagePastTheEndIs404(t *testing.T) {
|
|
h := listingHandler(t, 3)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/page/9/", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("got %d, want 404: an empty page is a URL that means nothing", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
|
|
"static/style.css": {Data: []byte("body{}")},
|
|
"static/img/logo.svg": {Data: []byte("<svg/>")},
|
|
}
|
|
bundles, err := content.Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err := render.New(fsys, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
|
for path, want := range map[string]int{
|
|
"/static/style.css": http.StatusOK,
|
|
"/static/img/logo.svg": http.StatusOK,
|
|
"/static/": http.StatusNotFound,
|
|
"/static/img/": http.StatusNotFound,
|
|
"/static/nope.css": http.StatusNotFound,
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != want {
|
|
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAStaticPathThatEscapesTheRootIs404(t *testing.T) {
|
|
// A real directory, not a MapFS: the guard being tested belongs to os.Root (ADR-0031), and the point is
|
|
// what the *response* is when it refuses — a miss, never an error page that confirms the path.
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "static"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "static", "ok.css"), []byte("body{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outside := filepath.Join(dir, "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(dir, "static", "escape.txt")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
fsys, err := content.OpenSite(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err := render.New(fsys, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := Handler(content.NewSite(nil), r, fsys, nil, content.Settings{})
|
|
for path, want := range map[string]int{
|
|
"/static/ok.css": http.StatusOK,
|
|
"/static/escape.txt": http.StatusNotFound,
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != want {
|
|
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "secret") {
|
|
t.Fatalf("GET %s served bytes from outside the root", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func seriesHandler(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{
|
|
"content/comics/the-long-monsoon/_index.md": {Data: []byte("---\ntitle: The Long Monsoon\n---\nA series.\n")},
|
|
"content/comics/the-long-monsoon/first-rain.md": {Data: []byte("---\ntitle: First Rain\norder: 10\n---\n")},
|
|
"content/comics/the-long-monsoon/the-flood.md": {Data: []byte("---\ntitle: The Flood\norder: 20\n---\n")},
|
|
"content/comics/the-long-monsoon/aftermath.md": {Data: []byte("---\ntitle: Aftermath\norder: 30\n---\n")},
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\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(content.NewSite(bundles), r, nil, nil, content.Settings{})
|
|
}
|
|
|
|
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
|
|
h := seriesHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/the-long-monsoon/the-flood/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`rel="prev" href="/comics/the-long-monsoon/first-rain/"`,
|
|
`rel="next" href="/comics/the-long-monsoon/aftermath/"`,
|
|
`href="/comics/the-long-monsoon/">The Long Monsoon</a> 2 of 3`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("missing %q — prev is the *earlier* chapter:\n%s", want, body)
|
|
}
|
|
}
|
|
nav := body[strings.Index(body, `<nav class="sequence">`):]
|
|
if strings.Contains(nav[:strings.Index(nav, "</nav>")], "the-flood") {
|
|
t.Error("the nav should link its neighbours and the series, never the chapter it is on")
|
|
}
|
|
}
|
|
|
|
func TestSequenceEndsHaveNoNeighbourBeyondThem(t *testing.T) {
|
|
h := seriesHandler(t)
|
|
for path, absent := range map[string]string{
|
|
"/comics/the-long-monsoon/first-rain/": `rel="prev"`,
|
|
"/comics/the-long-monsoon/aftermath/": `rel="next"`,
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200", path, rec.Code)
|
|
}
|
|
if strings.Contains(rec.Body.String(), absent) {
|
|
t.Errorf("GET %s should not carry %s at the end of a series", path, absent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSeriesLandingPageListsChaptersInOrder(t *testing.T) {
|
|
h := seriesHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/the-long-monsoon/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "A series.") {
|
|
t.Error("the landing page is still a bundle and renders its own body")
|
|
}
|
|
first, second, third := strings.Index(body, "First Rain"), strings.Index(body, "The Flood"), strings.Index(body, "Aftermath")
|
|
if first < 0 || second < first || third < second {
|
|
t.Errorf("the archive must read in sequence order, not newest first:\n%s", body)
|
|
}
|
|
if strings.Contains(body, `rel="prev"`) || strings.Contains(body, `rel="next"`) {
|
|
t.Error("a landing page holds no position of its own, so it has no neighbours")
|
|
}
|
|
}
|
|
|
|
func TestPagesOutsideASeriesGetNoSequence(t *testing.T) {
|
|
h := seriesHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
if body := rec.Body.String(); strings.Contains(body, "sequence") {
|
|
t.Errorf("an unrelated page must render no sequence nav:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func tagHandler(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
fsys := fstest.MapFS{
|
|
"content/posts/essay.md": {Data: []byte("---\ntitle: Essay\ndate: 2026-01-03\ntags: [Monsoon]\n---\n")},
|
|
"content/comics/rain.md": {Data: []byte("---\ntitle: Rain\ndate: 2026-01-02\ntags: [monsoon]\n---\n")},
|
|
"content/posts/other.md": {Data: []byte("---\ntitle: Other\ndate: 2026-01-01\ntags: [prose]\n---\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(content.NewSite(bundles), r, nil, nil, content.Settings{})
|
|
}
|
|
|
|
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {
|
|
h := tagHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tags/monsoon/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"<h2>posts</h2>", "<h2>comics</h2>", "Essay", "Rain"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("missing %q — a tag spans sections and groups by one:\n%s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "Other") {
|
|
t.Error("a different tag leaked in")
|
|
}
|
|
}
|
|
|
|
func TestSectionNarrowedTagListing(t *testing.T) {
|
|
h := tagHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/tags/monsoon/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Rain") || strings.Contains(body, "Essay") {
|
|
t.Errorf("narrowing to comics should drop the posts entry:\n%s", body)
|
|
}
|
|
}
|