meocord 4.1.2 → 4.2.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 (95) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
  3. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
  4. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
  5. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
  6. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
  7. package/dist/cjs/common/index.cjs +4 -4
  8. package/dist/cjs/core/index.cjs +195 -157
  9. package/dist/cjs/decorator/index.cjs +66 -57
  10. package/dist/cjs/testing/index.cjs +733 -301
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/README.md.template +3 -1
  13. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  15. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  17. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  19. package/dist/esm/build/load-config.pre-entry.js +4 -1
  20. package/dist/esm/common/decorator.js +6 -6
  21. package/dist/esm/common/deprecation.js +2 -2
  22. package/dist/esm/common/errors.js +2 -2
  23. package/dist/esm/common/execution-context.js +1 -1
  24. package/dist/esm/common/metadata.js +1 -1
  25. package/dist/esm/common/route.js +10 -1
  26. package/dist/esm/common/translate-error.js +1 -1
  27. package/dist/esm/core/command-conflicts.js +34 -34
  28. package/dist/esm/core/component-routes.js +74 -31
  29. package/dist/esm/core/dispatcher.js +51 -7
  30. package/dist/esm/core/filter-runner.js +2 -2
  31. package/dist/esm/core/handler-pipeline.js +27 -14
  32. package/dist/esm/core/handler-registry.js +1 -1
  33. package/dist/esm/core/interceptor-runner.js +2 -2
  34. package/dist/esm/core/meocord-factory.js +196 -157
  35. package/dist/esm/core/message-routes.js +4 -3
  36. package/dist/esm/core/observer-runner.js +4 -3
  37. package/dist/esm/core/providers.js +38 -21
  38. package/dist/esm/core/route-specificity.js +4 -4
  39. package/dist/esm/core/scalar-types.js +15 -3
  40. package/dist/esm/core/shard-context.js +1 -1
  41. package/dist/esm/core/startup-roots.js +2 -3
  42. package/dist/esm/core/theme-runtime.js +2 -2
  43. package/dist/esm/decorator/app.decorator.js +12 -7
  44. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  45. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  46. package/dist/esm/decorator/controller.decorator.js +110 -67
  47. package/dist/esm/decorator/cooldown.decorator.js +3 -3
  48. package/dist/esm/decorator/defer.decorator.js +4 -4
  49. package/dist/esm/decorator/event.decorator.js +5 -5
  50. package/dist/esm/decorator/filter.decorator.js +7 -7
  51. package/dist/esm/decorator/guard.decorator.js +8 -8
  52. package/dist/esm/decorator/inject.decorator.js +4 -4
  53. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  54. package/dist/esm/decorator/observer.decorator.js +4 -4
  55. package/dist/esm/decorator/service.decorator.js +4 -4
  56. package/dist/esm/decorator/theme.decorator.js +3 -3
  57. package/dist/esm/decorator/validation.decorator.js +10 -10
  58. package/dist/esm/package.json.js +1 -1
  59. package/dist/esm/testing/catalog.js +1 -1
  60. package/dist/esm/testing/cooldown-store-suite.js +18 -10
  61. package/dist/esm/testing/discord-defaults.js +60 -7
  62. package/dist/esm/testing/index.js +4 -2
  63. package/dist/esm/testing/inspect-handler.js +20 -3
  64. package/dist/esm/testing/meocord-testing-module.js +229 -188
  65. package/dist/esm/testing/mock-fn.js +80 -10
  66. package/dist/esm/testing/mock-interaction.js +223 -72
  67. package/dist/esm/testing/response.js +1 -1
  68. package/dist/esm/testing/routing.js +39 -14
  69. package/dist/esm/testing/startup-errors.js +26 -0
  70. package/dist/esm/testing/strict-mocks.js +45 -0
  71. package/dist/esm/testing/theme.js +1 -1
  72. package/dist/esm/util/bundle-entry.util.js +2 -1
  73. package/dist/esm/util/interaction.util.js +1 -1
  74. package/dist/esm/util/meocord-config-validation.util.js +1 -0
  75. package/dist/esm/util/metadata-keys.js +1 -0
  76. package/dist/esm/util/refusal.util.js +148 -8
  77. package/dist/types/common/index.d.cts +8 -8
  78. package/dist/types/common/index.d.ts +8 -8
  79. package/dist/types/core/index.d.cts +8 -6
  80. package/dist/types/core/index.d.ts +8 -6
  81. package/dist/types/decorator/index.d.cts +30 -29
  82. package/dist/types/decorator/index.d.ts +30 -29
  83. package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
  84. package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
  88. package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
  89. package/dist/types/testing/index.d.cts +245 -31
  90. package/dist/types/testing/index.d.ts +245 -31
  91. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
  92. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
  93. package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
  94. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
  95. package/package.json +3 -2
