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,9 +1,9 @@
1
1
  import 'reflect-metadata';
2
- import { handlerMethods, classLevelGuards, classChain, consumeDispatchMark, runDirectCall, declaringPrototype } from '../core/guard-runner.js';
2
+ import { handlerMethods, classLevelGuards, consumeDispatchMark, runDirectCall, declaringPrototype, classChain } from '../core/guard-runner.js';
3
3
  import { makeInjectable } from '../util/injectable.util.js';
4
4
  import { defineStageTypes, assertStageEntries } from '../core/stage-scope.js';
5
5
  import { META } from '../util/metadata-keys.js';
6
- import { deprecatedOnMethod } from '../util/refusal.util.js';
6
+ import { declaring, deprecatedOnMethod } from '../util/refusal.util.js';
7
7
 
8
8
  /**
9
9
  * Adds guards to a method's class or method list, then republishes the effective list under
@@ -119,14 +119,14 @@ import { deprecatedOnMethod } from '../util/refusal.util.js';
119
119
  * @see {@link UseGuard}
120
120
  * @see {@link GuardDeniedError}
121
121
  * @see {@link ExecutionContext}
122
- * @see {@link https://meocord.dev/docs/4.1/guards | Guards}
122
+ * @see {@link https://meocord.dev/docs/4.2/guards | Guards}
123
123
  */ function Guard(options = {}) {
124
- return function(target, propertyKey) {
124
+ return declaring(function(target, propertyKey) {
125
125
  if (deprecatedOnMethod('@Guard', target, propertyKey)) return;
126
126
  makeInjectable(target);
127
127
  defineStageTypes(target, options.types, 'Guard');
128
128
  Reflect.defineMetadata(META.guardClass, true, target);
129
- };
129
+ });
130
130
  }
131
131
  /**
132
132
  * Runs guards before a handler, or before every handler of a controller.
@@ -157,10 +157,10 @@ import { deprecatedOnMethod } from '../util/refusal.util.js';
157
157
  * @see {@link Guard}
158
158
  * @see {@link GuardDeniedError}
159
159
  * @see {@link ExecutionContext}
160
- * @see {@link https://meocord.dev/docs/4.1/guards | Guards}
160
+ * @see {@link https://meocord.dev/docs/4.2/guards | Guards}
161
161
  */ function UseGuard(...entries) {
162
162
  const guards = entries;
163
- return function(target, propertyKey, descriptor) {
163
+ return declaring(function(target, propertyKey, descriptor) {
164
164
  const where = propertyKey === undefined ? String(target?.name) : `${target.constructor.name}.${String(propertyKey)}`;
165
165
  assertStageEntries('@UseGuard', 'guard', where, guards);
166
166
  if (descriptor && propertyKey) {
@@ -185,7 +185,7 @@ import { deprecatedOnMethod } from '../util/refusal.util.js';
185
185
  }
186
186
  }
187
187
  }
188
- };
188
+ });
189
189
  }
190
190
 
191
191
  export { Guard, UseGuard, guardOwnHandlersWithBaseGuards };
@@ -1,5 +1,5 @@
1
1
  import { inject } from 'inversify';
2
- import { refuseOnClass } from '../util/refusal.util.js';
2
+ import { declaring, refuseOnClass } from '../util/refusal.util.js';
3
3
 
4
4
  /**
5
5
  * Injects what a token provides into a constructor parameter.
@@ -24,14 +24,14 @@ import { refuseOnClass } from '../util/refusal.util.js';
24
24
  * @category Params
25
25
  * @see {@link Service}
26
26
  * @see {@link createToken}
27
- * @see {@link https://meocord.dev/docs/4.1/services | Services}
27
+ * @see {@link https://meocord.dev/docs/4.2/services | Services}
28
28
  */ function Inject(token) {
29
29
  const decorate = inject(token);
30
- return (target, propertyKey, parameterIndex)=>{
30
+ return declaring((target, propertyKey, parameterIndex)=>{
31
31
  // A constructor parameter has no property key either, but has an index
32
32
  if (typeof parameterIndex !== 'number') refuseOnClass('@Inject', target, propertyKey, 'a constructor parameter or a property');
33
33
  decorate(target, propertyKey, parameterIndex);
34
- };
34
+ });
35
35
  }
