Files
khosra/internal/content/content_test.go
T
bdeshi 449850c83d add tag listings, global and section-narrowed
One global namespace (ADR-0018): /tags/{term}/ spans every section and
/{section}/tags/{term}/ narrows it. Listings group by section so one busy term
stays readable, which needed List.Groups alongside Items — list.html renders
whichever is set.

This is Query's second use, so it gained a Tag field rather than being generalised
on speculation: one filter, two callers. Tag slugs lowercase and hyphenate,
preserving script, so "Long Monsoon" and "long monsoon" are one term while Bengali
passes through unchanged. Hand-chosen slugs per term still wait for the type
declaration that owns overrides.

`tags` is reserved at the top level and inside every section, alongside `page` and
the language prefixes. A tag listing redirects to its canonical URL only once it is
known to exist, matching the rule bundles already followed — otherwise a canonical
URL for nothing confirms what is not there.

One stale test expectation fixed rather than worked around: it asserted tags land
in Extra, which stopped being true when tags became a named field.

Evidence: /tags/monsoon/ lists Hello World under posts and First Rain under comics;
/comics/tags/monsoon/ shows one; /tags/monsoon 301s; /tags/nothing/ and /tags/ 404.
2026-07-30 02:17:05 +06:00

318 lines
10 KiB
Go

