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, "

About

") || !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, "
  • ") != content.PerPage { t.Errorf("page one holds %d entries, want %d", strings.Count(body, "
  • "), 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, "
  • ") != 2 { t.Errorf("page two holds %d entries, want 2", strings.Count(body, "
  • ")) } 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("")}, } 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 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, `")], "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, `