// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package payloads import ( "errors" "os" "path/filepath" "strings" "testing" "time" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/volumen/internal/frontmatter" "sourcedock.dev/petrbalvin/volumen/internal/post" "sourcedock.dev/petrbalvin/volumen/internal/store" ) // An edit through the form only rewrites the fields the form names: a // key the form does not carry keeps the comment written above it. func TestPostFromParamsKeepsUntouchedComments(t *testing.T) { existing, _, err := frontmatter.Parse("+++\ntitle = \"Old\"\nslug = \"old\"\nlang = \"en\"\n\n# chapters live here\n[[chapters]]\nx = 1\n+++\n\nbody\n") if err != nil { t.Fatalf("Parse: %v", err) } p, err := PostFromParams(map[string]string{ "title": "New", "slug": "old", "lang": "en", }, post.New(existing, "body\n")) if err != nil { t.Fatalf("PostFromParams: %v", err) } if p.Title() != "New" { t.Fatalf("title = %q, want New", p.Title()) } out, err := frontmatter.Dump(p.Metadata, "body\n") if err != nil { t.Fatalf("Dump: %v", err) } if !strings.Contains(out, "# chapters live here") || !strings.Contains(out, "[[chapters]]") { t.Fatalf("untouched frontmatter lost its comment:\n%s", out) } } // Every form field the editor saves is a key the engine consumes, so // none of them may leak into the API's fields object: this guards the // two lists against drifting apart. func TestMetadataOrderStaysReserved(t *testing.T) { for _, key := range metadataOrder { if !post.ReservedMetadata[key] { t.Fatalf("form key %q is not in post.ReservedMetadata", key) } } } func newStoreWithPosts(t *testing.T, files map[string]string) *store.Store { t.Helper() dir := filepath.Join(t.TempDir(), "posts") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } for name, body := range files { target := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(target, []byte(body), 0o644); err != nil { t.Fatalf("write: %v", err) } } return store.New(store.Options{ContentDir: dir, DefaultLang: "en", RevisionLimit: 10}) } const publishedFile = `+++ title = "Published" slug = "published" date = 2026-08-18 lang = "cs" tags = ["go", "blog"] series = "S" series_order = 2 +++ published body ` const draftFile = `+++ title = "Draft" slug = "draft" draft = true +++ draft body ` const scheduledFile = `+++ title = "Future" slug = "future" publish_at = 2999-01-01 +++ future body ` func TestPublishedPostsExcludesDraftsAndScheduled(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "published.md": publishedFile, "draft.md": draftFile, "future.md": scheduledFile, }) posts := PublishedPosts(s) if len(posts) != 1 || posts[0].Slug() != "published" { t.Fatalf("published = %v", posts) } } func TestPublishedPostsSortsNewestFirst(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "old.md": "+++\nslug = \"old\"\ndate = 2020-01-01\n+++\nx\n", "new.md": "+++\nslug = \"new\"\ndate = 2026-01-01\n+++\nx\n", }) posts := PublishedPosts(s) if len(posts) != 2 || posts[0].Slug() != "new" { t.Fatalf("order = %v, %v", posts[0].Slug(), posts[1].Slug()) } } func TestFilterPosts(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "a.md": "+++\nslug = \"a\"\nlang = \"cs\"\ntags = [\"go\"]\ntitle = \"Go tipy\"\n+++\nbody here\n", "b.md": "+++\nslug = \"b\"\nlang = \"en\"\ntags = [\"life\"]\ntitle = \"Hello\"\n+++\nother\n", "c.md": "+++\nslug = \"c\"\nall_langs = true\nlang = \"cs\"\ntags = [\"go\"]\ntitle = \"CS GO\"\n+++\nxx\n", }) all := PublishedPosts(s) if got := FilterPosts(all, "en", "", ""); len(got) != 2 { t.Fatalf("lang filter = %v", slugs(got)) } if got := FilterPosts(all, "", "go", ""); len(got) != 2 { t.Fatalf("tag filter = %v", slugs(got)) } if got := FilterPosts(all, "", "", "body here"); len(got) != 1 || got[0].Slug() != "a" { t.Fatalf("query filter = %v", slugs(got)) } if got := FilterPosts(all, "", "", "HELLO"); len(got) != 1 || got[0].Slug() != "b" { t.Fatalf("case-insensitive query = %v", slugs(got)) } } func parsePost(t *testing.T, content string) *post.Post { t.Helper() p, err := post.Parse(content) if err != nil { t.Fatalf("parse: %v", err) } return p } func slugs(posts []*post.Post) []string { out := make([]string, len(posts)) for i, p := range posts { out[i] = p.Slug() } return out } func TestPostsPayloadPagination(t *testing.T) { files := map[string]string{} for i, slug := range []string{"one", "two", "three", "four", "five"} { files[slug+".md"] = "+++\nslug = \"" + slug + "\"\ntitle = \"" + slug + "\"\ndate = 2026-01-0" + string(rune('1'+i)) + "\n+++\nx\n" } s := newStoreWithPosts(t, files) first, ok := PostsPayload(s, "", "", "", 1, 2, "").(PageList) if !ok { t.Fatal("page mode must return a PageList") } if first.Total != 5 || first.Page != 1 || !first.HasNext || first.HasPrev { t.Fatalf("first page = %+v", first) } if len(first.Posts) != 2 { t.Fatalf("posts = %v", first.Posts) } if !first.HasNext { t.Fatalf("first page must have a next page: %+v", first) } last, ok := PostsPayload(s, "", "", "", 3, 2, "").(PageList) if !ok { t.Fatal("page mode must return a PageList") } if last.HasNext || !last.HasPrev { t.Fatalf("last page = %+v", last) } cursorPage, ok := PostsPayload(s, "", "", "", 1, 2, "five").(CursorList) if !ok { t.Fatal("cursor mode must return a CursorList") } if cursorPage.NextCursor == nil { t.Fatalf("cursor page = %+v", cursorPage) } } func TestPostsPayloadLimitClamping(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "a.md": "+++\nslug = \"a\"\n+++\nx\n", }) huge, ok := PostsPayload(s, "", "", "", 1, 5000, "").(PageList) if !ok || huge.PageSize != 100 { t.Fatalf("page_size = %v, want 100", huge.PageSize) } zero, ok := PostsPayload(s, "", "", "", 1, 0, "").(PageList) if !ok || zero.PageSize != 1 { t.Fatalf("page_size = %v, want 1", zero.PageSize) } } func TestPostsPayloadIsEmpty(t *testing.T) { s := newStoreWithPosts(t, nil) if !IsEmpty(PostsPayload(s, "", "", "", 1, 20, "")) { t.Fatal("empty store must yield an empty payload") } if IsEmpty("not a payload") { t.Fatal("unknown types count as empty") } } func TestTagCountsAndSeries(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "a.md": "+++\nslug = \"a\"\ntags = [\"go\", \"blog\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n", "b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-03\n+++\nx\n", "c.md": "+++\nslug = \"c\"\ntags = [\"zz\"]\nseries = \"T\"\ndate = 2026-01-04\n+++\nx\n", }) tags := BuildTagCounts(PublishedPosts(s)) if len(tags) != 3 || tags[0].Name != "go" || tags[0].Count != 2 { t.Fatalf("tags = %v", tags) } series := BuildSeriesList(s) if len(series) != 2 || series[0].Name != "S" || series[0].Count != 2 { t.Fatalf("series = %v", series) } posts := SeriesPosts(s, "S") if len(posts) != 2 || posts[0].Slug() != "b" || posts[1].Slug() != "a" { t.Fatalf("series posts = %v", slugs(posts)) } } func TestSeriesPostsOrderFallbacks(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "a.md": "+++\nslug = \"a\"\nseries = \"S\"\ndate = 2026-01-02\n+++\nx\n", "b.md": "+++\nslug = \"b\"\nseries = \"S\"\ndate = 2026-01-01\n+++\nx\n", }) posts := SeriesPosts(s, "S") // No series_order: date ascending. if posts[0].Slug() != "b" || posts[1].Slug() != "a" { t.Fatalf("order = %v", slugs(posts)) } } func TestPresenceAndParsers(t *testing.T) { if Presence(" x ") != "x" || Presence(nil) != "" || Presence(" ") != "" { t.Fatal("Presence wrong") } if _, ok := ParseDate("2026-08-18"); !ok { t.Fatal("ParseDate failed") } if _, ok := ParseDate("nonsense"); ok { t.Fatal("ParseDate accepted nonsense") } if _, ok := ParseDate(""); ok { t.Fatal("ParseDate accepted empty") } if n, ok := ParseInt(" 42 "); !ok || n != 42 { t.Fatalf("ParseInt = %d, %v", n, ok) } if _, ok := ParseInt("x"); ok { t.Fatal("ParseInt accepted nonsense") } if _, ok := ParseInt(""); ok { t.Fatal("ParseInt accepted empty") } tags := ParseTags("go, blog ,,rust") if len(tags) != 3 || tags[1] != "blog" { t.Fatalf("ParseTags = %v", tags) } if ParseTags("") != nil { t.Fatal("ParseTags empty should be nil") } } func TestPostFromParams(t *testing.T) { form := map[string]string{ "title": "Nadpis", "slug": "nadpis", "lang": "cs", "author": "Petr", "fediverse_creator": "@petr@social", "date": "2026-08-18", "publish_at": "", "tags": "go, blog", "excerpt": "Perex", "cover": "/media/c.webp", "cover_alt": "alt", "cover_caption": "caption", "series": "S", "series_order": "3", "draft": "on", "all_langs": "on", "body": "obsah", } p, err := PostFromParams(form, nil) if err != nil { t.Fatalf("PostFromParams: %v", err) } if p.Slug() != "nadpis" || p.Title() != "Nadpis" || p.Body != "obsah" { t.Fatalf("post = %v", p) } if !p.Draft() || !p.AllLangs() { t.Fatal("flags not set") } if p.DateString() != "2026-08-18" { t.Fatalf("date = %q", p.DateString()) } if order, ok := p.SeriesOrder(); !ok || order != 3 { t.Fatalf("series_order = %d, %v", order, ok) } if _, present := p.Metadata.Get("publish_at"); present { t.Fatal("empty publish_at kept") } if len(p.Tags()) != 2 { t.Fatalf("tags = %v", p.Tags()) } // Cleaned metadata drops empties and keeps date as a bare TOML date. out, err := p.ToFile() if err != nil { t.Fatalf("ToFile: %v", err) } if !strings.Contains(out, "date = 2026-08-18") { t.Fatalf("date not written bare:\n%s", out) } if strings.Contains(out, "excerpt = \"\"") { t.Fatalf("empty values kept:\n%s", out) } } func TestPostFromParamsKeepsExistingPathAndSlug(t *testing.T) { existing := parsePost(t, "+++\nslug = \"old\"\n+++\nx\n") p, err := PostFromParams(map[string]string{"title": "T", "body": "b"}, existing) if err != nil { t.Fatalf("PostFromParams: %v", err) } if p.Slug() != "old" { t.Fatalf("slug = %q, want fallback to existing", p.Slug()) } if p.Path != existing.Path { t.Fatalf("path not carried: %q", p.Path) } } func TestCreationError(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "taken.md": "+++\nslug = \"taken\"\n+++\nx\n", }) newPost := func(slug, lang, fediverse string) *post.Post { meta := frontmatter.NewMeta() meta.Set("slug", slug) if lang != "" { meta.Set("lang", lang) } if fediverse != "" { meta.Set("fediverse_creator", fediverse) } return post.New(meta, "x") } check := func(err error, want string) { t.Helper() if want == "" { if err != nil { t.Fatalf("err = %v, want nil", err) } return } validation, ok := errors.AsType[*ValidationError](err) if err == nil || !ok { t.Fatalf("err = %v, want a ValidationError", err) } if validation.Message != want { t.Fatalf("message = %q, want %q", validation.Message, want) } } check(CreationError(newPost("", "", ""), s, nil), "Slug is required.") check(CreationError(newPost("Upper!", "", ""), s, nil), "Invalid slug.") if err := CreationError(newPost(strings.Repeat("a", MaxSlugLength+1), "", ""), s, nil); err == nil { t.Fatal("want slug length error") } check(CreationError(newPost("ok", "bad lang!", ""), s, nil), "Invalid language.") check(CreationError(newPost("taken", "", ""), s, nil), "A post with that slug already exists.") // Editing the same post keeps its own slug. existing := s.Find("taken", "") check(CreationError(newPost("taken", "", ""), s, existing), "") check(CreationError(newPost("fresh", "", "not-a-handle"), s, nil), "Fediverse creator must look like @user@host.") check(CreationError(newPost("fresh", "cs", "@ok@host"), s, nil), "") check(CreationError(newPost("a.b_c-d", "", ""), s, nil), "") } func TestSlugAndLangRegexes(t *testing.T) { if SlugRegex.MatchString("-leading") || SlugRegex.MatchString("trailing-") || SlugRegex.MatchString("UPPER") { t.Fatal("SlugRegex too permissive") } if !SlugRegex.MatchString("a") || !SlugRegex.MatchString("a.b-c_d") { t.Fatal("SlugRegex too strict") } if LangRegex.MatchString("cs CS") || !LangRegex.MatchString("pt-BR") { t.Fatal("LangRegex wrong") } } func TestLocalDateDumpShape(t *testing.T) { // Guard: form dates must serialise as bare TOML dates, not RFC3339. d := time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC) meta := frontmatter.NewMeta() meta.Set("date", interpres.LocalDate{Time: d}) out, err := frontmatter.Dump(meta, "x") if err != nil { t.Fatalf("Dump: %v", err) } if !strings.Contains(out, "date = 2026-03-04") { t.Fatalf("out = %s", out) } } // A malformed date in the form must be rejected rather than dropped: // dropping it would delete an inherited schedule and publish the post. func TestPostFromParamsRejectsAMalformedDate(t *testing.T) { existing := parsePost(t, "+++\nslug = \"s\"\ntitle = \"T\"\npublish_at = 2999-01-01\n+++\nx\n") p, err := PostFromParams(map[string]string{"title": "T", "publish_at": "not a date", "body": "b"}, existing) if err == nil { t.Fatal("a malformed publish_at was accepted") } if want := "publish_at must be an ISO 8601 date."; err.Error() != want { t.Fatalf("error = %q, want %q", err.Error(), want) } // The post comes back for the form re-render; the schedule is kept // because the form value never reached the metadata. if _, ok := p.DueAt(); !ok { t.Fatal("the inherited schedule was dropped on a rejected form") } } // Two published posts may share a slug across languages; the cursor must // then name the exact post or the walk serves the second variant twice // and, with a tight limit, never advances. func TestCursorPaginationAcrossSameSlugTranslations(t *testing.T) { s := newStoreWithPosts(t, map[string]string{ "en/shared.md": "+++\nslug = \"shared\"\ntitle = \"EN\"\nlang = \"en\"\ndate = 2026-01-03\n+++\nx\n", "cs/shared.md": "+++\nslug = \"shared\"\ntitle = \"CS\"\nlang = \"cs\"\ndate = 2026-01-02\n+++\nx\n", "other.md": "+++\nslug = \"other\"\ntitle = \"O\"\ndate = 2026-01-01\n+++\nx\n", }) seen := map[string]bool{} // The walk starts after the first variant, as it would for a client // that paged one post at a time and just received the EN variant. cursor := "shared" pages := 0 for { payload := PostsPayload(s, "", "", "", 1, 1, cursor).(CursorList) for _, post := range payload.Posts { key := post.Slug + "|" + post.Lang if seen[key] { t.Fatalf("post %s served twice", key) } seen[key] = true } pages++ if payload.NextCursor == nil { break } cursor = *payload.NextCursor if pages > 10 { t.Fatal("the cursor walk does not terminate") } } if len(seen) != 2 { t.Fatalf("walked %d posts, want the CS variant and one more: %v", len(seen), seen) } } // Invalid UTF-8 typed into a form field is rejected rather than saved: // the writer would otherwise silently replace the bytes with U+FFFD. func TestPostFromParamsRejectsInvalidUTF8(t *testing.T) { p, err := PostFromParams(map[string]string{ "title": "Bad \xff\xfe", "slug": "utf8-test", "body": "ok", }, nil) if err == nil { t.Fatal("invalid UTF-8 was accepted") } if want := "title must be valid UTF-8 text."; err.Error() != want { t.Fatalf("error = %q, want %q", err.Error(), want) } if p == nil { t.Fatal("the post must come back for the form re-render") } // A clean body alone is fine. if _, err := PostFromParams(map[string]string{"title": "Ok", "body": "ok"}, nil); err != nil { t.Fatalf("clean form rejected: %v", err) } }