meocord 2.1.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHOR.md +2 -3
- package/README.md +289 -41
- package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
- package/dist/cjs/core/index.cjs +288 -85
- package/dist/cjs/decorator/index.cjs +5 -1
- package/dist/cjs/enum/index.cjs +17 -4
- package/dist/cjs/testing/index.cjs +88 -4
- package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
- package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
- package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
- package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
- package/dist/esm/bin/generator.js +4 -9
- package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
- package/dist/esm/core/meocord.app.js +289 -85
- package/dist/esm/decorator/controller.decorator.js +157 -24
- package/dist/esm/decorator/index.js +1 -1
- package/dist/esm/enum/controller.enum.js +23 -4
- package/dist/esm/testing/mock-interaction.js +89 -5
- package/dist/esm/util/interaction.util.js +174 -0
- package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
- package/dist/types/core/index.d.ts +94 -0
- package/dist/types/decorator/index.d.ts +74 -41
- package/dist/types/enum/index.d.ts +1 -1
- package/dist/types/interface/index.d.ts +87 -3
- package/dist/types/testing/index.d.ts +3 -21
- package/package.json +2 -2
- package/dist/cjs/_shared/controller.decorator-DX5lFlPZ.cjs +0 -216
- package/dist/types/controller.enum-QA-IuReF.d.ts +0 -18
package/AUTHOR.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Authors
|
|
2
2
|
|
|
3
3
|
- **Ukasyah Rahmatullah Zada**
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
- Primary creator and maintainer of the MeoCord Framework.
|
|
5
|
+
- Contact: [ukasyahrz@outlook.com](mailto:ukasyahrz@outlook.com)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -10,4 +10,3 @@
|
|
|
10
10
|
|
|
11
11
|
This project is currently maintained by the original author.
|
|
12
12
|
Contributions, issues, and feature suggestions are always welcome!
|
|
13
|
-
|
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
- [meocord.config.ts](#meocordconfigts)
|
|
17
17
|
- [ESLint](#eslint)
|
|
18
18
|
- [CLI Reference](#cli-reference)
|
|
19
|
+
- [Command Types](#command-types)
|
|
20
|
+
- [Command Parameters](#command-parameters)
|
|
21
|
+
- [Subcommands](#subcommands)
|
|
22
|
+
- [Autocomplete](#autocomplete)
|
|
19
23
|
- [Guards](#guards)
|
|
20
24
|
- [Custom Decorators](#custom-decorators)
|
|
21
25
|
- [Testing](#testing)
|
|
@@ -27,7 +31,7 @@
|
|
|
27
31
|
|
|
28
32
|
## Features
|
|
29
33
|
|
|
30
|
-
- **Decorator-based controllers** — Handle slash commands, buttons, modals, select menus, context menus, messages, and reactions with `@Command`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
|
|
34
|
+
- **Decorator-based controllers** — Handle every Discord interaction type — slash commands and their subcommands, autocomplete, buttons, modals, all five select menus, context menus, activity entry points, messages, and reactions — with `@Command`, `@Autocomplete`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
|
|
31
35
|
- **Dependency injection** — Built on Inversify. Services are wired into controllers automatically; no manual instantiation or service locators.
|
|
32
36
|
- **Guard system** — Pre-execution hooks for auth, rate limiting, metrics, and anything else. Apply per-method or per-class with `@UseGuard`. Guards receive the full interaction context.
|
|
33
37
|
- **Full CLI** — `meocord create`, `build`, `start`, `generate`. Scaffolds controllers, services, and guards; handles Webpack builds for both development and production.
|
|
@@ -219,13 +223,13 @@ export default [
|
|
|
219
223
|
npx meocord --help
|
|
220
224
|
```
|
|
221
225
|
|
|
222
|
-
| Command | Alias | Description
|
|
223
|
-
|
|
224
|
-
| `create` | — | Scaffold a new MeoCord application
|
|
225
|
-
| `build` | — | Compile the application via Webpack
|
|
226
|
-
| `start` | — | Start the application
|
|
226
|
+
| Command | Alias | Description |
|
|
227
|
+
| ---------- | ----- | -------------------------------------- |
|
|
228
|
+
| `create` | — | Scaffold a new MeoCord application |
|
|
229
|
+
| `build` | — | Compile the application via Webpack |
|
|
230
|
+
| `start` | — | Start the application |
|
|
227
231
|
| `generate` | `g` | Scaffold controllers, services, guards |
|
|
228
|
-
| `show` | — | Display framework info
|
|
232
|
+
| `show` | — | Display framework info |
|
|
229
233
|
|
|
230
234
|
**Common flags:**
|
|
231
235
|
|
|
@@ -238,6 +242,227 @@ npx meocord g co slash "profile" # generate a slash controller
|
|
|
238
242
|
npx meocord g --help # list all generator sub-commands
|
|
239
243
|
```
|
|
240
244
|
|
|
245
|
+
### Generating a controller
|
|
246
|
+
|
|
247
|
+
```shell
|
|
248
|
+
npx meocord g co <type> <name>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`<type>` is one of:
|
|
252
|
+
|
|
253
|
+
`button` · `modal-submit` · `select-menu` · `user-select-menu` · `role-select-menu` · `mentionable-select-menu` · `channel-select-menu` · `reaction` · `message` · `slash` · `autocomplete` · `context-menu` · `primary-entry-point`
|
|
254
|
+
|
|
255
|
+
Each one lands in its own directory, named after the type:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
src/controllers/<type>/
|
|
259
|
+
├── <name>.<type>.controller.ts
|
|
260
|
+
├── <name>.<type>.controller.spec.ts
|
|
261
|
+
└── builders/sample.builder.ts # slash, context-menu and primary-entry-point only
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
A builder is generated only for the three types Discord registers by name. Everything else is addressed by `customId` or, for autocomplete, by the command path it completes — there is nothing to register.
|
|
265
|
+
|
|
266
|
+
`<name>` may contain `/` to nest: `npx meocord g co button "admin/ban"` writes into `src/controllers/button/admin/`.
|
|
267
|
+
|
|
268
|
+
Directory layout is organisational only. Controllers are wired up by the `controllers` array on `@MeoCord()`, not by where they sit on disk.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Command Types
|
|
273
|
+
|
|
274
|
+
`@Command` binds a method to one kind of interaction, and the interaction class the handler receives follows from that. Every type Discord sends is covered.
|
|
275
|
+
|
|
276
|
+
| `CommandType` | Handler receives | Routed by |
|
|
277
|
+
| ------------------------- | --------------------------------------------------------------------------- | ---------- |
|
|
278
|
+
| `SLASH` | `ChatInputCommandInteraction` | name |
|
|
279
|
+
| `CONTEXT_MENU` | `UserContextMenuCommandInteraction \| MessageContextMenuCommandInteraction` | name |
|
|
280
|
+
| `PRIMARY_ENTRY_POINT` | `PrimaryEntryPointCommandInteraction` | name |
|
|
281
|
+
| `BUTTON` | `ButtonInteraction` | `customId` |
|
|
282
|
+
| `SELECT_MENU` | `StringSelectMenuInteraction` | `customId` |
|
|
283
|
+
| `USER_SELECT_MENU` | `UserSelectMenuInteraction` | `customId` |
|
|
284
|
+
| `ROLE_SELECT_MENU` | `RoleSelectMenuInteraction` | `customId` |
|
|
285
|
+
| `MENTIONABLE_SELECT_MENU` | `MentionableSelectMenuInteraction` | `customId` |
|
|
286
|
+
| `CHANNEL_SELECT_MENU` | `ChannelSelectMenuInteraction` | `customId` |
|
|
287
|
+
| `MODAL_SUBMIT` | `ModalSubmitInteraction` | `customId` |
|
|
288
|
+
|
|
289
|
+
Autocomplete has its own decorator — see [Autocomplete](#autocomplete). It has no `CommandType` member, because it registers nothing and is answered with `respond()` rather than a reply. `@MessageHandler` and `@ReactionHandler` are outside `CommandType` for the same reason: `CommandType` is the set of things `@Command` can bind to, not the set of things MeoCord handles.
|
|
290
|
+
|
|
291
|
+
The kebab-case `ControllerType` used by the CLI is a wider list — it names every kind of controller that can be scaffolded, including the three that are not commands.
|
|
292
|
+
|
|
293
|
+
The four entity select menus are separate types because Discord sends them as separate component types carrying different resolved data. Declaring `SELECT_MENU` for a user select menu is a type error, not a silent mismatch:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
@Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
|
|
297
|
+
async assign(interaction: UserSelectMenuInteraction, { taskId }) {
|
|
298
|
+
await interaction.reply(`Assigned to ${interaction.users.map(user => user.username).join(', ')}`)
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Slash command options
|
|
303
|
+
|
|
304
|
+
A slash handler's second argument holds the options the command was invoked with, keyed by name. Entity options arrive resolved — a `User`, `Role`, `GuildChannel` or `Attachment`, not the snowflake:
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
@Command('kick', KickCommandBuilder)
|
|
308
|
+
async kick(interaction: ChatInputCommandInteraction, { target, reason }) {
|
|
309
|
+
// target is a User, reason is a string
|
|
310
|
+
await interaction.reply(`Kicked ${target.username}: ${reason}`)
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Entry point commands
|
|
315
|
+
|
|
316
|
+
Activity entry points have no builder class in `@discordjs/builders`, so their builder returns the REST body directly. `handler: AppHandler` is what makes Discord send the interaction to the bot at all:
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
@CommandBuilder(CommandType.PRIMARY_ENTRY_POINT)
|
|
320
|
+
export class LaunchCommandBuilder {
|
|
321
|
+
build() {
|
|
322
|
+
return {
|
|
323
|
+
type: ApplicationCommandType.PrimaryEntryPoint as const,
|
|
324
|
+
name: 'launch',
|
|
325
|
+
description: 'Launch the activity',
|
|
326
|
+
handler: EntryPointCommandHandlerType.AppHandler,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Command Parameters
|
|
335
|
+
|
|
336
|
+
Buttons, select menus and modals route on their `customId`, and a pattern can capture parts of it. Captured values arrive as the handler's second argument.
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
@Command('profile/{ownerId}/{uid}', CommandType.BUTTON)
|
|
340
|
+
async showProfile(interaction: ButtonInteraction, { ownerId, uid }) {
|
|
341
|
+
// customId `profile/123/800000001` gives ownerId '123', uid '800000001'
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`/` separates segments, and **a parameter must occupy a whole segment** — the same rule Express and Rails use for a path. A pattern that breaks it is rejected when the command is registered:
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
@Command('profile/{uuid}', CommandType.BUTTON) // fine
|
|
349
|
+
@Command('gi-profile/{ownerId}', CommandType.BUTTON) // fine — the hyphen is inside a literal segment
|
|
350
|
+
@Command('profile-{uuid}', CommandType.BUTTON) // throws
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```
|
|
354
|
+
Invalid pattern "profile-{uuid}": {uuid} must occupy a whole segment, so it has to be
|
|
355
|
+
preceded and followed by "/" or by the ends of the pattern. Write "a/{uuid}" rather
|
|
356
|
+
than "a-{uuid}".
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Why the rule exists
|
|
360
|
+
|
|
361
|
+
A parameter matches anything up to the next `/`, so an identifier you do not control is captured whole — a hyphen inside a uuid is data, not structure:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
@Command('profile/{uuid}', CommandType.BUTTON)
|
|
365
|
+
// `profile/8400e29b-41d4-a716` -> uuid '8400e29b-41d4-a716'
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
That only works because the separator cannot appear inside a value. Let a parameter share a segment with a literal and the boundary disappears: `profile-{uuid}` and `profile-{uuid}-{id}` both match `profile-a-b-c`, and neither reading is more correct than the other. No rule can settle that afterwards, so the shape is refused up front.
|
|
369
|
+
|
|
370
|
+
Segment counts then keep neighbours apart on their own:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
@Command('profile/{uuid}', CommandType.BUTTON) // profile/8400e29b-41d4-a716
|
|
374
|
+
@Command('profile/{uuid}/{id}', CommandType.BUTTON) // profile/8400e29b-41d4-a716/99
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Each id matches exactly one of them.
|
|
378
|
+
|
|
379
|
+
### Overlapping patterns
|
|
380
|
+
|
|
381
|
+
Two patterns with the same segment count can still both match. The one spelling out more literal text wins, so declaration order and file layout never decide it:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
@Command('profile/summary/{ownerId}/{uid}', CommandType.BUTTON) // wins profile/summary/123/456
|
|
385
|
+
@Command('profile/{uuid}/{other}/{uid}', CommandType.BUTTON) // wins everything else
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Ties between equally literal patterns go to the one with fewer parameters. The ranking is computed once when commands are registered, so dispatch stays a single ordered lookup.
|
|
389
|
+
|
|
390
|
+
Where two patterns trade a literal for a parameter in opposite positions — `a/{x}/c` and `a/b/{y}` both take `a/b/c` — neither is more literal, and MeoCord logs a warning at startup naming the pair.
|
|
391
|
+
|
|
392
|
+
### When nothing matches
|
|
393
|
+
|
|
394
|
+
An unroutable interaction replies "Command not found!" to the user and logs a warning naming the `customId` or command that failed to match. If a control appears dead, that log line is the first place to look.
|
|
395
|
+
|
|
396
|
+
Autocomplete cannot be replied to, so an unclaimed option is answered with an empty list instead and the warning names the command and option.
|
|
397
|
+
|
|
398
|
+
A handler that throws is logged and gets the same treatment — except when it had already replied or deferred, in which case MeoCord leaves the response alone rather than sending a second one Discord would reject.
|
|
399
|
+
|
|
400
|
+
### Failures never take the bot down
|
|
401
|
+
|
|
402
|
+
discord.js calls event listeners without awaiting them, so anything that rejects out of one is an unhandled rejection — which terminates the process by default. MeoCord wraps every listener it registers, so one bad interaction, one unresolvable controller, or one reaction on a deleted message costs that event and nothing else. The error is logged against the event that produced it, so a genuine misconfiguration still shows up on the first interaction rather than staying hidden.
|
|
403
|
+
|
|
404
|
+
Where the failure happened before the handler ran, the user is still told: a command interaction gets the error reply, an autocomplete gets its window closed. A reaction whose message can no longer be fetched — deleted, or in a channel the bot lost access to — is skipped quietly, since that is an ordinary outcome rather than a fault.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Subcommands
|
|
409
|
+
|
|
410
|
+
Discord sends `/settings notify email` as a single interaction named `settings`, so a command with subcommands would otherwise have one handler for all of them. Name the full path — parts separated by a space, the way Discord displays them — to give each subcommand its own method:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
@Controller()
|
|
414
|
+
export class SettingsController {
|
|
415
|
+
// The builder is declared once, on the command itself.
|
|
416
|
+
@Command('settings', SettingsCommandBuilder)
|
|
417
|
+
async settings(interaction: ChatInputCommandInteraction) {
|
|
418
|
+
await interaction.reply('Pick a subcommand.')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
@Command('settings notify email', CommandType.SLASH)
|
|
422
|
+
async notifyEmail(interaction: ChatInputCommandInteraction, { enabled }) {
|
|
423
|
+
await interaction.reply(`Email notifications ${enabled ? 'on' : 'off'}`)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Subcommand handlers take the plain `CommandType.SLASH` and no builder: the subcommand is already described by the parent's builder, and registering a second command for it would be rejected by Discord. Options are flattened, so `notifyEmail` receives `{ enabled }` rather than the wrapping subcommand.
|
|
429
|
+
|
|
430
|
+
The full path is always tried before the bare command name, whatever order the controllers were registered in, and a subcommand nobody claimed falls back to the command's own handler. A group is never dropped on the way down — `settings notify email` does not fall back to `settings email`, because another group could declare its own `email`.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Autocomplete
|
|
435
|
+
|
|
436
|
+
Autocomplete is a separate interaction from the command it belongs to: Discord sends it while the user is still typing, it is answered with `respond()` rather than a reply, and the window closes after three seconds. `@Autocomplete` binds a handler to it.
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
@Controller()
|
|
440
|
+
export class SearchController {
|
|
441
|
+
constructor(private catalog: CatalogService) {}
|
|
442
|
+
|
|
443
|
+
@Autocomplete('search', 'query')
|
|
444
|
+
async completeQuery(interaction: AutocompleteInteraction) {
|
|
445
|
+
const { value } = interaction.options.getFocused(true)
|
|
446
|
+
const matches = this.catalog.find(value).slice(0, 25)
|
|
447
|
+
|
|
448
|
+
await interaction.respond(matches.map(name => ({ name, value: name })))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
The option must be declared with `.setAutocomplete(true)` on the command builder — that is what makes Discord send the interaction.
|
|
454
|
+
|
|
455
|
+
Omit the option name to handle every option of a command and branch on `getFocused(true)` yourself. An option-specific handler always wins over a command-wide one, so the two can coexist. The first argument is the command path, so subcommands work the same way as they do for `@Command`:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
@Autocomplete('settings notify email', 'address')
|
|
459
|
+
async completeAddress(interaction: AutocompleteInteraction, { region }) { /* … */ }
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The second argument holds the options already filled in, which is what lets one option's suggestions depend on another's value.
|
|
463
|
+
|
|
464
|
+
If no handler claims an option, MeoCord answers with an empty list and logs which command and option are missing one — a visibly empty menu rather than a client stuck loading.
|
|
465
|
+
|
|
241
466
|
---
|
|
242
467
|
|
|
243
468
|
## Guards
|
|
@@ -294,9 +519,7 @@ import { UseGuard } from 'meocord/decorator'
|
|
|
294
519
|
import { DefaultGuard, RateLimiterGuard } from '@src/guards/index.js'
|
|
295
520
|
|
|
296
521
|
export const Protected = (limit = 5) =>
|
|
297
|
-
applyDecorators(
|
|
298
|
-
UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }),
|
|
299
|
-
)
|
|
522
|
+
applyDecorators(UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }))
|
|
300
523
|
```
|
|
301
524
|
|
|
302
525
|
```typescript
|
|
@@ -376,7 +599,11 @@ Creates a smart mock instance of any discord.js class. The full prototype chain
|
|
|
376
599
|
|
|
377
600
|
**Type guards run real logic** — `isButton()`, `isRepliable()`, `isChatInputCommand()`, etc. are backed by the actual discord.js prototype methods. The right fields (`type`, `componentType`, `commandType`) are set based on the class you pass in, so no manual `.mockReturnValue(true)` setup is needed. All type guard methods are still mock functions and can be overridden per test.
|
|
378
601
|
|
|
379
|
-
**Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral
|
|
602
|
+
**Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral`, read from `flags` only — the deprecated `ephemeral: true` reply option is not honoured. All reply methods are still mock functions so call assertions work normally.
|
|
603
|
+
|
|
604
|
+
Autocomplete interactions are not repliable but get the equivalent for their own single-shot response: `responded` starts as `false`, `respond()` sets it, and a second call throws.
|
|
605
|
+
|
|
606
|
+
Guards discord.js has deprecated are deliberately left unwired — `isSelectMenu()` returns `undefined` rather than reproducing behaviour the library is removing. Use `isStringSelectMenu()`.
|
|
380
607
|
|
|
381
608
|
> **Framework-agnostic** — the mocks returned here are plain mock functions that stamp `_isMockFunction` and expose `.mock.calls`, the exact contract both `jest` and `vitest` check. Use them with either framework's `expect(...).toHaveBeenCalledWith(...)` / `toHaveBeenCalledTimes(...)` — no jest or vitest import is required to produce them. For typed stubs in your own code, import `MockedFunction`, `createMockFn`, and `DeepMocked` from `meocord/testing`.
|
|
382
609
|
|
|
@@ -388,17 +615,17 @@ const interaction = createMockInteraction(ChatInputCommandInteraction)
|
|
|
388
615
|
|
|
389
616
|
// instanceof works at every level
|
|
390
617
|
expect(interaction).toBeInstanceOf(ChatInputCommandInteraction) // true
|
|
391
|
-
expect(interaction).toBeInstanceOf(BaseInteraction)
|
|
618
|
+
expect(interaction).toBeInstanceOf(BaseInteraction) // true
|
|
392
619
|
|
|
393
620
|
// type guards work — no manual setup needed
|
|
394
621
|
interaction.isChatInputCommand() // → true
|
|
395
|
-
interaction.isRepliable()
|
|
396
|
-
interaction.isButton()
|
|
622
|
+
interaction.isRepliable() // → true
|
|
623
|
+
interaction.isButton() // → false
|
|
397
624
|
|
|
398
625
|
// reply state machine
|
|
399
|
-
interaction.replied
|
|
626
|
+
interaction.replied // → false
|
|
400
627
|
await interaction.reply({ content: 'hi' })
|
|
401
|
-
interaction.replied
|
|
628
|
+
interaction.replied // → true
|
|
402
629
|
await interaction.reply({ content: 'again' }) // → throws (already replied)
|
|
403
630
|
|
|
404
631
|
// still a mock fn — call assertions work normally
|
|
@@ -454,15 +681,38 @@ interaction.options = createChatInputOptions({
|
|
|
454
681
|
duration: 7,
|
|
455
682
|
})
|
|
456
683
|
|
|
457
|
-
interaction.options.getSubcommandGroup()
|
|
458
|
-
interaction.options.getSubcommand(true)
|
|
459
|
-
interaction.options.getUser('user')
|
|
460
|
-
interaction.options.getString('reason')
|
|
461
|
-
interaction.options.getNumber('duration')
|
|
462
|
-
interaction.options.getString('duration')
|
|
463
|
-
interaction.options.getNumber('x', true)
|
|
684
|
+
interaction.options.getSubcommandGroup() // → 'admin'
|
|
685
|
+
interaction.options.getSubcommand(true) // → 'ban'
|
|
686
|
+
interaction.options.getUser('user') // → { id: '123456789' }
|
|
687
|
+
interaction.options.getString('reason') // → 'spam'
|
|
688
|
+
interaction.options.getNumber('duration') // → 7
|
|
689
|
+
interaction.options.getString('duration') // → null (wrong type)
|
|
690
|
+
interaction.options.getNumber('x', true) // → throws (absent + required)
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`data` is materialised too, nested under the subcommand path exactly as Discord sends it. That is what the framework reads to build a handler's second argument, so a params assertion sees the same record production would:
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
interaction.options.data
|
|
697
|
+
// → [{ name: 'admin', type: SubcommandGroup, options: [{ name: 'ban', type: Subcommand, options: [...] }] }]
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Entity options are set on both `value` (the snowflake) and their own resolved field, so a handler that reads only one of the two is caught rather than silently passing. Pass a `createMockInteraction(User, …)`, `Role`, channel or `Attachment` mock and it lands on `user`/`role`/`channel`/`attachment`.
|
|
701
|
+
|
|
702
|
+
For autocomplete, `focused` names the option being typed:
|
|
703
|
+
|
|
704
|
+
```typescript
|
|
705
|
+
const interaction = createMockInteraction(AutocompleteInteraction)
|
|
706
|
+
interaction.options = createChatInputOptions({ focused: 'query', query: 'ad' })
|
|
707
|
+
|
|
708
|
+
interaction.options.getFocused(true) // → { name: 'query', value: 'ad', focused: true, … }
|
|
709
|
+
interaction.options.getFocused() // → 'ad'
|
|
464
710
|
```
|
|
465
711
|
|
|
712
|
+
Omit it and `getFocused` throws, the same as the real resolver does when no option is focused.
|
|
713
|
+
|
|
714
|
+
`subcommandGroup`, `subcommand` and `focused` are reserved keys — an option of your own cannot use those names.
|
|
715
|
+
|
|
466
716
|
All methods are mock functions — override any per test with `.mockReturnValue()`.
|
|
467
717
|
|
|
468
718
|
### `createMockUser` / `createMockClient` / `createMockGuild` / `createMockChannel`
|
|
@@ -470,18 +720,12 @@ All methods are mock functions — override any per test with `.mockReturnValue(
|
|
|
470
720
|
Convenience wrappers for common discord.js classes. All methods are auto-stubbed as mock functions. Nested managers (`client.users`, `guild.members`, etc.) are independent nested stubs.
|
|
471
721
|
|
|
472
722
|
```typescript
|
|
473
|
-
import {
|
|
474
|
-
createMockFn,
|
|
475
|
-
createMockUser,
|
|
476
|
-
createMockClient,
|
|
477
|
-
createMockGuild,
|
|
478
|
-
createMockChannel,
|
|
479
|
-
} from 'meocord/testing'
|
|
723
|
+
import { createMockFn, createMockUser, createMockClient, createMockGuild, createMockChannel } from 'meocord/testing'
|
|
480
724
|
import { TextChannel } from 'discord.js'
|
|
481
725
|
|
|
482
|
-
const user
|
|
483
|
-
const client
|
|
484
|
-
const guild
|
|
726
|
+
const user = createMockUser()
|
|
727
|
+
const client = createMockClient()
|
|
728
|
+
const guild = createMockGuild()
|
|
485
729
|
const channel = createMockChannel(TextChannel)
|
|
486
730
|
|
|
487
731
|
// override nested manager methods per test (createMockFn works with vitest and jest matchers)
|
|
@@ -499,15 +743,15 @@ import { createMockMessage } from 'meocord/testing'
|
|
|
499
743
|
|
|
500
744
|
const msg = createMockMessage()
|
|
501
745
|
|
|
502
|
-
msg.deleted
|
|
746
|
+
msg.deleted // → false
|
|
503
747
|
await msg.delete()
|
|
504
|
-
msg.deleted
|
|
505
|
-
await msg.delete()
|
|
748
|
+
msg.deleted // → true
|
|
749
|
+
await msg.delete() // → throws (already deleted)
|
|
506
750
|
await msg.edit({ content: 'x' }) // → throws (already deleted)
|
|
507
751
|
|
|
508
752
|
// edit() and reply() resolve to a new Message mock
|
|
509
753
|
const edited = await createMockMessage().edit({ content: 'updated' })
|
|
510
|
-
edited.delete
|
|
754
|
+
edited.delete // → a mock fn
|
|
511
755
|
|
|
512
756
|
// still a mock fn — assertions work
|
|
513
757
|
expect(msg.delete).toHaveBeenCalledTimes(1)
|
|
@@ -545,8 +789,10 @@ const module = MeoCordTestingModule.create({
|
|
|
545
789
|
controllers: [GreetingSlashController],
|
|
546
790
|
providers: [{ provide: GreetingService, useValue: mockGreetingService }],
|
|
547
791
|
})
|
|
548
|
-
.overrideGuard(MetricsGuard)
|
|
549
|
-
.
|
|
792
|
+
.overrideGuard(MetricsGuard)
|
|
793
|
+
.useValue({ canActivate: () => true })
|
|
794
|
+
.overrideGuard(RateLimiterGuard)
|
|
795
|
+
.useValue({ canActivate: () => true })
|
|
550
796
|
.compile()
|
|
551
797
|
```
|
|
552
798
|
|
|
@@ -561,7 +807,8 @@ const module = MeoCordTestingModule.create({
|
|
|
561
807
|
controllers: [GreetingSlashController],
|
|
562
808
|
providers: [{ provide: GreetingService, useValue: realGreetingService }],
|
|
563
809
|
})
|
|
564
|
-
.overrideProvider(GreetingService)
|
|
810
|
+
.overrideProvider(GreetingService)
|
|
811
|
+
.useValue({ buildGreeting: createMockFn() })
|
|
565
812
|
.compile()
|
|
566
813
|
```
|
|
567
814
|
|
|
@@ -591,7 +838,8 @@ describe('GreetingSlashController', () => {
|
|
|
591
838
|
controllers: [GreetingSlashController],
|
|
592
839
|
providers: [{ provide: GreetingService, useValue: greetingService }],
|
|
593
840
|
})
|
|
594
|
-
.overrideGuard(RateLimiterGuard)
|
|
841
|
+
.overrideGuard(RateLimiterGuard)
|
|
842
|
+
.useValue({ canActivate: () => true })
|
|
595
843
|
.compile()
|
|
596
844
|
|
|
597
845
|
controller = module.get(GreetingSlashController)
|