package topdata import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitea.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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 TestNodeBBNamespacePaginationRejectsRepeatedCursor(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ if requests > 2 { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "response": map[string]any{ "pages": []map[string]any{}, "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 error") } if !strings.Contains(err.Error(), "repeated pagination cursor") { t.Fatalf("expected repeated cursor error, got %v", err) } if requests != 2 { t.Fatalf("expected pagination to stop after repeated cursor, got %d requests", requests) } } 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 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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(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": []any{}, "hasMore": false}}) case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7": calls = append(calls, "purge") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": calls = append(calls, "create") if len(calls) != 2 || calls[0] != "purge" { 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, ",") != "purge,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) } var calls []string server := httptest.NewServer(http.HandlerFunc(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{ {"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.MethodDelete && r.URL.Path == "/api/v3/topics/7": calls = append(calls, "purge:7") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/8": calls = append(calls, "purge:8") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": calls = append(calls, "create") if strings.Join(calls, ",") != "purge:7,purge:8,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 || result.Stale != 0 { t.Fatalf("expected one create and two namespace purges, got %#v", result) } if strings.Join(calls, ",") != "purge:7,purge:8,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 := httptest.NewServer(http.HandlerFunc(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 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } seen[r.URL.Path]++ w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/v3/topics/7": http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound) case "/api/v3/topics/8": _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) default: t.Fatalf("unexpected purge path %s", 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 with missing topic failed: %v", err) } if result.Stale != 2 || result.Purged != 2 { t.Fatalf("expected two stale purges, got %#v", result) } if seen["/api/v3/topics/7"] != 1 || seen["/api/v3/topics/8"] != 1 { t.Fatalf("expected one purge call for each tracked topic, 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 := httptest.NewServer(http.HandlerFunc(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}}) }