@@ -1,13 +1,14 @@
1
1
  import 'reflect-metadata';
2
2
  import { createMockFn } from './mock-fn.js';
3
- import { DataManager, RoleManager, Role, GuildChannel, GuildMember, User, BaseGuild, MessageMentions, Message, ThreadChannel, BaseGuildVoiceChannel, BaseChannel, MessageReaction, InteractionType, ComponentType, ApplicationCommandType, GuildMemberManager, GuildChannelManager, UserManager, ChannelManager, GuildManager, Guild, GuildBanManager, GuildBan, ThreadMemberManager, ThreadMember, GuildMessageManager, DMMessageManager, GuildTextThreadManager, GuildForumThreadManager, PermissionOverwriteManager, PermissionOverwrites, CommandInteractionOptionResolver, ApplicationCommandOptionType, Client, ApplicationCommandManager, ClientUser, CDN, AuthorizingIntegrationOwners, BaseInteraction, UserContextMenuCommandInteraction, Locale, StringSelectMenuInteraction, UserSelectMenuInteraction, RoleSelectMenuInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, Collection, MessageFlagsBitField, Base, SnowflakeUtil, DMChannel, GuildMemberRoleManager, TextChannel, Component, Embed, Attachment, BaseManager, ApplicationCommand, NewsChannel, ForumChannel, MediaChannel, DiscordjsErrorCodes, DiscordjsTypeError } from 'discord.js';
3
+ import { DataManager, RoleManager, Role, GuildChannel, GuildMember, User, BaseGuild, MessageMentions, ThreadChannel, Message, BaseGuildVoiceChannel, VoiceChannel, BaseChannel, MessageReaction, InteractionType, ComponentType, ApplicationCommandType, GuildMemberManager, GuildChannelManager, UserManager, ChannelManager, GuildManager, Guild, GuildBanManager, GuildBan, ThreadMemberManager, ThreadMember, GuildMessageManager, DMMessageManager, GuildTextThreadManager, GuildForumThreadManager, PermissionOverwriteManager, PermissionOverwrites, CommandInteractionOptionResolver, ApplicationCommandOptionType, Client, ApplicationCommandManager, ClientUser, CDN, AuthorizingIntegrationOwners, BaseInteraction, UserContextMenuCommandInteraction, Locale, StringSelectMenuInteraction, UserSelectMenuInteraction, RoleSelectMenuInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, Collection, MessageFlagsBitField, PermissionsBitField, Base, SnowflakeUtil, DMChannel, GuildMemberRoleManager, TextChannel, Component, Embed, Attachment, BaseManager, ApplicationCommand, NewsChannel, ForumChannel, MediaChannel, DiscordjsErrorCodes, DiscordjsTypeError } from 'discord.js';
4
4
  import { createDiscordError } from './response.js';
5
5
  import { stampCall } from '../common/response/call-order.js';
6
6
  import { discordDefault, REAL_GETTER } from './discord-defaults.js';
7
7
  import { Logger } from '../common/logger.js';
8
- import { warnPlaceholder } from '../common/deprecation.js';
8
+ import { warnOnce, warnPlaceholder } from '../common/deprecation.js';
9
9
  import { embedsAsDiscordStores, asDiscordStores } from './discord-shape.js';
10
10
  import { nextSnowflake, MOCK_BOT_ID } from './snowflake.js';
11
+ import { noteMockMade, strictMocks } from './strict-mocks.js';
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // stubDeep — Proxy that answers each property it lacks: a mock fn for a method, a default, or a nested stub
@@ -20,6 +21,7 @@ const SKIP = new Set([
20
21
  'then'
21
22
  ]);
22
23
  function stubDeep(instance, externalStubs, onSet) {
24
+ noteMockMade();
23
25
  const stubs = externalStubs ?? new Map();
24
26
  const proxy = new Proxy(instance, {
25
27
  get (target, prop) {
@@ -60,7 +62,11 @@ function stubDeep(instance, externalStubs, onSet) {
60
62
  const desc = Object.getOwnPropertyDescriptor(proto, key);
61
63
  if (desc !== undefined) {
62
64
  protoValue = desc.value;
63
- if (desc.get && PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) warnBooleanPlaceholder(target, key);
65
+ if (desc.get && isPlaceholder(proto, key)) {
66
+ // Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
67
+ if (strictMocks()) return Reflect.get(target, prop, proxy);
68
+ warnBooleanPlaceholder(target, key);
69
+ }
64
70
  break;
65
71
  }
66
72
  proto = Object.getPrototypeOf(proto);
@@ -150,9 +156,18 @@ const REAL_METHODS = new Map([
150
156
  ])
151
157
  ]
152
158
  ]);
159
+ // What strict mocks also run for real: a thread's permissions are its parent channel's, as discord.js reads them
160
+ const STRICT_REAL_METHODS = new Map([
161
+ [
162
+ ThreadChannel.prototype,
163
+ new Set([
164
+ 'permissionsFor'
165
+ ])
166
+ ]
167
+ ]);
153
168
  function isRealMethod(target, key) {
154
169
  for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
155
- if (REAL_METHODS.get(proto)?.has(key)) return true;
170
+ if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
156
171
  }
157
172
  return false;
158
173
  }
@@ -212,6 +227,12 @@ const PLACEHOLDER_BOOLEANS = new Map([
212
227
  'joinable'
213
228
  ])
214
229
  ],
