Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases — no bundle can collide, since a key always sits under a section. robots.txt at the site root is served verbatim, because a site that ships one has said something deliberate; otherwise the engine emits the minimum that is true and points at the sitemap. The sitemap lists every bundle in every language it exists in, since each variant is separately reachable, with lastmod only where a bundle has a date. Every URL comes from content.URL like every other path the engine emits, so a sitemap cannot disagree with what is actually served. Both need a declared base. Without one the sitemap answers 404 rather than listing paths no crawler can resolve, and robots omits the Sitemap line rather than writing a relative one. write() was setting text/html for every caller, and headers only go out with the first byte — so a handler setting its own type would have had it silently replaced, which is how a sitemap gets served as a web page. It now splits into write and writeAs, and the tests assert the content types rather than only the bodies.
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, 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, 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, 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, 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, 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, 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, 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, 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)
|
|
}
|
|
}
|