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.
Files changed (34) hide show
  1. package/AUTHOR.md +2 -3
  2. package/README.md +289 -41
  3. package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
  4. package/dist/cjs/core/index.cjs +288 -85
  5. package/dist/cjs/decorator/index.cjs +5 -1
  6. package/dist/cjs/enum/index.cjs +17 -4
  7. package/dist/cjs/testing/index.cjs +88 -4
  8. package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
  9. package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
  10. package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
  11. package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
  12. package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
  13. package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
  14. package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
  15. package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
  16. package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
  17. package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
  18. package/dist/esm/bin/generator.js +4 -9
  19. package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
  20. package/dist/esm/core/meocord.app.js +289 -85
  21. package/dist/esm/decorator/controller.decorator.js +157 -24
  22. package/dist/esm/decorator/index.js +1 -1
  23. package/dist/esm/enum/controller.enum.js +23 -4
  24. package/dist/esm/testing/mock-interaction.js +89 -5
  25. package/dist/esm/util/interaction.util.js +174 -0
  26. package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
  27. package/dist/types/core/index.d.ts +94 -0
  28. package/dist/types/decorator/index.d.ts +74 -41
  29. package/dist/types/enum/index.d.ts +1 -1
  30. package/dist/types/interface/index.d.ts +87 -3
  31. package/dist/types/testing/index.d.ts +3 -21
  32. package/package.json +2 -2
  33. package/dist/cjs/_shared/controller.decorator-DX5lFlPZ.cjs +0 -216
  34. 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
- - Primary creator and maintainer of the MeoCord Framework.
5
- - Contact: [ukasyahrz@outlook.com](mailto:ukasyahrz@outlook.com)
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`. All reply methods are still mock functions so call assertions work normally.
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) // true
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() // → true
396
- interaction.isButton() // → false
622
+ interaction.isRepliable() // → true
623
+ interaction.isButton() // → false
397
624
 
398
625
  // reply state machine
399
- interaction.replied // → false
626
+ interaction.replied // → false
400
627
  await interaction.reply({ content: 'hi' })
401
- interaction.replied // → true
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() // → 'admin'
458
- interaction.options.getSubcommand(true) // → 'ban'
459
- interaction.options.getUser('user') // → { id: '123456789' }
460
- interaction.options.getString('reason') // → 'spam'
461
- interaction.options.getNumber('duration') // → 7
462
- interaction.options.getString('duration') // → null (wrong type)
463
- interaction.options.getNumber('x', true) // → throws (absent + required)
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 = createMockUser()
483
- const client = createMockClient()
484
- const guild = createMockGuild()
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 // → false
746
+ msg.deleted // → false
503
747
  await msg.delete()
504
- msg.deleted // → true
505
- await msg.delete() // → throws (already deleted)
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 // → a mock fn
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).useValue({ canActivate: () => true })
549
- .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true })
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).useValue({ buildGreeting: createMockFn() })
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).useValue({ canActivate: () => true })
841
+ .overrideGuard(RateLimiterGuard)
842
+ .useValue({ canActivate: () => true })
595
843
  .compile()
596
844
 
597
845
  controller = module.get(GreetingSlashController)