230
+ [
231
+ VoiceChannel.prototype,
232
+ new Set([
233
+ 'speakable'
234
+ ])
235
+ ],
215
236
  [
216
237
  User.prototype,
217
238
  new Set([
@@ -231,11 +252,18 @@ const PLACEHOLDER_BOOLEANS = new Map([
231
252
  ])
232
253
  ]
233
254
  ]);
255
+ /** Whether `proto`'s getter for `key` is one of those, or a subclass's override of one, such as a voice channel's `joinable`. */ function isPlaceholder(proto, key) {
256
+ for(; proto !== null; proto = Object.getPrototypeOf(proto)){
257
+ if (PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) return true;
258
+ }
259
+ return false;
260
+ }
234
261
  const mockLogger = new Logger('Mocks');
235
262
  function warnBooleanPlaceholder(target, key) {
236
263
  const name = Object.getPrototypeOf(target).constructor.name;
237
- const variable = name.charAt(0).toLowerCase() + name.slice(1);
238
- warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = false, to test either way.`);
264
+ // The class name in camel case, a leading acronym included: dmChannel, guildMember
265
+ const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
266
+ warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = false, to test either way, or call useStrictMocks() to have the mock compute it now.`);
239
267
  }
240
268
  // The client of a structure made without one: the one its guild has, else one of its own, made once
241
269
  const ownClients = new WeakMap();
@@ -618,22 +646,13 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
618
646
  }
619
647
  }
620
648
  /** Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order. */ const RESPONSE_LOG = Symbol('response log');
