// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package payloads import ( "strings" "testing" "sourcedock.dev/petrbalvin/volumen/internal/frontmatter" "sourcedock.dev/petrbalvin/volumen/internal/post" ) const refsPost = `+++ title = "Cite" slug = "cite" date = 2026-08-18 [[refs]] title = "Observational evidence from supernovae" authors = [{ name = "Riess, A. G.", orcid = "0000-0002-1825-0097" }] venue = "The Astronomical Journal" year = 1998 doi = "10.1103/PhysRevD.59.103502" +++ TvrzenĂ­ [1]. ` func TestDetailCarriesReferences(t *testing.T) { p := parsePost(t, refsPost) d, err := BuildDetail(p, "https://example.com") if err != nil { t.Fatalf("BuildDetail: %v", err) } if len(d.References) != 1 { t.Fatalf("references = %d", len(d.References)) } ref := d.References[0] if ref.Title == "" || ref.DOI != "10.1103/PhysRevD.59.103502" || ref.Authors[0].ORCID != "0000-0002-1825-0097" { t.Fatalf("reference = %+v", ref) } // The JSON-LD block cites the work back with author and identifier. for _, want := range []string{ `"citation"`, `"ScholarlyArticle"`, `https://doi.org/10.1103/PhysRevD.59.103502`, `https://orcid.org/0000-0002-1825-0097`, } { if !strings.Contains(d.Meta.JSONLD, want) { t.Fatalf("json-ld missing %q:\n%s", want, d.Meta.JSONLD) } } // The HTML carries the linked citation too. if !strings.Contains(d.HTML, `href="#ref-1"`) { t.Fatalf("html missing the citation anchor:\n%s", d.HTML) } } // A post without refs omits the member entirely: the wire shape of // every ordinary post is unchanged. func TestDetailOmitsEmptyReferences(t *testing.T) { p := parsePost(t, "+++\ntitle = \"Plain\"\nslug = \"plain\"\n+++\nx\n") d, err := BuildDetail(p, "https://example.com") if err != nil { t.Fatalf("BuildDetail: %v", err) } if len(d.References) != 0 { t.Fatalf("references = %v", d.References) } if strings.Contains(d.Meta.JSONLD, "citation") { t.Fatalf("plain post gained citations:\n%s", d.Meta.JSONLD) } } // When the cited work is published in the same instance, the detail // exposes the internal link in every shape the consumer reads: the // rendered HTML, the references member, and the JSON-LD citation. func TestReferencesCarryInternalLink(t *testing.T) { p := parsePost(t, refsPost) p.SetLinkIndex(map[string]string{"10.1103/physrevd.59.103502": "riess-1998"}) d, err := BuildDetail(p, "https://example.com") if err != nil { t.Fatalf("BuildDetail: %v", err) } if d.References[0].Internal != "/api/volumen/posts/riess-1998" { t.Fatalf("references internal = %+v", d.References[0]) } if !strings.Contains(d.HTML, `href="/api/volumen/posts/riess-1998"`) { t.Fatalf("html missing the internal link:\n%s", d.HTML) } if !strings.Contains(d.Meta.JSONLD, "/api/volumen/posts/riess-1998") { t.Fatalf("json-ld missing the internal url:\n%s", d.Meta.JSONLD) } // The canonical DOI identifier stays in the JSON-LD beside it. if !strings.Contains(d.Meta.JSONLD, "https://doi.org/10.1103/PhysRevD.59.103502") { t.Fatalf("canonical identifier lost:\n%s", d.Meta.JSONLD) } } func existingWithRefs(t *testing.T) *post.Post { t.Helper() m := frontmatter.NewMeta() m.Set("title", "Hello") m.Set("slug", "hello") // The shape a parsed file carries: an array of tables. m.Set("refs", []map[string]any{ {"raw": "Riess, A. G. et al. 1998, AJ 116, 1009"}, }) return post.New(m, "body") } func refTables(t *testing.T, p *post.Post) []map[string]any { t.Helper() raw, ok := p.Metadata.Get("refs") if !ok { return nil } list, ok := raw.([]map[string]any) if !ok { t.Fatalf("refs = %T, want []map[string]any", raw) } return list } // A form that carries no refs field leaves the stored list untouched: a // save from a page without its bibliography card still round-trips the // file as it stands. func TestRefsAbsentFromFormKeepsTheStoredList(t *testing.T) { p, err := PostFromParams(map[string]string{ "title": "Hello", "slug": "hello", "body": "new", }, existingWithRefs(t)) if err != nil { t.Fatalf("PostFromParams: %v", err) } tables := refTables(t, p) if len(tables) != 1 || tables[0]["raw"] != "Riess, A. G. et al. 1998, AJ 116, 1009" { t.Fatalf("refs = %v", tables) } } // An empty list is an explicit deletion, not a lost field. func TestRefsEmptyListDeletesTheKey(t *testing.T) { p, err := PostFromParams(map[string]string{ "title": "Hello", "slug": "hello", "body": "b", "refs": "[]", }, existingWithRefs(t)) if err != nil { t.Fatalf("PostFromParams: %v", err) } if _, ok := p.Metadata.Get("refs"); ok { t.Fatal("refs survived an explicit empty list") } } // The editor's JSON becomes the frontmatter tables: values trimmed, // identifiers normalised, author ORCIDs kept as name tables, an explicit // number preserved, a blank row dropped. func TestRefsFormWritesCanonicalTables(t *testing.T) { p, err := PostFromParams(map[string]string{ "title": "Hello", "slug": "hello", "body": "b", "refs": `[ {"raw": " Trimmed raw line. "}, {}, {"num": 2, "authors": [{"name": "Adam Riess", "orcid": "0000-0002-1825-0097"}, "plain author"], "title": "Observational Evidence from Supernovae", "venue": "The Astronomical Journal", "year": "1998", "volume": "116", "pages": "1009", "doi": "https://doi.org/10.1086/300499", "arxiv": "arXiv:astro-ph/9805201", "url": "https://example.com/paper"} ]`, }, existingWithRefs(t)) if err != nil { t.Fatalf("PostFromParams: %v", err) } tables := refTables(t, p) if len(tables) != 2 { t.Fatalf("tables = %v", tables) } if tables[0]["raw"] != "Trimmed raw line." { t.Fatalf("raw = %q", tables[0]["raw"]) } second := tables[1] if second["num"] != int64(2) { t.Fatalf("num = %v", second["num"]) } if second["doi"] != "10.1086/300499" { t.Fatalf("doi = %q", second["doi"]) } if second["arxiv"] != "astro-ph/9805201" { t.Fatalf("arxiv = %q", second["arxiv"]) } authors, ok := second["authors"].([]any) if !ok || len(authors) != 2 { t.Fatalf("authors = %v", second["authors"]) } first, ok := authors[0].(map[string]any) if !ok || first["name"] != "Adam Riess" || first["orcid"] != "0000-0002-1825-0097" { t.Fatalf("author = %v", authors[0]) } if authors[1] != "plain author" { t.Fatalf("second author = %v", authors[1]) } } func TestRefsFormRejectsBadPayloads(t *testing.T) { cases := []struct { name string refs string want string }{ {"not a list", `{"raw": "x"}`, "References must be a list of entries."}, {"bad doi", `[{"raw": "x", "doi": "not-a-doi"}]`, "Reference 1: DOI must look like 10.xxxx/suffix."}, {"bad orcid", `[{"raw": "x", "authors": [{"name": "A", "orcid": "0000-0002-1825-0098"}]}]`, "Reference 1: ORCID must look like 0000-0002-1825-0097."}, {"bad url", `[{"raw": "x", "url": "ftp://example.com"}]`, "Reference 1: URL must be an http(s) address."}, {"bad arxiv", `[{"raw": "x", "arxiv": "not an id"}]`, "Reference 1: arXiv must be the bare identifier"}, {"no citation", `[{"doi": "10.1086/300499"}]`, "Reference 1 needs the citation itself"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := PostFromParams(map[string]string{ "title": "Hello", "slug": "hello", "body": "b", "refs": tc.refs, }, nil) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("err = %v, want %q", err, tc.want) } }) } }