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 := "\n

Athletics

\n

Existing athletics page

\n\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 := `

Athletics

` 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 := `

Updated

New generated content

` renamePage := `

Renamed

Unchanged generated content

` 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 := `

Updated

Old generated content

` 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 := `

Inspire Competence

Generated page.

` 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 := `

Fear

Generated page.

` 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 := `

Acid Fog

` 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 := `

Generated acid fog page.

SchoolConjuration
` 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 := `

Fear

` 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 := `

Acolyte

Generated acolyte page

` if err := os.WriteFile(filepath.Join(sourceDir, "classes", "acolyte.html"), []byte(generated), 0644); err != nil { t.Fatalf("write source page: %v", err) } old := `

Acolyte

Old acolyte page

` 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", `Bigby’s Clenched Fist`: "Bigbys_Clenched_Fist", `Hardiness vs. Enchantments`: "Hardiness_vs_Enchantments", `Clairaudience/Clairvoyance`: "Clairaudience_Clairvoyance", `Élite & 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": "<p>Rendered as text</p>", "sourceContent": "

Stored source HTML

", }, }) if post.Content != "

Stored source HTML

" { t.Fatalf("expected sourceContent to be canonical deploy content, got %#v", post) } if post.SourceContent != "

Stored source HTML

" { 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 := `

Ability: Strength.

Key AbilitySTR
` 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{ ``, ``, `

Acolyte

`, ``, `

User-written overview.

`, ``, `

Old generated body.

`, ``, `

User-written bottom content.

`, ``, ``, }, "\n") generated := strings.Join([]string{ ``, ``, `

Acolyte

`, ``, `

`, ``, `

New generated body.

`, ``, `

`, ``, ``, }, "\n") merged := mergeManagedContent(existing, generated) for _, expected := range []string{ "

User-written overview.

", "

User-written bottom content.

", "

New generated body.

", } { 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 := `

Belt

Generated belt page

` 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 := `

Weapon Focus (dwarven waraxe)

Generated weapon focus page

` 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 := `

Generated acid fog page.

SchoolConjuration
` 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 := `

Generated acid fog page.

SchoolConjuration
` 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 := `

Acolyte

Generated acolyte page

` if err := os.WriteFile(filepath.Join(sourceDir, "classes", "acolyte.html"), []byte(generated), 0644); err != nil { t.Fatalf("write source page: %v", err) } old := `

Acolyte

Old acolyte page

Keep this remote note.

` 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, "

Generated acolyte page

", "

Keep this remote note.

") { 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 := `

Athletics

Generated athletics page

` if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil { t.Fatalf("write source page: %v", err) } old := `

Athletics

Old generated text

Keep this human note.

` 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, "

Generated athletics page

", "

Keep this human note.

", `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 := `

Athletics

Generated athletics page

` if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil { t.Fatalf("write source page: %v", err) } old := `

Athletics

Old athletics page

` 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 := `

Bard

Generated bard page

` if err := os.WriteFile(filepath.Join(sourceDir, "classes", "bard.html"), []byte(generated), 0644); err != nil { t.Fatalf("write source page: %v", err) } old := `

Bard

Old bard page

` 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, "

Generated bard page

") { 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 := `

Wand (not enchanted)

Generated wand page

` 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 := `

Wand (not enchanted)

Old wand page

` 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, "

Generated wand page

") { 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, "

Retired Skill

", "no longer present", `

Replacement

Generated replacement page

` 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 := `

Replacement

Generated replacement page

` 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 := `

Replacement

` 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 ":" 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) } }