509 lines
16 KiB
Go
509 lines
16 KiB
Go
// 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)
|
||
|
|
}
|
||
|
|
}
|