36
36
 
37
37
  export { Inject };
@@ -2,7 +2,7 @@ import 'reflect-metadata';
2
2
  import { makeInjectable } from '../util/injectable.util.js';
3
3
  import { defineStageTypes, assertStageEntries } from '../core/stage-scope.js';
4
4
  import { META } from '../util/metadata-keys.js';
5
- import { refuseOnMethod } from '../util/refusal.util.js';
5
+ import { declaring, refuseOnMethod } from '../util/refusal.util.js';
6
6
 
7
7
  /**
8
8
  * Marks a class as an interceptor, which wraps a handler to act before and after it.
@@ -36,13 +36,13 @@ import { refuseOnMethod } from '../util/refusal.util.js';
36
36
  * @group Decorators
37
37
  * @category Pipeline stages
38
38
  * @see {@link UseInterceptor}
39
- * @see {@link https://meocord.dev/docs/4.1/interceptors | Interceptors}
39
+ * @see {@link https://meocord.dev/docs/4.2/interceptors | Interceptors}
40
40
  */ function Interceptor(options = {}) {
41
- return function(target, propertyKey) {
41
+ return declaring(function(target, propertyKey) {
42
42
  refuseOnMethod('@Interceptor', target, propertyKey);
43
43
  makeInjectable(target);
44
44
  defineStageTypes(target, options.types, 'Interceptor');
45
- };
45
+ });
46
46
  }
47
47
  /**
48
48
  * Runs interceptors around a handler, or around every handler of a controller.
@@ -73,9 +73,9 @@ import { refuseOnMethod } from '../util/refusal.util.js';
73
73
  * @group Decorators
74
74
  * @category Pipeline stages
75
75
  * @see {@link Interceptor}
76
- * @see {@link https://meocord.dev/docs/4.1/interceptors | Interceptors}
76
+ * @see {@link https://meocord.dev/docs/4.2/interceptors | Interceptors}
77
77
  */ function UseInterceptor(...interceptors) {
78
- return function(target, propertyKey) {
78
+ return declaring(function(target, propertyKey) {
79
79
  const where = propertyKey === undefined ? target.name : `${target.constructor.name}.${String(propertyKey)}`;
80
80
  assertStageEntries('@UseInterceptor', 'interceptor', where, interceptors);
81
81
  // Decorators apply bottom-up, so a higher decorator's interceptors go first, as with @UseGuard.
@@ -92,7 +92,7 @@ import { refuseOnMethod } from '../util/refusal.util.js';
92
92
  ...existing
93
93
  ], target, propertyKey);
94
94
  }
95
- };
95
+ });
96
96
  }
97
97
 
98
98
  export { Interceptor, UseInterceptor };
@@ -1,7 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { makeInjectable } from '../util/injectable.util.js';
3
3
  import { defineStageTypes } from '../core/stage-scope.js';
4
- import { refuseOnMethod, refuse } from '../util/refusal.util.js';
4
+ import { declaring, refuseOnMethod, refuse } from '../util/refusal.util.js';
5
5
  import { META } from '../util/metadata-keys.js';
6
6
 
