Addresses like /.well-known/security.txt are fixed by somebody else's spec. None is a bundle, none belongs under /static/, and core had no way to serve one. This is the trigger the extension registry has been held for, in those words: ADR-0042 called core's generic derived-file route "the seam to revisit when a second feature wants output of its own", and state.md's counter note said to build the registry "when a feature wants a route". Raw passthrough is that feature, so the seam is built rather than worked around. Only Routes, not the seven-field Extension struct extensions.md describes. Five of the other six fields have no implementor and building them would be the speculation rule 6 forbids. It also kept the change inside the core budget, which had 65 lines left: the seam is ~30 core lines and the feature's own code lands in internal/ext/, where there is room. Core is 2965/3000. A feature returns map[string]http.Handler; core mounts each as an exact pattern and learns nothing about who owns it. A path core already answers is skipped with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so a site shipping root/robots.txt would otherwise take the server down at startup. Verified: server alive, engine keeps /robots.txt, warning logged, zero panics. Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and the file is rendered with text/template — never html/template, which would turn an ampersand in a contact address into & and a JSON quote into ". Opt-in by name rather than by sniffing the type, because a key or a signature may contain anything and a pass choosing for itself which files to rewrite would eventually eat one. The data is the site's own declarations and nothing more, which is the point: a security.txt naming its canonical URL should not repeat what site.yaml already says. Headers come from root/_headers.yaml, exact paths only. Globs are a second-use feature and the concrete need is a handful of .well-known names. The manifest is not served, by the leading-underscore rule that already means "not addressable" everywhere else — no special case was added for it. A manifest that will not parse is logged and ignored; the files still serve. Found while counting: the Extensions row read 4 while five packages existed. notation landed in ADR-0061/0062 and was never counted, though the prose beside the number already named all five. Corrected to 6. That is the latent item about counters having no mechanical check, demonstrating itself. Not done, and logged as latent: khosra check cannot report a root/ file shadowing an engine path, because verify.sh fails a feature that imports a sibling and the reserved paths live in passthrough. The startup warning fires on every boot, which is louder than a check finding. Evidence against the demo with a fresh binary: /pubkey answers with its declared text/plain despite having no extension; /.well-known/security.txt answers with Canonical filled from site.yaml's base, plus the declared CORS header; /humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are untouched. Eight unit tests cover layout, absence, interpolation, non-escaping, declared and derived headers, a broken template, and a broken manifest. 24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact paths are mux entries, not resolver cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
16 KiB
Go
475 lines
16 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(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
|
|
}
|
|
|
|
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 TestTheRootListsEverything(t *testing.T) {
|
|
// The engine owns "/" (ADR-0008), so it answers with the one thing it can: every bundle, newest first
|
|
// (ADR-0050). Found by serving the demo, where the front page was a 404.
|
|
h := testHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET / = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"About", "Hello"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the root should list every section's bundles, missing %q:\n%s", want, body)
|
|
}
|
|
}
|
|
|
|
// A site with nothing published has no front page rather than an empty one, which is the same rule every
|
|
// listing follows.
|
|
empty, err := render.New(nil, content.Settings{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bare := Handler(Fixed(content.NewSite(nil), empty), nil, nil, content.Settings{}, nil)
|
|
rec = httptest.NewRecorder()
|
|
bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("an empty site's root = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
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(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
|
|
}
|
|
|
|
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(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
|
|
}
|
|
|
|
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(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
|
|
}
|
|
|
|
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(Fixed(content.NewSite(bundles), r), fsys, nil, content.Settings{}, nil)
|
|
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(Fixed(content.NewSite(nil), r), fsys, nil, content.Settings{}, nil)
|
|
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(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{}, nil)
|
|
}
|
|
|
|
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)
|
|
}
|
|
// Matched on the element, not the word: the stylesheet is inlined into every page, so "sequence" appears in
|
|
// the CSS whether or not the nav does.
|
|
if body := rec.Body.String(); strings.Contains(body, `<nav class="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(Fixed(content.NewSite(bundles), r), nil, nil, content.Settings{}, nil)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|