Discord Mock Harness
The Discord mock harness is a local, self-contained replacement for discord.com. It lets engineers drive the bot end-to-end without ever touching the real Discord API.
It does two things:
- Inbound — fabricates signed
POST /discord/interactionspayloads (slash commands, buttons, modals, selects) and sends them to the bot. - Outbound — stands in for
discord.com/api/v10, capturing every HTTP call the bot makes back to "Discord" (interaction callbacks, follow-up webhooks, channel messages, DM create, etc.).
Everything lives under devtools/discord-mock/, e2e/helpers/, and
scripts/. Nothing in src/** depends on it; it only pokes the bot over
HTTP.
Quick start
Boot the bot with the mock's base URL as Discord, and a known Ed25519 public key for signature verification:
# In one shell — start the mock and grab its public key + base URL from
# the log output
pnpm discord:mock:repl
# In another shell — start the bot with those values
export DISCORD_INTERACTIONS_PUBLIC_KEY=<public-key-from-mock>
export DISCORD_API_BASE_URL=<base-url-from-mock>
pnpm dev
Then at the REPL prompt:
discord - mock > (await send('help', []))
You'll get back the captured { interactionId, status, body } response the
bot returned, and mock.getCapturedOutbound() will list any REST calls the
bot made back to "Discord".
Running a scenario
A scenario is a small JSON document describing a sequence of user actions and the assertions that should hold after each one. Example:
pnpm discord:mock run devtools/discord-mock/scenarios/hello-world.json
Two scenarios ship in-tree:
hello-world.json— single/helpslash command.order-flow.json— placeholder covering the order-placement click path (slash → button → modal → button). Expects a seeded product; currently hits the wiring without strict outbound assertions. Fill in once the order fixtures stabilize.
Scenario format
{
"name": "hello-world",
"steps": [
{
"kind": "slash",
"name": "help",
"options": [],
"as": { "userId": "u1", "guildId": "g1", "channelId": "c1" },
"expectResponse": { "status": 200, "type": 4 },
"expectOutbound": [
{
"method": "POST",
"pathIncludes": "/channels/",
"bodyIncludes": "help"
}
]
}
]
}
Supported kind values: slash, button, select, modal. See
devtools/discord-mock/scenario-runner.ts for the full type definitions.
Vitest helpers
For tests, import from e2e/helpers/discord-mock.ts:
import {
startMockServer,
sendSlashCommand,
clickButton,
submitModal,
selectOption,
expectOutbound
} from './e2e/helpers/discord-mock.js'
const mock = await startMockServer({
botInteractionsUrl: 'http://127.0.0.1:3000/discord/interactions'
})
const resp = await sendSlashCommand(mock, 'help', [], { userId: 'u1' })
expect(resp.status).toBe(200)
const outbound = expectOutbound(
mock,
o => o.method === 'POST' && o.path.includes('/channels/')
)
expect(outbound).toHaveLength(1)
The MockController exposes publicKey, which is the value your bot must
be configured with via DISCORD_INTERACTIONS_PUBLIC_KEY so signature
verification succeeds. Alternatively, pre-generate the keypair in the test
and pass both publicKey and secretKey to startMockServer for full
control.
Seeing the outbound surface
mock.getCapturedOutbound() returns an array of CapturedOutbound:
type CapturedOutbound = {
seq: number
timestamp: number
method: string
path: string
params: Record<string, string>
body: unknown
headers: Record<string, string>
}
Every inbound HTTP call to the mock is captured — even paths that aren't
explicitly modeled (a catch-all responds with { mock: true, note: ... }
so you can spot gaps).
Routes the mock handles
The mock accepts routes both bare and under /api/v10/ so it works
regardless of whether the client prefixes the REST base path:
POST /interactions/:id/:token/callbackPOST /webhooks/:id/:tokenPOST|PATCH|DELETE /webhooks/:id/:token/messages/@originalPOST /channels/:id/messagesPATCH /channels/:id/messages/:messageIdDELETE /channels/:id/messages/:messageIdPOST /users/@me/channels
Other routes are captured by a catch-all and will return
{ mock: true, note: 'unhandled-route' }.
Configuring the bot
Two env vars make the bot talk to the mock:
DISCORD_INTERACTIONS_PUBLIC_KEY— hex public key the mock signs with. Must match. The mock prints its key on startup.DISCORD_API_BASE_URL— base URL the bot uses for outbound REST. Default:https://discord.com/api/v10. Point it at the mock'sbaseUrl(e.g.http://127.0.0.1:54321) and/notify/ channel sends will route through the mock.
Only discord-notify.service.ts currently honors DISCORD_API_BASE_URL.
If new call sites are added (e.g. follow-up webhooks), thread the same env
var through them.
Related: TaxJar mock harness
A companion mock for api.taxjar.com ships in devtools/taxjar-mock/.
It stubs POST /v2/taxes and GET /v2/rates/:zip with deterministic
hard-coded US state rates and nexus awareness. Start it with:
pnpm taxjar-mock
# then run the app with:
# TAXJAR_API_BASE=http://localhost:4004 TAXJAR_API_KEY=test-key pnpm dev
See docs/test-harness/taxjar-mock.md
for the full reference.
Known limitations
- Scenarios are JSON, not YAML. The harness has no YAML dependency by
design. Adding YAML support is a drop-in (see
scenario-runner.ts). - Signature spoofing not supported. All interactions the mock sends are validly signed. There's no built-in way to force an invalid signature — use a separate supertest for that edge case.
- No gateway / realtime events. Ledgerline's bot is webhook-shaped, so gateway WS events are out of scope. If we ever add a gateway bot, this harness will need a WS server alongside the REST one.
- State is in-memory. Restarting the mock clears captured calls, DM channel ids, and channel message ids.
- Order flow scenario is a placeholder. The steps hit the real router paths but the scenario doesn't yet seed a product or a customer; flesh out the fixtures before relying on it as a regression signal.
- No rate-limit simulation. Real Discord rate-limits are not modeled. If you need to test the bot's handling of 429s, force it by swapping in a custom handler on the mock before a step runs.
DISCORD_API_BASE_URLthread. Only the notify service currently reads it. Callers that hit hardcodeddiscord.comURLs (none today) must be updated if they're added.