Document the test plan for the bot/ workspace — the last of the three website npm workspaces without a suite (server + client landed in website PR #86). Records the shared node --test conventions (no jest/vitest/jsdom; DB pool at a dead port; Discord objects hand-faked) and maps the meaningful bot behavior to lock: the normalize/duration/spam pure logic, the invite/site-api/internal-key single-collaborator units, the messageFilter pipeline (bypass-first decision order + fixed-duration mute + best-effort recording), and the models with real shaping logic. Includes the CI + SonarQube coverage wiring to mirror PR #86 and a suggested phasing. Co-Authored-By: Claude <noreply@anthropic.com>
9.3 KiB
Runic Gateway Website — Test Plan (Discord bot)
Companion to website-README.md (overview) and BACKEND_DESIGN.md (server API/schema/security contract). Establishes the test plan for the
bot/workspace (the Discord bot), the last of the threewebsitenpm 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):
"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 transitivelyrequires../db, so the pool is built against a dead port and a stray query fails fast instead of hanging. Models are exercised by monkeypatchingdb.query(or the model method the unit under test calls) with an in-memory fake; restore it inafterEach. - No Discord. The
discord.jsClient,Message,GuildMember, andInteractionobjects 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).
detectSpammust evaluateisRateLimitedfirst 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/recordSpamHitthrowing 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.jsinternals / 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-cronscheduling 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 abot-testsjob (or aRun bot testsstep) that runsnpm test --prefix bot, gating PRs intomain. -
.gitea/workflows/sonarqube.yml— generate a bot LCOV from the repo root soSF:paths resolve tobot/src/...:node --test --experimental-test-coverage \ --test-reporter=lcov --test-reporter-destination=bot/coverage/lcov.info \ bot/test/*.test.js -
sonar-project.properties— addbot/testtosonar.tests, the glob tosonar.test.inclusions,bot/coverage/lcov.infotosonar.javascript.lcov.reportPaths, andbot/coverage/**tosonar.exclusions. (bot/srcis already insonar.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
- Harness + pure logic —
testscript, thennormalize,duration,spamFilter. Fast, zero mocks, immediate value. - Single-collaborator units —
inviteFilter,siteApiClient,requireInternalKey. - Pipeline —
messageFilterdecision order + actions (the payoff test). - Models with logic, then the CI/Sonar wiring so it all counts.