Skip to content
GitHub

Invoke and dispatch

MeoCord 4.2 · Testing · page 33 of 41 · since 4.1.0

Run one handler you name with invoke, or send an input through the bot's routing with dispatch.

You'll learn

  • Run a handler through the pipeline with invoke
  • Check which handler an interaction, message or reaction reaches with dispatch
  • Read what the user was sent with getResponse

A testing module runs handlers in two ways. invoke runs the handler you name, through everything the bot runs around it. dispatch hands the module an input, an interaction, a message or a reaction, and lets it find the handlers the way the bot does.

Both run the whole pipeline, and both leave a record of what the user was sent.

When to use it

Use invoke for most handler tests: you know which handler you're testing, and you want its guards, validation and answer checked. The method name and its arguments are type-checked against the handler.

Use dispatch when routing is the question: which handler a customId, a command or a message reaches, with which params, and what the user sees when nothing matches. It also answers errors with the built-in fallback, as the bot does, so it shows the member's view of a failure.

To check a route without running anything, resolveRoute is lighter. To test a service on its own, build it with new.

Example

testing/dispatch.spec.ts
it('routes a click to the handler its customId matches, as the bot does', async () => {
  const module = MeoCordTestingModule.create({ controllers: [CardButtonController] }).compile()
  const click = createMockInteraction(ButtonInteraction, { customId: 'card/111111111111111111/like' })

  const { ran, handlers } = await module.dispatch(click)

  expect(ran).toBe(true)
  expect(handlers).toEqual([{ controller: CardButtonController, method: 'like', ran: true }])
  expect(getResponse(click).calls[0]).toMatchObject({ method: 'update', payload: { content: 'Liked.' } })
})

The click's customId, card/111111111111111111/like, reaches CardButtonController.like through the module's routing table. handlers lists every handler it ran, and getResponse shows the update the handler sent.

How it works

Both run the handler through the pipeline: @Defer, guards, interceptors around validation, pipes, cooldowns and the handler, all inside its exception filters. Guards and filters resolve from the module, so overrideGuard stubs work, and so does an injected ExecutionContext.

They differ in where the handler comes from, and in what happens to an error:

invoke(Controller, 'method', ...args)dispatch(input)
Handlerthe one you namewhichever the bot's routing reaches
Paramsbuilt from the input, as dispatch builds themthe same
Resolves to{ ran, error? }{ ran, handlers, error? }
An error no filter handlesrejects, and the fallback doesn't runthe fallback answers, then it rejects
The user's own outcomea refusal rejectsanswered, and resolves with error

The user's own outcome is a usage reply, an unknown command, or the refusal of a guard, a cooldown or the cooldown store, a validation or a UserError. Both wait for the module's observers before they resolve.

Running a handler with invoke

Pass the arguments dispatch would: the interaction, message or reaction, then the handler's params. Passed alone, an interaction gets its params built as dispatch builds them: a command's or an autocomplete's options, or a component's customId params with a modal's fields or a select menu's choices. A message passed alone gets what its @MessageHandler pattern captures after the app's prefix, the handler's own prefix, or a mention, typed as dispatch types it; a word that isn't of its type, or a missing param, goes through the handler's filters as a MessageUsageError.

invoke resolves to { ran }. ran is false when a guard stopped the call or an interceptor skipped the handler, and error is set when a filter handled one. A guard that returns false stops the call with no answer:

controllers/slash/moderation.slash.controller.spec.ts
it('runs in an allowed channel, and a guard that returns false stops it silently', async () => {
  const allowed = createMockInteraction(ChatInputCommandInteraction, { channelId: '111111111111111111' })
  const elsewhere = createMockInteraction(ChatInputCommandInteraction, { channelId: '222222222222222222' })

  await expect(module.invoke(ModerationSlashController, 'trade', allowed)).resolves.toEqual({ ran: true })
  await expect(module.invoke(ModerationSlashController, 'trade', elsewhere)).resolves.toEqual({ ran: false })
  expect(getResponse(elsewhere).sent).toBe(false)
})

A guard that throws GuardDeniedError, a handler that throws UserError, and a cooldown's CooldownError reject invoke with that error, where dispatch answers the user and resolves with it as error. Assert them with await expect(module.invoke(...)).rejects.toThrow(GuardDeniedError).