621
- const ANSWER_METHODS = [
622
- 'reply',
623
- 'deferReply',
624
- 'editReply',
625
- 'followUp',
626
- 'deleteReply',
627
- 'update',
628
- 'deferUpdate',
629
- 'showModal'
630
- ];
631
649
  /**
632
- * `mock`, recording each call in `log` as it is made, with what it sent and, once it settles, what it rejected with.
633
- * `withResponse` is how a call asks discord.js for the message back, not part of what it sends, so it is left out.
634
- */ function recorded(method, mock, log) {
635
- return new Proxy(mock, {
636
- apply (target, self, args) {
650
+ * A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
651
+ * and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
652
+ * test sets, so the mock stays the plain mock function a runner's matchers read, bun's included. `withResponse` is how
653
+ * a call asks discord.js for the message back, not part of what it sends, so it is left out.
654
+ */ function recordedMock(method, impl, log) {
655
+ const record = (behaviour)=>function(...args) {
637
656
  const [payload] = args;
638
657
  const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
639
658
  const call = {
@@ -643,7 +662,7 @@ const ANSWER_METHODS = [
643
662
  stampCall(call);
644
663
  log.push(call);
645
664
  try {
646
- const result = Reflect.apply(target, self, args);
665
+ const result = Reflect.apply(behaviour, this, args);
647
666
  // Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
648
667
  if (result instanceof Promise) {
649
668
  return result.then(undefined, (error)=>{
@@ -656,8 +675,38 @@ const ANSWER_METHODS = [
656
675
  call.error = error;
657
676
  throw error;
658
677
  }
659
- }
660
- });
678
+ };
679
+ const mock = createMockFn(record(impl));
680
+ const always = mock.mockImplementation.bind(mock);
681
+ const once = mock.mockImplementationOnce.bind(mock);
682
+ // Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
683
+ const setters = {
684
+ mockImplementation: (fn)=>fn,
685
+ mockReturnValue: (value)=>()=>value,
686
+ mockResolvedValue: (value)=>()=>Promise.resolve(value),
687
+ mockRejectedValue: (value)=>()=>Promise.reject(value)
688
+ };
689
+ // In place of the mock's own, keeping whether each is among its keys
690
+ const define = (name, value)=>Object.defineProperty(mock, name, {
691
+ value,
692
+ writable: true,
693
+ enumerable: Object.getOwnPropertyDescriptor(mock, name)?.enumerable ?? false,
694
+ configurable: true
695
+ });
696
+ for (const [name, behaviourOf] of Object.entries(setters)){
697
+ define(name, (value)=>(always(record(behaviourOf(value))), mock));
698
+ define(`${name}Once`, (value)=>(once(record(behaviourOf(value))), mock));
699
+ }
700
+ if (typeof mock.mockReturnThis === 'function') {
701
+ define('mockReturnThis', ()=>(always(record(function() {
702
+ return this;
703
+ })), mock));
704
+ }
705
+ const withImplementation = mock.withImplementation;
706
+ if (typeof withImplementation === 'function') {
707
+ define('withImplementation', (fn, callback)=>withImplementation.call(mock, record(fn), callback));
708
+ }
709
+ return mock;
661
710
  }
662
711
  /** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
663
712
  /** An error as discord.js's options resolver throws it: a `TypeError` with discord.js's code and message. */ const optionError = (code, ...args)=>new DiscordjsTypeError(code, ...args);
@@ -716,21 +765,27 @@ function createMockInteraction(Class, props) {
716
765
  if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
717
766
  return false;
718
767
  };
719
- stubs.set('reply', createMockFn(async (...args)=>{
768
+ // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
769
+ const log = [];
770
+ Object.defineProperty(instance, RESPONSE_LOG, {
771
+ value: log
772
+ });
773
+ const answer = (method, impl)=>stubs.set(method, recordedMock(method, impl, log));
774
+ answer('reply', async (...args)=>{
720
775
  if (instance.deferred || instance.replied) throw alreadyReplied();
721
776
  instance.replied = true;
722
777
  if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
723
- }));
724
- stubs.set('deferReply', createMockFn(async (...args)=>{
778
+ });
779
+ answer('deferReply', async (...args)=>{
725
780
  if (instance.deferred || instance.replied) throw alreadyReplied();
726
781
  instance.deferred = true;
727
782
  if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
728
- }));
729
- stubs.set('followUp', createMockFn(async ()=>{
783
+ });
784
+ answer('followUp', async ()=>{
730
785
  if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
731
786
  instance.replied = true;
732
787
  return createMockMessage();
733
- }));
788
+ });
734
789
  // The interaction's own message as Discord holds it: the message a component is on, or the original response.
735
790
  // An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
736
791
  // and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
@@ -739,7 +794,7 @@ function createMockInteraction(Class, props) {
739
794
  // A command answered with a modal has no original response, and Discord refuses one asked for
740
795
  let modalAnswered = false;
741
796
  const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
742
- stubs.set('editReply', createMockFn(async (options)=>{
797
+ answer('editReply', async (options)=>{
743
798
  if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
744
799
  if (modalAnswered) throw unknownMessage();
745
800
  instance.replied = true;
@@ -749,42 +804,33 @@ function createMockInteraction(Class, props) {
749
804
  ...held,
750
805
  components: held.returned ?? held.components
751
806
  });
752
- }));
807
+ });
753
808
  stubs.set('fetchReply', createMockFn(async ()=>{
754
809
  if (modalAnswered) throw unknownMessage();
755
810
  return messageFrom(current());
756
811
  }));
757
- stubs.set('deleteReply', createMockFn(async ()=>{
812
+ answer('deleteReply', async ()=>{
758
813
  if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply');
759
814
  if (modalAnswered) throw unknownMessage();
760
- }));
815
+ });
761
816
  // showModal — the first response of a command or a component, like reply()
762
817
  if (instance.type === InteractionType.ApplicationCommand || instance.type === InteractionType.MessageComponent) {
763
- stubs.set('showModal', createMockFn(async ()=>{
818
+ answer('showModal', async ()=>{
764
819
  if (instance.deferred || instance.replied) throw alreadyReplied();
765
820
  instance.replied = true;
766
821
  modalAnswered = instance.type === InteractionType.ApplicationCommand;
767
- }));
822
+ });
768
823
  }
769
824
  // deferUpdate / update — components, and modals submitted from a message's component
770
825
  if (instance.type === InteractionType.MessageComponent || instance.type === InteractionType.ModalSubmit) {
771
- stubs.set('update', createMockFn(async ()=>{
826
+ answer('update', async ()=>{
772
827
  if (instance.deferred || instance.replied) throw alreadyReplied();
773
828
  instance.replied = true;
774
- }));
775
- stubs.set('deferUpdate', createMockFn(async ()=>{
829
+ });
830
+ answer('deferUpdate', async ()=>{
776
831
  if (instance.deferred || instance.replied) throw alreadyReplied();
777
832
  instance.deferred = true;
778
- }));
779
- }
780
- // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
781
- const log = [];
782
- Object.defineProperty(instance, RESPONSE_LOG, {
783
- value: log
784
- });
785
- for (const method of ANSWER_METHODS){
786
- const stub = stubs.get(method);
787
- if (stub) stubs.set(method, recorded(method, stub, log));
833
+ });
788
834
  }
789
835
  }
790
836
  // Autocomplete is not repliable, but it has a response of its own: Discord accepts
@@ -823,6 +869,43 @@ function createMockInteraction(Class, props) {
823
869
  const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
824
870
  const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
825
871
  defineCreatedTime(instance, generatedId);
872
+ // A raw member is from a server the bot isn't in: no cached guild, and its user the interaction's, as discord.js reads it
873
+ const raw = own('member');
874
+ // Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
875
+ if (isRawMember(raw) && own('guild')) {
876
+ warnOnce(mockLogger, "The member given is a raw member, from a server the bot isn't in, but the mock was given a guild, so it reads as a " + 'cached server: leave the guild out for a raw member, or give a GuildMember.');
877
+ } else if (isRawMember(raw)) {
878
+ const rawOnly = "The member given is a raw member, from a server the bot isn't in";
879
+ if (!own('guildId')) throw new Error(`${rawOnly}, so the mock needs that server's guildId.`);
880
+ if (own('channel') !== undefined) throw new Error(`${rawOnly}, where discord.js caches no channel, but the mock was given a channel: leave the channel out.`);
881
+ const givenUser = own('user');
882
+ if (givenUser && givenUser.id !== raw.user.id) {
883
+ throw new Error(`The member given is user ${raw.user.id}'s, but the mock's user is ${givenUser.id}: give the same user, or leave one of them out.`);
884
+ }
885
+ if (!givenUser) {
886
+ const { id, username, global_name: globalName, discriminator, avatar } = raw.user;
887
+ instance.user = createMockUser({
888
+ id,
889
+ username,
890
+ globalName: globalName ?? null,
891
+ discriminator,
892
+ avatar: avatar ?? null
893
+ });
894
+ }
895
+ Object.defineProperty(instance, 'guild', {
896
+ value: null,
897
+ writable: true,
898
+ enumerable: true,
899
+ configurable: true
900
+ });
901
+ // Only its channelId: discord.js caches no channel from a server the bot isn't in
902
+ Object.defineProperty(instance, 'channel', {
903
+ value: null,
904
+ writable: true,
905
+ enumerable: true,
906
+ configurable: true
907
+ });
908
+ }
826
909
  const userGiven = !unset('user');
827
910
  if (!userGiven) instance.user = mockUser();
828
911
  // Assigned once by discord.js's Base constructor, as on a message, and the gateway caches the user on it
@@ -965,29 +1048,25 @@ function createMockInteraction(Class, props) {
965
1048
  * on a double whose shape is an interface with nothing at runtime to read it from.
966
1049
  */ function stubCallable() {
967
1050
  const fn = createMockFn();
968
- const nested = new Map();
969
- return new Proxy(fn, {
970
- get (target, prop) {
971
- if (typeof prop === 'symbol') return Reflect.get(target, prop, target);
972
- const key = prop;
1051
+ // Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
1052
+ // keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
1053
+ const inherited = Object.getPrototypeOf(fn);
1054
+ Object.setPrototypeOf(fn, new Proxy(inherited, {
1055
+ get (proto, prop, receiver) {
1056
+ if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
973
1057
  // Never thenable — otherwise awaiting a mock hangs on itself
974
- if (key === 'then') return undefined;
975
- // The mock's own API (`mock`, `mockReturnValue`, `_isMockFunction`, …) and
976
- // the function intrinsics pass straight through.
977
- if (key in target) return Reflect.get(target, prop, target);
978
- if (!nested.has(key)) nested.set(key, stubCallable());
979
- return nested.get(key);
980
- },
981
- set (target, prop, value) {
982
- Object.defineProperty(target, prop, {
983
- value,
1058
+ if (prop === 'then') return undefined;
1059
+ const nested = stubCallable();
1060
+ Object.defineProperty(receiver, prop, {
1061
+ value: nested,
984
1062
  writable: true,
985
- enumerable: true,
1063
+ enumerable: false,
986
1064
  configurable: true
987
1065
  });
988
- return true;
1066
+ return nested;
989
1067
  }
990
- });
1068
+ }));
1069
+ return fn;
991
1070
  }
992
1071
  /**
993
1072
  * Creates a mock of any type, with no class needed, for service doubles and interfaces.
@@ -1463,10 +1542,16 @@ const CHANNEL_TYPE_GUARDS = [
1463
1542
  });
1464
1543
  return stubDeep(manager);
1465
1544
  }
1466
- /** A server's @everyone role, which has the server's id and ranks lowest, at position 0. */ const everyoneRole = (guildId)=>createMockInteraction(Role, {
1545
+ /** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
1546
+ // A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
1547
+ // permissions Discord gives it in a new server
1548
+ const everyoneRole = (guildId)=>createMockInteraction(Role, {
1467
1549
  id: guildId,
1468
1550
  name: '@everyone',
1469
- position: 0
1551
+ position: 0,
1552
+ ...strictMocks() ? {
1553
+ permissions: new PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
1554
+ } : {}
1470
1555
  });
1471
1556
  /**
1472
1557
  * A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
@@ -1494,6 +1579,68 @@ const CHANNEL_TYPE_GUARDS = [
1494
1579
  });
1495
1580
  return manager;
1496
1581
  }
1582
+ /**
1583
+ * Creates the member Discord sends with an interaction from a server the bot isn't in, as discord.js keeps it.
1584
+ *
1585
+ * Give it as an interaction's `member`, with the server's `guildId` and no `guild`, to test a user-installed command
1586
+ * run there: the interaction reads `inRawGuild()` true, `guild` and `channel` null, `user` the member's user and
1587
+ * `memberPermissions` the member's, and a user option's member is the plain member Discord resolves too.
1588
+ *
1589
+ * @remarks
1590
+ * It is plain data, not a `GuildMember`: `roles` are role ids, `permissions` is the permission bitfield as a string,
1591
+ * and the rest are as Discord sends them, `null` for what may be absent and `false` for a flag. Code that reads
1592
+ * `member.roles.cache` throws on it, as it does in Discord.
1593
+ *
1594
+ * @param overrides - Fields for the member; see {@link MockRawMemberOverrides}.
1595
+ * @returns The member, as an `APIInteractionGuildMember`.
1596
+ *
1597
+ * @example
1598
+ * ```ts
1599
+ * import { expect } from 'vitest'
1600
+ *
1601
+ * const member = createMockRawMember({ roles: ['300000000000000001'], permissions: [PermissionFlagsBits.SendMessages] })
1602
+ * const command = createMockInteraction(ChatInputCommandInteraction, { guildId: '100000000000000001', member })
1603
+ * expect(command.inRawGuild()).toBe(true)
1604
+ * ```
1605
+ *
1606
+ * @group Testing
1607
+ * @category Mocks
1608
+ * @see {@link createMockMember}
1609
+ */ function createMockRawMember(overrides = {}) {
1610
+ const { permissions, user, ...given } = overrides;
1611
+ return {
1612
+ user: {
1613
+ id: nextSnowflake(),
1614
+ username: 'user',
1615
+ discriminator: '0',
1616
+ global_name: null,
1617
+ avatar: null,
1618
+ ...user
1619
+ },
1620
+ roles: [],
1621
+ nick: null,
1622
+ avatar: null,
1623
+ banner: null,
1624
+ premium_since: null,
1625
+ communication_disabled_until: null,
1626
+ joined_at: new Date().toISOString(),
1627
+ deaf: false,
1628
+ mute: false,
1629
+ pending: false,
1630
+ flags: 0,
1631
+ ...given,
1632
+ permissions: String(new PermissionsBitField(permissions ?? NEW_SERVER_EVERYONE_PERMISSIONS).bitfield)
1633
+ };
1634
+ }
1635
+ /** Whether `value` is a member Discord sends from a server the bot isn't in: plain data, not a `GuildMember`. */ function isRawMember(value) {
1636
+ if (typeof value !== 'object' || value === null || value instanceof GuildMember) return false;
1637
+ const { roles, permissions, user } = value;
1638
+ return Array.isArray(roles) && typeof permissions === 'string' && typeof user === 'object' && user !== null;
1639
+ }
1640
+ /** A user option's member as Discord resolves it with an interaction from a server the bot isn't in. */ function rawResolvedMember() {
1641
+ const { user: _user, deaf: _deaf, mute: _mute, ...resolved } = createMockRawMember();
1642
+ return resolved;
1643
+ }
1497
1644
  /**
1498
1645
  * Creates a mock {@link GuildMember}: a user in a server, with the roles given.
1499
1646
  *
@@ -1644,6 +1791,8 @@ const CHANNEL_TYPE_GUARDS = [
1644
1791
  if (!Object.prototype.hasOwnProperty.call(item, 'guild')) item.guild = guild;
1645
1792
  }
1646
1793
  }
1794
+ // Strict mocks cache the bot's member from the start, as the gateway does when the bot joins, so lookups find it
1795
+ if (strictMocks()) void members.me;
1647
1796
  }
1648
1797
  /** The ids each kind of mention in `content` names, in order and once each. */ function mentionedIds(content) {
1649
1798
  const ids = (pattern)=>[
@@ -1866,7 +2015,8 @@ const CHANNEL_TYPE_GUARDS = [
1866
2015
  get: ()=>{
1867
2016
  const found = cacheOf(instance.channel.threads)?.get(instance.id);
1868
2017
  if (found) return found;
1869
- warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread).');
2018
+ if (strictMocks()) return null;
2019
+ warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread), or call useStrictMocks() to have it read null now.');
1870
2020
  return placeholderThread ?? (placeholderThread = stubDeep(Object.create(ThreadChannel.prototype)));
1871
2021
  },
1872
2022
  set: (value)=>Object.defineProperty(instance, 'thread', {
@@ -2135,7 +2285,8 @@ const CHANNEL_TYPE_GUARDS = [
2135
2285
  if (!unowned.has(name)) unowned.set(name, memberIn(undefined, user.id, user));
2136
2286
  return unowned.get(name);
2137
2287
  }
2138
- if (!members.has(name)) members.set(name, owner.guildId ? memberFor(owner.guild, user, true) : null);
2288
+ const rawOwner = !owner.guild && isRawMember(Object.getOwnPropertyDescriptor(owner, 'member')?.value);
2289
+ if (!members.has(name)) members.set(name, owner.guildId ? rawOwner ? rawResolvedMember() : memberFor(owner.guild, user, true) : null);
2139
2290
  return members.get(name) ?? null;
2140
2291
  };
2141
2292
  base.getUser = createMockFn((name, required)=>{
@@ -2194,4 +2345,4 @@ const CHANNEL_TYPE_GUARDS = [
2194
2345
  return resolver;
2195
2346
  }
2196
2347
 
2197
- export { RESPONSE_LOG, createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockUser, memberRoles };
2348
+ export { RESPONSE_LOG, createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockRawMember, createMockUser, isRawMember, memberRoles };
@@ -42,7 +42,7 @@ const VISIBLE = new Set([
42
42
  * @group Testing
43
43
  * @category Inspection
44
44
  * @see {@link TestingModule}
45
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
45
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
46
46
  */ function getResponse(interaction) {
47
47
  const state = existingResponse(interaction);
48
48
  // A mock records every answer it gets; respond()'s edits through the channel are its own, so they are merged in
@@ -1,4 +1,4 @@
1
- import { findComponentRouteConflicts, buildComponentRoutes, matchComponentRoute } from '../core/component-routes.js';
1
+ import { findComponentRouteConflicts, buildComponentRoutes, matchComponentRoute, readCustomId } from '../core/component-routes.js';
2
2
  import { isCustomIdRouted } from '../util/interaction.util.js';
3
3
  import { staticMessageStarts, matchMessageRoute, buildMessageRoutes, fitsScope } from '../core/message-routes.js';
4
4
  import { needsGuild } from '../core/message-params.js';
@@ -47,7 +47,7 @@ import { META } from '../util/metadata-keys.js';
47
47
  * @group Testing
48
48
  * @category Inspection
49
49
  * @see {@link findRouteConflicts}
50
- * @see {@link https://meocord.dev/docs/4.1/components | Buttons, selects and modals}
50
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
51
51
  */ function resolveRoute(app, input) {
52
52
  if ('content' in input) {
53
53
  const { messages = {} } = appOptionsOf(app);
@@ -73,7 +73,10 @@ import { META } from '../util/metadata-keys.js';
73
73
  const { route, params, text } = matched;
74
74
  const method = route.meta.methodName;
75
75
  const typed = Object.keys(route.types).length > 0;
76
- return {
76
+ // The routes ranked behind it that the customId matches too, which dispatch never reaches for it
77
+ const behind = routes.slice(routes.indexOf(route) + 1);
78
+ const alsoMatches = behind.filter((other)=>other.meta.type === input.type && readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
79
+ const resolved = {
77
80
  controller: route.controllerClass,
78
81
  method,
79
82
  handler: route.controllerClass.prototype[method],
@@ -82,28 +85,34 @@ import { META } from '../util/metadata-keys.js';
82
85
  values: params
83
86
  }
84
87
  };
88
+ return alsoMatches.length > 0 ? readableOnly(resolved, {
89
+ alsoMatches
90
+ }) : resolved;
85
91
  }
86
92
  /**
87
- * Finds component patterns that can match the same `customId`, which MeoCord otherwise only warns about at startup.
93
+ * Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
94
+ * about at startup.
88
95
  *
89
- * Use it in a test to keep the warning from reaching production: two patterns that trade a literal for a parameter
90
- * in opposite places both take one id, and neither is more specific. Patterns are compared within a component type.
96
+ * Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
97
+ * param and then the narrower type, so two collide only with the same literals and equally narrow params at each
98
+ * position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
99
+ * first runs. Patterns are compared within a component type.
91
100
  *
92
101
  * @param app - The application class decorated with `@MeoCord`.
93
- * @returns Each pair of patterns that can match the same `customId`, with its component type.
102
+ * @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
94
103
  *
95
104
  * @example
96
105
  * ```ts
97
106
  * import { expect } from 'vitest'
98
107
  *
99
108
  * @Controller()
100
- * class CardController {
101
- * @Command('card/{id}/open', CommandType.BUTTON)
102
- * async open(interaction: ButtonInteraction) { await respond(interaction).send('Opened.') }
103
- * @Command('card/new/{kind}', CommandType.BUTTON)
104
- * async create(interaction: ButtonInteraction) { await respond(interaction).send('Created.') }
109
+ * class TicketController {
110
+ * @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
111
+ * async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
112
+ * @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
113
+ * async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
105
114
  * }
106
- * @MeoCord({ controllers: [CardController], clientOptions: { intents: [] } })
115
+ * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
107
116
  * class App {}
108
117
  * expect(findRouteConflicts(App)).toHaveLength(1)
109
118
  * ```
@@ -112,7 +121,23 @@ import { META } from '../util/metadata-keys.js';
112
121
  * @category Inspection
113
122
  * @see {@link resolveRoute}
114
123
  */ function findRouteConflicts(app) {
115
- return findComponentRouteConflicts(buildComponentRoutes(controllersOf(app)));
124
+ return findComponentRouteConflicts(buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
125
+ type: type,
126
+ patterns
127
+ }, {
128
+ runs
129
+ }));
130
+ }
131
+ /**
132
+ * `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
133
+ * don't see, so an assertion written for the fields it had before still holds.
134
+ */ function readableOnly(target, fields) {
135
+ for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
136
+ value,
137
+ enumerable: false,
138
+ configurable: true
139
+ });
140
+ return target;
116
141
  }
117
142
 
118
143
  export { findRouteConflicts, resolveRoute };
@@ -0,0 +1,26 @@
1
+ import { setStartupErrorsMode } from '../util/refusal.util.js';
2
+
3
+ /**
4
+ * Has decorators keep the startup errors they find, as a bot built with `startupErrors: 'all'` does, so a testing
5
+ * module reports every one at once.
6
+ *
7
+ * Call it once in the test runner's setup file, before any test imports a controller: a decorator runs when its class
8
+ * is defined, as its file is imported. A decorator given what it cannot use then throws nothing as the file loads, and
9
+ * `MeoCordTestingModule.create(...).compile()` logs every startup error of the classes it runs, each with its handler
10
+ * and file, then throws the first. Without it, a decorator throws its error as its class is defined.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // vitest.setup.ts
15
+ * reportAllStartupErrors()
16
+ * ```
17
+ *
18
+ * @group Testing
19
+ * @category Module
20
+ * @see {@link MeoCordTestingModule}
21
+ * @see {@link https://meocord.dev/docs/4.2/configuration | Configuration}
22
+ */ function reportAllStartupErrors() {
23
+ setStartupErrorsMode('all');
24
+ }
25
+
26
+ export { reportAllStartupErrors };
@@ -0,0 +1,45 @@
1
+ // Whether useStrictMocks() is on, and whether a mock was made before it could be
2
+ let strict = false;
3
+ let mockMade = false;
4
+ /** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>strict;
5
+ /** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
6
+ mockMade = true;
7
+ }
8
+ /**
9
+ * Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
10
+ *
11
+ * Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
12
+ * `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
13
+ * role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest, and
14
+ * `partial` on users, channels and reactions then run discord.js's own getters against the mock, and `message.thread`
15
+ * is `null` when the channel caches no thread under the message's id. No placeholder warning is logged.
16
+ *
17
+ * @remarks
18
+ * Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in
19
+ * the server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a
20
+ * channel or thread made without a server has one of its own, a thread with a text channel as its parent. A message
21
+ * has empty `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. Give the bot's member
22
+ * a role to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the
23
+ * computed one. The next major version (5.0) computes these values without the call.
24
+ *
25
+ * @throws Error when a mock already exists, which was made without it.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // vitest.setup.ts
30
+ * useStrictMocks()
31
+ * ```
32
+ *
33
+ * @group Testing
34
+ * @category Mocks
35
+ * @see {@link useMockFn}
36
+ * @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
37
+ */ function useStrictMocks() {
38
+ if (strict) return;
39
+ if (mockMade) {
40
+ throw new Error('useStrictMocks() goes before any mock is made: a mock made before it reads placeholders, and the two kinds would ' + "mix. Call it once, in the test runner's setup file.");
41
+ }
42
+ strict = true;
43
+ }
44
+
45
+ export { noteMockMade, strictMocks, useStrictMocks };