Files
khosra/internal/web/slug_test.go
T
bdeshiandClaude Opus 5 69a7eb4733 move robots and sitemap out of core, and raise the ceiling on purpose
Item 0 of the roadmap's order of work, and it blocked everything after it: core
sat at 2965 of 3000 while the review scheduled four core-bound items, the first
of which — logging — wanted the whole remainder.

/robots.txt and /sitemap.xml are exact paths somebody else's software asks for by
name. They own no core concept and pass every test the architecture applies to a
feature; they lived in internal/web only because a feature could not own a route
until ADR-0081. internal/ext/discover/ now holds them. Core 2965 → 2913.

The seam gained one parameter to make it possible: a func() *content.Site, since a
sitemap must list what is served now and the index is swapped whole on every
rebuild (ADR-0077). A captured pointer would have frozen the site at startup —
which is the kind of bug that only shows up after a rebuild, in production.

The ceiling rises to 3400 as well as the move, because the move alone could not buy
the room. feed.go and web/extras.go cannot follow discover out: a feed lives at
/{section}/feed.xml and extras under a bundle's own URL, so both are resolver cases
while the seam mounts exact paths only. Raising by the minimum that unblocks one
item produces a ceiling nobody believes, so 3400 fits the View cluster with
headroom. HARNESS.md asks that a raise be read as evidence something belongs in
ext before evidence the number was small; both readings were true, so both actions
were taken.

web no longer reserves those two paths, so a clash between features is wire.go's:
it merges route maps in declaration order, keeps the earlier claim, logs the loser.
Verified — a site shipping root/robots.txt starts, serves the engine's robots.txt,
and logs the passthrough claim, where an unguarded mux.Handle would have panicked.

Evidence: robots.txt and sitemap.xml are byte-identical before and after the move
against the demo site (67 and 2701 bytes, cmp clean), and the sitemap keeps its
application/xml type.

One real cost, recorded in both places rather than hidden. internal/web's
visibility test asserted that a listing, a feed *and* a sitemap all hide
unpublished bundles — one property, one test, because all three share a Query. The
sitemap half moved to the feature instead of a web test importing ext, which would
invert the one-way layering the architecture gate enforces. That property is now
asserted twice, once per package owning a surface.

Three gates caught real mistakes on the way: the staged-tree check found a partial
stage where git rm had staged a deletion while the caller edits were unstaged, the
coupling gates demanded state.md and HARNESS.md, and the nesting advisory rejected
a closure that put the merge loop one level too deep — fixed by making it a plain
function rather than tolerated.

Extensions 6 → 7. Routing cases unmoved: exact paths are mux entries, never
resolver cases, which is what that counter's exclusion column already said.

13 files. Core 2913/3400, ext 2495/3500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:26:40 +06:00

154 lines
6.3 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func slugHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
t.Helper()
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 TestASlugRenamesTheAddressInEveryLanguage(t *testing.T) {
h := slugHandler(t, fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\nslug: ekti-post\n---\nEnglish.\n")},
"content/posts/hello-world.bn.md": {Data: []byte("---\ntitle: একটি পোস্ট\n---\nবাংলা।\n")},
})
for _, path := range []string{"/posts/ekti-post/", "/bn/posts/ekti-post/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200 — a slug renames every language (ADR-0035)", path, rec.Code)
}
}
// The old path is gone: the engine serves the new one only, and an author who wants both writes an alias.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/hello-world/", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("the derived path = %d, want 404", rec.Code)
}
// Canonical and hreflang must name the new address, not the key.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/posts/ekti-post/", nil))
body := rec.Body.String()
if !strings.Contains(body, `rel="canonical" href="/bn/posts/ekti-post/"`) {
t.Errorf("canonical should name the served address:\n%s", body)
}
if strings.Contains(body, "hello-world") {
t.Errorf("nothing the engine emits should still say hello-world:\n%s", body)
}
}
func TestAnAliasCanKeepTheOldPathWorking(t *testing.T) {
h := slugHandler(t, fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\nslug: ekti-post\naliases: [posts/hello-world]\n---\nx\n")},
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/hello-world/", nil))
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("got %d, want 301 — this is how a rename keeps its promise (ADR-0008)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/posts/ekti-post/" {
t.Errorf("Location = %q, want the new address", loc)
}
}
// The sitemap half of this moved to internal/ext/discover with the sitemap itself (ADR-0085); a web test
// cannot reach a feature, since the layering runs one way only (conventions.md).
func TestListingsUseTheSluggedAddress(t *testing.T) {
fsys := fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\ndate: 2026-03-01\nslug: ekti-post\n---\nx\n")},
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-02-01\n---\nx\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
settings := content.Settings{Base: "https://khosra.example"}
r, err := render.New(nil, settings, nil)
if err != nil {
t.Fatal(err)
}
h := Handler(Fixed(content.NewSite(bundles), r), fsys, nil, settings, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
if body := rec.Body.String(); !strings.Contains(body, `href="/posts/ekti-post/"`) || strings.Contains(body, "hello-world") {
t.Errorf("a listing must link the address, not the key:\n%s", body)
}
}
func TestAmbiguousOrCollidingSlugsAreDropped(t *testing.T) {
// Both cases keep the derived path rather than picking a winner, the same rule aliases follow.
h := slugHandler(t, fstest.MapFS{
// Variants disagree.
"content/posts/a.md": {Data: []byte("---\ntitle: A\nslug: one\n---\nx\n")},
"content/posts/a.bn.md": {Data: []byte("---\ntitle: ক\nslug: two\n---\nx\n")},
// Slug lands on a bundle that already exists.
"content/posts/b.md": {Data: []byte("---\ntitle: B\nslug: taken\n---\nx\n")},
"content/posts/taken.md": {Data: []byte("---\ntitle: Taken\n---\nx\n")},
})
for path, want := range map[string]int{
"/posts/a/": http.StatusOK, // kept its derived path
"/posts/one/": http.StatusNotFound, // neither declared slug won
"/posts/two/": http.StatusNotFound,
"/posts/b/": http.StatusOK,
"/posts/taken/": http.StatusOK, // the bundle already there keeps its URL
} {
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)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/taken/", nil))
if body := rec.Body.String(); !strings.Contains(body, "<h1>Taken</h1>") {
t.Errorf("the bundle already there must still be the one served:\n%s", body)
}
}
func TestASeriesSurvivesItsLandingBeingSlugged(t *testing.T) {
// The reason a slug does not touch the key: membership is the directory (ADR-0033), so renaming the
// landing page's address must not orphan its chapters.
h := slugHandler(t, fstest.MapFS{
"content/comics/the-long-monsoon/_index.md": {Data: []byte("---\ntitle: The Long Monsoon\nslug: monsoon\n---\nx\n")},
"content/comics/the-long-monsoon/first-rain.md": {Data: []byte("---\ntitle: First Rain\norder: 10\n---\nx\n")},
"content/comics/the-long-monsoon/the-flood.md": {Data: []byte("---\ntitle: The Flood\norder: 20\n---\nx\n")},
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/monsoon/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("the landing page = %d, want 200 at its new address", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"First Rain", "The Flood"} {
if !strings.Contains(body, want) {
t.Errorf("the archive lost %q — a slug must not orphan a series:\n%s", want, body)
}
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/the-long-monsoon/the-flood/", nil))
if rec.Code != http.StatusOK {
t.Errorf("a chapter = %d, want 200: chapters keep their own addresses", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, `href="/comics/monsoon/"`) {
t.Errorf("the chapter should link its series at the slugged address:\n%s", body)
}
}