7
7
  /**
@@ -35,9 +35,9 @@ import { META } from '../util/metadata-keys.js';
35
35
  * @pipeline observers-settled once the call has settled and been answered, however it ended
36
36
  * @group Decorators
37
37
  * @category Pipeline stages
38
- * @see {@link https://meocord.dev/docs/4.1/observers | Observers}
38
+ * @see {@link https://meocord.dev/docs/4.2/observers | Observers}
39
39
  */ function Observer(options = {}) {
40
- return function(target, propertyKey) {
40
+ return declaring(function(target, propertyKey) {
41
41
  refuseOnMethod('@Observer', target, propertyKey);
42
42
  if (typeof target.prototype.onSettled !== 'function') {
43
43
  throw refuse(new Error(`${target.name}: an @Observer needs an onSettled method, and it has none.`));
@@ -45,7 +45,7 @@ import { META } from '../util/metadata-keys.js';
45
45
  makeInjectable(target);
46
46
  defineStageTypes(target, options.types, 'Observer');
47
47
  Reflect.defineMetadata(META.observerClass, true, target);
48
- };
48
+ });
49
49
  }
50
50
 
51
51
  export { Observer };
@@ -1,6 +1,6 @@
1
1
  import 'reflect-metadata';
2
2
  import { makeInjectable } from '../util/injectable.util.js';
3
- import { deprecatedOnMethod } from '../util/refusal.util.js';
3
+ import { declaring, deprecatedOnMethod } from '../util/refusal.util.js';
4
4
 
5
5
  /**
6
6
  * Marks a class as a service, which controllers and other services inject by its type.
@@ -27,12 +27,12 @@ import { deprecatedOnMethod } from '../util/refusal.util.js';
27
27
  * @group Decorators
28
28
  * @category App
29
29
  * @see {@link Inject}
30
- * @see {@link https://meocord.dev/docs/4.1/services | Services}
30
+ * @see {@link https://meocord.dev/docs/4.2/services | Services}
31
31
  */ function Service() {
32
- return function(target, propertyKey) {
32
+ return declaring(function(target, propertyKey) {
33
33
  if (deprecatedOnMethod('@Service', target, propertyKey)) return;
34
34
  makeInjectable(target);
35
- };
35
+ });
36
36
  }
37
37
 
38
38
  export { Service };
@@ -2,7 +2,7 @@ import 'reflect-metadata';
2
2
  import { assertValidTheme } from '../core/theme-validation.js';
3
3
  import { THEMED_CLASSES } from '../core/theme-runtime.js';
4
4
  import { copyLayer } from '../core/theme-scope.js';
5
- import { refuse } from '../util/refusal.util.js';
5
+ import { declaring, refuse } from '../util/refusal.util.js';
6
6
  import { META } from '../util/metadata-keys.js';
7
7
 
8
8
  /**
@@ -35,7 +35,7 @@ import { META } from '../util/metadata-keys.js';
35
35
  * @group Decorators
36
36
  * @category Controllers
37
37
  */ function UseTheme(theme) {
38
- return function(target, propertyKey) {
38
+ return declaring(function(target, propertyKey) {
39
39
  const onClass = propertyKey === undefined;
40
40
  const owner = onClass ? target : target.constructor;
41
41
  const where = `${owner.name}${onClass ? '' : `.${String(propertyKey)}`}: @UseTheme`;
@@ -47,7 +47,7 @@ import { META } from '../util/metadata-keys.js';
47
47
  if (onClass) Reflect.defineMetadata(key, layer, target);
48
48
  else Reflect.defineMetadata(key, layer, target, propertyKey);
49
49
  THEMED_CLASSES.add(owner);
50
- };
50
+ });
51
51
  }
52
52
 
53
53
  export { UseTheme };
@@ -1,7 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { makeInjectable } from '../util/injectable.util.js';
3
3
  import { assertStageEntries } from '../core/stage-scope.js';
4
- import { refuseOnMethod, refuseOnClass, refuse } from '../util/refusal.util.js';
4
+ import { declaring, refuseOnClass, refuse, refuseOnMethod } from '../util/refusal.util.js';
5
5
  import { META } from '../util/metadata-keys.js';
6
6
 
7
7
  /**
@@ -37,9 +37,9 @@ import { META } from '../util/metadata-keys.js';
37
37
  * @category Pipeline stages
38
38
  * @see {@link UsePipe}
39
39
  * @see {@link ValidationError}
40
- * @see {@link https://meocord.dev/docs/4.1/validation | Validation and pipes}
40
+ * @see {@link https://meocord.dev/docs/4.2/validation | Validation and pipes}
41
41
  */ function Validate(schema, options = {}) {
42
- return function(target, propertyKey, _descriptor) {
42
+ return declaring(function(target, propertyKey, _descriptor) {
43
43
  refuseOnClass('@Validate', target, propertyKey);
44
44
  // Checked where it applies, so the refusal names the handler
45
45
  if (typeof schema?.['~standard']?.validate !== 'function') {
@@ -57,7 +57,7 @@ import { META } from '../util/metadata-keys.js';
57
57
  pipes: options.pipes ?? {}
58
58
  };
59
59
  Reflect.defineMetadata(META.methodValidation, metadata, target, propertyKey);
60
- };
60
+ });
61
61
  }
62
62
  /**
63
63
  * Runs pipes on one value of a handler's input, to turn it into what the handler works with.
@@ -89,9 +89,9 @@ import { META } from '../util/metadata-keys.js';
89
89
  * @category Pipeline stages
90
90
  * @see {@link Pipe}
91
91
  * @see {@link Validate}
92
- * @see {@link https://meocord.dev/docs/4.1/validation | Validation and pipes}
92
+ * @see {@link https://meocord.dev/docs/4.2/validation | Validation and pipes}
93
93
  */ function UsePipe(key, ...pipes) {
94
- return function(target, propertyKey, _descriptor) {
94
+ return declaring(function(target, propertyKey, _descriptor) {
95
95
  refuseOnClass('@UsePipe', target, propertyKey);
96
96
  assertStageEntries('@UsePipe', 'pipe', `${target.constructor.name}.${propertyKey}`, pipes);
97
97
  // Decorators apply bottom-up, so a higher @UsePipe's pipes go first, in the order they read.
@@ -103,7 +103,7 @@ import { META } from '../util/metadata-keys.js';
103
103
  })),
104
104
  ...existing
105
105
  ], target, propertyKey);
