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 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/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": []map[string]any{ {"tid": 7, "title": "Itemtypes: Belt", "titleLeaf": "Itemtypes: Belt", "slug": "7/itemtypes-belt", "wikiPath": "/wiki/itemtypes/itemtypes-belt"}, }, "hasMore": false, }, }) 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 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 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, "