The interaction must be one dispatch routes to the handler, ranking every handler of the module as the bot does. A customId another handler's pattern takes first, or a command another handler takes by its subcommand path, rejects, naming the handler that runs, and so does one no pattern takes, or a command the handler doesn't handle, so a typo in a test doesn't pass silently. A handler declared under two patterns gets the params of the one dispatch picks. A mock built without a customId or command name isn't checked.

Sending input with dispatch

dispatch routes over the module's controllers, with its app's message options, exactly as the bot does. A message reaches its command after the app's prefix:

testing/dispatch.spec.ts
it('reads a message after the app’s prefix, and runs the command it names', async () => {
  const module = MeoCordTestingModule.create({ app: App, controllers: [DiceMessageController] }).compile()
  const message = createMockMessage({ content: '!roll 20 for initiative' })

  const { handlers } = await module.dispatch(message)

  expect(handlers.map(({ method }) => method)).toEqual(['roll'])
  expect(message.reply).toHaveBeenCalledWith(expect.stringMatching(/^\d+ \(for initiative\)$/))
})

A reaction takes the user who reacted and, optionally, the action; it's an add unless you say otherwise:

testing/dispatch.spec.ts
it('delivers a reaction with its user and action', async () => {
  const module = MeoCordTestingModule.create({ controllers: [StarReactionController] }).compile()
  const message = createMockMessage()
  const reaction = createMockInteraction(MessageReaction, { message, emoji: { name: '⭐' } as never })
  const user = createMockInteraction(User, { username: 'mika', bot: false })

  await module.dispatch(reaction, { user, action: ReactionHandlerAction.ADD })

  expect(message.reply).toHaveBeenCalledWith('mika starred this.')
})

handlers lists each handler reached, in the order it ran, with its own ran and error. A message can reach a patterned handler and every @MessageHandler() listener at once, and a reaction several handlers.

When nothing matches

An input no route takes is answered as the bot answers it: "Command not found!" for an interaction, and the call resolves with that error; an empty list for an autocomplete, and the call resolves with no error. Either way, handlers is empty:

testing/dispatch.spec.ts
it('answers a click no route takes as the bot does, and reports why', async () => {
  const module = MeoCordTestingModule.create({ controllers: [CardButtonController] }).compile()
  const click = createMockInteraction(ButtonInteraction, { customId: 'card/111111111111111111/share' })

  const { ran, handlers, error } = await module.dispatch(click)

  expect([ran, handlers]).toEqual([false, []])
  expect(error).toBeInstanceOf(CommandNotFoundError)
  expect(getResponse(click).sent).toBe(true)
})

Whatever the bot skips reaches nothing: a message from a bot, or a bot's reaction to a handler without bots: true, resolves to { ran: false, handlers: [] }.

A button, select menu or modal submission no route takes may belong to a collector. When the interaction's client has another interactionCreate listener, dispatch waits the same 1.5 seconds the bot does before answering "not found", and says nothing if the listener answered first. A mock's own client has no listeners, so the answer is immediate. See Components.

Reading what was sent

getResponse(interaction) reports every answer a mock interaction got, whether the handler made it through respond() or with discord.js directly, such as interaction.reply() or interaction.followUp():

  • state: where the answer stands, 'unanswered', 'deferred' or 'replied';
  • sent: whether a reply, an update, an edit or a follow-up went out, counting only the calls Discord accepted;
  • calls: each answer, once, in the order made, with what it sent, and the error of one Discord refused.

A call's payload is typed by its method: once a test checks call.method === 'reply', the payload is what reply() takes: a string, a MessagePayload or its options. Narrow it to the options before reading content or embeds: after typeof call.payload === 'object' && !(call.payload instanceof MessagePayload), with MessagePayload from discord.js, call.payload.content compiles with no cast.

A call a mock rejects, such as a reply refused with 10062 once the three seconds have passed, stays in calls with its error, and doesn't count as sent: the member saw nothing.

For messages and reactions, read the mock's own methods, such as message.reply.

Events and handler setup

To send a gateway event to the module's @On and @Once handlers, use module.emit(event, ...args). It resolves to { ran }, how many handlers ran, and once every handler has settled, rejects if any threw: with that error, or an AggregateError holding each in its errors.

