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
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 := `New generated content
` renamePage := `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 := `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", 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 := `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 := `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 TestExtractWikiPagePublicPathAcceptsCanonicalSlashPath(t *testing.T) { content := `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 := `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"` } 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) } 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) } } func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) { existing := strings.Join([]string{ ``, ``, `User-written overview.
`, ``, `Old generated body.
`, ``, `User-written bottom content.
`, ``, ``, }, "\n") generated := strings.Join([]string{ ``, ``, `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 := `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", 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 := `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", 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 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 := `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 := `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 := `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 := `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 := `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 := `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 := `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 := `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 := `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 := `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, "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 := `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 := `