106
- };
106
+ });
107
107
  }
108
108
  /**
109
109
  * Marks a class as a pipe, which turns one input value into what the handler works with.
@@ -129,12 +129,12 @@ import { META } from '../util/metadata-keys.js';
129
129
  * @group Decorators
130
130
  * @category Pipeline stages
131
131
  * @see {@link UsePipe}
132
- * @see {@link https://meocord.dev/docs/4.1/validation | Validation and pipes}
132
+ * @see {@link https://meocord.dev/docs/4.2/validation | Validation and pipes}
133
133
  */ function Pipe() {
134
- return function(target, propertyKey) {
134
+ return declaring(function(target, propertyKey) {
135
135
  refuseOnMethod('@Pipe', target, propertyKey);
136
136
  makeInjectable(target);
137
- };
137
+ });
138
138
  }
139
139
 
140
140
  export { Pipe, UsePipe, Validate };
@@ -1,4 +1,4 @@
1
- var version = "4.1.2";
1
+ var version = "4.2.0";
2
2
  var engines = {
3
3
  node: ">=22.13"
4
4
  };
@@ -79,7 +79,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
79
79
  * @group Testing
80
80
  * @category Inspection
81
81
  * @see {@link createTranslator}
82
- * @see {@link https://meocord.dev/docs/4.1/localisation | Localisation}
82
+ * @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
83
83
  */ function expectCompleteCatalog(translator, options = {}) {
84
84
  const catalogs = translator[CATALOGS];
85
85
  if (!catalogs) throw new Error('expectCompleteCatalog takes a translator made by createTranslator.');
@@ -3,6 +3,7 @@ import { CooldownStore } from '../common/cooldown-store.js';
3
3
 
4
4
  /** Long enough for a store over the network to answer each case, short enough that the suite takes seconds. */ const CASE_TIMEOUT_MS = 10000;
5
5
  const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
6
+ /** How far a store's clock may run from this process's, as a store's server keeps its own time. */ const CLOCK_SLACK_MS = 50;
6
7
  /**
7
8
  * Checks that a `CooldownStore` counts calls as `MemoryCooldownStore` does, as a suite of test cases.
8
9
  *
@@ -13,7 +14,8 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
13
14
  * The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
14
15
  * `retryAfterMs` from the oldest call still in the window, calls in the same millisecond kept apart, and concurrent
15
16
  * calls at the limit where exactly one may pass. They use real time, with windows short enough that the suite takes
16
- * a few seconds, and each case counts under keys of its own. Each window is a whole number of milliseconds, as
17
+ * a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
18
+ * and each case counts under keys of its own. Each window is a whole number of milliseconds, as
17
19
  * `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
18
20
  *
19
21
  * @param name - What the store is called in the report.
@@ -30,7 +32,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
30
32
  * @group Testing
31
33
  * @category Inspection
32
34
  * @see {@link CooldownStore}
33
- * @see {@link https://meocord.dev/docs/4.1/recipes/cooldown-stores | Cooldown stores}
35
+ * @see {@link https://meocord.dev/docs/4.2/recipes/cooldown-stores | Cooldown stores}
34
36
  */ function testCooldownStore(name, factory, { describe, it, expect }) {
35
37
  const run = randomUUID();
36
38
  const key = (label)=>`meocord-suite:${run}:${label}`;
@@ -53,32 +55,38 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
53
55
  expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
54
56
  });
55
57
  test('slides its window: a call is allowed once the oldest leaves it, not when a bucket resets', async (store)=>{
58
+ // A second between calls, in a 2 s window, leaves room for a timer that fires late on a busy runner
56
59
  const limit = {
57
60
  uses: 2,
58
- windowMs: 500
61
+ windowMs: 2000
59
62
  };
60
63
  await store.consume(key('sliding'), limit);
61
- await sleep(250);
64
+ await sleep(1000);
62
65
  await store.consume(key('sliding'), limit);
63
66
  const refused = await store.consume(key('sliding'), limit);
64
67
  expect(refused.allowed).toBe(false);
65
68
  await sleep(refused.retryAfterMs + 40);
66
- // The first call has left the window; the second, 250ms younger, still holds its use.
69
+ // The first call has left the window; the second, a second younger, still holds its use.
67
70
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(true);
68
71
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(false);
69
72
  });
70
73
  test('counts retryAfterMs from the oldest call still in the window', async (store)=>{
71
74
  const limit = {
72
75
  uses: 2,
73
- windowMs: 1000
76
+ windowMs: 2000
74
77
  };
78
+ const firstSent = Date.now();
75
79
  await store.consume(key('retry'), limit);
76
- await sleep(300);
80
+ const firstDone = Date.now();
81
+ await sleep(1000);
82
+ const secondSent = Date.now();
77
83
  await store.consume(key('retry'), limit);
78
- // From the oldest call, about 700ms remain; from the newest, about 1000ms would.
79
84
  const { retryAfterMs } = await store.consume(key('retry'), limit);
80
- expect(retryAfterMs).toBeGreaterThan(limit.windowMs - 600);
81
- expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - 200);
85
+ const thirdDone = Date.now();
86
+ // What is left of the oldest call's window, by the times measured around each call, as timers may fire late;
87
+ // counted from the newest call, nearly the whole window would be left instead
88
+ expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
89
+ expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
82
90
  });
