Files
sow-tools/internal/topdata/wiki_deploy_test.go
T
archvillainette 3f1e33a39a
build-binaries / build-binaries (push) Successful in 2m56s
fix(wiki): compare drift against what NodeBB stored, not what we rendered (#104)
Fixes #103.

## What was wrong

`crucible wiki deploy` decided a page had drifted by hashing NodeBB's stored
copy and comparing it to the hash of the text Crucible rendered. Those match
only if NodeBB gives our HTML back byte for byte. It does not, so every
`sow-topdata` tag deploy failed:

```
local pages: 1200, updated: 1, skipped: 1176, drifted: 23
remote managed wiki content drifted; rerun with --force to overwrite
```

Nobody had edited those pages. The operator's only way out was to leave
`--force` on, which removes the protection the guard exists for.

## What changed

- New manifest field `remote_hash`: the managed-region hash of the post NodeBB
  hands back right after we write it. Drift compares against that, so it means
  "the live page changed after we last wrote it".
- A post with no `sourceContent` is not drift. It predates `sourceContent`
  sync, reads back as rendered HTML, and belongs to the existing
  `SourceContentSynced` repair — which the drift refusal used to block.
- The error names the drifted pages, capped at ten plus a count.
- Deleted the dead `wikiDeployPlan.RemoteHash` field.

Old manifests keep the previous comparison until each page is next written, so
no re-seed is needed. Cost: one extra post read per page written. Pages that
skip are still never fetched.

## Tests

`internal/topdata/wiki_deploy_test.go`, same seam as the rest of the file
(`DeployWikiWithOptions` against the fake NodeBB): normalized remote copy is not
drift, a hand-edited page still is and the error names it, `--force` overwrites
it and re-records the hash, a post without `sourceContent` is repaired instead
of refused, and an old-format manifest deploys and gains a `remote_hash`.

`make check` passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #104

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-07 21:46:36 +00:00

2826 lines
106 KiB
Go
Raw 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.
package topdata
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Athletics\n\nGenerated athletics page\n[//]: # (sow-topdata-wiki:managed:end)\n"
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.md"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := "<!-- sow-topdata-wiki:managed:start -->\n<h1>Athletics</h1>\n<p>Existing athletics page</p>\n<!-- sow-topdata-wiki:managed:end -->\n"
oldHash := computeManagedHash(old)
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:athletics": {Hash: oldHash, TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
updateCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got)
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": old}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42":
updateCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions dry run failed: %v", err)
}
if result.Updated != 1 {
t.Fatalf("expected one planned update, got %d", result.Updated)
}
if updateCalls != 0 {
t.Fatalf("expected dry run not to update NodeBB posts, got %d calls", updateCalls)
}
after := loadDeployManifest(manifestPath)
if after.Pages["skills:athletics"].Hash != oldHash {
t.Fatalf("expected dry run not to rewrite manifest")
}
}
func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
progress := []string{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []map[string]any{}, "hasMore": false}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: filepath.Join(root, "deploy-manifest.json"),
CategoryIDs: map[string]int{"skills": 3},
DryRun: true,
AllowCreates: true,
}, func(message string) {
progress = append(progress, message)
})
if err != nil {
t.Fatalf("DeployWikiWithOptions dry run failed: %v", err)
}
joined := strings.Join(progress, "\n")
for _, expected := range []string{
"Collecting local wiki pages from",
"Loaded 1 local wiki page(s)",
"Loaded wiki deploy manifest",
"Planning NodeBB wiki deploy for 1 local page(s)",
"Listing NodeBB wiki namespace category 3",
} {
if !strings.Contains(joined, expected) {
t.Fatalf("expected progress %q, got:\n%s", expected, joined)
}
}
}
func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "feat"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
updatePage := `<!-- sow-topdata-wiki:page=feat:updated -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local-update" -->
<h1>Updated</h1>
<p>New generated content</p>
<!-- sow-topdata-wiki:managed:end -->
`
renamePage := `<!-- sow-topdata-wiki:page=feat:renamed -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local-rename" -->
<h1>Renamed</h1>
<p>Unchanged generated content</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "feat", "updated.html"), []byte(updatePage), 0644); err != nil {
t.Fatalf("write update page: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "feat", "renamed.html"), []byte(renamePage), 0644); err != nil {
t.Fatalf("write rename page: %v", err)
}
oldUpdate := `<!-- sow-topdata-wiki:page=feat:updated -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old-update" -->
<h1>Updated</h1>
<p>Old generated content</p>
<!-- sow-topdata-wiki:managed:end -->
`
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"feat:renamed": {Hash: computeManagedHash(renamePage), Title: "Old Renamed", TopicTitle: "Old Renamed", Namespace: "feat", SourceContentSynced: true, TID: 7, PID: 70, CID: 5},
"feat:updated": {Hash: computeManagedHash(oldUpdate), Title: "Updated", TopicTitle: "Updated", Namespace: "feat", TID: 8, PID: 80, CID: 5},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
progress := []string{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7, "title": "Old Renamed", "mainPid": 70}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/80":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 80, "tid": 8, "content": oldUpdate}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"token": "lock-token"}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/80":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 80, "tid": 8}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, func(message string) {
progress = append(progress, message)
})
if err != nil {
t.Fatalf("DeployWikiWithOptions live deploy failed: %v", err)
}
if result.Updated != 1 || result.Renamed != 1 {
t.Fatalf("expected one update and one rename, got %#v", result)
}
joined := strings.Join(progress, "\n")
for _, expected := range []string{
"Executing NodeBB wiki actions: total 2, create 0, update 1, rename 1, archive 0, purge 0",
"Executing NodeBB wiki action 1/2:",
"Executing NodeBB wiki action 2/2:",
"rename feat:renamed",
"update feat:updated",
} {
if !strings.Contains(joined, expected) {
t.Fatalf("expected execution progress %q, got:\n%s", expected, joined)
}
}
}
func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) {
requests := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{"tid": 2461, "title": "Favored Enemy: Elves", "titleLeaf": "Favored Enemy: Elves"},
},
"hasMore": true,
"nextCursor": "same-cursor",
},
})
})
defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token")
pages, err := client.listNamespacePages(9)
if err != nil {
t.Fatalf("expected repeated cursor with duplicate pages to stop cleanly, got %v", err)
}
if len(pages) != 1 || pages[0].TID != 2461 {
t.Fatalf("expected one unique page from repeated cursor response, got %#v", pages)
}
if requests != 2 {
t.Fatalf("expected pagination to stop after repeated cursor, got %d requests", requests)
}
}
func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) {
requests := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
pages := []map[string]any{
{"tid": 1, "title": "First", "titleLeaf": "First"},
}
if requests == 2 {
pages = []map[string]any{
{"tid": 2, "title": "Second", "titleLeaf": "Second"},
}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": pages,
"hasMore": true,
"nextCursor": "same-cursor",
},
})
})
defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token")
_, err := client.listNamespacePages(9)
if err == nil {
t.Fatal("expected repeated cursor with new pages to fail")
}
if !strings.Contains(err.Error(), "repeated pagination cursor") {
t.Fatalf("expected repeated cursor error, got %v", err)
}
}
func TestCollectLocalPagesReadsGeneratedPageIDFromMarkerWhenPathIsTitleBased(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "feat"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=feat:bardic_music_inspire_competence -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Inspire Competence</h1>
<p>Generated page.</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "feat", "Inspire_Competence.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
pages, err := collectLocalPages(sourceDir, "", []string{"feat"})
if err != nil {
t.Fatalf("collectLocalPages failed: %v", err)
}
page, ok := pages["feat:bardic_music_inspire_competence"]
if !ok {
t.Fatalf("expected marker page ID to be used as deploy identity, got keys %#v", sortedKeysFromMap(pages))
}
if page.PageID != "feat:bardic_music_inspire_competence" || page.Namespace != "feat" || page.Title != "Inspire Competence" {
t.Fatalf("unexpected collected page metadata: %#v", page)
}
if _, ok := pages["feat:Inspire_Competence"]; ok {
t.Fatalf("expected title-based path not to become generated page identity")
}
}
func TestCollectLocalPagesReadsCanonicalPublicPathFromPageIndex(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=spells:pdk:fear -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Fear</h1>
<p>Generated page.</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Fear.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{
PageID: "spells:pdk:fear",
PublicPath: "Magic/Fear",
OutputPath: "pages/spells/Fear.html",
},
},
}); err != nil {
t.Fatalf("write page index: %v", err)
}
pages, err := collectLocalPages(sourceDir, "", []string{"spells"})
if err != nil {
t.Fatalf("collectLocalPages failed: %v", err)
}
if got := pages["spells:pdk:fear"].PublicPath; got != "Magic/Fear" {
t.Fatalf("expected canonical public path from page-index, got %q", got)
}
}
func TestCollectLocalPagesUsesConfiguredPageIndexPath(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=spells:acid_fog -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Acid Fog</h1>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
indexPath := filepath.Join(root, "manifest", "pages.json")
if err := saveWikiPageIndex(indexPath, wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{
PageID: "spells:acid_fog",
Title: "Acid Fog",
PublicPath: "Magic/Acid_Fog",
OutputPath: "pages/spells/Acid_Fog.html",
},
},
}); err != nil {
t.Fatalf("write page index: %v", err)
}
pages, err := collectLocalPages(sourceDir, indexPath, []string{"spells"})
if err != nil {
t.Fatalf("collectLocalPages failed: %v", err)
}
if got := pages["spells:acid_fog"].PublicPath; got != "Magic/Acid_Fog" {
t.Fatalf("expected configured page-index public path, got %q", got)
}
}
func TestCollectLocalPagesReadsTitleFromPageIndexForHeadinglessHTML(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=spells:acid_fog -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<p>Generated acid fog page.</p>
<table><tbody><tr><th>School</th><td>Conjuration</td></tr></tbody></table>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{
PageID: "spells:acid_fog",
Title: "Acid Fog",
PublicPath: "Spells/Acid_Fog",
OutputPath: "pages/spells/Acid_Fog.html",
},
},
}); err != nil {
t.Fatalf("write page index: %v", err)
}
pages, err := collectLocalPages(sourceDir, "", []string{"spells"})
if err != nil {
t.Fatalf("collectLocalPages failed: %v", err)
}
page := pages["spells:acid_fog"]
if page.Title != "Acid Fog" {
t.Fatalf("expected page-index title for headingless HTML, got %#v", page)
}
if page.PublicPath != "Spells/Acid_Fog" {
t.Fatalf("expected page-index public path for headingless HTML, got %#v", page)
}
}
func TestExtractWikiPagePublicPathAcceptsCanonicalSlashPath(t *testing.T) {
content := `<!-- sow-topdata-wiki:page=spells:pdk:fear public_path="Magic/Fear" -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Fear</h1>
<!-- sow-topdata-wiki:managed:end -->
`
if got := extractWikiPagePublicPath(content); got != "Magic/Fear" {
t.Fatalf("expected slash-separated public path, got %q", got)
}
}
func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "classes"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=classes:acolyte -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Acolyte</h1>
<p>Generated acolyte page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "classes", "acolyte.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := `<!-- sow-topdata-wiki:page=classes:acolyte -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Acolyte</h1>
<p>Old acolyte page</p>
<!-- sow-topdata-wiki:managed:end -->
`
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"classes:acolyte": {Hash: computeManagedHash(old), TID: 7, PID: 42, CID: 9},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": map[string]any{"code": "not-found", "message": "Post does not exist"},
"response": map[string]any{},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{"tid": 77, "title": "Acolyte", "titleLeaf": "Acolyte", "slug": "77/acolyte", "wikiPath": "/wiki/classes/acolyte"},
},
"hasMore": false,
},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions dry-run re-adoption failed: %v", err)
}
if result.Updated != 1 {
t.Fatalf("expected one planned update after re-adoption, got %#v", result)
}
}
func TestMatchExistingNodeBBPageIgnoresRetiredGeneratedPublicSlug(t *testing.T) {
page := wikiDeployPage{
PageID: "spells:pdk:fear",
Title: "Fear",
PublicPath: "Spells/Fear",
}
matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
{TID: 31, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/spells/fear"},
{TID: 32, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/spells/pdk-fear"},
})
if !ok || matched.TID != 31 {
t.Fatalf("expected canonical title match to select tid 31, got %#v ok=%t", matched, ok)
}
}
func TestMatchExistingNodeBBPageDoesNotPreferRetiredGeneratedSlugWhenRemoteOrderChanges(t *testing.T) {
page := wikiDeployPage{
PageID: "spells:pdk:fear",
Title: "Fear",
PublicPath: "Spells/Fear",
}
matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
{TID: 32, Title: "Fear", TitleLeaf: "Fear", Slug: "32/pdk-fear", WikiPath: "/wiki/spells/pdk-fear"},
{TID: 31, Title: "Fear", TitleLeaf: "Fear", Slug: "31/fear"},
})
if !ok || matched.TID != 31 {
t.Fatalf("expected canonical leaf match to select tid 31 over retired generated slug, got %#v ok=%t", matched, ok)
}
}
func TestMatchExistingNodeBBPageAdoptsCanonicalWikiPathWhenSlugIsRetired(t *testing.T) {
page := wikiDeployPage{
PageID: "spells:pdk:fear",
Title: "Fear",
}
matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
{TID: 32, Title: "Fear", TitleLeaf: "Fear", Slug: "32/pdk-fear", WikiPath: "/wiki/Spells/Fear"},
})
if !ok || matched.TID != 32 {
t.Fatalf("expected title fallback to adopt canonical wiki path despite retired NodeBB topic slug, got %#v ok=%t", matched, ok)
}
}
func TestMatchExistingNodeBBPageRequiresFullPathBeforeLeafFallback(t *testing.T) {
page := wikiDeployPage{
PageID: "spells:pdk:fear",
Title: "Fear",
PublicPath: "Spells/Necromancy/Fear",
}
matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
{TID: 31, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/Spells/Illusion/Fear"},
})
if ok {
t.Fatalf("expected no cross-namespace leaf fallback match, got %#v", matched)
}
}
func TestMatchExistingNodeBBPageRequiresCanonicalPathForNestedPublicPath(t *testing.T) {
page := wikiDeployPage{
PageID: "spells:pdk:fear",
Title: "Fear",
PublicPath: "Spells/Necromancy/Fear",
}
matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
{TID: 31, Title: "Fear", TitleLeaf: "Fear", Slug: "31/fear"},
})
if ok {
t.Fatalf("expected no nested-path title or leaf fallback match without canonical path, got %#v", matched)
}
matched, ok = matchExistingNodeBBPage(page, []nodeBBWikiPage{
{TID: 31, Title: "Fear", TitleLeaf: "Fear", CanonicalPath: "Spells/Necromancy/Fear"},
})
if !ok || matched.TID != 31 {
t.Fatalf("expected canonical path match for nested public path, got %#v ok=%t", matched, ok)
}
}
func TestNestedCanonicalAdoptionFallbackError(t *testing.T) {
page := wikiDeployPage{
PageID: "spells:pdk:fear",
Title: "Fear",
PublicPath: "Spells/Necromancy/Fear",
}
err := nestedCanonicalAdoptionFallbackError(page, []nodeBBWikiPage{
{TID: 31, Title: "Fear", TitleLeaf: "Fear", Slug: "31/fear"},
})
if err == nil || !strings.Contains(err.Error(), "requires exact canonical wikiPath/canonicalPath") {
t.Fatalf("expected exact canonical remapping error, got %v", err)
}
}
func TestDeployCanonicalWikiSegmentMatchesGeneratedPathPolicy(t *testing.T) {
tests := map[string]string{
`Grandmaster's Battle Momentum`: "Grandmasters_Battle_Momentum",
`Bigbys Clenched Fist`: "Bigbys_Clenched_Fist",
`Hardiness vs. Enchantments`: "Hardiness_vs_Enchantments",
`Clairaudience/Clairvoyance`: "Clairaudience_Clairvoyance",
`Élite &amp; Noble Houses`: "Elite_Noble_Houses",
`Alpha!@#$%^&*()[]-=_+/?.,<>` + "`" + `~|\Omega`: "Alpha_Omega",
`Æther Œuvre Øresund Straße Þorn Łódź Đelta`: "Aether_Oeuvre_Oresund_Strasse_Thorn_Lodz_Delta",
}
for input, want := range tests {
if got := canonicalWikiSegment(input); got != want {
t.Fatalf("canonicalWikiSegment(%q) = %q, want %q", input, got, want)
}
}
}
func TestLoadWikiNamespaceDeclarationsValidatesCanonicalPaths(t *testing.T) {
root := testProjectRoot(t)
wikiDir := filepath.Join(root, "topdata", "wiki")
if err := os.MkdirAll(wikiDir, 0755); err != nil {
t.Fatalf("create wiki dir: %v", err)
}
write := func(text string) {
t.Helper()
if err := os.WriteFile(filepath.Join(wikiDir, "namespaces.yaml"), []byte(text), 0644); err != nil {
t.Fatalf("write namespaces: %v", err)
}
}
write(`
namespaces:
- id: feat
title: Feats
canonical_path: Wiki/Feats
category_env: SOW_WIKI_FEATS_CID
edit_policy: preserve_manual_sections
`)
declarations, err := loadWikiNamespaceDeclarations(testProject(root))
if err != nil {
t.Fatalf("expected canonical namespace path declaration to load: %v", err)
}
if got := declarations[0].CanonicalPath; got != "Wiki/Feats" {
t.Fatalf("expected canonical namespace path to be preserved, got %q", got)
}
write(`
namespaces:
- id: search
title: Search
canonical_path: Wiki/Search
category_env: SOW_WIKI_SEARCH_CID
edit_policy: preserve_manual_sections
`)
declarations, err = loadWikiNamespaceDeclarations(testProject(root))
if err != nil {
t.Fatalf("expected reserved display title with safe canonical path to load: %v", err)
}
if got := declarations[0].CanonicalPath; got != "Wiki/Search" {
t.Fatalf("expected safe canonical namespace path to be preserved, got %q", got)
}
write(`
namespaces:
- id: bad
title: "!!!"
category_env: SOW_WIKI_BAD_CID
edit_policy: preserve_manual_sections
`)
_, err = loadWikiNamespaceDeclarations(testProject(root))
if err == nil || !strings.Contains(err.Error(), `topdata wiki namespace "bad" title`) {
t.Fatalf("expected invalid namespace title error, got %v", err)
}
write(`
namespaces:
- id: search
title: Search
canonical_path: search
category_env: SOW_WIKI_SEARCH_CID
edit_policy: preserve_manual_sections
`)
_, err = loadWikiNamespaceDeclarations(testProject(root))
if err == nil || !strings.Contains(err.Error(), `reserved first segment`) {
t.Fatalf("expected reserved namespace path error, got %v", err)
}
write(`
namespaces:
- id: numeric
title: Feats
canonical_path: 123/Feats
category_env: SOW_WIKI_NUMERIC_CID
edit_policy: preserve_manual_sections
`)
_, err = loadWikiNamespaceDeclarations(testProject(root))
if err == nil || !strings.Contains(err.Error(), `reserved first segment`) {
t.Fatalf("expected numeric namespace path error, got %v", err)
}
}
func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Athletics\n\nGenerated athletics page\n[//]: # (sow-topdata-wiki:managed:end)\n"
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.md"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
createCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got)
}
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
return
}
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42" {
respondWikiPost(w, 42, 11, generated)
return
}
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
createCalls++
var req struct {
CID int `json:"cid"`
Title string `json:"title"`
Content string `json:"content"`
SourceContent string `json:"sourceContent"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode create request: %v", err)
}
if req.CID != 5 || req.Title != "Athletics" || req.Content != generated {
t.Fatalf("unexpected create request: %#v", req)
}
if req.SourceContent != generated {
t.Fatalf("expected create request sourceContent to match generated HTML, got %q", req.SourceContent)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 11,
"mainPost": map[string]any{
"pid": 42,
"tid": 11,
},
},
})
})
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"skills": 5},
AllowCreates: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions create failed: %v", err)
}
if result.Created != 1 || createCalls != 1 {
t.Fatalf("expected one created topic, result=%d calls=%d", result.Created, createCalls)
}
manifest := loadDeployManifest(manifestPath)
entry := manifest.Pages["skills:athletics"]
if entry.TID != 11 || entry.PID != 42 || entry.CID != 5 || entry.Hash != computeManagedHash(generated) {
t.Fatalf("unexpected manifest entry: %#v", entry)
}
if !entry.SourceContentSynced {
t.Fatalf("expected created manifest entry to record sourceContent sync")
}
if entry.RemoteHash != computeManagedHash(generated) {
t.Fatalf("expected created manifest entry to record the read-back remote hash, got %#v", entry)
}
}
func TestParseNodeBBPostPrefersSourceContentForWikiHTML(t *testing.T) {
post := parseNodeBBPost(map[string]any{
"response": map[string]any{
"pid": 42,
"tid": 7,
"content": "&lt;p&gt;Rendered as text&lt;/p&gt;",
"sourceContent": "<p>Stored source HTML</p>",
},
})
if post.Content != "<p>Stored source HTML</p>" {
t.Fatalf("expected sourceContent to be canonical deploy content, got %#v", post)
}
if post.SourceContent != "<p>Stored source HTML</p>" {
t.Fatalf("expected sourceContent field to be preserved, got %#v", post)
}
}
func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<p>Ability: Strength.</p>
<table><tbody><tr><th>Key Ability</th><td>STR</td></tr></tbody></table>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "Athletics.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:athletics": {
Hash: computeManagedHash(generated),
Title: "Athletics",
TopicTitle: "Athletics",
Namespace: "skills",
TID: 7,
PID: 42,
CID: 3,
},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
updateCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": generated}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 7, "source-sync-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42":
updateCalls++
var req struct {
Content string `json:"content"`
SourceContent string `json:"sourceContent"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
if req.WikiEditLockToken != "source-sync-lock" {
t.Fatalf("expected update to include wiki edit lock token, got %q", req.WikiEditLockToken)
}
if req.Content != generated || req.SourceContent != generated {
t.Fatalf("expected repair update to write content and sourceContent, got %#v", req)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions sourceContent repair failed: %v", err)
}
if result.Updated != 1 || result.Skipped != 0 || updateCalls != 1 {
t.Fatalf("expected one sourceContent repair update, result=%#v updateCalls=%d", result, updateCalls)
}
entry := loadDeployManifest(manifestPath).Pages["skills:athletics"]
if !entry.SourceContentSynced {
t.Fatalf("expected repair deploy to record sourceContent sync, got %#v", entry)
}
}
func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) {
existing := strings.Join([]string{
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
`<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->`,
`<h1>Acolyte</h1>`,
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
`<p>User-written overview.</p>`,
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
`<p>Old generated body.</p>`,
`<!-- sow-topdata-wiki:manual:start id="user_bottom" -->`,
`<p>User-written bottom content.</p>`,
`<!-- sow-topdata-wiki:manual:end id="user_bottom" -->`,
`<!-- sow-topdata-wiki:managed:end -->`,
}, "\n")
generated := strings.Join([]string{
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
`<!-- sow-topdata-wiki:managed:start hash="sha256:new" -->`,
`<h1>Acolyte</h1>`,
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
`<p></p>`,
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
`<p>New generated body.</p>`,
`<!-- sow-topdata-wiki:manual:start id="user_bottom" -->`,
`<p></p>`,
`<!-- sow-topdata-wiki:manual:end id="user_bottom" -->`,
`<!-- sow-topdata-wiki:managed:end -->`,
}, "\n")
merged := mergeManagedContent(existing, generated)
for _, expected := range []string{
"<p>User-written overview.</p>",
"<p>User-written bottom content.</p>",
"<p>New generated body.</p>",
} {
if !strings.Contains(merged, expected) {
t.Fatalf("expected merged content to contain %q:\n%s", expected, merged)
}
}
if strings.Contains(merged, "Old generated body") {
t.Fatalf("expected old generated body to be replaced:\n%s", merged)
}
}
func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Aid\n\nGenerated spell page\n[//]: # (sow-topdata-wiki:managed:end)\n"
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "aid.md"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
return
}
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42" {
respondWikiPost(w, 42, 11, generated)
return
}
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode create request: %v", err)
}
if req.Title != "Aid" {
t.Fatalf("expected unprefixed title, got %q", req.Title)
}
if req.Content != generated {
t.Fatalf("expected page body to keep short h1 unchanged:\n%s", req.Content)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 11,
"mainPost": map[string]any{
"pid": 42,
"tid": 11,
},
},
})
})
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: filepath.Join(root, "deploy-manifest.json"),
CategoryIDs: map[string]int{"spells": 5},
AllowCreates: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions create failed: %v", err)
}
}
func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMinimum(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Ox\n\nGenerated spell page\n[//]: # (sow-topdata-wiki:managed:end)\n"
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "ox.md"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
return
}
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42" {
respondWikiPost(w, 42, 11, generated)
return
}
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
var req struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode create request: %v", err)
}
if req.Title != "Spells: Ox" {
t.Fatalf("expected fallback title, got %q", req.Title)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 11,
"mainPost": map[string]any{
"pid": 42,
"tid": 11,
},
},
})
})
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: filepath.Join(root, "deploy-manifest.json"),
CategoryIDs: map[string]int{"spells": 5},
AllowCreates: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions create failed: %v", err)
}
}
func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "itemtypes"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=itemtypes:belt -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Belt</h1>
<p>Generated belt page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "itemtypes", "belt.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"itemtypes:belt": {Hash: computeManagedHash(generated), Title: "Belt", Namespace: "itemtypes", SourceContentSynced: true, TID: 7, PID: 42, CID: 5},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 7,
"title": "Itemtypes: Belt",
"mainPid": 42,
},
})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move":
renameCalls++
var req struct {
TID int `json:"tid"`
CID int `json:"cid"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode rename request: %v", err)
}
if req.TID != 7 || req.CID != 5 || req.Title != "Belt" {
t.Fatalf("unexpected rename request: %#v", req)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7, "cid": 5, "title": "Belt"}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions rename cleanup failed: %v", err)
}
if result.Renamed != 1 || result.Updated != 0 || result.Skipped != 1 || renameCalls != 1 {
t.Fatalf("expected one rename cleanup and skipped body update, result=%#v renameCalls=%d", result, renameCalls)
}
manifest := loadDeployManifest(manifestPath)
entry := manifest.Pages["itemtypes:belt"]
if entry.TopicTitle != "Belt" || entry.Title != "Belt" {
t.Fatalf("expected manifest to record cleaned topic title, got %#v", entry)
}
}
func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "feat"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=feat:weapon:focus:dwaxe -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Weapon Focus (dwarven waraxe)</h1>
<p>Generated weapon focus page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "feat", "weapon:focus:dwaxe.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"feat:weapon:focus:dwaxe": {
Hash: computeManagedHash(generated),
Title: "Weapon Focus (Dwaxe)",
TopicTitle: "Weapon Focus (Dwaxe)",
Namespace: "feat",
SourceContentSynced: true,
TID: 7,
PID: 42,
CID: 5,
},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 7,
"title": "Weapon Focus (Dwaxe)",
"mainPid": 42,
},
})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move":
renameCalls++
var req struct {
TID int `json:"tid"`
CID int `json:"cid"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode rename request: %v", err)
}
if req.TID != 7 || req.CID != 5 || req.Title != "Weapon Focus (dwarven waraxe)" {
t.Fatalf("unexpected rename request: %#v", req)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions title rename failed: %v", err)
}
if result.Renamed != 1 || result.Updated != 0 || result.Created != 0 || result.Archived != 0 || result.Purged != 0 || renameCalls != 1 {
t.Fatalf("expected generated title rename only, result=%#v renameCalls=%d", result, renameCalls)
}
entry := loadDeployManifest(manifestPath).Pages["feat:weapon:focus:dwaxe"]
if entry.Title != "Weapon Focus (dwarven waraxe)" || entry.TopicTitle != "Weapon Focus (dwarven waraxe)" {
t.Fatalf("expected renamed title state in manifest, got %#v", entry)
}
}
func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=spells:acid_fog -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<p>Generated acid fog page.</p>
<table><tbody><tr><th>School</th><td>Conjuration</td></tr></tbody></table>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{PageID: "spells:acid_fog", Title: "Acid Fog", PublicPath: "Spells/Acid_Fog", OutputPath: "pages/spells/Acid_Fog.html"},
},
}); err != nil {
t.Fatalf("write page index: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"spells:acid_fog": {
Hash: computeManagedHash(generated),
Title: "Acid Fog",
TopicTitle: "Acid Fog",
Namespace: "spells",
SourceContentSynced: true,
TID: 7,
PID: 42,
CID: 5,
},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String())
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions headingless skip failed: %v", err)
}
if result.Renamed != 0 || result.Updated != 0 || result.Skipped != 1 {
t.Fatalf("expected headingless page to skip without page-id fallback rename, got %#v", result)
}
}
func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=spells:acid_fog -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<p>Generated acid fog page.</p>
<table><tbody><tr><th>School</th><td>Conjuration</td></tr></tbody></table>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{PageID: "spells:acid_fog", Title: "Acid Fog", PublicPath: "Spells/Acid_Fog", OutputPath: "pages/spells/Acid_Fog.html"},
},
}); err != nil {
t.Fatalf("write page index: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"spells:acid_fog": {
Hash: computeManagedHash(generated),
Title: "Acid_fog",
TopicTitle: "Acid_fog",
Namespace: "spells",
SourceContentSynced: true,
TID: 7,
PID: 42,
CID: 5,
},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{"tid": 7, "title": "Acid_fog", "mainPid": 42},
})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move":
renameCalls++
var req struct {
TID int `json:"tid"`
CID int `json:"cid"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode rename request: %v", err)
}
if req.TID != 7 || req.CID != 5 || req.Title != "Acid Fog" {
t.Fatalf("unexpected rename request: %#v", req)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions headingless title repair failed: %v", err)
}
if result.Renamed != 1 || result.Updated != 0 || result.Skipped != 1 || renameCalls != 1 {
t.Fatalf("expected one title repair rename and skipped body update, result=%#v renameCalls=%d", result, renameCalls)
}
entry := loadDeployManifest(manifestPath).Pages["spells:acid_fog"]
if entry.Title != "Acid Fog" || entry.TopicTitle != "Acid Fog" {
t.Fatalf("expected repaired manifest title from page-index, got %#v", entry)
}
}
func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "classes"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=classes:acolyte -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Acolyte</h1>
<p>Generated acolyte page</p>
<!-- sow-topdata-wiki:managed:end -->
<!-- sow-topdata-wiki:manual:start id="user_bottom" -->
<p></p>
<!-- sow-topdata-wiki:manual:end id="user_bottom" -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "classes", "acolyte.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := `<!-- sow-topdata-wiki:page=classes:acolyte -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Acolyte</h1>
<p>Old acolyte page</p>
<!-- sow-topdata-wiki:managed:end -->
<!-- sow-topdata-wiki:manual:start id="user_bottom" -->
<p>Keep this remote note.</p>
<!-- sow-topdata-wiki:manual:end id="user_bottom" -->
`
createCalls := 0
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{
"tid": 77,
"title": "Acolyte",
"titleLeaf": "Acolyte",
"slug": "77/acolyte",
"wikiPath": "/wiki/classes/acolyte",
},
},
"hasMore": false,
},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 77, "adopt-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/88":
var req struct {
Content string `json:"content"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
if req.WikiEditLockToken != "adopt-lock" {
t.Fatalf("expected adopted update to include wiki edit lock token, got %q", req.WikiEditLockToken)
}
updated = req.Content
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77}})
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
createCalls++
t.Fatalf("existing wiki page should be adopted and updated, not recreated")
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"classes": 9},
StalePolicy: "report",
NotesDelimiter: "",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions adopt failed: %v", err)
}
if result.Created != 0 || result.Updated != 1 || createCalls != 0 {
t.Fatalf("expected one adopted update and no creates, result=%#v createCalls=%d", result, createCalls)
}
if !containsAll(updated, "<p>Generated acolyte page</p>", "<p>Keep this remote note.</p>") {
t.Fatalf("expected adopted update to merge generated and remote manual content:\n%s", updated)
}
manifest := loadDeployManifest(manifestPath)
entry := manifest.Pages["classes:acolyte"]
if entry.TID != 77 || entry.PID != 88 || entry.CID != 9 || entry.Hash != computeManagedHash(generated) {
t.Fatalf("unexpected adopted manifest entry: %#v", entry)
}
}
func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>Generated athletics page</p>
<!-- sow-topdata-wiki:managed:end -->
<!-- sow-topdata-wiki:manual:start id="user_bottom" -->
<p></p>
<!-- sow-topdata-wiki:manual:end id="user_bottom" -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Athletics</h1>
<p>Old generated text</p>
<!-- sow-topdata-wiki:managed:end -->
<!-- sow-topdata-wiki:manual:start id="user_bottom" -->
<p>Keep this human note.</p>
<!-- sow-topdata-wiki:manual:end id="user_bottom" -->
`
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:athletics": {Hash: computeManagedHash(old), TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": old}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 7, "merge-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42":
var req struct {
Content string `json:"content"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
if req.WikiEditLockToken != "merge-lock" {
t.Fatalf("expected update to include wiki edit lock token, got %q", req.WikiEditLockToken)
}
updated = req.Content
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions update failed: %v", err)
}
if result.Updated != 1 {
t.Fatalf("expected one update, got %#v", result)
}
if !containsAll(updated, "<p>Generated athletics page</p>", "<p>Keep this human note.</p>", `manual:start id="user_bottom"`) {
t.Fatalf("expected update to replace managed HTML and preserve/bootstrap manual regions:\n%s", updated)
}
if strings.Contains(updated, `manual:start id="notes"`) || strings.Contains(updated, `manual:start id="see_also"`) {
t.Fatalf("expected update to omit legacy split bottom manual regions:\n%s", updated)
}
}
func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>Generated athletics page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Athletics</h1>
<p>Old athletics page</p>
<!-- sow-topdata-wiki:managed:end -->
`
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:athletics": {Hash: computeManagedHash(old), TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
lockAcquired := false
var updateToken string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": old}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
var req struct {
TID int `json:"tid"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode edit lock request: %v", err)
}
if req.TID != 7 {
t.Fatalf("expected edit lock for tid 7, got %d", req.TID)
}
lockAcquired = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": 7, "token": "lock-token"}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42":
var req struct {
Content string `json:"content"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
updateToken = req.WikiEditLockToken
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions update failed: %v", err)
}
if result.Updated != 1 {
t.Fatalf("expected one update, got %#v", result)
}
if !lockAcquired {
t.Fatalf("expected deployer to acquire a wiki edit lock before update")
}
if updateToken != "lock-token" {
t.Fatalf("expected update to include wiki edit lock token, got %q", updateToken)
}
}
func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "classes"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=classes:bard -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Bard</h1>
<p>Generated bard page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "classes", "bard.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := `<!-- sow-topdata-wiki:page=classes:bard -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Bard</h1>
<p>Old bard page</p>
<!-- sow-topdata-wiki:managed:end -->
`
createCalls := 0
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
createCalls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": map[string]any{
"code": "bad-request",
"message": "A wiki page with this URL already exists in this namespace. Rename the page before publishing.",
},
"response": map[string]any{},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "bard":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{"tid": 77, "title": "Bard", "titleLeaf": "Bard", "slug": "77/bard", "wikiPath": "/wiki/classes/bard"},
},
"hasMore": false,
},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 77, "collision-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/88":
var req struct {
Content string `json:"content"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
if req.WikiEditLockToken != "collision-lock" {
t.Fatalf("expected collision recovery update to include edit lock token, got %q", req.WikiEditLockToken)
}
updated = req.Content
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"classes": 9},
AllowCreates: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions collision recovery failed: %v", err)
}
if result.Created != 0 || result.Updated != 1 || createCalls != 1 {
t.Fatalf("expected collision recovery to update existing page, result=%#v createCalls=%d", result, createCalls)
}
if !containsAll(updated, "<p>Generated bard page</p>") {
t.Fatalf("expected collision recovery to update adopted page:\n%s", updated)
}
manifest := loadDeployManifest(manifestPath)
entry := manifest.Pages["classes:bard"]
if entry.TID != 77 || entry.PID != 88 || entry.CID != 9 || entry.Hash != computeManagedHash(generated) {
t.Fatalf("unexpected adopted manifest entry: %#v", entry)
}
}
func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "itemtypes"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=itemtypes:blank_magicwand -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Wand (not enchanted)</h1>
<p>Generated wand page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "itemtypes", "Wand_not_enchanted.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
old := `<!-- sow-topdata-wiki:page=itemtypes:blank_magicwand -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Wand (not enchanted)</h1>
<p>Old wand page</p>
<!-- sow-topdata-wiki:managed:end -->
`
createCalls := 0
var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
createCalls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": map[string]any{
"code": "bad-request",
"message": "A wiki page with this URL already exists in this namespace. Rename the page before publishing.",
},
"response": map[string]any{},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "blank_magicwand":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "Wand (not enchanted)":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "Wand_not_enchanted":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{"tid": 77, "title": "Wand (not enchanted)", "titleLeaf": "Wand (not enchanted)", "slug": "77/wand-not-enchanted", "wikiPath": "/wiki/Itemtypes/Wand_not_enchanted"},
},
"hasMore": false,
},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 77, "wand-collision-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/88":
var req struct {
Content string `json:"content"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
if req.WikiEditLockToken != "wand-collision-lock" {
t.Fatalf("expected collision recovery update to include edit lock token, got %q", req.WikiEditLockToken)
}
updated = req.Content
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"itemtypes": 58},
AllowCreates: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions collision recovery failed: %v", err)
}
if result.Created != 0 || result.Updated != 1 || createCalls != 1 {
t.Fatalf("expected collision recovery to update existing page, result=%#v createCalls=%d", result, createCalls)
}
if !containsAll(updated, "<p>Generated wand page</p>") {
t.Fatalf("expected collision recovery to update adopted page:\n%s", updated)
}
}
func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var archived string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" {
respondWikiEditLock(t, w, r, 7, "archive-lock")
return
}
if r.Method != http.MethodPut || r.URL.Path != "/api/v3/posts/42" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
var req struct {
Content string `json:"content"`
WikiEditLockToken string `json:"wikiEditLockToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode archive request: %v", err)
}
if req.WikiEditLockToken != "archive-lock" {
t.Fatalf("expected archive to include wiki edit lock token, got %q", req.WikiEditLockToken)
}
archived = req.Content
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
})
defer server.Close()
report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
StalePolicy: "report",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale report failed: %v", err)
}
if report.Stale != 1 {
t.Fatalf("expected one stale report, got %#v", report)
}
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
StalePolicy: "archive",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale archive failed: %v", err)
}
if result.Stale != 1 || result.Archived != 1 {
t.Fatalf("expected one stale archive, got %#v", result)
}
if !containsAll(archived, "<h1>Retired Skill</h1>", "no longer present", `<!-- sow-topdata-wiki:managed:start hash="sha256:`) {
t.Fatalf("unexpected archived body:\n%s", archived)
}
manifest := loadDeployManifest(manifestPath)
entry := manifest.Pages["skills:retired"]
if !entry.Stale || entry.ArchivedHash == "" || entry.PID != 42 {
t.Fatalf("expected archived stale manifest entry, got %#v", entry)
}
}
func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale purge dry run failed: %v", err)
}
if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one planned stale purge, got %#v", result)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; !ok {
t.Fatalf("dry-run purge must not change the deploy manifest")
}
}
func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
StalePolicy: "purge",
}, nil)
if err == nil || !strings.Contains(err.Error(), "topic id") {
t.Fatalf("expected stale purge to require tracked topic id, got %v", err)
}
}
func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if !answerWikiPagePurge(t, w, r, &calls) {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale purge failed: %v", err)
}
if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one stale purge, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7" {
t.Fatalf("expected the wiki plugin page actions to purge topic 7, got %#v", calls)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed")
}
}
func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:replacement -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Replacement</h1>
<p>Generated replacement page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Replacement", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/99":
respondWikiPost(w, 99, 11, generated)
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create")
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" {
t.Fatalf("expected stale purge before create, got calls %#v", calls)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 11,
"mainPost": map[string]any{
"pid": 99,
"tid": 11,
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"skills": 3},
AllowCreates: true,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions purge-and-create failed: %v", err)
}
if result.Created != 1 || result.Purged != 1 {
t.Fatalf("expected one create and one purge, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" {
t.Fatalf("expected purge before create, got %#v", calls)
}
}
func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshManifest(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:replacement -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Replacement</h1>
<p>Generated replacement page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:stale": {Hash: "old-hash", Title: "Replacement", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
const resetCalls = "tombstone:7,hard-purge:7,tombstone:8,hard-purge:8"
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"pages": []map[string]any{
{"tid": 7, "title": "Old Replacement", "titleLeaf": "Old Replacement", "slug": "7/replacement", "wikiPath": "/wiki/skills/replacement"},
{"tid": 8, "title": "Manual Note", "titleLeaf": "Manual Note", "slug": "8/manual-note", "wikiPath": "/wiki/skills/manual-note"},
},
"hasMore": false,
},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/99":
respondWikiPost(w, 99, 11, generated)
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create")
if strings.Join(calls, ",") != resetCalls+",create" {
t.Fatalf("expected namespace reset purges before create, got %#v", calls)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"response": map[string]any{
"tid": 11,
"mainPost": map[string]any{
"pid": 99,
"tid": 11,
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
CategoryIDs: map[string]int{"skills": 3},
AllowCreates: true,
ResetManagedNamespaces: true,
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err)
}
if result.Created != 1 || result.Purged != 2 {
t.Fatalf("expected one create and two namespace purges, got %#v", result)
}
// Callers word their destructive-policy warning around the stale count, so
// reset deletions have to show up there rather than on a separate path the
// preview never mentions.
if result.Stale != 2 || result.ResetPurged != 2 {
t.Fatalf("expected reset deletions counted as stale, got %#v", result)
}
// tid 7 is the manifest's own page; tid 8 is a topic the deployer never
// wrote, and re-seeding cannot bring it back.
if result.ResetUnrecognized != 1 {
t.Fatalf("expected one unrecognized reset deletion, got %#v", result)
}
if strings.Join(calls, ",") != resetCalls+",create" {
t.Fatalf("expected reset purges before create, got %#v", calls)
}
manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:stale"]; ok {
t.Fatalf("expected reset to remove previous manifest mapping")
}
entry := manifest.Pages["skills:replacement"]
if entry.TID != 11 || entry.PID != 99 || entry.CID != 3 {
t.Fatalf("expected fresh created mapping in manifest, got %#v", entry)
}
}
func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:replacement -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Replacement</h1>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String())
})
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
CategoryIDs: map[string]int{"skills": 3},
ResetManagedNamespaces: true,
}, nil)
if err == nil || !strings.Contains(err.Error(), "requires --create") {
t.Fatalf("expected reset without create to fail clearly, got %v", err)
}
}
func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
"skills:gone": {Hash: "old-hash", Title: "Gone Skill", Namespace: "skills", TID: 8, PID: 43, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
seen := map[string]int{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
seen[call]++
w.Header().Set("Content-Type", "application/json")
// Topic 7 is already gone, so the plugin cannot find the page to
// tombstone; topic 8 still exists and purges normally.
if call == "tombstone:7" {
http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale purge with missing topic failed: %v", err)
}
if result.Stale != 2 || result.Purged != 2 {
t.Fatalf("expected two stale purges, got %#v", result)
}
if seen["tombstone:7"] != 1 || seen["hard-purge:7"] != 0 {
t.Fatalf("expected the already-missing topic to stop after tombstone, got %#v", seen)
}
if seen["tombstone:8"] != 1 || seen["hard-purge:8"] != 1 {
t.Fatalf("expected the surviving topic to be tombstoned then purged, got %#v", seen)
}
manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:retired"]; ok {
t.Fatalf("expected already-missing stale manifest entry to be removed")
}
if _, ok := manifest.Pages["skills:gone"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed")
}
}
func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Athletics\n\nGenerated athletics page\n[//]: # (sow-topdata-wiki:managed:end)\n"
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.md"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:athletics": {
Hash: computeManagedHash(generated),
Title: "Athletics",
Namespace: "skills",
SourceContentSynced: true,
TID: 7,
PID: 42,
CID: 3,
},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions current page purge deploy failed: %v", err)
}
if result.Stale != 0 || result.Purged != 0 || result.Skipped != 1 {
t.Fatalf("expected current generated page to skip purge, got %#v", result)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:athletics"]; !ok {
t.Fatalf("expected current generated page to remain in deploy manifest")
}
}
func containsAll(haystack string, needles ...string) bool {
for _, needle := range needles {
if !strings.Contains(haystack, needle) {
return false
}
}
return true
}
func respondWikiEditLock(t *testing.T, w http.ResponseWriter, r *http.Request, expectedTID int, token string) {
t.Helper()
var req struct {
TID int `json:"tid"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode edit lock request: %v", err)
}
if req.TID != expectedTID {
t.Fatalf("expected edit lock for tid %d, got %d", expectedTID, req.TID)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}})
}
// newFakeNodeBB stands up a fake NodeBB that behaves like one running
// nodebb-plugin-westgate-wiki: native topic mutation on wiki categories is
// refused, because wiki revision history is plugin-owned. Every deploy test
// goes through here, so a deployer that reaches for the core topic API fails in
// CI the way it fails in production rather than passing against a fake that is
// more permissive than the real thing.
// driftScenario is one managed page whose generated text changed since the last
// deploy, so the deployer has to read the live post and rule on drift. The fake
// NodeBB serves whatever it was last told to store, which is what a real deploy
// reads back after a write.
type driftScenario struct {
sourceDir string
manifestPath string
endpoint string
generated string
stored string
updateCalls int
lastWritten string
}
// newDriftScenario writes the local page and the manifest entry. remoteStored is
// the body NodeBB hands back; hasSourceContent says whether the post carries the
// sourceContent the wiki plugin stores.
func newDriftScenario(t *testing.T, entry wikiDeployManifestPage, remoteStored string, hasSourceContent bool) (*driftScenario, *httptest.Server) {
t.Helper()
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>Generated athletics page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
entry.TID, entry.PID, entry.CID = 7, 42, 3
entry.SourceContentSynced = true
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{"skills:athletics": entry},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
scenario := &driftScenario{sourceDir: sourceDir, manifestPath: manifestPath, generated: generated, stored: remoteStored}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
post := map[string]any{"pid": 42, "tid": 7, "content": scenario.stored}
if hasSourceContent {
post["sourceContent"] = scenario.stored
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": post})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 7, "drift-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42":
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
scenario.updateCalls++
scenario.lastWritten = req.Content
// NodeBB owns the stored body from here on, and the read-back that
// follows has to see it.
scenario.stored = req.Content
hasSourceContent = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
return scenario, server
}
func (s *driftScenario) deploy(t *testing.T, force bool) (DeployResult, error) {
t.Helper()
return DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: s.sourceDir,
Endpoint: s.endpoint,
Token: "nodebb-token",
ManifestPath: s.manifestPath,
Force: force,
}, nil)
}
func TestDeployWikiDoesNotTreatNodeBBNormalizationAsDrift(t *testing.T) {
// NodeBB stores its own copy of the page, so what it hands back never has
// to be byte-equal to the text we rendered. Only a change since our last
// write is drift.
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1><p>Old generated text</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
RemoteHash: computeManagedHash(stored),
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err != nil {
t.Fatalf("expected normalized remote copy to deploy cleanly, got: %v", err)
}
if result.Drifted != 0 || result.Updated != 1 || scenario.updateCalls != 1 {
t.Fatalf("expected a clean update, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
entry := loadDeployManifest(scenario.manifestPath).Pages["skills:athletics"]
if entry.RemoteHash != computeManagedHash(scenario.lastWritten) {
t.Fatalf("expected manifest to record the read-back remote hash, got %#v", entry)
}
}
func TestDeployWikiReportsEditedPageAsDriftedAndNamesIt(t *testing.T) {
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>A person rewrote this by hand.</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
RemoteHash: "sha256:what-we-wrote-last-time",
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err == nil {
t.Fatalf("expected a hand-edited page to block the deploy, got %#v", result)
}
if !strings.Contains(err.Error(), "skills:athletics") {
t.Fatalf("expected the drift error to name the page, got: %v", err)
}
if result.Drifted != 1 || scenario.updateCalls != 0 {
t.Fatalf("expected one drifted page and no write, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
}
func TestDeployWikiForceOverwritesDriftedPageAndRerecordsRemoteHash(t *testing.T) {
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>A person rewrote this by hand.</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
RemoteHash: "sha256:what-we-wrote-last-time",
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, true)
if err != nil {
t.Fatalf("expected --force to overwrite the drifted page, got: %v", err)
}
if result.Drifted != 1 || result.Updated != 1 || scenario.updateCalls != 1 {
t.Fatalf("expected the drifted page overwritten once, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
entry := loadDeployManifest(scenario.manifestPath).Pages["skills:athletics"]
if entry.RemoteHash != computeManagedHash(scenario.lastWritten) {
t.Fatalf("expected the forced write to re-record the remote hash, got %#v", entry)
}
}
func TestDeployWikiTreatsPostWithoutSourceContentAsUnsyncedNotDrifted(t *testing.T) {
// A post written before the deployer stored sourceContent reads back as
// NodeBB's rendered HTML, which can never match anything we wrote. That is
// a page waiting for the sourceContent repair, not a hand edit.
rendered := "&lt;h1&gt;Athletics&lt;/h1&gt;&lt;p&gt;Old generated text&lt;/p&gt;"
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
}, rendered, false)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err != nil {
t.Fatalf("expected an unsynced page to deploy, got: %v", err)
}
if result.Drifted != 0 || result.Updated != 1 || scenario.updateCalls != 1 {
t.Fatalf("expected one repair update and no drift, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
}
func TestDeployWikiFallsBackToLocalHashForManifestWithoutRemoteHash(t *testing.T) {
// Manifest written before remote_hash existed: the remote still matches the
// recorded local hash, so nothing drifted, and this run records the remote
// hash for the next one.
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Athletics</h1>
<p>Old generated text</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: computeManagedHash(stored),
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err != nil {
t.Fatalf("expected an old-format manifest to deploy, got: %v", err)
}
if result.Drifted != 0 || result.Updated != 1 {
t.Fatalf("expected a clean update, got %#v", result)
}
entry := loadDeployManifest(scenario.manifestPath).Pages["skills:athletics"]
if entry.RemoteHash == "" {
t.Fatalf("expected the deploy to record a remote hash, got %#v", entry)
}
}
// respondWikiPost answers a post read the way the wiki plugin does: the stored
// source HTML in sourceContent, alongside the body NodeBB renders from it.
func respondWikiPost(w http.ResponseWriter, pid, tid int, content string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{
"pid": pid, "tid": tid, "content": content, "sourceContent": content,
}})
}
func newFakeNodeBB(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v3/topics/") && (r.Method == http.MethodDelete || r.Method == http.MethodPut) {
http.Error(
w,
`{"status":{"code":"bad-request","message":"Use the wiki page actions to delete, restore, or purge wiki pages."}}`,
http.StatusBadRequest,
)
return
}
handler(w, r)
}))
}
// wikiPagePurgeCall recognizes the plugin page actions a purge goes through and
// reports them as "<action>:<tid>" so tests can assert the order.
func wikiPagePurgeCall(t *testing.T, r *http.Request) (string, bool) {
t.Helper()
var action string
switch {
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/tombstone":
action = "tombstone"
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/hard-purge":
action = "hard-purge"
default:
return "", false
}
var req struct {
TID int `json:"tid"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode wiki %s request: %v", action, err)
}
if req.TID == 0 {
t.Fatalf("expected wiki %s request to carry a topic id", action)
}
return fmt.Sprintf("%s:%d", action, req.TID), true
}
// answerWikiPagePurge records and acknowledges a plugin purge action, which is
// what most fakes want to do with one.
func answerWikiPagePurge(t *testing.T, w http.ResponseWriter, r *http.Request, calls *[]string) bool {
t.Helper()
call, ok := wikiPagePurgeCall(t, r)
if !ok {
return false
}
*calls = append(*calls, call)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
return true
}
// resetPurgeFixture builds a source tree, a deploy manifest, and the reset
// options the namespace-reset tests share.
func resetPurgeFixture(t *testing.T, namespaces map[string]int, tidsByCID map[int][]int) (DeployWikiOptions, func(w http.ResponseWriter, r *http.Request) bool) {
t.Helper()
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
listPages := func(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
var cid int
if _, err := fmt.Sscanf(r.URL.Path, "/api/v3/plugins/westgate-wiki/namespace/%d/pages", &cid); err != nil {
return false
}
tids, ok := tidsByCID[cid]
if !ok {
t.Fatalf("unexpected namespace listing for category %d", cid)
}
pages := make([]map[string]any, 0, len(tids))
for _, tid := range tids {
pages = append(pages, map[string]any{"tid": tid, "title": fmt.Sprintf("Page %d", tid)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": pages, "hasMore": false}})
return true
}
return DeployWikiOptions{
SourceDir: sourceDir,
ManifestPath: manifestPath,
Token: "nodebb-token",
CategoryIDs: namespaces,
AllowCreates: true,
ResetManagedNamespaces: true,
}, listPages
}
func TestDeployWikiResetSkipsTopicsNodeBBRefusesToDelete(t *testing.T) {
opts, listPages := resetPurgeFixture(t, map[string]int{"skills": 3}, map[int][]int{3: {5, 6}})
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if listPages(w, r) {
return
}
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
calls = append(calls, call)
// Topic 5 stands in for the wiki home page, which the plugin excludes
// from tombstone, restore, and purge alike.
if call == "tombstone:5" {
http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
opts.Endpoint = server.URL
result, err := DeployWikiWithOptions(&project.Project{}, opts, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions reset with an undeletable topic failed: %v", err)
}
if result.ResetSkipped != 1 || result.ResetPurged != 1 {
t.Fatalf("expected one skipped and one completed reset deletion, got %#v", result)
}
if result.Purged != 1 || result.Stale != 1 {
t.Fatalf("expected the skipped topic to be dropped from the counts, got %#v", result)
}
if result.ResetUnrecognized != 1 {
t.Fatalf("expected only the purged unrecognized topic to be counted, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:5,tombstone:6,hard-purge:6" {
t.Fatalf("expected the reset to continue past the refused topic, got %#v", calls)
}
}
// NodeBB answers 403 both for the wiki home page and for a token without purge
// privileges. A category where nothing could be deleted at all is the second
// case, and reporting it as a completed reset would leave the manifest claiming
// a fresh start over pages that are all still there.
func TestDeployWikiResetFailsWhenACategoryRefusesEveryDeletion(t *testing.T) {
opts, listPages := resetPurgeFixture(t,
map[string]int{"skills": 3, "feats": 4},
map[int][]int{3: {5, 6}, 4: {7, 8}},
)
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if listPages(w, r) {
return
}
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
// Category 4 holds topics 7 and 8, and the token can purge neither.
if strings.HasSuffix(call, ":7") || strings.HasSuffix(call, ":8") {
http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
opts.Endpoint = server.URL
_, err := DeployWikiWithOptions(&project.Project{}, opts, nil)
if err == nil || !strings.Contains(err.Error(), "wiki purge privileges") {
t.Fatalf("expected a category that deleted nothing to fail loudly, got %v", err)
}
if !strings.Contains(err.Error(), "4") {
t.Fatalf("expected the failure to name the refusing category, got %v", err)
}
}