package render import ( "bytes" "context" "encoding/json" "encoding/xml" "flag" "fmt" "os" "path/filepath" "strings" "testing" "time" "git.pablomurad.com/pablo/patchwork/internal/config" "git.pablomurad.com/pablo/patchwork/internal/engine" "git.pablomurad.com/pablo/patchwork/internal/store" ) var update = flag.Bool("update", false, "rewrite testdata/golden") // scenario builds a small moonflower-like site with a known history: // a page with two revisions (the second amended), one page never revised, // and one page whose only revision was retracted. func scenario(t *testing.T) (*store.Store, *config.Config) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "pw.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) cfg := config.Default() cfg.Site = config.Site{ Title: "Moon Flower Society", BaseURL: "https://moonflowersociety.org", Timezone: "America/Sao_Paulo", Author: "Pablo Murad", TagAuthority: "moonflowersociety.org,2026", PageURL: "/idea/{slug}", VersionURL: "/idea/{slug}/v/{n}", HistoryURL: "/idea/{slug}/history", Feeds: config.SiteFeeds{Atom: "/revisions.atom", JSON: "/revisions.json", PageAtom: "/idea/{slug}/revisions.atom", PageJSON: "/idea/{slug}/revisions.json"}, } cfg.Source.Slug = "datestrip-slugify" e, err := engine.New(st, cfg) if err != nil { t.Fatal(err) } now := time.Date(2026, 10, 1, 15, 0, 0, 0, time.UTC) e.Now = func() time.Time { return now } uid := 0 e.NewUID = func() string { uid++; return fmt.Sprintf("p_test%04d", uid) } ctx := context.Background() put := func(path, body string) { t.Helper() if _, err := e.Ingest(ctx, engine.Observation{Path: path, Raw: []byte(body)}, false); err != nil { t.Fatal(err) } now = now.Add(24 * time.Hour) } remote := "2026-06-21-remote-work.md" put(remote, "---\ntitle: Remote work\nmaturity: seed\n---\n# Remote work\n\nThe hybrid model is the worst of both worlds.\n\nSmall teams should pick one place.\n") put("notes.md", "# Notes\n\nA page that never changes.\n") put(remote, "---\ntitle: Remote work\nmaturity: sprout\nrevision: Two years in, I changed my mind.\nrevision_kind: position\n---\n# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n") put(remote, "---\ntitle: Remote work\nmaturity: sprout\nrevision: Added what I'd tell a new team why.\nrevision_kind: expansion\n---\n# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n\n- meet in person every month\n- write things down\n") put(remote, "---\ntitle: Remote work\nmaturity: sprout\nrevision: Added what I'd tell a new team & why.\nrevision_kind: expansion\n---\n# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n\n- meet in person every month\n- write things down\n") put("draft.md", "A draft.\n") put("draft.md", "---\nrevision: Oops, published by mistake.\n---\nA draft with a secret.\n") p, err := st.PageByPath(ctx, "folder", "draft.md") if err != nil { t.Fatal(err) } if err := e.Retract(ctx, p.ID, 2, false); err != nil { t.Fatal(err) } return st, cfg } func build(t *testing.T) *Tree { t.Helper() st, cfg := scenario(t) tree, err := Build(context.Background(), st, cfg) if err != nil { t.Fatal(err) } return tree } // TestTreeGolden pins every published byte. Review a diff in testdata/golden // like a diff in the API: it's what sites and feed readers will see. func TestTreeGolden(t *testing.T) { tree := build(t) root := filepath.Join("testdata", "golden") if *update { os.RemoveAll(root) } seen := map[string]bool{} for _, p := range tree.Paths() { seen[p] = true path := filepath.Join(root, filepath.FromSlash(p)) body := tree.Files[p].Body if *update { os.MkdirAll(filepath.Dir(path), 0o755) if err := os.WriteFile(path, body, 0o644); err != nil { t.Fatal(err) } continue } want, err := os.ReadFile(path) if err != nil { t.Errorf("%s: %v (run with -update)", p, err) continue } if !bytes.Equal(bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")), body) { t.Errorf("%s differs from golden; got:\n%s", p, body) } } filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err == nil && !d.IsDir() { rel, _ := filepath.Rel(root, path) if !seen[filepath.ToSlash(rel)] { t.Errorf("golden file %s is no longer produced", rel) } } return nil }) } func TestRetractedRevisionIsGone(t *testing.T) { tree := build(t) for p, f := range tree.Files { if strings.Contains(string(f.Body), "secret") && !strings.Contains(p, "/versions/1") { // Version 1 of draft.md never had the secret; any mention of it // means the retracted text leaked. t.Errorf("retracted text published in %s", p) } } if _, ok := tree.Files["api/v1/pages/draft/versions/2.json"]; ok { t.Error("retracted version is still served") } } func TestAtomRoundTrip(t *testing.T) { tree := build(t) var f atomFeed if err := xml.Unmarshal(tree.Files["feeds/revisions.atom"].Body, &f); err != nil { t.Fatal(err) } if len(f.Entries) != 2 { t.Fatalf("entries %d", len(f.Entries)) } ids := map[string]bool{} for _, e := range f.Entries { if ids[e.ID] { t.Errorf("duplicate id %s", e.ID) } ids[e.ID] = true if !strings.HasPrefix(e.ID, "tag:moonflowersociety.org,2026:patchwork/") { t.Errorf("id %s", e.ID) } if _, err := time.Parse(time.RFC3339, e.Updated); err != nil { t.Errorf("updated %q: %v", e.Updated, err) } } // The amended revision keeps its id and moves its updated time. newest := f.Entries[0] if newest.Published == newest.Updated || !strings.Contains(newest.Content.Body, "& why") { t.Errorf("amended entry %+v", newest) } self := "" for _, l := range f.Links { if l.Rel == "self" { self = l.Href } } if self != "https://moonflowersociety.org/revisions.atom" { t.Errorf("self link %q", self) } } func TestJSONFeedShape(t *testing.T) { tree := build(t) var f map[string]any if err := json.Unmarshal(tree.Files["feeds/pages/remote-work.json"].Body, &f); err != nil { t.Fatal(err) } if f["version"] != "https://jsonfeed.org/version/1.1" || f["feed_url"] != "https://moonflowersociety.org/idea/remote-work/revisions.json" { t.Fatalf("feed %v", f) } items := f["items"].([]any) if len(items) != 2 { t.Fatalf("items %d", len(items)) } } func TestTextWrapsAndColors(t *testing.T) { st, cfg := scenario(t) m, err := Load(context.Background(), st, cfg) if err != nil { t.Fatal(err) } out := m.TextRevisions(m.Revisions, TextOptions{Width: 48, Color: true}) if !strings.Contains(out, "\x1b[32m") || !strings.Contains(out, "\x1b[31m") { t.Error("colored output should mark insertions and deletions") } for _, line := range strings.Split(stripANSI(out), "\n") { // A single unbreakable word (a URL) may overflow: cutting it would // break copy and paste. if visibleWidth(line) > 48 && strings.Contains(strings.TrimSpace(line), " ") { t.Errorf("line wider than 48: %q", line) } } plain := m.TextRevisions(m.Revisions, TextOptions{Width: 80}) if strings.Contains(plain, "\x1b[") || !strings.Contains(plain, "\n - ") || !strings.Contains(plain, "\n + ") { t.Errorf("plain output should mark changes without ANSI:\n%s", plain) } } func stripANSI(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { if s[i] == 0x1b { for i < len(s) && s[i] != 'm' { i++ } continue } b.WriteByte(s[i]) } return b.String() }