Files
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

847 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}