# Runic Gateway Website — Test Plan (Discord bot) > Companion to [website-README.md](website-README.md) (overview) and > [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (server API/schema/security contract). > Establishes the test plan for the **`bot/` workspace** (the Discord bot), the > last of the three `website` npm workspaces without a suite. The server and > client suites already exist (website PR #86); this doc closes the bot gap and > records the shared conventions so all three stay consistent. ## 1. Goal & philosophy Cover **meaningful bot behavior** — the moderation/filter decisions a future change could silently break — not a coverage number. The bot's value is in *what it decides to delete, warn, mute, or let through*; a good test reads "given this message + config, what action does the bot take?", never "did this function call that function?". The whole `website` repo tests on **Node's built-in runner (`node --test`)** with **zero external test dependencies** — no jest, no vitest, no jsdom. Unit tests run without a database or a live Discord gateway: the DB pool is pointed at a dead port and every collaborator (`db.query`, a model, a Discord `message`/`client`) is replaced with an in-memory fake. This mirrors the server suite exactly (the bot is CommonJS, like the server — `require`/`module.exports` — so the same patterns apply verbatim). ## 2. Current state | Workspace | Runner | Status | |---|---|---| | `server/` | `node --test` | **Exists** — models, controllers, auth/session, shard ingest. | | `client/` | `node --test` (pure-logic ESM) | **Exists** (PR #86) — `lib/`, `api/`, `data/`. | | `bot/` | — | **This plan** — no runner or tests yet. | The 46% SonarQube aggregate is dragged down by the bot (and the client's un-unit-testable React components) reading as 0% covered. Standing up the bot suite lifts the real number and, more importantly, locks the filter/moderation rules. ## 3. Harness Add a test script to `bot/package.json` (mirrors server/client): ```json "scripts": { "test": "node --test" } ``` Conventions, identical to `server/test/`: - **No DB.** Set `process.env.DB_HOST = '127.0.0.1'` / `DB_PORT = '59999'` at the top of any test whose module transitively `require`s `../db`, so the pool is built against a dead port and a stray query fails fast instead of hanging. Models are exercised by monkeypatching `db.query` (or the model method the unit under test calls) with an in-memory fake; restore it in `afterEach`. - **No Discord.** The `discord.js` `Client`, `Message`, `GuildMember`, and `Interaction` objects are hand-rolled fakes carrying only the fields the unit reads (e.g. `message.mentions.users.size`, `message.member.roles.cache`, `message.client.fetchInvite`). Never construct a real client or open a gateway connection. - **Tests live in `bot/test/*.test.js`.** One file per module under test. ## 4. Units to cover Ordered high-value first. Each row names the module, the behavior worth locking, and the seam a test drives it through. ### 4.1 Pure logic (no mocks beyond inputs) | Module | Behaviors to lock | Notes | |---|---|---| | `filter/normalize.js` | leetspeak folding (`b4d`→`bad`, `@ss`→`ass`), 3+-repeat collapse (`sooooo`→`so`), case-fold; `matches()` is word-boundary anchored (so `bad` does not fire inside `badminton`); `findMatch()` returns the first `{word, severity}` row or `null`; the *documented gaps* stay gaps (spaced-out `b a d` is **not** caught). | The core obfuscation-resistance contract. Pin the gaps too, so a future tightening is a deliberate, test-visible change. | | `utils/duration.js` | `"30s"/"10m"/"2h"/"1d"` → correct ms; whitespace tolerated; garbage/empty/unknown-unit → `null`; `MAX_TIMEOUT_MS` is 28 days (the Discord cap callers clamp to). | Feeds `/mute` and temp-role expiry — a wrong parse mutes for the wrong span. | | `filter/spamFilter.js` | `isRateLimited` trips on the 6th message inside the 5 s window and is per-`guild:user`; it has a **side effect** (records the timestamp) so it must be called once; `isMassMention` counts users **+** roles against the threshold; `isMassEmoji` counts custom `<:name:id>` and unicode pictographs. | Drive time with a stubbed `Date.now` (or accept the real clock and use tight windows). The module-load `setInterval(...).unref()` sweep must not keep the runner alive — `.unref()` already handles this; assert no leak by letting the suite exit. | ### 4.2 Logic with a single mocked collaborator | Module | Behaviors to lock | Seam / mock | |---|---|---| | `filter/inviteFilter.js` | returns the **code** (not a bool) of the first *foreign* invite; an invite resolving to the current guild is allowed; an invite that **fails to resolve** (expired/invalid) is treated as foreign (fail-closed); no invite in the message → `null`. | Fake `message.client.fetchInvite(code)` — resolve with `{ guild: { id } }` for local/foreign, or throw for unresolvable. Set `message.guildId` + `message.content`. | | `site/siteApiClient.js` | never throws on a network/timeout/HTTP failure — always returns the `{ ok, ... }` shape so a command never breaks when the site is down: `{ ok: true, data }` on success, `{ ok: false, error }` on failure, and the distinct `{ ok: false, maintenance: true, message }` for the site's 503 maintenance response. (Public read client — **no** shared secret; the keyed channel is `botInternalClient.js`.) | Stub `global.fetch` (resolve various statuses/bodies, reject/timeout via `AbortController`). Same non-throwing contract the server's `uoLinkClient` follows. | | `internal/requireInternalKey.js` | `401` on a missing/wrong key; `next()` on the configured key; **fail-closed** when `BOT_INTERNAL_KEY` is empty (never matches); timing-safe compare (equal-length + `crypto.timingSafeEqual`, no early-exit length leak). | Call the middleware with a fake `req` (`req.get('X-Internal-Key')`) + `res`/`next` spies; mirrors `server/test/requireInternalKey.test.js`. | ### 4.3 The filter pipeline (orchestration) `discord/messageFilter.js` is the highest-value integration seam — it composes all of §4.1–4.2. Lock the **decision order and the actions**, with every collaborator faked: - **Bypass wins first:** an allow-listed channel, or a member holding an allow-listed role, short-circuits the whole pipeline (`isBypassed`) — no delete, no hit recorded. - **Order:** invite link → banned word → spam (rate-limit → mass-mention → mass-emoji). `detectSpam` must evaluate `isRateLimited` **first and exactly once** (it has the timestamp side effect). - **Actions:** invite/spam always delete + warn (no severity tiers); a word-filter hit applies the action for its severity; filter-triggered mutes use the fixed `FILTER_MUTE_SECONDS` (600 s). - **Best-effort recording never breaks moderation:** `recordFilterHit` / `recordSpamHit` throwing must be swallowed — the delete/warn still happens. Mocks: `filterCache` (in-memory `allowChannels`/`allowRoles` Sets + words), a fake `message` (content, author, member roles, `delete()`, `guildId`), and spies on `warnings`/`filterHits`/`spamHits`/`modLog` to assert what was called. ### 4.4 Models (`bot/model/*.js`) Thin `db.query` wrappers (e.g. `warnings.js`, `filterWords.js`, `filterHits.js`, `spamHits.js`, `memberEvents.js`, `guildConfig.js`, `scheduledMessages.js`, `tempRoles.js`). Cover only those with **shaping/branching logic** (a query that maps rows, applies an "active/not-expired" predicate, or upserts) by asserting the **SQL params** passed to a fake `db.query` and the shape returned. Skip pure one-line inserts with no logic — testing those only proves the mock. ## 5. Explicitly out of scope - **`discord.js` internals / the live gateway** — not ours to test; never open a real connection. - **Thin command wrappers** (`discord/commands/*.command.js`) that only validate args and call a Discord API + a model — cover their *logic* (arg parsing, duration clamping) if any, but not the Discord round-trip. - **`node-cron` scheduling itself** — test the job's callback logic, not that cron fires. ## 6. CI & coverage wiring Mirror what PR #86 did for the client: - **`.gitea/workflows/pr-checks.yml`** — add a `bot-tests` job (or a `Run bot tests` step) that runs `npm test --prefix bot`, gating PRs into `main`. - **`.gitea/workflows/sonarqube.yml`** — generate a bot LCOV from the repo root so `SF:` paths resolve to `bot/src/...`: ``` node --test --experimental-test-coverage \ --test-reporter=lcov --test-reporter-destination=bot/coverage/lcov.info \ bot/test/*.test.js ``` - **`sonar-project.properties`** — add `bot/test` to `sonar.tests`, the glob to `sonar.test.inclusions`, `bot/coverage/lcov.info` to `sonar.javascript.lcov.reportPaths`, and `bot/coverage/**` to `sonar.exclusions`. (`bot/src` is already in `sonar.sources`.) Bot units need no `npm ci` to run when they import only relative files + Node built-ins; a test that stubs `global.fetch` or fakes `db` needs no `discord.js` either. Keep the pool pointed at a dead port so the run is hermetic. ## 7. Suggested phasing 1. **Harness + pure logic** — `test` script, then `normalize`, `duration`, `spamFilter`. Fast, zero mocks, immediate value. 2. **Single-collaborator units** — `inviteFilter`, `siteApiClient`, `requireInternalKey`. 3. **Pipeline** — `messageFilter` decision order + actions (the payoff test). 4. **Models with logic**, then the **CI/Sonar wiring** so it all counts.