Compare commits

...
6 Commits
Author SHA1 Message Date
archvillainette 87feaf96b7 fix(wiki): purge through the plugin page actions and count reset deletions (#101)
build-binaries / build-binaries (push) Successful in 2m27s
Closes #99. Closes #100.

## #99 — purge used the core topic API

`deploy-wiki --stale-policy purge` deleted pages with `DELETE /api/v3/topics/{tid}`. On a NodeBB running `nodebb-plugin-westgate-wiki` that is refused for topics in wiki categories — revision history is plugin-owned — so every purge failed with HTTP 400 and the deploy exited 1.

Purge now goes through the plugin's own page actions:

1. `PUT /api/v3/plugins/westgate-wiki/page/tombstone`
2. `DELETE /api/v3/plugins/westgate-wiki/page/hard-purge`

in that order, because a page must be tombstoned before it can be purged. A page that is already gone answers 404 on the tombstone and is treated as a completed purge, as before.

**Archive was audited and needs no change.** It rewrites the page through `updatePost`, which is an ordinary post edit the plugin allows; only delete, restore, and purge are reserved to the page actions.

**The wiki home topic.** A namespace reset enumerates every topic in the category, including the home page, which the plugin excludes from tombstone, restore, and purge alike. Those are now skipped instead of aborting the reset. NodeBB answers 403 for that and for a token without purge privileges alike, and the response body cannot tell the two apart — what can is scope. A category where nothing at all could be deleted is a privilege problem, so the run still fails there rather than writing a manifest that claims a fresh start over pages that are all still present.

**The fakes.** Every fake NodeBB in `wiki_deploy_test.go` now goes through one constructor that refuses native topic mutation exactly the way the plugin does. The old fakes answered the core API, which is how a purge path that has never worked in production stayed green in CI.

## #100 — `stale: 0` above `purged: 1213`

The reset purge never went through stale computation, so the preview reported zero deletions on a run that would delete every topic in the managed categories.

Reset deletions are now counted in `stale`, which is the number callers word their destructive-policy warning around, and the summary gains a line naming the reset and how many of its targets the manifest has no record of writing:

```
stale:       1213
purged:      1213
namespace reset: 1213 (unrecognized: 13)
  unrecognized pages were not written by this deployer; recreating them is not possible
```

The unrecognized subset is the number worth surfacing, since those are the deletions a re-seed cannot undo. The `--reset-managed-namespaces` help text now says plainly that the flag deletes every page in the managed categories, not only the ones this deployer wrote.

`DeployResult` is exported so the console reads named fields instead of eleven positional ints.

## Verification

`go vet ./...` and `go test ./...` pass. New tests cover the plugin purge order, the already-missing page, the skipped undeletable topic, the per-category privilege failure, and the reset counts.Reviewed-on: #101

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-05 16:01:51 +00:00
archvillainette a02e06d644 docs(agents): record that tea 0.14 cannot apply org-level labels (#98)
`tea issues edit --add-labels` and `--remove-labels` resolve label names against
the repository's own label set only. Every `Kind/*`, `Priority/*`, `Reviewed/*`
and `Status/*` label lives at the org level, so none of them match: the command
exits 0, prints the issue, and applies nothing. Repo-local labels
(`wayfinder:*`, `ready-for-agent`) work normally, which makes the failure look
intermittent instead of categorical.

Upstream tea fixed this in v0.15 — `modules/task/labels.go` now queries
`ListOrgLabels` alongside the repo labels. nixpkgs pins 0.14.0, so this note
expires when tea is bumped. `tea labels` has the same repo-only limitation and
will not show the org set; `tea api orgs/ShadowsOverWestgate/labels` will.

The second bullet is the more durable half: re-read a label change before
believing it. An earlier version of this branch confidently documented the
opposite cause, because a label that a human had applied by hand was read back
as proof that the CLI had applied it. `tea issues ls -o json` exposes a `labels`
field; a zero exit code does not.

Identical addition in all seven repos carrying `docs/agents/issue-tracker.md`.Reviewed-on: #98

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-05 11:38:35 +00:00
archvillainette f395d86db5 Replace the stale triage label vocabulary with the org label taxonomy (#97)
`docs/agents/triage-labels.md` named five labels that do not exist in the tracker (`needs-triage`, `needs-info`, `ready-for-human`, `wontfix`). Meanwhile the org has an 18-label taxonomy at https://git.westgate.pw/org/ShadowsOverWestgate/settings/labels that agents never touched, because no doc pointed at it — `Kind/Bug` had been used twice across every repo.

This replaces the doc with the real taxonomy and makes one `Kind/*` label required on every issue and PR at creation time. `AGENTS.md` gets the short version.

Enforcement lands separately in `sow-platform` (`ops/policy/labels.yml` + a nightly `ops/checks/check-labels.sh` drift check). Identical doc change in every `sow-*` repo.Reviewed-on: #97

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-04 21:55:47 +00:00
archvillainette 9747384838 fix(extract): keep committed palette skeletons out of stale cleanup (#96)
build-binaries / build-binaries (push) Successful in 2m50s
Extract skips writing *palcus.itp back to source (build projects them from blueprints, #50), but the skip branch never marked the skeleton target as desired, so cleanupStaleFiles deleted the committed source/palettes/*.itp.json on every extract — shipping modules with empty custom palettes. Fix marks the skipped target desired; regression test builds a module with a committed skeleton and asserts extract leaves it in place.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #96

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-04 08:51:12 +00:00
archvillainette b47a8a7afd Generate racial radial rows from race UsableFeat (#94) (#95)
build-binaries / build-binaries (push) Successful in 2m26s
Closes #94.

Generates the racial radial rows at build time from the `UsableFeat` column in the race feats tables, replacing the hand-maintained racial block in `sow-topdata` `data/classes/feats/global.json`.

## Why

Racial spell-like abilities reach the in-game radial only via an `OnMenu` row in every `cls_feat_<class>.2da`. Racial feats are granted by race, never by a class, so nothing adds them automatically — they were hand-written and prepended into every class table. The list drifts: this build already had **24** usable racial feats in the race tables but only **23** hand rows, so one activatable feat was silently missing.

## What

- `racialUsableFeatRules()` scans `race_feat_*.2da` datasets, collects `UsableFeat=1` feats, emits one rule each: `List=3, GrantedOnLevel=99, OnMenu=1`. Deduped across races, sorted for deterministic output.
- Row shape is exactly the in-game-verified hand rows. `List=3` keeps it off every level-up selection list; `GrantedOnLevel=99` is above the level cap so no class ever actually grants it (`nLevelGranted` is `uint8_t`); `OnMenu=1` renders the button once the creature possesses the feat. Possession stays chargen / the login racial-feat sync (`sow-codebase#359`).
- Reuses the existing `globalRules` injection path (same dedup, feat-existence check, label lookup) but applies **unconditionally**, so a leftover hand row in `global.json` deduplicates to a no-op — the `sow-topdata` cleanup lands separately.
- Not sourced from `feat.2da`: a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass.

## Tests

- `racial_feat_rules_test.go`: usable-only, non-usable excluded, cross-race dedup, non-`race_feat_` ignored, deterministic order.
- Existing `cls_feat` global-injection build tests still pass with the new parameter.
- End-to-end: with the racial block removed from `global.json`, a real `build-topdata` emits `TieflingDarkness 3 99 1` once per table across all 21, 24 racial rows total.

## Merge order

The `sow-topdata` `global.json` racial-row deletion depends on this — it must ship first (or the racial radial vanishes on the next topdata build with the released tool).

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #95

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-03 18:58:43 +00:00
archvillainette f23009ed50 verify is what tells you which keys to purge (#91)
build-binaries / build-binaries (push) Successful in 2m48s
Running the repair disproved the advice #90 landed an hour earlier.

"Purge the zone, then believe `verify`" assumed the stale set was unknowable. It is not. `verify` reads the edge, so the run straight after a repair names every key the edge is still serving stale — a survey, not a verdict. Purge those, re-run, and the second run is the verdict.

The measured numbers are the whole argument:

| | |
| --- | --- |
| blobs rewritten at the origin | 2,603 |
| blobs stale at the edge | **8** |

All eight were ones a failed player sync had pulled ninety minutes before the backfill. The edge only caches what someone fetched, so purging the zone would have cooled 69,169 objects to fix 8.

Full sweep after the targeted purge: `verified 69177 of 69177 blobs behind 72544 resources: 0 failures, 14887519535 bytes checked`. #75's gate is met.

Docs only. Runbook side in sow-platform.

Refs #88, #89, #75.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #91
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-08-01 09:20:16 +00:00
16 changed files with 843 additions and 163 deletions
+2 -2
View File
@@ -94,9 +94,9 @@ Tests must survive harmless changes to constants, defaults, wording, ordering, f
Issues live in Gitea at git.westgate.pw (`ShadowsOverWestgate/sow-tools`), managed with the `tea` CLI. Issues follow ownership — file work in the repo that owns it, not the one you happen to be standing in. See `docs/agents/issue-tracker.md`. Issues live in Gitea at git.westgate.pw (`ShadowsOverWestgate/sow-tools`), managed with the `tea` CLI. Issues follow ownership — file work in the repo that owns it, not the one you happen to be standing in. See `docs/agents/issue-tracker.md`.
### Triage labels ### Labels
Default label vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. Every issue and PR gets exactly one org-wide `Kind/*` label at creation (`Kind/Bug`, `Kind/Feature`, `Kind/Enhancement`, `Kind/Documentation`, `Kind/Testing`, `Kind/Security`); `Priority/*`, `Status/*`, `Reviewed/*` and `Compat/Breaking` are optional. `tea issues create -L "Kind/Bug"`. See `docs/agents/triage-labels.md`.
### Domain docs ### Domain docs
+26 -2
View File
@@ -57,8 +57,32 @@ preference — move it.
so any non-interactive shell (every agent) hangs forever without so any non-interactive shell (every agent) hangs forever without
`</dev/null`. Same trap on `tea issues create --description` and `</dev/null`. Same trap on `tea issues create --description` and
`tea pr create`. `tea pr create`.
- **Apply / remove labels**: `tea api --method PATCH` on the issue, or - **Apply / remove labels**: `tea issues edit <number> --add-labels "Kind/Bug"`
`tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>/labels` endpoints. (and `--remove-labels`). This handles org-level labels (`Kind/*`,
`Priority/*`, `Reviewed/*`, `Status/*`) from tea 0.15 onwards. On 0.14 it did
not: name resolution searched only this repo's own label set, so an org label
matched nothing and the command exited 0, printed the issue, and changed
nothing. Upstream fixed it in v0.15 (`modules/task/labels.go` also queries
`ListOrgLabels`). Note `tea labels` lists repo labels only and will not show
you the org set — `tea api orgs/ShadowsOverWestgate/labels` does.
- **`tea api` needs a token in the login; SSH auth is not enough.** It sends
only the login's `token:` field and does not sign requests with your SSH key,
so an SSH-key-only login gets `{"message":"token is required"}` on every call
that needs auth. Reads against these public repos still succeed anonymously,
which hides the gap until the first write. Add a token to the login in
`~/.config/tea/config.yml` (Settings > Applications; `write:issue` covers
labels, comments and dependencies) and `tea api` works for reads and writes
alike. The `tea issues` / `tea pr` subcommands authenticate either way, so
they keep working with no token at all — that asymmetry is what makes this
confusing to diagnose.
- **Verify every label change by re-reading it.** A label command exiting 0 is
not evidence it applied — that is exactly how the 0.14 silent no-op above hid
for so long, and assuming otherwise has already cost one investigation
several wrong turns. Read the resulting set back with
`tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>` and check its
`labels` field, or `tea issues ls -o json`. The read-back reflects the write
immediately; if it comes back empty, the write genuinely failed. Do not
explain an empty read-back away as replication lag.
- **Close**: `tea issues close <number>` - **Close**: `tea issues close <number>`
`tea` infers the repo from the git remote when run inside the clone. `tea` infers the repo from the git remote when run inside the clone.
+105 -11
View File
@@ -1,15 +1,109 @@
# Triage Labels # Issue and PR labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker (Gitea — see `issue-tracker.md` for how to apply labels with `tea`). Labels are **org-wide**. They are defined once, for the whole
`ShadowsOverWestgate` org, at
<https://git.westgate.pw/org/ShadowsOverWestgate/settings/labels>, and every
repo in the org can use them. Never create a per-repo copy of a label that
already exists at org level.
| Label in mattpocock/skills | Label in our tracker | Meaning | ## The rule
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. **Every issue and every pull request gets exactly one `Kind/*` label, at the
moment it is created.** Not later, not "when someone triages it". If you open
it, you label it.
Edit the right-hand column to match whatever vocabulary you actually use. An issue with no `Kind/*` label is untriaged. That is the only meaning of
"untriaged" here — there is no `needs-triage` label.
The other groups are optional, and each one is *exclusive*: an issue can carry
at most one `Priority/*`, one `Status/*`, and one `Reviewed/*`. Gitea enforces
this.
```sh
# always from inside the owning repo's clone
tea issues create --title "..." --description "..." --labels "Kind/Bug" </dev/null
tea pr create --title "..." --description "..." --labels "Kind/Feature" </dev/null
# add a label to something that already exists (needs tea >= 0.15 for org labels)
tea issues edit <n> --add-labels "Kind/Bug,Priority/High"
# read the result back — exit 0 is not evidence the label applied
tea api "repos/ShadowsOverWestgate/<repo>/issues/<n>"
```
`tea api` sends only the login's `token:` and never your SSH key, so on an
SSH-only login every authenticated call fails with
`{"message":"token is required"}` while anonymous reads still work. Add a token
to `~/.config/tea/config.yml` if you want the raw endpoints. `tea issues edit`
needs no token.
## Kind — what this is (pick exactly one)
| Label | Use it when |
| -------------------- | --------------------------------------------------------------- |
| `Kind/Bug` | Something that used to work, or is documented to work, does not |
| `Kind/Feature` | New functionality that does not exist yet |
| `Kind/Enhancement` | Existing functionality gets better, faster, or nicer |
| `Kind/Documentation` | Docs, ADRs, runbooks, agent guides |
| `Kind/Testing` | Tests, CI checks, contract scripts |
| `Kind/Security` | Secrets, auth, permissions, hardening, a vulnerability |
Bug vs Enhancement, when it is unclear: if the current behaviour is wrong, it
is a bug. If the current behaviour is right but weak, it is an enhancement.
## Priority — how urgent (optional, at most one)
`Priority/Critical`, `Priority/High`, `Priority/Medium`, `Priority/Low`.
Leave it off if you do not know. A wrong priority is worse than none.
## Status — why it is not moving (optional, at most one)
| Label | Meaning |
| ----------------------- | -------------------------------------------- |
| `Status/Blocked` | Waiting on another issue, PR, or decision |
| `Status/Need More Info` | Waiting on the reporter or on a human answer |
| `Status/Abandoned` | Work started and stopped; nobody is on it |
## Reviewed — the verdict (optional, at most one)
`Reviewed/Confirmed`, `Reviewed/Duplicate`, `Reviewed/Invalid`,
`Reviewed/Won't Fix`. Apply one of these when closing without a fix, so the
reason survives.
## Compat
`Compat/Breaking` — add it on top of the `Kind/*` label when the change breaks
something that already works for a player, an operator, or another repo.
## Workflow labels (repo-level, not org-level)
These live in the repo, not the org, and are orthogonal to the groups above:
- `ready-for-agent` — the spec is complete; an AFK agent may pick this up.
No label means it needs a human.
- `wayfinder:map`, `wayfinder:task`, `wayfinder:research`,
`wayfinder:prototype`, `wayfinder:grilling` — set by `/wayfinder`. Leave
them alone unless you are running a wayfinder operation.
- `sow-nodebb` also has `package/*` labels naming the plugin or theme a ticket
touches.
## When a skill names a label we do not have
Skills written elsewhere (mattpocock/skills and friends) use a different
vocabulary. Translate it:
| Skill says | Do this here |
| ----------------- | ------------------------------------------------- |
| `needs-triage` | Nothing — no `Kind/*` label already means this |
| `needs-info` | `Status/Need More Info` |
| `ready-for-agent` | `ready-for-agent` |
| `ready-for-human` | Nothing — absence of `ready-for-agent` means this |
| `wontfix` | `Reviewed/Won't Fix` |
## Drift check
`ops/checks/check-labels.sh` in `sow-platform` runs nightly. It compares the
live org labels to `ops/policy/labels.yml` and lists every open issue and PR in
the org that does not have exactly one `Kind/*` label. That check is the
enforcement; this file is the rule.
+14 -9
View File
@@ -97,17 +97,22 @@ broken — is skipped by every later run forever and no backfill repairs it. Wit
and replaced when it does not match. It costs a full GET per existing blob, so and replaced when it does not match. It costs a full GET per existing blob, so
it is a repair pass, not the default. it is a repair pass, not the default.
**After a repair, purge the pull zone before believing `verify`.** A repair is **After a repair, `verify` is what tells you which keys to purge.** A repair is
the one thing that makes a key serve different bytes than it did before, and the the one thing that makes a key serve different bytes than it did before, and the
edge caches these objects for 30 days precisely because that normally cannot edge caches these objects for 30 days precisely because that normally cannot
happen. The two commands therefore look at different copies on purpose: `emit happen. The two commands look at different copies on purpose: `emit --verify`
--verify` repairs the **origin**, `verify` reads the **edge**, and in between a repairs the **origin**, `verify` reads the **edge**. So a `verify` run straight
warm PoP still answers with the old bytes while a cold one answers with the new. after a repair is not a verdict — it is a survey, and every blob it still calls
Until the zone is purged `verify`'s verdict is per-PoP and settles nothing — a bad is one the edge is serving stale. Purge exactly those, then re-run it; only
pass is not proof, and a failure is not the repair having failed. The purge is that second run is the verdict.
one call against the pull zone; it belongs in the repair procedure rather than
in `emit`, which holds a storage credential and no CDN one (sow-tools#89, and Purging the keys `verify` names beats purging the zone, because the edge only
the procedure itself is in sow-platform's NWSync runbook). ever cached what somebody actually fetched: the 2026-08-01 repair rewrote 2,603
blobs at the origin and left 8 stale at the edge. The purge belongs in the
repair procedure rather than in `emit`, which reports how many blobs it wrote
and never which ones — so it could not target one even with a CDN credential,
which it deliberately does not hold (#89; the procedure itself is in
sow-platform's NWSync runbook).
`emit` uploads blobs first and the index last, so the presence of an index is `emit` uploads blobs first and the index last, so the presence of an index is
the publication marker: an artifact whose emit died halfway leaves real blobs in the publication marker: an artifact whose emit died halfway leaves real blobs in
+20 -11
View File
@@ -927,19 +927,28 @@ func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, st
fmt.Fprintf(c.stdout, "wiki status: %s\n", status) fmt.Fprintf(c.stdout, "wiki status: %s\n", status)
} }
func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) { func (c *topdataConsole) emitWikiDeployResult(result topdata.DeployResult) {
spin.linebreak() spin.linebreak()
fmt.Fprintln(c.stdout, "Deploy Wiki ----------") fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "local pages: %d\n", localPages) fmt.Fprintf(c.stdout, "local pages: %d\n", result.LocalPages)
fmt.Fprintf(c.stdout, "created: %d\n", created) fmt.Fprintf(c.stdout, "created: %d\n", result.Created)
fmt.Fprintf(c.stdout, "updated: %d\n", updated) fmt.Fprintf(c.stdout, "updated: %d\n", result.Updated)
fmt.Fprintf(c.stdout, "skipped: %d\n", skipped) fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
fmt.Fprintf(c.stdout, "stale: %d\n", stale) fmt.Fprintf(c.stdout, "stale: %d\n", result.Stale)
fmt.Fprintf(c.stdout, "archived: %d\n", archived) fmt.Fprintf(c.stdout, "archived: %d\n", result.Archived)
fmt.Fprintf(c.stdout, "purged: %d\n", purged) fmt.Fprintf(c.stdout, "purged: %d\n", result.Purged)
fmt.Fprintf(c.stdout, "drifted: %d\n", drifted) fmt.Fprintf(c.stdout, "drifted: %d\n", result.Drifted)
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest)) if result.ResetPurged > 0 || result.ResetSkipped > 0 {
fmt.Fprintf(c.stdout, "namespace reset: %d (unrecognized: %d)\n", result.ResetPurged, result.ResetUnrecognized)
}
if result.ResetUnrecognized > 0 {
fmt.Fprintln(c.stdout, " unrecognized pages were not written by this deployer; recreating them is not possible")
}
if result.ResetSkipped > 0 {
fmt.Fprintf(c.stdout, " reset skipped: %d (NodeBB refused deletion, e.g. the wiki home page)\n", result.ResetSkipped)
}
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest))
} }
func (c *topdataConsole) relPath(path string) string { func (c *topdataConsole) relPath(path string) string {
@@ -1579,7 +1588,7 @@ func runDeployWiki(ctx context) error {
return err return err
} }
console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Purged, result.Drifted, result.Manifest) console.emitWikiDeployResult(result)
return nil return nil
} }
+58 -1
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
) )
func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) { func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) {
@@ -218,7 +219,16 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) {
} }
console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0") console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0")
console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json") console.emitWikiDeployResult(topdata.DeployResult{
LocalPages: 10,
Created: 1,
Updated: 2,
Skipped: 3,
Stale: 4,
Archived: 5,
Purged: 6,
Manifest: "/workspace/project/build/wiki/deploy-manifest.json",
})
output := stdout.String() output := stdout.String()
if !strings.Contains(output, "NodeBB wiki plan") { if !strings.Contains(output, "NodeBB wiki plan") {
@@ -229,6 +239,53 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) {
} }
} }
func TestTopdataConsoleReportsManagedNamespaceReset(t *testing.T) {
var stdout bytes.Buffer
console := &topdataConsole{
stdout: &stdout,
projectRoot: "/workspace/project",
projectName: "Test Module",
commandName: "deploy-wiki",
commandLabel: "Deploy Wiki",
}
console.emitWikiDeployResult(topdata.DeployResult{
LocalPages: 1200,
Created: 1200,
Stale: 1213,
Purged: 1213,
ResetPurged: 1213,
ResetUnrecognized: 13,
ResetSkipped: 1,
Manifest: "/workspace/project/build/wiki/deploy-manifest.json",
})
// The counts are what an operator reads to decide whether a destructive run
// is safe, so each has to reach the output on a line that names what it
// counts; the wording around them is free to change.
output := stdout.String()
var staleLine, resetLine, skippedLine string
for _, line := range strings.Split(output, "\n") {
switch {
case strings.Contains(line, "stale"):
staleLine = line
case strings.Contains(line, "skipped"):
skippedLine = line
case strings.Contains(line, "reset"):
resetLine = line
}
}
if !strings.Contains(staleLine, "1213") {
t.Fatalf("expected reset deletions counted as stale, got %q", output)
}
if !strings.Contains(resetLine, "1213") || !strings.Contains(resetLine, "13") {
t.Fatalf("expected the reset line to carry both the total and the unrecognized count, got %q", output)
}
if !strings.Contains(skippedLine, "1") {
t.Fatalf("expected refused reset deletions to be reported, got %q", output)
}
}
func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) { func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"}) _, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") { if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") {
+2 -1
View File
@@ -289,7 +289,8 @@ var Registry = []Builder{
"--dry-run report changes without writing", "--dry-run report changes without writing",
"--create allow missing pages to be created", "--create allow missing pages to be created",
"--force update unchanged pages", "--force update unchanged pages",
"--reset-managed-namespaces reset managed namespace state", "--reset-managed-namespaces delete every page in the managed categories, including",
" pages this deployer never wrote, then recreate from source",
}, },
Aliases: []CommandAlias{{Name: "deploy-wiki"}}, Aliases: []CommandAlias{{Name: "deploy-wiki"}},
}, },
+3 -2
View File
@@ -67,8 +67,9 @@ check on a published blob upstream of a player's client.
--verify makes emit hash what it would otherwise skip. emit normally treats a --verify makes emit hash what it would otherwise skip. emit normally treats a
blob's presence as proof of its contents, so without this an object written blob's presence as proof of its contents, so without this an object written
truncated, or written by an emitter since found broken, is skipped forever. truncated, or written by an emitter since found broken, is skipped forever.
--verify repairs the storage zone, while verify reads the edge in front of it, --verify repairs the storage zone, while verify reads the edge in front of it.
so purge the pull zone after a repair or verify answers differently per PoP. So a verify run right after a repair is a survey, not a verdict: it names the
keys the edge still serves stale. Purge those, then run it again.
--out DIR writes to a local repository tree instead of uploading, which is the --out DIR writes to a local repository tree instead of uploading, which is the
conformance path against upstream nwn_nwsync_write. Without it, the zone comes conformance path against upstream nwn_nwsync_write. Without it, the zone comes
+7 -1
View File
@@ -126,8 +126,14 @@ func extractArchiveResources(p *project.Project, archive erf.Archive, desired ma
} }
// *palcus.itp are Toolset-generated palette projections; the module // *palcus.itp are Toolset-generated palette projections; the module
// build regenerates them from source blueprints, so extraction never // build regenerates them from source blueprints, so extraction never
// writes them back into source. // writes them back into source. The committed skeleton still has to
// survive stale cleanup, so mark its target as desired.
if ext == "itp" && isPaletteProjectionResref(resource.Name) { if ext == "itp" && isPaletteProjectionResref(resource.Name) {
effective := p.EffectiveConfig()
target, err := extractionTarget(p, "paths.source", effective.Paths.Source, p.SourceDir(), sourceSubdir(ext), strings.ToLower(resource.Name)+"."+ext+".json")
if err == nil {
desired[target] = struct{}{}
}
skippedCount++ skippedCount++
continue continue
} }
+82
View File
@@ -3312,6 +3312,88 @@ func TestExtractOverwritesAndRemovesStaleFiles(t *testing.T) {
} }
} }
func TestExtractKeepsPaletteSkeletonsThroughStaleCleanup(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module"))
mustMkdir(t, filepath.Join(root, "src", "palettes"))
mustMkdir(t, filepath.Join(root, "assets"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
}
}
`)
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{
"label": "Mod_Name",
"type": "CExoString",
"value": "Original Module"
}
]
}
}
`)
skeletonPath := filepath.Join(root, "src", "palettes", "creaturepalcus.itp.json")
mustWriteFile(t, skeletonPath, `{
"file_type": "ITP ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{
"label": "MAIN",
"type": "List",
"value": []
}
]
}
}
`)
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.ValidateLayout(); err != nil {
t.Fatalf("validate layout: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("scan: %v", err)
}
if _, err := BuildModule(p); err != nil {
t.Fatalf("build module: %v", err)
}
if err := p.Scan(); err != nil {
t.Fatalf("rescan before extract: %v", err)
}
result, err := Extract(p)
if err != nil {
t.Fatalf("extract: %v", err)
}
if result.Removed != 0 {
t.Fatalf("expected no removed files, got %d", result.Removed)
}
if _, err := os.Stat(skeletonPath); err != nil {
t.Fatalf("expected palette skeleton to survive extract, stat err=%v", err)
}
}
func TestExtractMergesConfiguredGFFJSONFieldsAndLists(t *testing.T) { func TestExtractMergesConfiguredGFFJSONFieldsAndLists(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "src", "module"))
+2 -1
View File
@@ -98,8 +98,9 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat
} }
results := make([]Generated2DAAsset, 0, len(collected)) results := make([]Generated2DAAsset, 0, len(collected))
racialFeatRules := racialUsableFeatRules(collected)
for _, dataset := range collected { for _, dataset := range collected {
compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, nil) compiled, err := resolveNativeDataset(dataset, keyToID, rowByKey, tableRegistry, nil, project.TopDataClassFeatInjectionConfig{}, racialFeatRules, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+59 -4
View File
@@ -530,6 +530,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
groupStats := nativeCompileGroupStats(collected) groupStats := nativeCompileGroupStats(collected)
currentGroup := "" currentGroup := ""
sidecars := newNativeSidecarCollector() sidecars := newNativeSidecarCollector()
racialFeatRules := racialUsableFeatRules(collected)
for _, dataset := range collected { for _, dataset := range collected {
group := nativeCompileGroup(dataset.Dataset.Name) group := nativeCompileGroup(dataset.Dataset.Name)
if group != currentGroup { if group != currentGroup {
@@ -542,7 +543,7 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
stats.SourceFragments, stats.SourceFragments,
)) ))
} }
compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, sidecars) compiled, err := resolveNativeDataset(dataset, globalKeyToID, globalRowByKey, tableRegistry, compiler, p.EffectiveConfig().TopData.ClassFeatInjections, racialFeatRules, sidecars)
if err != nil { if err != nil {
return BuildResult{}, err return BuildResult{}, err
} }
@@ -3996,13 +3997,13 @@ func normalizeGlobalReferenceID(value string) string {
return parts[0] + ":" + strings.ReplaceAll(parts[1], "_", "") return parts[0] + ":" + strings.ReplaceAll(parts[1], "_", "")
} }
func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, sidecars *nativeSidecarCollector) (map[string]any, error) { func resolveNativeDataset(dataset nativeCollectedDataset, keyToID map[string]int, globalRowByKey map[string]map[string]any, tableRegistry resolvedTableRegistry, compiler *tlkCompiler, classFeatInjections project.TopDataClassFeatInjectionConfig, racialFeatRules []project.TopDataClassFeatGlobalRule, sidecars *nativeSidecarCollector) (map[string]any, error) {
rows := dataset.Rows rows := dataset.Rows
if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") { if strings.HasPrefix(filepath.ToSlash(dataset.Dataset.Name), "classes/feats/") {
classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:] classKey := "classes:" + dataset.Dataset.Name[strings.LastIndex(dataset.Dataset.Name, "/")+1:]
featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID) featSuccessors := buildFeatSuccessorsIndex(globalRowByKey, keyToID)
classSkills := buildClassSkillsIndex(tableRegistry, classKey) classSkills := buildClassSkillsIndex(tableRegistry, classKey)
expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, !dataset.Dataset.HasGlobalInjections) expanded, err := expandClassesFeatRows(rows, keyToID, globalRowByKey, featSuccessors, classSkills, globalRowByKey, classKey, classFeatInjections, racialFeatRules, !dataset.Dataset.HasGlobalInjections)
if err != nil { if err != nil {
return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err) return nil, fmt.Errorf("dataset %s: %w", dataset.Dataset.Name, err)
} }
@@ -4051,12 +4052,18 @@ var (
} }
) )
func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig, useConfiguredInjections bool) ([]map[string]any, error) { func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByKey map[string]map[string]any, featSuccessors map[string]string, classSkills map[string]bool, allRowByKey map[string]map[string]any, classKey string, classFeatInjections project.TopDataClassFeatInjectionConfig, racialFeatRules []project.TopDataClassFeatGlobalRule, useConfiguredInjections bool) ([]map[string]any, error) {
globalRules, classSkillRules := []project.TopDataClassFeatGlobalRule{}, []project.TopDataClassFeatMasterfeatRule{} globalRules, classSkillRules := []project.TopDataClassFeatGlobalRule{}, []project.TopDataClassFeatMasterfeatRule{}
if useConfiguredInjections { if useConfiguredInjections {
globalRules, classSkillRules = effectiveClassFeatInjectionRules(classFeatInjections) globalRules, classSkillRules = effectiveClassFeatInjectionRules(classFeatInjections)
} }
// Racial usable-feat rows are generated from the race feats tables, not the
// hand-authored class-feat injections, so they apply to every class table
// regardless of whether that dataset carries its own global.json (which is
// what gates useConfiguredInjections). Deduped below against rows already
// present, so a leftover hand row in global.json is a no-op, not a double.
globalRules = append(globalRules, racialFeatRules...)
injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules)) injected := make([]map[string]any, 0, len(globalRules)+len(classSkillRules))
presentRefIDs := make(map[string]struct{}, len(rows)) presentRefIDs := make(map[string]struct{}, len(rows))
for _, row := range rows { for _, row := range rows {
@@ -4134,6 +4141,54 @@ func expandClassesFeatRows(rows []map[string]any, keyToID map[string]int, rowByK
return combined, nil return combined, nil
} }
// racialUsableFeatRules builds one class-feat injection rule per feat marked
// UsableFeat=1 in any race feats table (race_feat_*.2da). Racial feats are
// granted by race, never by a class, so an activatable one needs a menu-only
// cls_feat row to reach the client radial: List=3 keeps it off every level-up
// selection list, GrantedOnLevel=99 sits above the level cap so no class ever
// actually grants it, and OnMenu=1 renders the button once the creature holds
// the feat (possession comes from chargen / the login racial-feat sync). This
// replaces the hand-maintained racial rows in classes/feats/global.json - mark
// UsableFeat in the race table and the radial row follows automatically.
func racialUsableFeatRules(collected []nativeCollectedDataset) []project.TopDataClassFeatGlobalRule {
seen := map[string]struct{}{}
rules := []project.TopDataClassFeatGlobalRule{}
for _, ds := range collected {
if !strings.HasPrefix(ds.Dataset.OutputName, "race_feat_") || !strings.HasSuffix(ds.Dataset.OutputName, ".2da") {
continue
}
for _, row := range ds.Rows {
if usable, err := asInt(fieldValue(row, "UsableFeat")); err != nil || usable != 1 {
continue
}
featRef, ok := row["FeatIndex"].(map[string]any)
if !ok {
continue
}
featID, _ := featRef["id"].(string)
if featID == "" {
continue
}
if _, dup := seen[featID]; dup {
continue
}
seen[featID] = struct{}{}
rules = append(rules, project.TopDataClassFeatGlobalRule{
Feat: featID,
List: "3",
GrantedOnLevel: "99",
OnMenu: "1",
})
}
}
// Discovery order across files and the dedup map are both unordered; sort so
// the injected rows (and the resulting 2DA row numbering) are deterministic.
slices.SortFunc(rules, func(a, b project.TopDataClassFeatGlobalRule) int {
return strings.Compare(a.Feat, b.Feat)
})
return rules
}
func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) { func effectiveClassFeatInjectionRules(config project.TopDataClassFeatInjectionConfig) ([]project.TopDataClassFeatGlobalRule, []project.TopDataClassFeatMasterfeatRule) {
if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 { if len(config.GlobalFeats) == 0 && len(config.ClassSkillMasterfeats) == 0 {
return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules return defaultClassFeatGlobalRules, defaultClassFeatClassSkillMasterfeatRules
@@ -0,0 +1,54 @@
package topdata
import (
"reflect"
"testing"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func raceFeatDataset(output string, rows ...map[string]any) nativeCollectedDataset {
return nativeCollectedDataset{
Dataset: nativeDataset{OutputName: output},
Rows: rows,
}
}
func usableRow(featID string, usable any) map[string]any {
row := map[string]any{"FeatIndex": map[string]any{"id": featID}}
if usable != nil {
row["UsableFeat"] = usable
}
return row
}
func TestRacialUsableFeatRules(t *testing.T) {
collected := []nativeCollectedDataset{
raceFeatDataset("race_feat_ddrw.2da",
usableRow("feat:keen_sense", nil), // passive, no UsableFeat -> skipped
usableRow("feat:darkvision", 1), // usable
usableRow("feat:use_poison", 0), // explicitly not usable -> skipped
usableRow("feat:drow/faerie_fire", "1"),// usable, string form
),
raceFeatDataset("race_feat_tief.2da",
usableRow("feat:darkvision", 1), // duplicate across races -> collapses to one
usableRow("feat:tiefling/darkness", 1),
),
raceFeatDataset("feat.2da", // not a race feats table -> ignored entirely
usableRow("feat:power_attack", 1),
),
}
got := racialUsableFeatRules(collected)
want := []project.TopDataClassFeatGlobalRule{
{Feat: "feat:darkvision", List: "3", GrantedOnLevel: "99", OnMenu: "1"},
{Feat: "feat:drow/faerie_fire", List: "3", GrantedOnLevel: "99", OnMenu: "1"},
{Feat: "feat:tiefling/darkness", List: "3", GrantedOnLevel: "99", OnMenu: "1"},
}
// Slice is sorted by Feat, so order is deterministic.
if !reflect.DeepEqual(got, want) {
t.Fatalf("rules: got %+v, want %+v", got, want)
}
}
+1
View File
@@ -1523,6 +1523,7 @@ func TestResolveNativeDatasetPreservesScalarTableReferenceBehavior(t *testing.T)
nil, nil,
project.TopDataClassFeatInjectionConfig{}, project.TopDataClassFeatInjectionConfig{},
nil, nil,
nil,
) )
if err != nil { if err != nil {
t.Fatalf("resolveNativeDataset failed: %v", err) t.Fatalf("resolveNativeDataset failed: %v", err)
+127 -28
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"maps"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -52,7 +53,7 @@ type DeployWikiOptions struct {
TitlePrefixMinLength int TitlePrefixMinLength int
} }
type deployResult struct { type DeployResult struct {
LocalPages int LocalPages int
Created int Created int
Updated int Updated int
@@ -63,6 +64,19 @@ type deployResult struct {
Drifted int Drifted int
Renamed int Renamed int
Manifest string Manifest string
// ResetPurged counts the deletions queued by --reset-managed-namespaces.
// They are also included in Stale and Purged, because callers warn about
// destructive policies in terms of the stale count: sow-topdata's
// deploy-wiki wrapper prints "of the pages counted as 'stale' below, they
// will be DELETED from NodeBB" directly above this block.
ResetPurged int
// ResetUnrecognized counts the subset of ResetPurged that the deploy
// manifest has no record of writing. Those deletions are the ones a
// re-seed cannot undo.
ResetUnrecognized int
// ResetSkipped counts reset targets NodeBB refused to delete, such as the
// wiki home topic, which the plugin excludes from tombstone and purge.
ResetSkipped int
} }
type wikiDeployPage struct { type wikiDeployPage struct {
@@ -100,6 +114,12 @@ type wikiDeployPlan struct {
Content string Content string
RemoteHash string RemoteHash string
Title string Title string
// Reset marks a purge queued by --reset-managed-namespaces rather than by
// stale computation over the manifest.
Reset bool
// Unrecognized marks a reset purge whose topic the manifest has no record
// of writing.
Unrecognized bool
} }
type wikiNamespacesDocument struct { type wikiNamespacesDocument struct {
@@ -116,7 +136,7 @@ type wikiNamespaceDeclaration struct {
EditPolicy string `json:"edit_policy" yaml:"edit_policy"` EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
} }
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) { func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (DeployResult, error) {
if progress == nil { if progress == nil {
progress = func(string) {} progress = func(string) {}
} }
@@ -132,19 +152,19 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
} }
} }
if _, err := os.Stat(opts.SourceDir); err != nil { if _, err := os.Stat(opts.SourceDir); err != nil {
return deployResult{}, fmt.Errorf("wiki source directory not found: %w", err) return DeployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
} }
if opts.Endpoint == "" { if opts.Endpoint == "" {
return deployResult{}, errors.New("NODEBB_API_ENDPOINT is required") return DeployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
} }
if opts.Token == "" { if opts.Token == "" {
return deployResult{}, errors.New("NODEBB_API_TOKEN is required") return DeployResult{}, errors.New("NODEBB_API_TOKEN is required")
} }
if opts.Username != "" || opts.Password != "" || opts.NotesDelimiter != "" { if opts.Username != "" || opts.Password != "" || opts.NotesDelimiter != "" {
return deployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings") return DeployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings")
} }
if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" && opts.StalePolicy != "purge" { if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" && opts.StalePolicy != "purge" {
return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy) return DeployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
} }
if opts.TitlePrefixMinLength <= 0 { if opts.TitlePrefixMinLength <= 0 {
opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength
@@ -154,7 +174,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
if len(namespaces) == 0 { if len(namespaces) == 0 {
declarations, err := loadWikiNamespaceDeclarations(p) declarations, err := loadWikiNamespaceDeclarations(p)
if err != nil { if err != nil {
return deployResult{}, err return DeployResult{}, err
} }
for _, declaration := range declarations { for _, declaration := range declarations {
namespaces = append(namespaces, declaration.ID) namespaces = append(namespaces, declaration.ID)
@@ -164,7 +184,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
} }
envCategories, err := categoryIDsFromNamespaceEnv(declarations) envCategories, err := categoryIDsFromNamespaceEnv(declarations)
if err != nil { if err != nil {
return deployResult{}, err return DeployResult{}, err
} }
if len(envCategories) > 0 { if len(envCategories) > 0 {
merged := envCategories merged := envCategories
@@ -183,7 +203,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir)) progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir))
pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces) pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces)
if err != nil { if err != nil {
return deployResult{}, err return DeployResult{}, err
} }
progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages))) progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages)))
manifest := loadDeployManifest(manifestPath) manifest := loadDeployManifest(manifestPath)
@@ -193,7 +213,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages))) progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages)))
plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress) plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress)
if err != nil { if err != nil {
return deployResult{}, err return DeployResult{}, err
} }
result.LocalPages = len(pages) result.LocalPages = len(pages)
result.Manifest = manifestPath result.Manifest = manifestPath
@@ -212,6 +232,8 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
} }
orderedPlans := orderNodeBBDeployPlans(plans) orderedPlans := orderNodeBBDeployPlans(plans)
progress(fmt.Sprintf("Executing NodeBB wiki actions: total %d, create %d, update %d, rename %d, archive %d, purge %d", len(orderedPlans), result.Created, result.Updated, result.Renamed, result.Archived, result.Purged)) progress(fmt.Sprintf("Executing NodeBB wiki actions: total %d, create %d, update %d, rename %d, archive %d, purge %d", len(orderedPlans), result.Created, result.Updated, result.Renamed, result.Archived, result.Purged))
resetPurgedByCID := map[int]int{}
resetSkippedByCID := map[int]int{}
for i, plan := range orderedPlans { for i, plan := range orderedPlans {
if shouldReportNodeBBDeployActionProgress(i, len(orderedPlans)) { if shouldReportNodeBBDeployActionProgress(i, len(orderedPlans)) {
progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID)) progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID))
@@ -242,19 +264,52 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
} }
case "archive": case "archive":
// Archiving rewrites the page rather than removing it, so it goes
// through the ordinary post edit the wiki plugin allows; only
// delete, restore, and purge are reserved to the page actions.
if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil {
return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
} }
case "purge": case "purge":
if err := client.purgeTopic(plan.Entry.TID); err != nil { if err := client.purgeWikiPage(plan.Entry.TID); err != nil {
// A namespace reset sweeps every topic in the category, so it
// can reach pages NodeBB will not delete at all — the wiki home
// topic above all. Skip those rather than abandoning the reset.
if plan.Reset && isNodeBBWikiPageUndeletable(err) {
result.ResetSkipped++
result.ResetPurged--
result.Purged--
result.Stale--
if plan.Unrecognized {
result.ResetUnrecognized--
}
resetSkippedByCID[plan.Entry.CID]++
progress(fmt.Sprintf("NodeBB refused to delete wiki topic %d during managed namespace reset; skipping it", plan.Entry.TID))
continue
}
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err) return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
} }
if plan.Reset {
resetPurgedByCID[plan.Entry.CID]++
}
case "rename": case "rename":
if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil { if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil {
return result, fmt.Errorf("deploy wiki page %q: rename NodeBB topic %d to %q: %w", plan.Page.PageID, plan.Entry.TID, plan.Title, err) return result, fmt.Errorf("deploy wiki page %q: rename NodeBB topic %d to %q: %w", plan.Page.PageID, plan.Entry.TID, plan.Title, err)
} }
} }
} }
// NodeBB answers 403 both for the pages it will never delete — the wiki
// home topic — and for a token without purge privileges, and the two are
// not distinguishable from the response. What tells them apart is scope: a
// category where nothing at all could be deleted is a privilege problem,
// not a home page. Reporting that as a completed reset would leave the
// manifest claiming a fresh start over pages that are all still there, so
// the next deploy would recreate every one of them as a duplicate.
for _, cid := range slices.Sorted(maps.Keys(resetSkippedByCID)) {
if resetPurgedByCID[cid] == 0 {
return result, fmt.Errorf("NodeBB refused every managed namespace reset deletion in category %d (%d topic(s)); check that the deploy token has wiki purge privileges there", cid, resetSkippedByCID[cid])
}
}
if err := saveDeployManifest(manifestPath, nextManifest); err != nil { if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
return result, err return result, err
} }
@@ -486,7 +541,7 @@ func isDir(path string) bool {
return err == nil && info.IsDir() return err == nil && info.IsDir()
} }
func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, deployResult, wikiDeployManifest, error) { func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, DeployResult, wikiDeployManifest, error) {
if progress == nil { if progress == nil {
progress = func(string) {} progress = func(string) {}
} }
@@ -494,17 +549,23 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
remotePagesByCID := map[int][]nodeBBWikiPage{} remotePagesByCID := map[int][]nodeBBWikiPage{}
var plans []wikiDeployPlan var plans []wikiDeployPlan
var result deployResult var result DeployResult
if opts.ResetManagedNamespaces { if opts.ResetManagedNamespaces {
if !opts.AllowCreates && len(pageIDs) > 0 { if !opts.AllowCreates && len(pageIDs) > 0 {
return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated") return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated")
} }
resetPlans, purged, err := planManagedNamespaceReset(opts, client, progress) resetPlans, purged, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress)
if err != nil { if err != nil {
return nil, result, next, err return nil, result, next, err
} }
plans = append(plans, resetPlans...) plans = append(plans, resetPlans...)
result.Purged += purged result.Purged += purged
// A namespace reset deletes remote pages the same way stale purge does,
// so it is counted as stale: that is the number callers word their
// destructive-policy warning around.
result.Stale += purged
result.ResetPurged = purged
result.ResetUnrecognized = unrecognized
manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
for _, cid := range opts.CategoryIDs { for _, cid := range opts.CategoryIDs {
if cid != 0 { if cid != 0 {
@@ -682,7 +743,11 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
return plans, result, next, nil return plans, result, next, nil
} }
func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, error) { // planManagedNamespaceReset queues a purge for every topic in each managed
// category, not only the ones the manifest says we wrote. The second return is
// the plan count; the third is how many of those topics the manifest has no
// record of, which is the subset a re-seed cannot put back.
func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManifest, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, int, error) {
namespaces := slices.Clone(opts.Namespaces) namespaces := slices.Clone(opts.Namespaces)
if len(namespaces) == 0 { if len(namespaces) == 0 {
for namespace := range opts.CategoryIDs { for namespace := range opts.CategoryIDs {
@@ -691,7 +756,15 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro
} }
slices.Sort(namespaces) slices.Sort(namespaces)
manifestTIDs := map[int]struct{}{}
for _, entry := range manifest.Pages {
if entry.TID != 0 {
manifestTIDs[entry.TID] = struct{}{}
}
}
seenTIDs := map[int]struct{}{} seenTIDs := map[int]struct{}{}
unrecognized := 0
var plans []wikiDeployPlan var plans []wikiDeployPlan
for _, namespace := range namespaces { for _, namespace := range namespaces {
cid := opts.CategoryIDs[namespace] cid := opts.CategoryIDs[namespace]
@@ -701,7 +774,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid)) progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
remotePages, err := client.listNamespacePages(cid) remotePages, err := client.listNamespacePages(cid)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, 0, err
} }
slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int { slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int {
return a.TID - b.TID return a.TID - b.TID
@@ -714,19 +787,25 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro
continue continue
} }
seenTIDs[remotePage.TID] = struct{}{} seenTIDs[remotePage.TID] = struct{}{}
_, known := manifestTIDs[remotePage.TID]
if !known {
unrecognized++
}
title := strings.TrimSpace(remotePage.Title) title := strings.TrimSpace(remotePage.Title)
if title == "" { if title == "" {
title = strings.TrimSpace(remotePage.TitleLeaf) title = strings.TrimSpace(remotePage.TitleLeaf)
} }
pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID) pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID)
plans = append(plans, wikiDeployPlan{ plans = append(plans, wikiDeployPlan{
Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace}, Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace},
Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title}, Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title},
Action: "purge", Action: "purge",
Reset: true,
Unrecognized: !known,
}) })
} }
} }
return plans, len(plans), nil return plans, len(plans), unrecognized, nil
} }
func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) { func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) {
@@ -1611,16 +1690,36 @@ func (c *nodeBBClient) renameWikiPage(tid, cid int, title string) error {
return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil) return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil)
} }
func (c *nodeBBClient) purgeTopic(tid int) error { // purgeWikiPage deletes a wiki topic through the wiki plugin's own page
// actions. The core DELETE /api/v3/topics/{tid} route is refused for topics in
// wiki categories, because revision history is plugin-owned, so a purge has to
// tombstone the page first and then hard-purge it.
func (c *nodeBBClient) purgeWikiPage(tid int) error {
if tid == 0 { if tid == 0 {
return fmt.Errorf("NodeBB topic purge requires topic id") return fmt.Errorf("NodeBB wiki page purge requires topic id")
} }
err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil) body := map[string]any{"tid": tid}
if err := c.request(http.MethodPut, "/api/v3/plugins/westgate-wiki/page/tombstone", body, nil); err != nil {
if isNodeBBMissingResource(err) {
return nil
}
return err
}
if err := c.request(http.MethodDelete, "/api/v3/plugins/westgate-wiki/page/hard-purge", body, nil); err != nil {
if isNodeBBMissingResource(err) {
return nil
}
return err
}
return nil
}
// isNodeBBWikiPageUndeletable reports whether NodeBB refused to delete the page
// outright rather than failing transiently. The wiki home topic answers this
// way: the plugin excludes it from tombstone, restore, and purge alike.
func isNodeBBWikiPageUndeletable(err error) bool {
var httpErr nodeBBHTTPError var httpErr nodeBBHTTPError
if errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) { return errors.As(err, &httpErr) && httpErr.Status == http.StatusForbidden
return nil
}
return err
} }
func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) { func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) {
+281 -90
View File
@@ -2,6 +2,7 @@ package topdata
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -35,7 +36,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
} }
updateCalls := 0 updateCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" { if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got) t.Fatalf("unexpected authorization header %q", got)
} }
@@ -50,7 +51,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -92,7 +93,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
} }
progress := []string{} progress := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -100,7 +101,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -174,7 +175,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
} }
progress := []string{} progress := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -194,7 +195,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -228,7 +229,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) { func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) {
requests := 0 requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
requests++ requests++
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
@@ -240,7 +241,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
"nextCursor": "same-cursor", "nextCursor": "same-cursor",
}, },
}) })
})) })
defer server.Close() defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token") client := newNodeBBClient(server.URL, "nodebb-token")
@@ -258,7 +259,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) { func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) {
requests := 0 requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
requests++ requests++
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
pages := []map[string]any{ pages := []map[string]any{
@@ -276,7 +277,7 @@ func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T
"nextCursor": "same-cursor", "nextCursor": "same-cursor",
}, },
}) })
})) })
defer server.Close() defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token") client := newNodeBBClient(server.URL, "nodebb-token")
@@ -480,7 +481,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -508,7 +509,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -731,7 +732,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
} }
createCalls := 0 createCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" { if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got) t.Fatalf("unexpected authorization header %q", got)
} }
@@ -769,7 +770,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
}, },
}, },
}) })
})) })
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -848,7 +849,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
} }
updateCalls := 0 updateCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -876,7 +877,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -951,7 +952,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
t.Fatalf("write source page: %v", err) t.Fatalf("write source page: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
@@ -983,7 +984,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
}, },
}, },
}) })
})) })
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1010,7 +1011,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
t.Fatalf("write source page: %v", err) t.Fatalf("write source page: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
@@ -1038,7 +1039,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
}, },
}, },
}) })
})) })
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1080,7 +1081,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
} }
renameCalls := 0 renameCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1109,7 +1110,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1166,7 +1167,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
} }
renameCalls := 0 renameCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1195,7 +1196,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1258,9 +1259,9 @@ func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String()) t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String())
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1320,7 +1321,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
} }
renameCalls := 0 renameCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1345,7 +1346,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1396,7 +1397,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
createCalls := 0 createCalls := 0
var updated string var updated string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1442,7 +1443,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1509,7 +1510,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
} }
var updated string var updated string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1533,7 +1534,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1589,7 +1590,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
lockAcquired := false lockAcquired := false
var updateToken string var updateToken string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1621,7 +1622,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1668,7 +1669,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
createCalls := 0 createCalls := 0
var updated string var updated string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "": 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") w.Header().Set("Content-Type", "application/json")
@@ -1719,7 +1720,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1771,7 +1772,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
createCalls := 0 createCalls := 0
var updated string var updated string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "": 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") w.Header().Set("Content-Type", "application/json")
@@ -1828,7 +1829,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1868,7 +1869,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
} }
var archived string var archived string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" { if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" {
respondWikiEditLock(t, w, r, 7, "archive-lock") respondWikiEditLock(t, w, r, 7, "archive-lock")
return return
@@ -1889,7 +1890,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
archived = req.Content archived = req.Content
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
})) })
defer server.Close() defer server.Close()
report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1945,9 +1946,9 @@ func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path) t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1984,9 +1985,9 @@ func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path) t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
})) })
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2017,15 +2018,12 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
purgeCalls := 0 var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/topics/7" { if !answerWikiPagePurge(t, w, r, &calls) {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
purgeCalls++ })
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
}))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2041,8 +2039,8 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
if result.Stale != 1 || result.Purged != 1 { if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one stale purge, got %#v", result) t.Fatalf("expected one stale purge, got %#v", result)
} }
if purgeCalls != 1 { if strings.Join(calls, ",") != "tombstone:7,hard-purge:7" {
t.Fatalf("expected one NodeBB topic purge call, got %d", purgeCalls) t.Fatalf("expected the wiki plugin page actions to purge topic 7, got %#v", calls)
} }
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok { if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed") t.Fatalf("expected purged stale manifest entry to be removed")
@@ -2075,18 +2073,17 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
} }
var calls []string var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = 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": case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create") calls = append(calls, "create")
if len(calls) != 2 || calls[0] != "purge" { if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" {
t.Fatalf("expected stale purge before create, got calls %#v", calls) t.Fatalf("expected stale purge before create, got calls %#v", calls)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -2102,7 +2099,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2120,7 +2117,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
if result.Created != 1 || result.Purged != 1 { if result.Created != 1 || result.Purged != 1 {
t.Fatalf("expected one create and one purge, got %#v", result) t.Fatalf("expected one create and one purge, got %#v", result)
} }
if strings.Join(calls, ",") != "purge,create" { if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" {
t.Fatalf("expected purge before create, got %#v", calls) t.Fatalf("expected purge before create, got %#v", calls)
} }
} }
@@ -2150,8 +2147,12 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
const resetCalls = "tombstone:7,hard-purge:7,tombstone:8,hard-purge:8"
var calls []string var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -2164,17 +2165,9 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
"hasMore": false, "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": case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create") calls = append(calls, "create")
if strings.Join(calls, ",") != "purge:7,purge:8,create" { if strings.Join(calls, ",") != resetCalls+",create" {
t.Fatalf("expected namespace reset purges before create, got %#v", calls) t.Fatalf("expected namespace reset purges before create, got %#v", calls)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -2190,7 +2183,7 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2205,10 +2198,21 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
if err != nil { if err != nil {
t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err) t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err)
} }
if result.Created != 1 || result.Purged != 2 || result.Stale != 0 { if result.Created != 1 || result.Purged != 2 {
t.Fatalf("expected one create and two namespace purges, got %#v", result) t.Fatalf("expected one create and two namespace purges, got %#v", result)
} }
if strings.Join(calls, ",") != "purge:7,purge:8,create" { // Callers word their destructive-policy warning around the stale count, so
// reset deletions have to show up there rather than on a separate path the
// preview never mentions.
if result.Stale != 2 || result.ResetPurged != 2 {
t.Fatalf("expected reset deletions counted as stale, got %#v", result)
}
// tid 7 is the manifest's own page; tid 8 is a topic the deployer never
// wrote, and re-seeding cannot bring it back.
if result.ResetUnrecognized != 1 {
t.Fatalf("expected one unrecognized reset deletion, got %#v", result)
}
if strings.Join(calls, ",") != resetCalls+",create" {
t.Fatalf("expected reset purges before create, got %#v", calls) t.Fatalf("expected reset purges before create, got %#v", calls)
} }
manifest := loadDeployManifest(manifestPath) manifest := loadDeployManifest(manifestPath)
@@ -2236,9 +2240,9 @@ func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing.
t.Fatalf("write source page: %v", err) t.Fatalf("write source page: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String()) t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String())
})) })
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2270,21 +2274,21 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
seen := map[string]int{} seen := map[string]int{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
seen[r.URL.Path]++ seen[call]++
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
switch r.URL.Path { // Topic 7 is already gone, so the plugin cannot find the page to
case "/api/v3/topics/7": // tombstone; topic 8 still exists and purges normally.
if call == "tombstone:7" {
http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound) http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound)
case "/api/v3/topics/8": return
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
default:
t.Fatalf("unexpected purge path %s", r.URL.Path)
} }
})) _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2300,8 +2304,11 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
if result.Stale != 2 || result.Purged != 2 { if result.Stale != 2 || result.Purged != 2 {
t.Fatalf("expected two stale purges, got %#v", result) t.Fatalf("expected two stale purges, got %#v", result)
} }
if seen["/api/v3/topics/7"] != 1 || seen["/api/v3/topics/8"] != 1 { if seen["tombstone:7"] != 1 || seen["hard-purge:7"] != 0 {
t.Fatalf("expected one purge call for each tracked topic, got %#v", seen) t.Fatalf("expected the already-missing topic to stop after tombstone, got %#v", seen)
}
if seen["tombstone:8"] != 1 || seen["hard-purge:8"] != 1 {
t.Fatalf("expected the surviving topic to be tombstoned then purged, got %#v", seen)
} }
manifest := loadDeployManifest(manifestPath) manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:retired"]; ok { if _, ok := manifest.Pages["skills:retired"]; ok {
@@ -2339,9 +2346,9 @@ func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path) t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path)
})) })
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2385,3 +2392,187 @@ func respondWikiEditLock(t *testing.T, w http.ResponseWriter, r *http.Request, e
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}})
} }
// newFakeNodeBB stands up a fake NodeBB that behaves like one running
// nodebb-plugin-westgate-wiki: native topic mutation on wiki categories is
// refused, because wiki revision history is plugin-owned. Every deploy test
// goes through here, so a deployer that reaches for the core topic API fails in
// CI the way it fails in production rather than passing against a fake that is
// more permissive than the real thing.
func newFakeNodeBB(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v3/topics/") && (r.Method == http.MethodDelete || r.Method == http.MethodPut) {
http.Error(
w,
`{"status":{"code":"bad-request","message":"Use the wiki page actions to delete, restore, or purge wiki pages."}}`,
http.StatusBadRequest,
)
return
}
handler(w, r)
}))
}
// wikiPagePurgeCall recognizes the plugin page actions a purge goes through and
// reports them as "<action>:<tid>" so tests can assert the order.
func wikiPagePurgeCall(t *testing.T, r *http.Request) (string, bool) {
t.Helper()
var action string
switch {
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/tombstone":
action = "tombstone"
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/hard-purge":
action = "hard-purge"
default:
return "", false
}
var req struct {
TID int `json:"tid"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode wiki %s request: %v", action, err)
}
if req.TID == 0 {
t.Fatalf("expected wiki %s request to carry a topic id", action)
}
return fmt.Sprintf("%s:%d", action, req.TID), true
}
// answerWikiPagePurge records and acknowledges a plugin purge action, which is
// what most fakes want to do with one.
func answerWikiPagePurge(t *testing.T, w http.ResponseWriter, r *http.Request, calls *[]string) bool {
t.Helper()
call, ok := wikiPagePurgeCall(t, r)
if !ok {
return false
}
*calls = append(*calls, call)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
return true
}
// resetPurgeFixture builds a source tree, a deploy manifest, and the reset
// options the namespace-reset tests share.
func resetPurgeFixture(t *testing.T, namespaces map[string]int, tidsByCID map[int][]int) (DeployWikiOptions, func(w http.ResponseWriter, r *http.Request) bool) {
t.Helper()
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
listPages := func(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
var cid int
if _, err := fmt.Sscanf(r.URL.Path, "/api/v3/plugins/westgate-wiki/namespace/%d/pages", &cid); err != nil {
return false
}
tids, ok := tidsByCID[cid]
if !ok {
t.Fatalf("unexpected namespace listing for category %d", cid)
}
pages := make([]map[string]any, 0, len(tids))
for _, tid := range tids {
pages = append(pages, map[string]any{"tid": tid, "title": fmt.Sprintf("Page %d", tid)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": pages, "hasMore": false}})
return true
}
return DeployWikiOptions{
SourceDir: sourceDir,
ManifestPath: manifestPath,
Token: "nodebb-token",
CategoryIDs: namespaces,
AllowCreates: true,
ResetManagedNamespaces: true,
}, listPages
}
func TestDeployWikiResetSkipsTopicsNodeBBRefusesToDelete(t *testing.T) {
opts, listPages := resetPurgeFixture(t, map[string]int{"skills": 3}, map[int][]int{3: {5, 6}})
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if listPages(w, r) {
return
}
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
calls = append(calls, call)
// Topic 5 stands in for the wiki home page, which the plugin excludes
// from tombstone, restore, and purge alike.
if call == "tombstone:5" {
http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
opts.Endpoint = server.URL
result, err := DeployWikiWithOptions(&project.Project{}, opts, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions reset with an undeletable topic failed: %v", err)
}
if result.ResetSkipped != 1 || result.ResetPurged != 1 {
t.Fatalf("expected one skipped and one completed reset deletion, got %#v", result)
}
if result.Purged != 1 || result.Stale != 1 {
t.Fatalf("expected the skipped topic to be dropped from the counts, got %#v", result)
}
if result.ResetUnrecognized != 1 {
t.Fatalf("expected only the purged unrecognized topic to be counted, got %#v", result)
}
if strings.Join(calls, ",") != "tombstone:5,tombstone:6,hard-purge:6" {
t.Fatalf("expected the reset to continue past the refused topic, got %#v", calls)
}
}
// NodeBB answers 403 both for the wiki home page and for a token without purge
// privileges. A category where nothing could be deleted at all is the second
// case, and reporting it as a completed reset would leave the manifest claiming
// a fresh start over pages that are all still there.
func TestDeployWikiResetFailsWhenACategoryRefusesEveryDeletion(t *testing.T) {
opts, listPages := resetPurgeFixture(t,
map[string]int{"skills": 3, "feats": 4},
map[int][]int{3: {5, 6}, 4: {7, 8}},
)
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if listPages(w, r) {
return
}
call, ok := wikiPagePurgeCall(t, r)
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
// Category 4 holds topics 7 and 8, and the token can purge neither.
if strings.HasSuffix(call, ":7") || strings.HasSuffix(call, ":8") {
http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
})
defer server.Close()
opts.Endpoint = server.URL
_, err := DeployWikiWithOptions(&project.Project{}, opts, nil)
if err == nil || !strings.Contains(err.Error(), "wiki purge privileges") {
t.Fatalf("expected a category that deleted nothing to fail loudly, got %v", err)
}
if !strings.Contains(err.Error(), "4") {
t.Fatalf("expected the failure to name the refusing category, got %v", err)
}
}