// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package store import ( "os" "path" "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.Fatal("MediaPath accepted a name that climbs out of the media directory") } 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") } // A URL that resolves to a directory removes nothing: the media and // avatar directories are namespaces, not deletable content. if s.DeleteMedia("/media/") { t.Fatal("DeleteMedia accepted the media directory itself") } } // A profile photo is account state, not library content: it lives under // avatars/ inside the media directory, which the listing skips, while // the public route serves it and the delete path removes it. func TestStoreAvatarLivesOutsideTheLibrary(t *testing.T) { s, _ := newStore(t) webpData := append([]byte("RIFF"), 0, 0, 0, 0) webpData = append(webpData, []byte("WEBPVP8 ")...) url, err := s.StoreAvatar(webpData) if err != nil { t.Fatalf("StoreAvatar: %v", err) } if !strings.HasPrefix(url, "/media/avatars/") || !strings.HasSuffix(url, ".webp") { t.Fatalf("url = %q", url) } name := strings.TrimPrefix(url, "/media/") // avatars/.webp if got, err := s.MediaPath(name); err != nil { t.Fatalf("MediaPath cannot find the avatar: %v", err) } else if !strings.Contains(filepath.ToSlash(got), "/media/avatars/") { t.Fatalf("avatar path = %q", got) } if _, err := s.MediaPath("avatars/" + path.Base(name)); err != nil { t.Fatalf("MediaPath cannot find the avatar by its route shape: %v", err) } if media := s.ListMedia(); len(media) != 0 { t.Fatalf("the avatar leaked into the library: %v", media) } if !s.DeleteMedia(url) { t.Fatal("DeleteMedia failed for the avatar") } if s.DeleteMedia(url) { t.Fatal("DeleteMedia succeeded twice") } if _, err := s.MediaPath(name); err == nil { t.Fatal("the avatar survived its deletion") } } // The avatar namespace is exactly one level below the media directory: // anything deeper, anything outside it and anything without an image // extension is refused, so the public route gains no reach. func TestMediaPathRefusesNonMediaNames(t *testing.T) { s, _ := newStore(t) for _, name := range []string{ "avatars/../x.webp", "avatars/a/b.webp", "sub/x.webp", "avatarss/x.webp", "avatars/x.txt", "avatars/", "avatars", "..", "a/../b.webp", } { if got, err := s.MediaPath(name); err == nil { t.Fatalf("MediaPath(%q) = %q, want a refusal", name, got) } } // The avatar directory itself is not deletable content, with or // without a trailing slash. if s.DeleteMedia("/media/avatars") || s.DeleteMedia("/media/avatars/") { t.Fatal("DeleteMedia removed the avatar directory") } } func TestStoreUploadSVGAndDimensions(t *testing.T) { s, _ := newStore(t) svg := []byte(` `) 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") } }