To check what a handler is set up with, without running it, use inspectHandler. It lists the guards, interceptors, filters and cooldowns dispatch applies, in order, and reads the handler's metadata as ExecutionContext does. Testing recipes has an example.

Gotchas

  • invoke refuses an input dispatch gives to another handler. Beside roll {sides}, a handler for roll 20 wins the message !roll 20, and beside card/{id}, a handler for card/summary wins that click. Invoking the first of each pair with it rejects, naming the handler that runs. Invoke the handler dispatch picks, or dispatch the input.
  • An unhandled error rejects both. With dispatch, the member still got the fallback's answer first. Assert the rejection with await expect(...).rejects.toThrow(...), and read getResponse after it.
  • A collector's click needs the bot's client. A click built with a fresh mock client isn't the client the call came to. Build it with { client: interaction.client }, as Mocks shows.

Build it

Each part of the feedback bot has a spec. Add one that runs the whole flow, from the form to the author's DM, through the same pipeline the bot uses. Ada submits in Indonesian, Grace approves, and the test checks what each of them sees:

tutorial/feedback.flow.spec.ts
describe('the feedback bot, from form to verdict', () => {
  const STAFF = '900'
  const module = MeoCordTestingModule.create({
    controllers: [FeedbackController, ReviewController],
    providers: [{ provide: FeedbackSettings, useValue: { reviewChannelId: '500', staffRoleId: STAFF } }],
    // The app's presenter styles what respond() shows, as on the running bot
    app: App,
  }).compile()

  // One client and one review channel, shared by every interaction, as on a running bot
  const channel = createMockChannel(TextChannel)
  channel.isSendable.mockReturnValue(true)
  const client = createMockClient()
  client.channels.fetch.mockResolvedValue(channel as never)
  const inServer = { guildId: '1', guild: createMockGuild(), client: client as never }

  it('carries feedback from the form to the staff, and the verdict back to its author', async () => {
    // Ada writes in Indonesian
    const submit = createMockInteraction(ModalSubmitInteraction, {
      ...inServer,
      customId: 'feedback/submit',
      locale: Locale.Indonesian,
      user: createMockInteraction(User, { id: '111', username: 'ada' }),
      fields: createModalFields({ about: 'Music bot', details: 'It skips songs.' }),
    })
    await module.invoke(FeedbackController, 'submit', submit)
    expect(getResponse(submit).calls[0].payload).toMatchObject({
      content: 'Terima kasih! Staf akan segera membacanya.',
    })

    // The review post, as the staff channel received it; its first button routes to approve
    const { embeds, components } = channel.send.mock.calls[0][0] as MockMessageOverrides
    const post = createMockMessage({ embeds, components })
    const customId = JSON.parse(JSON.stringify(components))[0].components[0].custom_id as string
    expect(resolveRoute(App, { type: CommandType.BUTTON, customId })).toMatchObject({
      handler: ReviewController.prototype.approve,
      params: { id: '1' },
    })

    // Grace, on the staff, approves it; they read Discord in Indonesian too
    const staffMember = createMockInteraction(GuildMember, {
      roles: { cache: new Collection([[STAFF, { id: STAFF }]]) } as never,
    })
    const approve = createMockInteraction(ButtonInteraction, {
      ...inServer,
      customId,
      locale: Locale.Indonesian,
      member: staffMember,
      message: post,
      user: createMockInteraction(User, { id: '222', username: 'grace' }),
    })
    await module.invoke(ReviewController, 'approve', approve)

    // While it worked, the post showed the presenter's loading view in their language; then the
    // verdict, in the server's
    const { calls } = getResponse(approve)
    expect(JSON.stringify(calls[1].payload)).toContain('Sedang diproses…')
    expect(JSON.parse(JSON.stringify(calls.at(-1)?.payload)).embeds[0]).toMatchObject({
      title: 'Feedback #1 from ada',
      footer: { text: 'Approved by grace.' },
    })
    expect(client.users.send).toHaveBeenCalledWith('111', { content: 'Masukanmu “Music bot” disetujui. Terima kasih!' })
  })
})

The review post Grace clicks is built from what the bot actually sent. resolveRoute(App, ...) confirms the button's customId reaches approve, and app: App applies the app's presenter, so the loading view is the real one, in Grace's language. Run npm test to see the flow pass.

Next steps