The server never fails on a bad bundle — it works around it and logs, which is easy to miss (ADR-0029). This is the command that looks on purpose, and it exits non-zero on anything that makes the site wrong rather than merely untidy. Fatal: content the engine had to drop (unparseable frontmatter, a key two files claim, an ignored slug or alias) and links that will 404 for a reader. Warnings: no title, a figure with no alt text, a series where some members declare order and others do not — that last one because unordered members sort last, so adding order to one chapter silently moves every chapter that lacks it. Two things this needed rather than invented. `Scan` and `Site` now *return* what they worked around instead of only logging it: `ScanReport` and `Site.Problems`, with `Scan` staying the logging wrapper so nothing else changed. And required-fields-per-type is deliberately absent — `title` is the only field the engine requires today, so checking more would mean inventing the type declaration that is still parked. Its trigger stays where it was. The link checker asks the same questions the resolver asks — bundle, alias, section listing, file inside a bundle — because a checker that guesses differently from the server is worse than no checker. Engine-owned paths are skipped: they are generated, not authored. It lives in internal/ext/check, so cmd/ stays wiring and the feature stays deletable. Verified against the evidence site: clean before, and five findings across five fault classes after I introduced them on purpose.
148 lines
5.3 KiB
Go
148 lines
5.3 KiB
Go
package check
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"khosra/internal/content"
|
|
)
|
|
|
|
func run(t *testing.T, fsys fstest.MapFS) []Finding {
|
|
t.Helper()
|
|
bundles, problems, err := content.ScanReport(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Run(fsys, bundles, content.NewSite(bundles), problems)
|
|
}
|
|
|
|
// says reports whether any finding mentions the fragment, and whether it was fatal.
|
|
func says(found []Finding, fragment string) (bool, bool) {
|
|
for _, f := range found {
|
|
if strings.Contains(f.What, fragment) || strings.Contains(f.Where, fragment) {
|
|
return true, f.Fatal
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func TestWhatTheEngineWorkedAroundIsReportedAsFatal(t *testing.T) {
|
|
// The server never fails on these; it works around them and logs. `check` is the place that looks on
|
|
// purpose (ADR-0029).
|
|
found := run(t, fstest.MapFS{
|
|
"content/posts/broken.md": {Data: []byte("---\ntitle: [unclosed\n---\nx\n")},
|
|
"content/posts/twice.md": {Data: []byte("---\ntitle: One\n---\nx\n")},
|
|
"content/posts/twice/index.md": {Data: []byte("---\ntitle: Two\n---\nx\n")},
|
|
"content/posts/a.md": {Data: []byte("---\ntitle: A\nslug: taken\n---\nx\n")},
|
|
"content/posts/taken.md": {Data: []byte("---\ntitle: Taken\n---\nx\n")},
|
|
})
|
|
for _, fragment := range []string{"cannot be parsed", "same key", "slug ignored"} {
|
|
said, fatal := says(found, fragment)
|
|
if !said {
|
|
t.Errorf("nothing reported %q:\n%v", fragment, found)
|
|
continue
|
|
}
|
|
if !fatal {
|
|
t.Errorf("%q is content the site is not serving, so it must be fatal", fragment)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestABrokenInternalLinkIsFatal(t *testing.T) {
|
|
found := run(t, fstest.MapFS{
|
|
"content/posts/one.md": {Data: []byte("---\ntitle: One\n---\n" +
|
|
"[good](/posts/two/) and [gone](/posts/never/) and [section](/posts/) and [alias](/posts/old/)\n")},
|
|
"content/posts/two.md": {Data: []byte("---\ntitle: Two\naliases: [posts/old]\n---\nx\n")},
|
|
})
|
|
if !brokenLink(found, "/posts/never/") {
|
|
t.Errorf("a link that 404s must be reported:\n%v", found)
|
|
}
|
|
if !Fatal(found) {
|
|
t.Error("a broken link makes the site wrong, so it must fail the check")
|
|
}
|
|
// Matched exactly, since "/posts/never/" contains "/posts/" — a substring test would pass either way.
|
|
for _, fine := range []string{"/posts/two/", "/posts/", "/posts/old/"} {
|
|
if brokenLink(found, fine) {
|
|
t.Errorf("%s resolves — a bundle, a listing and an alias are all real:\n%v", fine, found)
|
|
}
|
|
}
|
|
}
|
|
|
|
// brokenLink reports whether exactly this target was called out, rather than one that merely contains it.
|
|
func brokenLink(found []Finding, target string) bool {
|
|
for _, f := range found {
|
|
if strings.HasSuffix(f.What, "link goes nowhere: "+target) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestEngineOwnedAndAssetLinksAreNotComplaints(t *testing.T) {
|
|
found := run(t, fstest.MapFS{
|
|
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n" +
|
|
"[css](/static/x.css) [feed](/feed.xml) [tag](/tags/monsoon/) [home](/) " +
|
|
" \n")},
|
|
"content/art/set/one.jpg": {Data: []byte("bytes")},
|
|
})
|
|
for _, quiet := range []string{"/static/x.css", "/feed.xml", "/tags/monsoon/", "/art/set/one.jpg"} {
|
|
if brokenLink(found, quiet) {
|
|
t.Errorf("%s should not be a finding:\n%v", quiet, found)
|
|
}
|
|
}
|
|
if !brokenLink(found, "/art/set/nope.jpg") || !Fatal(found) {
|
|
t.Errorf("a missing asset is a broken link:\n%v", found)
|
|
}
|
|
}
|
|
|
|
func TestTitlesAltTextAndMixedOrderingAreWarnings(t *testing.T) {
|
|
found := run(t, fstest.MapFS{
|
|
"content/posts/untitled.md": {Data: []byte("no frontmatter at all\n")},
|
|
"content/posts/pics.md": {Data: []byte("---\ntitle: Pics\n---\n" +
|
|
"{{< figure src=\"a.jpg\" alt=\"A described picture\" >}}\n\n{{< figure src=\"b.jpg\" >}}\n")},
|
|
"content/comics/s/_index.md": {Data: []byte("---\ntitle: S\n---\nx\n")},
|
|
"content/comics/s/one.md": {Data: []byte("---\ntitle: One\norder: 10\n---\nx\n")},
|
|
"content/comics/s/two.md": {Data: []byte("---\ntitle: Two\n---\nx\n")},
|
|
})
|
|
for _, fragment := range []string{"no title", "no alt text", "sort last"} {
|
|
said, fatal := says(found, fragment)
|
|
if !said {
|
|
t.Errorf("nothing reported %q:\n%v", fragment, found)
|
|
continue
|
|
}
|
|
if fatal {
|
|
t.Errorf("%q is untidy, not wrong — it must not fail the check", fragment)
|
|
}
|
|
}
|
|
// The described figure is not a finding.
|
|
if strings.Count(findingsAbout(found, "no alt text"), "\n") > 1 {
|
|
t.Errorf("only the undescribed figure should be reported:\n%v", found)
|
|
}
|
|
if Fatal(found) {
|
|
t.Errorf("nothing here is fatal, so the command should exit zero:\n%v", found)
|
|
}
|
|
}
|
|
|
|
func findingsAbout(found []Finding, fragment string) string {
|
|
var out strings.Builder
|
|
for _, f := range found {
|
|
if strings.Contains(f.What, fragment) {
|
|
out.WriteString(f.What + "\n")
|
|
}
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func TestACleanSiteHasNothingToSay(t *testing.T) {
|
|
found := run(t, fstest.MapFS{
|
|
"content/posts/one.md": {Data: []byte("---\ntitle: One\ndate: 2026-01-01\n---\nSee [two](/posts/two/).\n")},
|
|
"content/posts/two.md": {Data: []byte("---\ntitle: Two\ndate: 2026-01-02\n---\nx\n")},
|
|
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n{{< figure src=\"one.jpg\" alt=\"Described\" >}}\n")},
|
|
"content/art/set/one.jpg": {Data: []byte("bytes")},
|
|
})
|
|
if len(found) != 0 {
|
|
t.Errorf("a clean site should produce no findings, got:\n%v", found)
|
|
}
|
|
}
|