Files

509 lines
16 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}
}