package content
import (
"io/fs"
"os"
"path/filepath"
"testing"
"testing/fstest"
)
func TestSplitNameDerivesKeyAndLang(t *testing.T) {
cases := []struct {
name, key, lang string
ok bool
}{
{name: "pages/about.md", key: "pages/about", lang: "en", ok: true},
{name: "pages/about.en.md", key: "pages/about", lang: "en", ok: true},
{name: "pages/about.bn.md", key: "pages/about", lang: "bn", ok: true},
{name: "posts/hello/index.md", key: "posts/hello", lang: "en", ok: true},
{name: "posts/hello/index.bn.md", key: "posts/hello", lang: "bn", ok: true},
{name: "comics/monsoon/_index.md", key: "comics/monsoon", lang: "en", ok: true},
{name: "posts/my.post.md", key: "posts/my.post", lang: "en", ok: true},
{name: "posts/notes.txt", ok: false},
}
for _, c := range cases {
key, lang, ok := splitName(c.name)
if ok != c.ok {
t.Errorf("%s: ok = %v, want %v", c.name, ok, c.ok)
continue
}
if ok && (key != c.key || lang != c.lang) {
t.Errorf("%s: got %q/%q, want %q/%q", c.name, key, lang, c.key, c.lang)
}
}
}
func TestParseSplitsFrontmatterAndKeepsUnknownKeys(t *testing.T) {
b, err := Parse("posts/hello.md", []byte("---\ntitle: Hello\nmood: cheerful\ntags: [a, b]\n---\n\nBody text.\n"))
if err != nil {
t.Fatal(err)
}
if b.Title != "Hello" {
t.Errorf("title = %q", b.Title)
}
if got := string(b.Body); got != "Body text.\n" {
t.Errorf("body = %q", got)
}
if b.Extra["mood"] != "cheerful" {
t.Error("an unnamed frontmatter key did not land in Extra")
}
if len(b.Tags) != 2 || b.Tags[0] != "a" {
t.Errorf("tags = %v, want [a b] lifted into the named field", b.Tags)
}
if _, leaked := b.Extra["tags"]; leaked {
t.Error("tags should be lifted out of Extra, not duplicated")
}
if _, ok := b.Extra["title"]; ok {
t.Error("title should be lifted out of Extra, not duplicated")
}
}
func TestParseWithoutFrontmatterIsAllBody(t *testing.T) {
b, err := Parse("pages/now.md", []byte("Just prose.\n"))
if err != nil {
t.Fatal(err)
}
if b.Title != "" || string(b.Body) != "Just prose.\n" {
t.Errorf("got title %q body %q", b.Title, b.Body)
}
}
func TestParseRejectsBrokenFrontmatter(t *testing.T) {
if _, err := Parse("posts/bad.md", []byte("---\ntitle: [unclosed\n---\nbody\n")); err == nil {
t.Fatal("want an error for unparseable YAML")
}
}
func TestParseMissingTitleIsLegal(t *testing.T) {
b, err := Parse("status/note.md", []byte("---\ndate: 2026-07-30\n---\nhi\n"))
if err != nil {
t.Fatalf("a titleless bundle must parse: %v", err)
}
if b.Title != "" {
t.Errorf("title = %q, want empty", b.Title)
}
}
func TestScanSkipsBadBundlesAndUnderscoreDirs(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
"content/posts/hello/index.md": {Data: []byte("---\ntitle: Hello\n---\ny\n")},
"content/posts/broken.md": {Data: []byte("---\ntitle: [oops\n---\nz\n")},
"content/_drafts/secret.md": {Data: []byte("---\ntitle: Secret\n---\nq\n")},
"content/pages/notes.txt": {Data: []byte("not markdown")},
}
got, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
keys := map[string]bool{}
for _, b := range got {
keys[b.Key] = true
}
if len(got) != 2 || !keys["pages/about"] || !keys["posts/hello"] {
t.Fatalf("got %d bundles %v, want pages/about and posts/hello only", len(got), keys)
}
}
func TestScanDropsAmbiguousVariants(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: A\n---\n")},
"content/pages/about.en.md": {Data: []byte("---\ntitle: B\n---\n")},
"content/pages/now.md": {Data: []byte("---\ntitle: Now\n---\n")},
}
got, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Key != "pages/now" {
t.Fatalf("got %+v, want only pages/now: two spellings of one variant are ambiguous", got)
}
}
func TestNormaliseFoldsDecomposedBengali(t *testing.T) {
decomposed := "\u0995\u09c7\u09be" // ka + vowel sign e + vowel sign aa
composed := "\u0995\u09cb" // ka + vowel sign o
if decomposed == composed {
t.Skip("inputs are already identical; nothing to prove")
}
if Normalise(decomposed) != composed {
t.Errorf("NFC(%q) = %q, want %q", decomposed, Normalise(decomposed), composed)
}
}
// TestOpenSiteRefusesSymlinkEscape is the path-traversal guard's evidence: os.DirFS would happily
// follow this symlink, os.Root does not (ADR-0031).
func TestOpenSiteRefusesSymlinkEscape(t *testing.T) {
tmp := t.TempDir()
site := filepath.Join(tmp, "site")
if err := os.MkdirAll(filepath.Join(site, "content"), 0o755); err != nil {
t.Fatal(err)
}
secret := filepath.Join(tmp, "secret.txt")
if err := os.WriteFile(secret, []byte("private"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../secret.txt", filepath.Join(site, "escape.txt")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
fsys, err := OpenSite(site)
if err != nil {
t.Fatal(err)
}
if data, err := fs.ReadFile(fsys, "escape.txt"); err == nil {
t.Fatalf("read outside the site root succeeded with %q", data)
}
if _, err := fs.ReadFile(fsys, "../secret.txt"); err == nil {
t.Fatal("traversal with .. succeeded")
}
}
func TestSiteLookupFindsDefaultVariant(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")},
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")},
}
bundles, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
site := NewSite(bundles)
if site.Len() != 2 {
t.Fatalf("indexed %d bundles, want 2", site.Len())
}
b, served, ok := site.Lookup("pages/about", "en")
if !ok || b.Title != "About" || served != "en" {
t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok)
}
if _, _, ok := site.Lookup("pages/missing", "en"); ok {
t.Error("Lookup invented a bundle")
}
}
func TestLookupFallsBackThroughLanguages(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")},
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")},
"content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")},
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")},
}
bundles, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
site := NewSite(bundles)
cases := []struct{ key, want, served string }{
{"pages/about", "পরিচিতি", "bn"}, // the requested language exists
{"posts/plain", "Plain", "en"}, // falls back to the default
{"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404
}
for _, c := range cases {
b, served, ok := site.Lookup(c.key, "bn")
if !ok || b.Title != c.want || served != c.served {
t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served)
}
}
if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" {
t.Errorf("Variants = %v, want [bn en]", got)
}
if !site.HasLang("bn") || site.HasLang("fr") {
t.Error("HasLang misreported")
}
}
func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) {
cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"}
for lang, want := range cases {
if got := URL("pages/about", lang); got != want {
t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want)
}
}
}
func TestAliasesAreParsedAndIndexed(t *testing.T) {
fsys := fstest.MapFS{
"content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [/posts/old-name/, posts/older]\n---\n")},
"content/pages/single.md": {Data: []byte("---\ntitle: Single\naliases: pages/one\n---\n")},
}
bundles, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
site := NewSite(bundles)
for alias, want := range map[string]string{
"posts/old-name": "posts/new-name",
"posts/older": "posts/new-name",
"pages/one": "pages/single",
} {
got, ok := site.Alias(alias)
if !ok || got != want {
t.Errorf("Alias(%q) = %q %v, want %q", alias, got, ok, want)
}
}
for _, b := range bundles {
if _, leaked := b.Extra["aliases"]; leaked {
t.Error("aliases should be lifted out of Extra, not duplicated")
}
}
}
func TestAmbiguousAliasesAreDropped(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/real.md": {Data: []byte("---\ntitle: Real\n---\n")},
"content/pages/a.md": {Data: []byte("---\ntitle: A\naliases: [pages/real, pages/shared]\n---\n")},
"content/pages/b.md": {Data: []byte("---\ntitle: B\naliases: [pages/shared]\n---\n")},
}
site := NewSite(mustScan(t, fsys))
if _, ok := site.Alias("pages/real"); ok {
t.Error("an alias naming a real bundle must be dropped, not shadow it")
}
if _, ok := site.Alias("pages/shared"); ok {
t.Error("an alias claimed by two bundles must be dropped, not picked arbitrarily")
}
if _, _, ok := site.Lookup("pages/real", "en"); !ok {
t.Error("the real bundle must keep its URL")
}
}
func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle {
t.Helper()
b, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
return b
}
func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) {
cases := map[string]string{
"Long Monsoon": "long-monsoon",
"WATERCOLOUR": "watercolour",
"জলরঙ": "জলরঙ",
" spaced out ": "spaced-out",
}
for in, want := range cases {
if got := TagSlug(in); got != want {
t.Errorf("TagSlug(%q) = %q, want %q", in, got, want)
}
}
}
func TestQueryFiltersByTagAndSection(t *testing.T) {
fsys := fstest.MapFS{
"content/posts/a.md": {Data: []byte("---\ntitle: A\ndate: 2026-01-03\ntags: [Monsoon, prose]\n---\n")},
"content/posts/b.md": {Data: []byte("---\ntitle: B\ndate: 2026-01-02\ntags: [prose]\n---\n")},
"content/comics/c.md": {Data: []byte("---\ntitle: C\ndate: 2026-01-01\ntags: [monsoon]\n---\n")},
"content/writing/d.md": {Data: []byte("---\ntitle: D\n---\n")},
}
site := NewSite(mustScan(t, fsys))
got := func(q Query) []string {
var titles []string
for _, b := range site.Run(q) {
titles = append(titles, b.Title)
}
return titles
}
if titles := got(Query{Tag: "monsoon", Lang: "en"}); len(titles) != 2 || titles[0] != "A" || titles[1] != "C" {
t.Errorf("global tag query = %v, want [A C] — a tag spans sections and case does not matter", titles)
}
if titles := got(Query{Section: "posts", Tag: "monsoon", Lang: "en"}); len(titles) != 1 || titles[0] != "A" {
t.Errorf("section-narrowed tag query = %v, want [A]", titles)
}
if titles := got(Query{Tag: "nothing", Lang: "en"}); titles != nil {
t.Errorf("unknown tag = %v, want none", titles)
}
}