387 lines
11 KiB
Go
387 lines
11 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||
|
|
|
||
|
|
package post
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
||
|
|
)
|
||
|
|
|
||
|
|
func frontmatterMeta(m map[string]any) *frontmatter.Meta {
|
||
|
|
meta := frontmatter.NewMeta()
|
||
|
|
for k, v := range m {
|
||
|
|
meta.Set(k, v)
|
||
|
|
}
|
||
|
|
return meta
|
||
|
|
}
|
||
|
|
|
||
|
|
func frontmatterPost(m map[string]any) *Post {
|
||
|
|
return New(frontmatterMeta(m), "")
|
||
|
|
}
|
||
|
|
|
||
|
|
func mustParse(t *testing.T, content string) *Post {
|
||
|
|
t.Helper()
|
||
|
|
p, err := Parse(content)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("post.Parse: %v", err)
|
||
|
|
}
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
|
||
|
|
const sample = `+++
|
||
|
|
title = "Ahoj světe"
|
||
|
|
slug = "ahoj-svete"
|
||
|
|
lang = "cs"
|
||
|
|
author = "Petr"
|
||
|
|
tags = ["go", "blog"]
|
||
|
|
date = 2026-08-18
|
||
|
|
series = "Série"
|
||
|
|
series_order = 2
|
||
|
|
cover = "/media/cover.webp"
|
||
|
|
cover_alt = "alt"
|
||
|
|
cover_caption = "caption"
|
||
|
|
fediverse_creator = "@petr@mastodon.social"
|
||
|
|
aliases = ["old-slug"]
|
||
|
|
|
||
|
|
[translations]
|
||
|
|
en = "hello-world"
|
||
|
|
+++
|
||
|
|
|
||
|
|
# Hlavička
|
||
|
|
|
||
|
|
První odstavec s dostatkem slov pro shrnutí, který popisuje obsah
|
||
|
|
celého článku a nemá nadpis, takže se z něj dá odvodit excerpt.
|
||
|
|
|
||
|
|
## Sekce
|
||
|
|
|
||
|
|
Další text.
|
||
|
|
`
|
||
|
|
|
||
|
|
func TestParseBasics(t *testing.T) {
|
||
|
|
p := mustParse(t, sample)
|
||
|
|
if p.Slug() != "ahoj-svete" {
|
||
|
|
t.Fatalf("slug = %q", p.Slug())
|
||
|
|
}
|
||
|
|
if p.Title() != "Ahoj světe" || p.Lang() != "cs" || p.Author() != "Petr" {
|
||
|
|
t.Fatalf("title/lang/author wrong")
|
||
|
|
}
|
||
|
|
if tags := p.Tags(); len(tags) != 2 || tags[0] != "go" || tags[1] != "blog" {
|
||
|
|
t.Fatalf("tags = %v", tags)
|
||
|
|
}
|
||
|
|
if p.Draft() || p.AllLangs() {
|
||
|
|
t.Fatal("draft/all_langs should be false")
|
||
|
|
}
|
||
|
|
if p.Translations()["en"] != "hello-world" {
|
||
|
|
t.Fatalf("translations = %v", p.Translations())
|
||
|
|
}
|
||
|
|
if p.Aliases()[0] != "old-slug" {
|
||
|
|
t.Fatalf("aliases = %v", p.Aliases())
|
||
|
|
}
|
||
|
|
if p.Cover() != "/media/cover.webp" || p.CoverAlt() != "alt" || p.CoverCaption() != "caption" {
|
||
|
|
t.Fatal("cover fields wrong")
|
||
|
|
}
|
||
|
|
if !fediverse.Valid(p.FediverseCreator()) {
|
||
|
|
t.Fatal("fediverse creator should be valid")
|
||
|
|
}
|
||
|
|
if p.Series() != "Série" {
|
||
|
|
t.Fatalf("series = %q", p.Series())
|
||
|
|
}
|
||
|
|
if order, ok := p.SeriesOrder(); !ok || order != 2 {
|
||
|
|
t.Fatalf("series_order = %d, %v", order, ok)
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(p.Body, "# Hlavička") {
|
||
|
|
t.Fatalf("body = %q", p.Body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDateStringAndTimestamp(t *testing.T) {
|
||
|
|
p := mustParse(t, sample)
|
||
|
|
if p.DateString() != "2026-08-18" {
|
||
|
|
t.Fatalf("DateString = %q", p.DateString())
|
||
|
|
}
|
||
|
|
d, ok := p.PublishedDate()
|
||
|
|
if !ok || d.Format("2006-01-02") != "2026-08-18" {
|
||
|
|
t.Fatalf("PublishedDate = %v, %v", d, ok)
|
||
|
|
}
|
||
|
|
ts, ok := p.PublishedTimestamp()
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("PublishedTimestamp missing")
|
||
|
|
}
|
||
|
|
want := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||
|
|
if ts != want {
|
||
|
|
t.Fatalf("timestamp = %d, want %d", ts, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPublishAtAndScheduled(t *testing.T) {
|
||
|
|
future := mustParse(t, "+++\npublish_at = 2999-01-01\n+++\nx\n")
|
||
|
|
if !future.Scheduled() {
|
||
|
|
t.Fatal("future publish_at should be scheduled")
|
||
|
|
}
|
||
|
|
past := mustParse(t, "+++\npublish_at = 2000-01-01\n+++\nx\n")
|
||
|
|
if past.Scheduled() {
|
||
|
|
t.Fatal("past publish_at should not be scheduled")
|
||
|
|
}
|
||
|
|
none := mustParse(t, "+++\ntitle = \"x\"\n+++\nx\n")
|
||
|
|
if none.Scheduled() {
|
||
|
|
t.Fatal("missing publish_at should not be scheduled")
|
||
|
|
}
|
||
|
|
if _, ok := none.DueAt(); ok {
|
||
|
|
t.Fatal("missing publish_at returned a date")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPublishAtStringForms(t *testing.T) {
|
||
|
|
p := mustParse(t, "+++\npublish_at = \"2026-02-03\"\n+++\nx\n")
|
||
|
|
d, ok := p.DueAt()
|
||
|
|
if !ok || d.Format("2006-01-02") != "2026-02-03" {
|
||
|
|
t.Fatalf("string publish_at = %v, %v", d, ok)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFlagsStrict(t *testing.T) {
|
||
|
|
p := mustParse(t, "+++\ndraft = \"true\"\n+++\nx\n")
|
||
|
|
if p.Draft() {
|
||
|
|
t.Fatal("string draft flag must not count as draft")
|
||
|
|
}
|
||
|
|
p2 := mustParse(t, "+++\ndraft = true\n+++\nx\n")
|
||
|
|
if !p2.Draft() {
|
||
|
|
t.Fatal("boolean draft flag should count")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestReadingTime(t *testing.T) {
|
||
|
|
short := New(nil, "one two three")
|
||
|
|
if got := short.ReadingTime(); got != 1 {
|
||
|
|
t.Fatalf("ReadingTime = %d, want 1", got)
|
||
|
|
}
|
||
|
|
body := strings.Repeat("word ", 450)
|
||
|
|
long := New(nil, body)
|
||
|
|
if got := long.ReadingTime(); got != 3 {
|
||
|
|
t.Fatalf("ReadingTime = %d, want 3", got)
|
||
|
|
}
|
||
|
|
empty := New(nil, "")
|
||
|
|
if got := empty.ReadingTime(); got != 1 {
|
||
|
|
t.Fatalf("empty ReadingTime = %d, want 1", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestExcerptPrefersMetadata(t *testing.T) {
|
||
|
|
p := mustParse(t, "+++\nexcerpt = \"Vlastní perex\"\n+++\nbody\n")
|
||
|
|
if p.Excerpt() != "Vlastní perex" {
|
||
|
|
t.Fatalf("Excerpt = %q", p.Excerpt())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDerivedExcerpt(t *testing.T) {
|
||
|
|
p := New(nil, "# Head\n\nFirst paragraph with **markdown** and <b>html</b>.\n\nSecond.")
|
||
|
|
ex := p.Excerpt()
|
||
|
|
if strings.Contains(ex, "*") || strings.Contains(ex, "<") || strings.HasPrefix(ex, "#") {
|
||
|
|
t.Fatalf("derived excerpt not cleaned: %q", ex)
|
||
|
|
}
|
||
|
|
if !strings.Contains(ex, "First paragraph") {
|
||
|
|
t.Fatalf("derived excerpt wrong: %q", ex)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDerivedExcerptTruncates(t *testing.T) {
|
||
|
|
p := New(nil, strings.Repeat("x", 500))
|
||
|
|
ex := p.Excerpt()
|
||
|
|
if !strings.HasSuffix(ex, "…") {
|
||
|
|
t.Fatalf("excerpt not truncated: %q", ex)
|
||
|
|
}
|
||
|
|
if len([]rune(ex)) != 201 {
|
||
|
|
t.Fatalf("excerpt length = %d, want 201", len([]rune(ex)))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestHTMLAndTOCCached(t *testing.T) {
|
||
|
|
p := mustParse(t, "+++\ntitle = \"x\"\n+++\n# H\n\nText\n")
|
||
|
|
h1, err := p.HTML()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("HTML: %v", err)
|
||
|
|
}
|
||
|
|
h2, err := p.HTML()
|
||
|
|
if err != nil || h1 != h2 {
|
||
|
|
t.Fatalf("HTML not cached: %q vs %q", h1, h2)
|
||
|
|
}
|
||
|
|
toc, err := p.TOC()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("TOC: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(toc, `href="#h"`) {
|
||
|
|
t.Fatalf("toc = %q", toc)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestToFileRoundTrip(t *testing.T) {
|
||
|
|
p := mustParse(t, sample)
|
||
|
|
out, err := p.ToFile()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ToFile: %v", err)
|
||
|
|
}
|
||
|
|
p2 := mustParse(t, out)
|
||
|
|
if p2.Title() != p.Title() || p2.Slug() != p.Slug() || p2.Body != p.Body {
|
||
|
|
t.Fatal("round-trip lost data")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCivilDateVariants(t *testing.T) {
|
||
|
|
meta := frontmatterMeta(map[string]any{
|
||
|
|
"date": interpres.LocalDate{Time: time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)},
|
||
|
|
})
|
||
|
|
if got := New(meta, "").DateString(); got != "2026-03-04" {
|
||
|
|
t.Fatalf("LocalDate DateString = %q", got)
|
||
|
|
}
|
||
|
|
meta = frontmatterMeta(map[string]any{"date": "2026-03-05"})
|
||
|
|
if got := New(meta, "").DateString(); got != "2026-03-05" {
|
||
|
|
t.Fatalf("string DateString = %q", got)
|
||
|
|
}
|
||
|
|
meta = frontmatterMeta(map[string]any{"date": 42})
|
||
|
|
if got := New(meta, "").DateString(); got != "42" {
|
||
|
|
t.Fatalf("int DateString = %q", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFediverseCreatorValidation(t *testing.T) {
|
||
|
|
if !fediverse.Valid("@user@host.social") {
|
||
|
|
t.Fatal("valid handle rejected")
|
||
|
|
}
|
||
|
|
for _, bad := range []string{"user@host", "@user@", "@@host", "@us er@host", ""} {
|
||
|
|
if fediverse.Valid(bad) {
|
||
|
|
t.Fatalf("invalid handle accepted: %q", bad)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDOIORCIDReservedAndReadable(t *testing.T) {
|
||
|
|
p := mustParse(t, "+++\nslug = \"paper\"\ndoi = \"10.1000/xyz\"\norcid = \"0000-0002-1825-0097\"\nmethod = \"gravimetric\"\n+++\nx\n")
|
||
|
|
if p.DOI() != "10.1000/xyz" {
|
||
|
|
t.Fatalf("DOI = %q", p.DOI())
|
||
|
|
}
|
||
|
|
if p.ORCID() != "0000-0002-1825-0097" {
|
||
|
|
t.Fatalf("ORCID = %q", p.ORCID())
|
||
|
|
}
|
||
|
|
// The identifiers are reserved: only the author's own key reaches
|
||
|
|
// the fields object.
|
||
|
|
fields := p.CustomFields()
|
||
|
|
if _, present := fields["doi"]; present {
|
||
|
|
t.Fatal("doi leaked into custom fields")
|
||
|
|
}
|
||
|
|
if _, present := fields["orcid"]; present {
|
||
|
|
t.Fatal("orcid leaked into custom fields")
|
||
|
|
}
|
||
|
|
if fields["method"] != "gravimetric" {
|
||
|
|
t.Fatalf("custom field lost: %v", fields)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSeriesOrderCoercions(t *testing.T) {
|
||
|
|
p := frontmatterPost(map[string]any{"series_order": "7"})
|
||
|
|
if order, ok := p.SeriesOrder(); !ok || order != 7 {
|
||
|
|
t.Fatalf("string series_order = %d, %v", order, ok)
|
||
|
|
}
|
||
|
|
p = frontmatterPost(map[string]any{"series_order": 3.9})
|
||
|
|
if order, ok := p.SeriesOrder(); !ok || order != 3 {
|
||
|
|
t.Fatalf("float series_order = %d, %v", order, ok)
|
||
|
|
}
|
||
|
|
p = frontmatterPost(map[string]any{"series_order": "x"})
|
||
|
|
if _, ok := p.SeriesOrder(); ok {
|
||
|
|
t.Fatal("garbage series_order accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestUnparseablePublishAtWithholdsThePost(t *testing.T) {
|
||
|
|
// A typo in publish_at must not publish the post early: it is
|
||
|
|
// withheld, and validate reports it.
|
||
|
|
p := mustParse(t, "+++\npublish_at = \"18/09/2026\"\n+++\nx\n")
|
||
|
|
if p.Published() {
|
||
|
|
t.Fatal("a post with an unreadable publish_at was published")
|
||
|
|
}
|
||
|
|
if !p.Scheduled() {
|
||
|
|
t.Fatal("a post with an unreadable publish_at was not withheld")
|
||
|
|
}
|
||
|
|
if _, ok := p.DueAt(); ok {
|
||
|
|
t.Fatal("an unreadable publish_at produced a date")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEmptyPublishAtIsPublished(t *testing.T) {
|
||
|
|
p := mustParse(t, "+++\npublish_at = \"\"\n+++\nx\n")
|
||
|
|
if !p.Published() {
|
||
|
|
t.Fatal("an empty publish_at withheld the post")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStatusPrecedence(t *testing.T) {
|
||
|
|
draft := mustParse(t, "+++\ndraft = true\npublish_at = 2999-01-01\n+++\nx\n")
|
||
|
|
if draft.Status() != StatusDraft {
|
||
|
|
t.Fatal("a draft outranks a scheduled post")
|
||
|
|
}
|
||
|
|
if draft.Published() {
|
||
|
|
t.Fatal("a draft was published")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCloneIsIndependent(t *testing.T) {
|
||
|
|
original := mustParse(t, "+++\ntitle = \"A\"\ntags = [\"go\", \"blog\"]\n[extra]\nkey = \"value\"\n+++\nbody\n")
|
||
|
|
clone := original.Clone()
|
||
|
|
clone.Metadata.Set("title", "B")
|
||
|
|
clone.Metadata.Set("tags", []string{"other"})
|
||
|
|
if table, ok := clone.Metadata.Get("extra"); ok {
|
||
|
|
if m, isMap := table.(map[string]any); isMap {
|
||
|
|
m["key"] = "changed"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
title, _ := original.Metadata.Get("title")
|
||
|
|
if title != "A" {
|
||
|
|
t.Fatalf("the clone wrote through to the original: title = %v", title)
|
||
|
|
}
|
||
|
|
if got := original.Tags(); len(got) != 2 || got[0] != "go" {
|
||
|
|
t.Fatalf("the clone shares the tag list: %v", got)
|
||
|
|
}
|
||
|
|
if table, ok := original.Metadata.Get("extra"); ok {
|
||
|
|
if m, isMap := table.(map[string]any); isMap && m["key"] != "value" {
|
||
|
|
t.Fatalf("the clone shares the nested table: %v", m)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if clone.Path != original.Path || clone.Body != original.Body {
|
||
|
|
t.Fatal("the clone lost the body or the path")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStoredExcerptIsNotDerived(t *testing.T) {
|
||
|
|
withExcerpt := mustParse(t, "+++\nexcerpt = \"Hand written\"\n+++\nFirst paragraph.\n")
|
||
|
|
if withExcerpt.StoredExcerpt() != "Hand written" {
|
||
|
|
t.Fatalf("StoredExcerpt = %q", withExcerpt.StoredExcerpt())
|
||
|
|
}
|
||
|
|
derived := mustParse(t, "+++\ntitle = \"x\"\n+++\nFirst paragraph.\n")
|
||
|
|
if derived.StoredExcerpt() != "" {
|
||
|
|
t.Fatalf("StoredExcerpt = %q, want empty before the editor fills it", derived.StoredExcerpt())
|
||
|
|
}
|
||
|
|
if derived.Excerpt() == "" {
|
||
|
|
t.Fatal("Excerpt should still derive from the body")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPlainTextAndTruncate(t *testing.T) {
|
||
|
|
if got := PlainText("<p>Hello <b>world</b></p>", 100); got != "Hello world" {
|
||
|
|
t.Fatalf("PlainText = %q", got)
|
||
|
|
}
|
||
|
|
if got := PlainText("<p>one two</p>", 100); got != "one two" {
|
||
|
|
t.Fatalf("PlainText did not collapse whitespace: %q", got)
|
||
|
|
}
|
||
|
|
if got := Truncate("abcdefghij", 4); got != "abcd…" {
|
||
|
|
t.Fatalf("Truncate = %q", got)
|
||
|
|
}
|
||
|
|
if got := Truncate("abc", 4); got != "abc" {
|
||
|
|
t.Fatalf("Truncate changed a short value: %q", got)
|
||
|
|
}
|
||
|
|
}
|