83
91
  test('counts each key on its own', async (store)=>{
84
92
  const limit = {
@@ -1,5 +1,6 @@
1
- import { GuildPremiumTier, GuildVerificationLevel, GuildNSFWLevel, Locale, Guild, ClientUser, User, GuildMember, Role, Collection, MessageType, Message, CommandInteraction, AutocompleteInteraction, GuildChannel, BaseInteraction, ChannelType, TextChannel, NewsChannel, VoiceChannel, ForumChannel, MediaChannel, ThreadChannel, DMChannel, BaseChannel, Client, BaseGuild, SnowflakeUtil, PermissionsBitField } from 'discord.js';
2
- import { memberRoles, createMockUser, createMockGuild } from './mock-interaction.js';
1
+ import { Message, Guild, TextChannel, ThreadChannel, GuildChannel, BaseGuildVoiceChannel, MessageReaction, GuildPremiumTier, GuildVerificationLevel, GuildNSFWLevel, Locale, ClientUser, User, GuildMember, Role, Collection, MessageType, CommandInteraction, AutocompleteInteraction, PermissionsBitField, BaseInteraction, ChannelType, NewsChannel, VoiceChannel, ForumChannel, MediaChannel, DMChannel, BaseChannel, Client, BaseGuild, SnowflakeUtil } from 'discord.js';
2
+ import { createMockChannel, createMockGuild, memberRoles, createMockUser, isRawMember } from './mock-interaction.js';
3
+ import { strictMocks } from './strict-mocks.js';
3
4
 
4
5
  /** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new PermissionsBitField().freeze();
5
6
  /** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
@@ -123,7 +124,14 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
123
124
  version: ()=>1,
124
125
  context: ()=>null,
125
126
  // What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
126
- memberPermissions: (interaction)=>interaction.guildId && interaction.member instanceof GuildMember ? interaction.member.permissions : null,
127
+ memberPermissions: (interaction)=>{
128
+ const { guildId } = interaction;
129
+ const member = interaction.member;
130
+ if (!guildId) return null;
131
+ if (member instanceof GuildMember) return member.permissions;
132
+ // A raw member's permissions are the string Discord sends, read as discord.js reads them
133
+ return isRawMember(member) && !interaction.guild ? new PermissionsBitField(BigInt(member.permissions)).freeze() : null;
134
+ },
127
135
  // The bot's permissions where the interaction was made: its member's in the channel, or none outside a server
128
136
  appPermissions: (interaction)=>{
129
137
  const { guild, channel } = interaction;
@@ -184,10 +192,55 @@ const snowflake = ()=>SnowflakeUtil.generate().toString();
184
192
  nameAcronym: REAL_GETTER
185
193
  })
186
194
  ]);
187
- /** The default a mock of `target`'s class has for `key`, read from the nearest class that has one. */ function discordDefault(target, key) {
188
- for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
189
- const found = DEFAULTS.get(proto)?.[key];
190
- if (found !== undefined) return found;
195
+ /**
196
+ * What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
197
+ * thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
198
+ */ const STRICT_DEFAULTS = new Map([
199
+ defaultsOf(Message, {
200
+ content: ()=>''
201
+ }),
202
+ defaultsOf(Guild, {
203
+ rulesChannelId: ()=>null,
204
+ publicUpdatesChannelId: ()=>null
205
+ }),
206
+ defaultsOf(ThreadChannel, {
207
+ guild: ()=>createMockGuild(),
208
+ guildId: (thread)=>thread.guild.id,
209
+ parentId: (thread)=>{
210
+ const parent = createMockChannel(TextChannel, {
211
+ guild: thread.guild
212
+ });
213
+ thread.guild.channels.cache.set(parent.id, parent);
214
+ return parent.id;
215
+ },
216
+ parent: REAL_GETTER,
217
+ ownerId: snowflake,
218
+ archived: ()=>false,
219
+ locked: ()=>false
220
+ }),
221
+ defaultsOf(GuildChannel, {
222
+ guild: ()=>createMockGuild()
223
+ }),
224
+ // Full once its members reach a user limit, which a voice channel has none of unless given one
225
+ defaultsOf(BaseGuildVoiceChannel, {
226
+ userLimit: ()=>0,
227
+ full: REAL_GETTER
228
+ }),
229
+ defaultsOf(MessageReaction, {
230
+ count: ()=>1
231
+ })
232
+ ]);
233
+ /** The default a mock of `target`'s class has for `key`, read from the nearest class that has one, strict ones first. */ function discordDefault(target, key) {
234
+ for (const defaults of strictMocks() ? [
235
+ STRICT_DEFAULTS,
236
+ DEFAULTS
237
+ ] : [
238
+ DEFAULTS
239
+ ]){
240
+ for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
241
+ const found = defaults.get(proto)?.[key];
242
+ if (found !== undefined) return found;
243
+ }
191
244
  }
192
245
  return undefined;
193
246
  }
@@ -1,6 +1,8 @@
1
1
  export { MeoCordTestingModule, TestingModule, TestingModuleBuilder } from './meocord-testing-module.js';
2
- export { createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockUser } from './mock-interaction.js';
3
- export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks } from './mock-fn.js';
2
+ export { createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockRawMember, createMockUser } from './mock-interaction.js';
3
+ export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks, useMockFn } from './mock-fn.js';
4
+ export { useStrictMocks } from './strict-mocks.js';
5
+ export { reportAllStartupErrors } from './startup-errors.js';
4
6
  export { findRouteConflicts, resolveRoute } from './routing.js';
5
7
  export { createModalFields } from './modal-fields.js';
6
8
  export { expectCompleteCatalog } from './catalog.js';
@@ -2,7 +2,7 @@ import 'reflect-metadata';
2
2
  import { HandlerExecutionContext } from '../common/execution-context.js';
3
3
  import { appStages, handlerStages } from '../core/handler-pipeline.js';
4
4
  import { handlerCooldowns } from '../core/cooldown-runner.js';
5
- import { getMessageHandlers } from '../decorator/controller.decorator.js';
5
+ import { getMessageHandlers, getHandlerRoutes, getDeclaredRoutes } from '../decorator/controller.decorator.js';
6
6
  import { appObservers } from '../core/observer-runner.js';
7
7
 
8
8
  /**
@@ -37,7 +37,7 @@ import { appObservers } from '../core/observer-runner.js';
37
37
  * @group Testing
38
38
  * @category Inspection
39
39
  * @see {@link createExecutionContext}
40
- * @see {@link https://meocord.dev/docs/4.1/testing-recipes | Testing recipes}
40
+ * @see {@link https://meocord.dev/docs/4.2/testing-recipes | Testing recipes}
41
41
  */ function inspectHandler(controller, methodName, options = {}) {
42
42
  const context = new HandlerExecutionContext({
43
43
  controller,
@@ -46,7 +46,7 @@ import { appObservers } from '../core/observer-runner.js';
46
46
  });
47
47
  const globals = options.app ? appStages(options.app) : undefined;
48
48
  const { guards, interceptors, filters } = handlerStages(controller.prototype, methodName, globals);
49
- return {
49
+ const inspection = {
50
50
  controller,
51
51
  methodName,
52
52
  guards: Object.freeze([
@@ -68,6 +68,23 @@ import { appObservers } from '../core/observer-runner.js';
68
68
  get: (metadata)=>context.get(metadata),
69
69
  getAll: (metadata)=>context.getAll(metadata)
70
70
  };
71
+ // Read by a test but not compared by toEqual, so an assertion on the result's other fields holds
72
+ Object.defineProperty(inspection, 'inheritedRoutes', {
73
+ value: Object.freeze(inheritedRoutesOf(controller.prototype, methodName)),
74
+ enumerable: false
75
+ });
76
+ return inspection;
77
+ }
78
+ /** The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits. */ function inheritedRoutesOf(prototype, methodName) {
79
+ const answered = [
80
+ ...new Set(getHandlerRoutes(prototype).filter((route)=>route.method === methodName).map((route)=>route.label))
81
+ ];
82
+ // The nearest class with its own decorators on the method; a class that only overrides it declares none
83
+ for(let at = prototype; at && at !== Object.prototype; at = Object.getPrototypeOf(at)){
84
+ const own = getDeclaredRoutes(at).filter((route)=>route.method === methodName).map((route)=>route.label);
85
+ if (own.length > 0) return at === prototype ? answered.filter((label)=>!own.includes(label)) : answered;
86
+ }
87
+ return [];
71
88
  }
72
89
 
73
90
  export { inspectHandler };