847 lines
25 KiB
Go
847 lines
25 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|||
|
|
|
|||
|
|
package store
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
|||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func newStore(t *testing.T) (*Store, string) {
|
|||
|
|
t.Helper()
|
|||
|
|
dir := t.TempDir()
|
|||
|
|
content := filepath.Join(dir, "posts")
|
|||
|
|
if err := os.MkdirAll(content, 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
return New(Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10}), content
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func writePost(t *testing.T, content, name, body string) string {
|
|||
|
|
t.Helper()
|
|||
|
|
path := filepath.Join(content, name)
|
|||
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|||
|
|
t.Fatalf("write post: %v", err)
|
|||
|
|
}
|
|||
|
|
return path
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const helloFile = `+++
|
|||
|
|
title = "Hello"
|
|||
|
|
slug = "hello"
|
|||
|
|
lang = "cs"
|
|||
|
|
tags = ["a"]
|
|||
|
|
|
|||
|
|
[translations]
|
|||
|
|
en = "hello-en"
|
|||
|
|
+++
|
|||
|
|
|
|||
|
|
Body text.
|
|||
|
|
`
|
|||
|
|
|
|||
|
|
func TestAllAndFind(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "hello.md", helloFile)
|
|||
|
|
writePost(t, content, "draft.md", "+++\ntitle = \"D\"\nslug = \"draft\"\ndraft = true\n+++\nx\n")
|
|||
|
|
|
|||
|
|
all := s.All()
|
|||
|
|
if len(all) != 2 {
|
|||
|
|
t.Fatalf("All() = %d posts, want 2", len(all))
|
|||
|
|
}
|
|||
|
|
p := s.Find("hello", "")
|
|||
|
|
if p == nil {
|
|||
|
|
t.Fatal("Find(hello) = nil")
|
|||
|
|
}
|
|||
|
|
if p.Title() != "Hello" || p.Lang() != "cs" {
|
|||
|
|
t.Fatalf("post wrong: %q %q", p.Title(), p.Lang())
|
|||
|
|
}
|
|||
|
|
if s.Find("hello", "en") != nil {
|
|||
|
|
t.Fatal("lang filter ignored")
|
|||
|
|
}
|
|||
|
|
if s.Find("missing", "") != nil {
|
|||
|
|
t.Fatal("Find(missing) found something")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestCacheInvalidatesOnChange(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "hello.md", helloFile)
|
|||
|
|
if len(s.All()) != 1 {
|
|||
|
|
t.Fatal("want 1 post")
|
|||
|
|
}
|
|||
|
|
// Cached read.
|
|||
|
|
if len(s.All()) != 1 {
|
|||
|
|
t.Fatal("cached read broken")
|
|||
|
|
}
|
|||
|
|
writePost(t, content, "second.md", "+++\nslug = \"second\"\n+++\nx\n")
|
|||
|
|
if got := len(s.All()); got != 2 {
|
|||
|
|
t.Fatalf("cache not invalidated: %d posts", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSlugFallbacksFromFilename(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "from-name.md", "+++\ntitle = \"T\"\n+++\nx\n")
|
|||
|
|
p := s.Find("from-name", "")
|
|||
|
|
if p == nil {
|
|||
|
|
t.Fatal("post not found via filename slug")
|
|||
|
|
}
|
|||
|
|
if p.Path != filepath.Join(content, "from-name.md") {
|
|||
|
|
t.Fatalf("path = %q", p.Path)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestLangFromSubdirectory(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
if err := os.MkdirAll(filepath.Join(content, "fr"), 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
writePost(t, content, filepath.Join("fr", "bonjour.md"), "+++\nslug = \"bonjour\"\n+++\nx\n")
|
|||
|
|
p := s.Find("bonjour", "fr")
|
|||
|
|
if p == nil {
|
|||
|
|
t.Fatal("post not found")
|
|||
|
|
}
|
|||
|
|
if p.Lang() != "fr" {
|
|||
|
|
t.Fatalf("lang = %q, want fr", p.Lang())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSkipsMediaAndRevisionsDirs(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "real.md", "+++\nslug = \"real\"\n+++\nx\n")
|
|||
|
|
for _, dir := range []string{MediaDirName, revisionsDirname} {
|
|||
|
|
if err := os.MkdirAll(filepath.Join(content, dir), 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
writePost(t, content, filepath.Join(MediaDirName, "x.md"), "+++\nslug = \"media-post\"\n+++\nx\n")
|
|||
|
|
writePost(t, content, filepath.Join(revisionsDirname, "y.md"), "+++\nslug = \"rev-post\"\n+++\nx\n")
|
|||
|
|
if got := len(s.All()); got != 1 {
|
|||
|
|
t.Fatalf("All() = %d, want 1", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSkipsBrokenFiles(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "ok.md", "+++\nslug = \"ok\"\n+++\nx\n")
|
|||
|
|
writePost(t, content, "broken.md", "+++\nthis is not = valid = toml\n+++\nx\n")
|
|||
|
|
if got := len(s.All()); got != 1 {
|
|||
|
|
t.Fatalf("All() = %d, want 1 (broken file skipped)", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSaveAndRevisions(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
p := mustParsePost(t, "+++\ntitle = \"V1\"\nslug = \"rev\"\n+++\nversion one\n")
|
|||
|
|
saved, err := s.Save(p)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("Save: %v", err)
|
|||
|
|
}
|
|||
|
|
if saved.Path != filepath.Join(content, "rev.md") {
|
|||
|
|
t.Fatalf("path = %q", saved.Path)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
p2 := mustParsePost(t, "+++\ntitle = \"V2\"\nslug = \"rev\"\n+++\nversion two\n")
|
|||
|
|
p2.Path = saved.Path
|
|||
|
|
if _, err := s.Save(p2); err != nil {
|
|||
|
|
t.Fatalf("Save 2: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
revs := s.Revisions("rev")
|
|||
|
|
if len(revs) != 1 {
|
|||
|
|
t.Fatalf("revisions = %v, want 1", revs)
|
|||
|
|
}
|
|||
|
|
if revs[0].When == "" || revs[0].Size == 0 {
|
|||
|
|
t.Fatalf("revision entry incomplete: %+v", revs[0])
|
|||
|
|
}
|
|||
|
|
body := s.RevisionContent("rev", revs[0].Name)
|
|||
|
|
if !strings.Contains(body, "version one") {
|
|||
|
|
t.Fatalf("revision content = %q", body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestRevisionLimitPruning(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
s.revisionLimit = 2
|
|||
|
|
p := mustParsePost(t, "+++\nslug = \"p\"\n+++\nv0\n")
|
|||
|
|
saved, err := s.Save(p)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("Save: %v", err)
|
|||
|
|
}
|
|||
|
|
for i := 1; i <= 4; i++ {
|
|||
|
|
next := mustParsePost(t, "+++\nslug = \"p\"\n+++\nv"+strings.Repeat("x", i)+"\n")
|
|||
|
|
next.Path = saved.Path
|
|||
|
|
if _, err := s.Save(next); err != nil {
|
|||
|
|
t.Fatalf("Save %d: %v", i, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if got := len(s.Revisions("p")); got != 2 {
|
|||
|
|
t.Fatalf("revisions = %d, want pruned to 2", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestDeleteAndUndelete(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "bye.md", "+++\ntitle = \"Bye\"\nslug = \"bye\"\n+++\ncontent\n")
|
|||
|
|
|
|||
|
|
deleted, undoable, err := s.Delete("bye", "")
|
|||
|
|
if err != nil || !undoable {
|
|||
|
|
t.Fatalf("Delete: %v, undoable=%v", err, undoable)
|
|||
|
|
}
|
|||
|
|
if deleted == nil {
|
|||
|
|
t.Fatal("Delete returned nil")
|
|||
|
|
}
|
|||
|
|
if _, err := os.Stat(filepath.Join(content, "bye.md")); err == nil {
|
|||
|
|
t.Fatal("live file still exists after delete")
|
|||
|
|
}
|
|||
|
|
if s.Find("bye", "") != nil {
|
|||
|
|
t.Fatal("deleted post still findable")
|
|||
|
|
}
|
|||
|
|
if s.TombstonePath("bye") == "" {
|
|||
|
|
t.Fatal("no tombstone")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
restored := s.Undelete("bye")
|
|||
|
|
if restored == nil {
|
|||
|
|
t.Fatal("Undelete returned nil")
|
|||
|
|
}
|
|||
|
|
if _, err := os.Stat(filepath.Join(content, "bye.md")); err != nil {
|
|||
|
|
t.Fatalf("file not restored: %v", err)
|
|||
|
|
}
|
|||
|
|
if s.TombstonePath("bye") != "" {
|
|||
|
|
t.Fatal("tombstone not removed")
|
|||
|
|
}
|
|||
|
|
if p := s.Find("bye", ""); p == nil || p.Title() != "Bye" {
|
|||
|
|
t.Fatalf("restored post wrong: %v", p)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestUndeleteRefusesClobber(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "dup.md", "+++\nslug = \"dup\"\ntitle = \"old\"\n+++\nx\n")
|
|||
|
|
if deleted, _, _ := s.Delete("dup", ""); deleted == nil {
|
|||
|
|
t.Fatal("Delete failed")
|
|||
|
|
}
|
|||
|
|
writePost(t, content, "dup.md", "+++\nslug = \"dup\"\ntitle = \"new\"\n+++\ny\n")
|
|||
|
|
if restored := s.Undelete("dup"); restored != nil {
|
|||
|
|
t.Fatal("Undelete clobbered an existing file")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestUndeleteWithoutTombstone(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
if p := s.Undelete("nothing"); p != nil {
|
|||
|
|
t.Fatal("want nil without tombstone")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestStaleTombstoneCleanup(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "gone.md", "+++\nslug = \"gone\"\n+++\nx\n")
|
|||
|
|
if deleted, _, _ := s.Delete("gone", ""); deleted == nil {
|
|||
|
|
t.Fatal("Delete failed")
|
|||
|
|
}
|
|||
|
|
// Recreate the live post, then run the cleanup the serving path runs:
|
|||
|
|
// the tombstone is now stale and must go.
|
|||
|
|
writePost(t, content, "gone.md", "+++\nslug = \"gone\"\n+++\ny\n")
|
|||
|
|
reopened := New(Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
|
|||
|
|
if reopened.TombstonePath("gone") == "" {
|
|||
|
|
t.Fatal("tombstone vanished before the cleanup ran")
|
|||
|
|
}
|
|||
|
|
reopened.CleanupStaleTombstones()
|
|||
|
|
if reopened.TombstonePath("gone") != "" {
|
|||
|
|
t.Fatal("stale tombstone survived the cleanup")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestDeleteWithRevisionsDisabled(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
s.revisionLimit = 0
|
|||
|
|
writePost(t, content, "hard.md", "+++\nslug = \"hard\"\n+++\nx\n")
|
|||
|
|
deleted, undoable, err := s.Delete("hard", "")
|
|||
|
|
if err != nil || deleted == nil || undoable {
|
|||
|
|
t.Fatal("hard delete failed")
|
|||
|
|
}
|
|||
|
|
if _, err := os.Stat(filepath.Join(content, "hard.md")); err == nil {
|
|||
|
|
t.Fatal("file still exists")
|
|||
|
|
}
|
|||
|
|
if s.TombstonePath("hard") != "" {
|
|||
|
|
t.Fatal("tombstone created despite revisions disabled")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestSaveRejectsSlugEscape(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
p := mustParsePost(t, "+++\nslug = \"../escape\"\n+++\nx\n")
|
|||
|
|
if _, err := s.Save(p); err == nil {
|
|||
|
|
t.Fatal("want error for escaping slug")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestRestoreRevision(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
p := mustParsePost(t, "+++\nslug = \"r\"\ntitle = \"one\"\n+++\nfirst\n")
|
|||
|
|
saved, err := s.Save(p)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("Save: %v", err)
|
|||
|
|
}
|
|||
|
|
p2 := mustParsePost(t, "+++\nslug = \"r\"\ntitle = \"two\"\n+++\nsecond\n")
|
|||
|
|
p2.Path = saved.Path
|
|||
|
|
if _, err := s.Save(p2); err != nil {
|
|||
|
|
t.Fatalf("Save 2: %v", err)
|
|||
|
|
}
|
|||
|
|
revs := s.Revisions("r")
|
|||
|
|
if len(revs) == 0 {
|
|||
|
|
t.Fatal("no revisions")
|
|||
|
|
}
|
|||
|
|
restored := s.RestoreRevision(p2, revs[0].Name)
|
|||
|
|
if restored == nil {
|
|||
|
|
t.Fatal("RestoreRevision returned nil")
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(string(mustRead(t, saved.Path)), "first") {
|
|||
|
|
t.Fatal("content not restored")
|
|||
|
|
}
|
|||
|
|
if restored.Slug() != "r" {
|
|||
|
|
t.Fatalf("slug = %q", restored.Slug())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestRestoreRevisionMissing(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
p := mustParsePost(t, "+++\nslug = \"r\"\n+++\nx\n")
|
|||
|
|
if restored := s.RestoreRevision(p, "nope.md"); restored != nil {
|
|||
|
|
t.Fatal("want nil for missing revision")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestResolveAlias(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "aliased.md", "+++\nslug = \"new\"\naliases = [\"old\", \"older\"]\n+++\nx\n")
|
|||
|
|
if got := s.ResolveAlias("old"); got != "new" {
|
|||
|
|
t.Fatalf("alias = %q, want new", got)
|
|||
|
|
}
|
|||
|
|
if got := s.ResolveAlias("unknown"); got != "" {
|
|||
|
|
t.Fatalf("alias = %q, want empty", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestInvalidateCache(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "a.md", "+++\nslug = \"a\"\n+++\nx\n")
|
|||
|
|
s.All()
|
|||
|
|
s.InvalidateCache()
|
|||
|
|
if got := len(s.All()); got != 1 {
|
|||
|
|
t.Fatalf("All after invalidate = %d", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// webpBytes is a minimal RIFF/WEBP header, enough for the signature
|
|||
|
|
// check.
|
|||
|
|
func webpBytes() []byte {
|
|||
|
|
data := append([]byte("RIFF"), 0, 0, 0, 0)
|
|||
|
|
return append(data, []byte("WEBPVP8 ")...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestStoreUploadAndListMedia(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
|
|||
|
|
webpData = append(webpData, []byte("WEBPVP8 ")...)
|
|||
|
|
url, err := s.StoreUpload("pic.png", webpData)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("StoreUpload: %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.HasPrefix(url, "/media/") || !strings.HasSuffix(url, ".webp") {
|
|||
|
|
t.Fatalf("url = %q", url)
|
|||
|
|
}
|
|||
|
|
name := strings.TrimPrefix(url, "/media/")
|
|||
|
|
if _, err := s.MediaPath(name); err != nil {
|
|||
|
|
t.Fatalf("MediaPath cannot find the upload: %v", err)
|
|||
|
|
}
|
|||
|
|
if _, err := s.MediaPath("../" + name); err != nil {
|
|||
|
|
t.Fatalf("a base name should still resolve: %v", err)
|
|||
|
|
}
|
|||
|
|
if _, err := s.MediaPath("notes.txt"); err == nil {
|
|||
|
|
t.Fatal("MediaPath accepted a name that is not an image")
|
|||
|
|
}
|
|||
|
|
media := s.ListMedia()
|
|||
|
|
if len(media) != 1 || media[0].Name != name {
|
|||
|
|
t.Fatalf("ListMedia = %v", media)
|
|||
|
|
}
|
|||
|
|
if !s.DeleteMedia(url) {
|
|||
|
|
t.Fatal("DeleteMedia failed")
|
|||
|
|
}
|
|||
|
|
if s.DeleteMedia(url) {
|
|||
|
|
t.Fatal("DeleteMedia succeeded twice")
|
|||
|
|
}
|
|||
|
|
if s.DeleteMedia("/etc/passwd") {
|
|||
|
|
t.Fatal("DeleteMedia accepted non-media URL")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestStoreUploadSVGAndDimensions(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
svg := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900"><rect width="10" height="10"/></svg>`)
|
|||
|
|
url, err := s.StoreUpload("figure.png", svg)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("StoreUpload(svg): %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.HasSuffix(url, ".svg") {
|
|||
|
|
t.Fatalf("url = %q, want .svg from the bytes", url)
|
|||
|
|
}
|
|||
|
|
name := strings.TrimPrefix(url, "/media/")
|
|||
|
|
if _, err := s.MediaPath(name); err != nil {
|
|||
|
|
t.Fatalf("MediaPath refuses the svg: %v", err)
|
|||
|
|
}
|
|||
|
|
media := s.ListMedia()
|
|||
|
|
if len(media) != 1 {
|
|||
|
|
t.Fatalf("ListMedia = %v", media)
|
|||
|
|
}
|
|||
|
|
if media[0].Width != 1200 || media[0].Height != 900 {
|
|||
|
|
t.Fatalf("svg dimensions = %d×%d, want 1200×900", media[0].Width, media[0].Height)
|
|||
|
|
}
|
|||
|
|
// A name that is not an allowed image extension is still refused.
|
|||
|
|
if _, err := s.MediaPath("notes.svg.txt"); err == nil {
|
|||
|
|
t.Fatal("MediaPath accepted a double-extension name")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestStoreUploadRequiresASignature(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
// The extension comes from the bytes, never from the filename, so a
|
|||
|
|
// file whose bytes are not an image is refused whatever it is called.
|
|||
|
|
for _, name := range []string{"photo.avif", "photo.gif", "photo.webp"} {
|
|||
|
|
if _, err := s.StoreUpload(name, []byte("not an image but long enough")); err == nil {
|
|||
|
|
t.Fatalf("StoreUpload accepted %q without a signature", name)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// The signature decides the stored extension even when the name says
|
|||
|
|
// something else.
|
|||
|
|
url, err := s.StoreUpload("photo.gif", webpBytes())
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("StoreUpload: %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.HasSuffix(url, ".webp") {
|
|||
|
|
t.Fatalf("url = %q, want .webp", url)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestMediaPathMissing(t *testing.T) {
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
if _, err := s.MediaPath("nope.webp"); err == nil {
|
|||
|
|
t.Fatal("want an error for missing media")
|
|||
|
|
}
|
|||
|
|
if got := s.ListMedia(); len(got) != 0 {
|
|||
|
|
t.Fatalf("ListMedia = %v, want empty", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestUndeleteSetsSlugFromRequest(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
// Post without explicit slug: filename provides it after load.
|
|||
|
|
writePost(t, content, "implicit.md", "+++\ntitle = \"I\"\n+++\nx\n")
|
|||
|
|
if deleted, _, _ := s.Delete("implicit", ""); deleted == nil {
|
|||
|
|
t.Fatal("Delete failed")
|
|||
|
|
}
|
|||
|
|
restored := s.Undelete("implicit")
|
|||
|
|
if restored == nil {
|
|||
|
|
t.Fatal("Undelete failed")
|
|||
|
|
}
|
|||
|
|
if restored.Slug() != "implicit" {
|
|||
|
|
t.Fatalf("slug = %q", restored.Slug())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestDefaultLangApplied(t *testing.T) {
|
|||
|
|
dir := t.TempDir()
|
|||
|
|
content := filepath.Join(dir, "posts")
|
|||
|
|
if err := os.MkdirAll(content, 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
writePost(t, content, "x.md", "+++\nslug = \"x\"\n+++\nbody\n")
|
|||
|
|
s := New(Options{ContentDir: content, DefaultLang: "cs", RevisionLimit: 10})
|
|||
|
|
p := s.Find("x", "")
|
|||
|
|
if p == nil {
|
|||
|
|
t.Fatal("post not found")
|
|||
|
|
}
|
|||
|
|
if p.Lang() != "cs" {
|
|||
|
|
t.Fatalf("lang = %q, want default cs", p.Lang())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func mustRead(t *testing.T, path string) []byte {
|
|||
|
|
t.Helper()
|
|||
|
|
data, err := os.ReadFile(path)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("read: %v", err)
|
|||
|
|
}
|
|||
|
|
return data
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestFrontmatterMetaUsedBySave(t *testing.T) {
|
|||
|
|
// Guard against accidental nil-metadata saves.
|
|||
|
|
s, _ := newStore(t)
|
|||
|
|
meta := frontmatter.NewMeta()
|
|||
|
|
meta.Set("slug", "meta-only")
|
|||
|
|
p := post.New(meta, "body")
|
|||
|
|
if _, err := s.Save(p); err != nil {
|
|||
|
|
t.Fatalf("Save: %v", err)
|
|||
|
|
}
|
|||
|
|
if s.Find("meta-only", "") == nil {
|
|||
|
|
t.Fatal("post not found after save")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func mustParsePost(t *testing.T, content string) *post.Post {
|
|||
|
|
t.Helper()
|
|||
|
|
p, err := post.Parse(content)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("post.Parse: %v", err)
|
|||
|
|
}
|
|||
|
|
return p
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The snapshot cache is what keeps a read from re-parsing every file, so
|
|||
|
|
// a cache hit must be observable: a second All() must not re-read.
|
|||
|
|
func TestCacheIsActuallyUsed(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "a.md", "+++\nslug = \"a\"\n+++\nbody\n")
|
|||
|
|
if len(s.All()) != 1 {
|
|||
|
|
t.Fatal("first read failed")
|
|||
|
|
}
|
|||
|
|
// Rewrite the file with the same size and mtime: only a cache hit can
|
|||
|
|
// explain the stale answer.
|
|||
|
|
path := filepath.Join(content, "a.md")
|
|||
|
|
info, err := os.Stat(path)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("stat: %v", err)
|
|||
|
|
}
|
|||
|
|
original := "+++\nslug = \"a\"\n+++\nbody\n"
|
|||
|
|
replacement := "+++\nslug = \"b\"\n+++\nbody\n"
|
|||
|
|
if len(replacement) != len(original) {
|
|||
|
|
t.Fatal("fixture sizes differ, the test cannot prove a cache hit")
|
|||
|
|
}
|
|||
|
|
if err := os.WriteFile(path, []byte(replacement), 0o644); err != nil {
|
|||
|
|
t.Fatalf("write: %v", err)
|
|||
|
|
}
|
|||
|
|
if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil {
|
|||
|
|
t.Fatalf("chtimes: %v", err)
|
|||
|
|
}
|
|||
|
|
if got := s.All(); len(got) != 1 || got[0].Slug() != "a" {
|
|||
|
|
t.Fatalf("the cache was bypassed: %v", got)
|
|||
|
|
}
|
|||
|
|
// An explicit invalidation must see the change.
|
|||
|
|
s.InvalidateCache()
|
|||
|
|
if got := s.All(); len(got) != 1 || got[0].Slug() != "b" {
|
|||
|
|
t.Fatalf("invalidation did not re-read: %v", got)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Find serves single-post requests from the same cache, so it must not
|
|||
|
|
// re-read the file either.
|
|||
|
|
func TestFindUsesTheCache(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "a.md", "+++\nslug = \"a\"\n+++\nbody\n")
|
|||
|
|
first := s.Find("a", "")
|
|||
|
|
if first == nil {
|
|||
|
|
t.Fatal("Find returned nothing")
|
|||
|
|
}
|
|||
|
|
second := s.Find("a", "")
|
|||
|
|
if second == nil {
|
|||
|
|
t.Fatal("second Find returned nothing")
|
|||
|
|
}
|
|||
|
|
if first != second {
|
|||
|
|
t.Fatal("Find rebuilt the post instead of using the cache")
|
|||
|
|
}
|
|||
|
|
// A write invalidates, so the next read is fresh.
|
|||
|
|
first.Metadata.Set("title", "changed")
|
|||
|
|
if _, err := s.Save(first.Clone()); err != nil {
|
|||
|
|
t.Fatalf("Save: %v", err)
|
|||
|
|
}
|
|||
|
|
if again := s.Find("a", ""); again == nil || again.Title() != "changed" {
|
|||
|
|
t.Fatalf("the cache survived a save: %+v", again)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestUnreadableFilesAreReported(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "good.md", "+++\nslug = \"good\"\n+++\nbody\n")
|
|||
|
|
writePost(t, content, "broken.md", "+++\nthis is not = valid = toml\n+++\nx\n")
|
|||
|
|
if len(s.All()) != 1 {
|
|||
|
|
t.Fatalf("All = %d posts, want only the parsable one", len(s.All()))
|
|||
|
|
}
|
|||
|
|
broken := s.Unreadable()
|
|||
|
|
if len(broken) != 1 || !strings.HasSuffix(broken[0].Path, "broken.md") || broken[0].Error == "" {
|
|||
|
|
t.Fatalf("Unreadable = %+v", broken)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A post deleted from a language subdirectory must come back to the
|
|||
|
|
// directory it came from, not to the root under the default language.
|
|||
|
|
func TestUndeleteKeepsTheLanguage(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
if err := os.MkdirAll(filepath.Join(content, "cs"), 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
writePost(t, content, "cs/ahoj.md", "+++\nslug = \"ahoj\"\n+++\ntělo\n")
|
|||
|
|
if p := s.Find("ahoj", ""); p == nil || p.Lang() != "cs" {
|
|||
|
|
t.Fatalf("fixture lang = %v", p)
|
|||
|
|
}
|
|||
|
|
if deleted, undoable, err := s.Delete("ahoj", ""); err != nil || deleted == nil || !undoable {
|
|||
|
|
t.Fatalf("Delete = %v, %v, %v", deleted, undoable, err)
|
|||
|
|
}
|
|||
|
|
restored := s.Undelete("ahoj")
|
|||
|
|
if restored == nil {
|
|||
|
|
t.Fatal("Undelete returned nothing")
|
|||
|
|
}
|
|||
|
|
if restored.Lang() != "cs" {
|
|||
|
|
t.Fatalf("restored lang = %q, want cs", restored.Lang())
|
|||
|
|
}
|
|||
|
|
if want := filepath.Join(content, "cs", "ahoj.md"); restored.Path != want {
|
|||
|
|
t.Fatalf("restored path = %q, want %q", restored.Path, want)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Two deletes inside the same second must resolve to the later one, not
|
|||
|
|
// to whichever the filesystem happened to order first.
|
|||
|
|
func TestNewestTombstoneWins(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "a.md", "+++\nslug = \"x\"\n+++\nfirst\n")
|
|||
|
|
if deleted, _, err := s.Delete("x", ""); err != nil || deleted == nil {
|
|||
|
|
t.Fatalf("first delete: %v", err)
|
|||
|
|
}
|
|||
|
|
writePost(t, content, "a.md", "+++\nslug = \"x\"\n+++\nsecond\n")
|
|||
|
|
if deleted, _, err := s.Delete("x", ""); err != nil || deleted == nil {
|
|||
|
|
t.Fatalf("second delete: %v", err)
|
|||
|
|
}
|
|||
|
|
restored := s.Undelete("x")
|
|||
|
|
if restored == nil {
|
|||
|
|
t.Fatal("Undelete returned nothing")
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(restored.Body, "second") {
|
|||
|
|
t.Fatalf("restored the earlier tombstone: %q", restored.Body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A content directory reached through a symlink must work for reads and
|
|||
|
|
// for writes: many deployments point one at a data volume.
|
|||
|
|
func TestSymlinkedContentDir(t *testing.T) {
|
|||
|
|
dir := t.TempDir()
|
|||
|
|
real := filepath.Join(dir, "data", "posts")
|
|||
|
|
if err := os.MkdirAll(real, 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
link := filepath.Join(dir, "posts")
|
|||
|
|
if err := os.Symlink(real, link); err != nil {
|
|||
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|||
|
|
}
|
|||
|
|
s := New(Options{ContentDir: link, DefaultLang: "en", RevisionLimit: 10})
|
|||
|
|
writePost(t, real, "a.md", "+++\nslug = \"a\"\n+++\nbody\n")
|
|||
|
|
if len(s.All()) != 1 {
|
|||
|
|
t.Fatal("a symlinked content directory hid the posts")
|
|||
|
|
}
|
|||
|
|
p, err := post.Parse("+++\nslug = \"new\"\ntitle = \"New\"\n+++\nbody\n")
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("parse: %v", err)
|
|||
|
|
}
|
|||
|
|
saved, err := s.Save(p)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("Save through a symlinked directory: %v", err)
|
|||
|
|
}
|
|||
|
|
if _, err := os.Stat(saved.Path); err != nil {
|
|||
|
|
t.Fatalf("saved file missing: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A delete whose file vanished between Find and the lock (another
|
|||
|
|
// request deleted it) reports "no such post", not a failure, and leaves
|
|||
|
|
// no second tombstone behind.
|
|||
|
|
func TestDeleteAfterConcurrentRemovalIsNoSuchPost(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "gone.md", "+++\ntitle = \"Gone\"\nslug = \"gone\"\n+++\ncontent\n")
|
|||
|
|
first, _, err := s.Delete("gone", "")
|
|||
|
|
if err != nil || first == nil {
|
|||
|
|
t.Fatalf("first Delete: %v", err)
|
|||
|
|
}
|
|||
|
|
// Simulate the race window: the cache still holds the post, the file
|
|||
|
|
// is already gone.
|
|||
|
|
s.mu.Lock()
|
|||
|
|
cached := s.cached
|
|||
|
|
s.mu.Unlock()
|
|||
|
|
_ = cached
|
|||
|
|
os.Remove(filepath.Join(content, "gone.md"))
|
|||
|
|
|
|||
|
|
// Recreate the same window the race would produce: find succeeded
|
|||
|
|
// before the removal, so call Delete on a slug whose cache entry is
|
|||
|
|
// repopulated with the stale file.
|
|||
|
|
s.InvalidateCache()
|
|||
|
|
if p := s.Find("gone", ""); p != nil {
|
|||
|
|
t.Fatal("the post should not resolve after removal")
|
|||
|
|
}
|
|||
|
|
deleted, _, err := s.Delete("gone", "")
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("second Delete errored: %v", err)
|
|||
|
|
}
|
|||
|
|
if deleted != nil {
|
|||
|
|
t.Fatalf("second Delete returned %v, want nil", deleted)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Repeated delete cycles keep one tombstone: the archive must not grow
|
|||
|
|
// without bound when revision_limit deliberately ignores tombstones.
|
|||
|
|
func TestTombstonesArePrunedToTheNewest(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
revDir := filepath.Join(content, ".revisions", "cycle")
|
|||
|
|
for i := range 3 {
|
|||
|
|
writePost(t, content, "cycle.md",
|
|||
|
|
"+++\ntitle = \"Cycle\"\nslug = \"cycle\"\n+++\ncontent "+strings.Repeat("x", i+1)+"\n")
|
|||
|
|
if p := s.Find("cycle", ""); p == nil {
|
|||
|
|
t.Fatalf("cycle %d: post missing", i)
|
|||
|
|
}
|
|||
|
|
if _, _, err := s.Delete("cycle", ""); err != nil {
|
|||
|
|
t.Fatalf("cycle %d delete: %v", i, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
entries, err := os.ReadDir(revDir)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("read revisions: %v", err)
|
|||
|
|
}
|
|||
|
|
tombstones := 0
|
|||
|
|
for _, e := range entries {
|
|||
|
|
if strings.HasPrefix(e.Name(), ".deleted-") {
|
|||
|
|
tombstones++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if tombstones != 1 {
|
|||
|
|
t.Fatalf("%d tombstones after three deletes, want 1", tombstones)
|
|||
|
|
}
|
|||
|
|
if s.TombstonePath("cycle") == "" {
|
|||
|
|
t.Fatal("the newest tombstone must survive for undelete")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Two language variants of one slug can save concurrently; their
|
|||
|
|
// revisions serialise on the shared archive directory rather than
|
|||
|
|
// colliding on one stamp or one temp file.
|
|||
|
|
func TestArchiveRevisionSerialisesPerSlug(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
if err := os.MkdirAll(filepath.Join(content, "en"), 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
if err := os.MkdirAll(filepath.Join(content, "fr"), 0o755); err != nil {
|
|||
|
|
t.Fatalf("mkdir: %v", err)
|
|||
|
|
}
|
|||
|
|
writePost(t, content, "en/shared.md", "+++\nslug = \"shared\"\nlang = \"en\"\ntitle = \"EN\"\n+++\nbody\n")
|
|||
|
|
writePost(t, content, "fr/shared.md", "+++\nslug = \"shared\"\nlang = \"fr\"\ntitle = \"FR\"\n+++\nbody\n")
|
|||
|
|
|
|||
|
|
var wg sync.WaitGroup
|
|||
|
|
for range 8 {
|
|||
|
|
wg.Go(func() {
|
|||
|
|
en := s.Find("shared", "en")
|
|||
|
|
fr := s.Find("shared", "fr")
|
|||
|
|
if en != nil {
|
|||
|
|
if _, err := s.Save(en.Clone()); err != nil {
|
|||
|
|
t.Errorf("save en: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if fr != nil {
|
|||
|
|
if _, err := s.Save(fr.Clone()); err != nil {
|
|||
|
|
t.Errorf("save fr: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
wg.Wait()
|
|||
|
|
// Every archived revision must be a complete document: a truncated
|
|||
|
|
// or interleaved file would fail to parse.
|
|||
|
|
for _, rev := range s.Revisions("shared") {
|
|||
|
|
if content := s.RevisionContent("shared", rev.Name); !strings.Contains(content, "+++\nslug = \"shared\"") {
|
|||
|
|
t.Fatalf("revision %s is not a complete document:\n%s", rev.Name, content)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A content file past the size bound is reported unreadable rather than
|
|||
|
|
// pulled into memory: no saved post can reach the bound, so anything
|
|||
|
|
// that does is debris.
|
|||
|
|
func TestOversizedFileIsReportedNotLoaded(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
big := content + "/big.md"
|
|||
|
|
handle, err := os.Create(big)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("create: %v", err)
|
|||
|
|
}
|
|||
|
|
if err := handle.Truncate(maxPostFileBytes + 1); err != nil {
|
|||
|
|
t.Fatalf("truncate: %v", err)
|
|||
|
|
}
|
|||
|
|
handle.Close()
|
|||
|
|
|
|||
|
|
if posts := s.All(); len(posts) != 0 {
|
|||
|
|
t.Fatalf("an oversized file became %d posts", len(posts))
|
|||
|
|
}
|
|||
|
|
skipped := s.Unreadable()
|
|||
|
|
if len(skipped) != 1 || !strings.Contains(skipped[0].Path, "big.md") {
|
|||
|
|
t.Fatalf("unreadable = %+v", skipped)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestDOILinkIndex(t *testing.T) {
|
|||
|
|
s, content := newStore(t)
|
|||
|
|
writePost(t, content, "tdssc.md", `+++
|
|||
|
|
title = "TDSSC"
|
|||
|
|
slug = "tdssc"
|
|||
|
|
doi = "10.5555/tdssc.2026"
|
|||
|
|
+++
|
|||
|
|
|
|||
|
|
Text.
|
|||
|
|
`)
|
|||
|
|
writePost(t, content, "sfs.md", `+++
|
|||
|
|
title = "SFS"
|
|||
|
|
slug = "sfs"
|
|||
|
|
|
|||
|
|
[[refs]]
|
|||
|
|
title = "Teorie deterministického substrátu"
|
|||
|
|
doi = "10.5555/tdssc.2026"
|
|||
|
|
+++
|
|||
|
|
|
|||
|
|
Odkaz [1].
|
|||
|
|
`)
|
|||
|
|
sfs := s.Find("sfs", "")
|
|||
|
|
if sfs == nil {
|
|||
|
|
t.Fatal("sfs missing")
|
|||
|
|
}
|
|||
|
|
htmlOut, err := sfs.HTML()
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("HTML: %v", err)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(htmlOut, `href="/api/volumen/posts/tdssc"`) {
|
|||
|
|
t.Fatalf("cross-post DOI did not resolve:\n%s", htmlOut)
|
|||
|
|
}
|
|||
|
|
// The cited post carrying the DOI links to neither itself nor a peer:
|
|||
|
|
// it has no refs at all here.
|
|||
|
|
tdssc := s.Find("tdssc", "")
|
|||
|
|
if h, _ := tdssc.HTML(); strings.Contains(h, "refs-link") {
|
|||
|
|
t.Fatalf("tdssc unexpectedly wove a bibliography:\n%s", h)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Removing the DOI from the cited post reverts the reference to its
|
|||
|
|
// resolver on the next read.
|
|||
|
|
writePost(t, content, "tdssc.md", `+++
|
|||
|
|
title = "TDSSC"
|
|||
|
|
slug = "tdssc"
|
|||
|
|
+++
|
|||
|
|
|
|||
|
|
Text.
|
|||
|
|
`)
|
|||
|
|
if h, _ := s.Find("sfs", "").HTML(); strings.Contains(h, "/api/volumen/posts/tdssc") {
|
|||
|
|
t.Fatal("stale index survived the change")
|
|||
|
|
}
|
|||
|
|
}
|