Skip to main content

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:

  1. Inbound — fabricates signed POST /discord/interactions payloads (slash commands, buttons, modals, selects) and sends them to the bot.
  2. 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 /help slash 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/callback
  • POST /webhooks/:id/:token
  • POST|PATCH|DELETE /webhooks/:id/:token/messages/@original
  • POST /channels/:id/messages
  • PATCH /channels/:id/messages/:messageId
  • DELETE /channels/:id/messages/:messageId
  • POST /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's baseUrl (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.

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_URL thread. Only the notify service currently reads it. Callers that hit hardcoded discord.com URLs (none today) must be updated if they're added.