Files
sow-tools/internal/topdata/wiki_deploy_test.go
T
archvillainetteandClaude Opus 5 9e52a62e66
ci / ci (pull_request) Successful in 3m52s
fix(wiki): purge through the plugin page actions and count reset deletions
deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB
running nodebb-plugin-westgate-wiki refuses that for topics in wiki
categories, because wiki revision history is plugin-owned, so every
purge failed with HTTP 400 and the deploy exited 1. Purge now goes
through the plugin's own page actions: tombstone the page, then
hard-purge it, since a page must be tombstoned before it can be purged.

Archiving is unaffected: it rewrites the page through the ordinary post
edit the plugin allows, rather than deleting anything.

Every fake NodeBB in the deploy tests now goes through one constructor
that refuses native topic mutation the way the plugin does. The old
fakes answered the core API, which is why this shipped green.

A namespace reset can reach pages NodeBB will not delete at all - the
wiki home topic above all - so those are skipped rather than aborting
the reset. NodeBB answers 403 for that and for a token without purge
privileges alike, and the response cannot tell them apart; what can is
scope, so a category where nothing at all could be deleted still fails
the run.

Under --reset-managed-namespaces the preview reported stale: 0 on a run
that would delete every topic in the managed categories, because the
reset purge never went through stale computation. The reset deletions
are now counted as stale, which is the number callers word their
destructive-policy warning around, and the summary names how many of
them the manifest has no record of writing - the deletions a re-seed
cannot undo.

Closes #99
Closes #100

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:44:09 +02:00

2579 lines
96 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.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")
}
}
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.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.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.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.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.
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)
}
}