meocord 1.8.3 → 1.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -20
- package/dist/cjs/testing/index.cjs +242 -44
- package/dist/esm/testing/index.js +1 -0
- package/dist/esm/testing/meocord-testing-module.js +3 -1
- package/dist/esm/testing/mock-fn.js +196 -0
- package/dist/esm/testing/mock-interaction.js +43 -43
- package/dist/types/testing/index.d.ts +79 -14
- package/meocord.eslint.cjs +1 -1
- package/meocord.eslint.mjs +1 -1
- package/package.json +16 -16
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ export class App {}
|
|
|
124
124
|
.
|
|
125
125
|
├── meocord.config.ts
|
|
126
126
|
├── eslint.config.ts
|
|
127
|
-
├──
|
|
127
|
+
├── vitest.config.ts
|
|
128
128
|
├── tsconfig.json
|
|
129
129
|
├── tsconfig.eslint.json
|
|
130
130
|
├── tsconfig.test.json
|
|
@@ -337,7 +337,21 @@ async ban(interaction: ChatInputCommandInteraction) { ... }
|
|
|
337
337
|
|
|
338
338
|
## Testing
|
|
339
339
|
|
|
340
|
-
MeoCord ships a `meocord/testing` entry point with utilities for testing controllers in isolation — no real Discord connection required.
|
|
340
|
+
MeoCord ships a `meocord/testing` entry point with utilities for testing controllers in isolation — no real Discord connection required. The framework repo runs tests with [Vitest](https://vitest.dev/); the mocks themselves are **framework-agnostic** and work with Vitest or Jest assertions (see below).
|
|
341
|
+
|
|
342
|
+
### Running tests
|
|
343
|
+
|
|
344
|
+
From the MeoCord repo root:
|
|
345
|
+
|
|
346
|
+
```shell
|
|
347
|
+
bun run test # run once
|
|
348
|
+
bun run test:watch # watch mode
|
|
349
|
+
bun run test:coverage # coverage report
|
|
350
|
+
bun run test:typecheck # tsc -p tsconfig.test.json
|
|
351
|
+
bun run lint # eslint --fix + tsc
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
In your own bot project, add Vitest (or keep Jest) plus a `vitest.config.ts` with SWC if you use decorator metadata — see this repo's config for reference. Generated apps include `*.spec.ts` stubs but not a test runner config yet.
|
|
341
355
|
|
|
342
356
|
### `MeoCordTestingModule`
|
|
343
357
|
|
|
@@ -360,9 +374,11 @@ const controller = module.get(GreetingSlashController)
|
|
|
360
374
|
|
|
361
375
|
Creates a smart mock instance of any discord.js class. The full prototype chain is preserved so `instanceof` checks pass at every level.
|
|
362
376
|
|
|
363
|
-
**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
|
|
377
|
+
**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
|
+
|
|
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.
|
|
364
380
|
|
|
365
|
-
|
|
381
|
+
> **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`.
|
|
366
382
|
|
|
367
383
|
```typescript
|
|
368
384
|
import { createMockInteraction } from 'meocord/testing'
|
|
@@ -385,7 +401,7 @@ await interaction.reply({ content: 'hi' })
|
|
|
385
401
|
interaction.replied // → true
|
|
386
402
|
await interaction.reply({ content: 'again' }) // → throws (already replied)
|
|
387
403
|
|
|
388
|
-
// still
|
|
404
|
+
// still a mock fn — call assertions work normally
|
|
389
405
|
expect(interaction.reply).toHaveBeenCalledWith({ content: 'hi' })
|
|
390
406
|
|
|
391
407
|
// direct property writes work normally
|
|
@@ -420,14 +436,20 @@ interaction.options.getString('duration') // → null (wrong type)
|
|
|
420
436
|
interaction.options.getNumber('x', true) // → throws (absent + required)
|
|
421
437
|
```
|
|
422
438
|
|
|
423
|
-
All methods are
|
|
439
|
+
All methods are mock functions — override any per test with `.mockReturnValue()`.
|
|
424
440
|
|
|
425
441
|
### `createMockUser` / `createMockClient` / `createMockGuild` / `createMockChannel`
|
|
426
442
|
|
|
427
|
-
Convenience wrappers for common discord.js classes. All methods are auto-stubbed as
|
|
443
|
+
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.
|
|
428
444
|
|
|
429
445
|
```typescript
|
|
430
|
-
import {
|
|
446
|
+
import {
|
|
447
|
+
createMockFn,
|
|
448
|
+
createMockUser,
|
|
449
|
+
createMockClient,
|
|
450
|
+
createMockGuild,
|
|
451
|
+
createMockChannel,
|
|
452
|
+
} from 'meocord/testing'
|
|
431
453
|
import { TextChannel } from 'discord.js'
|
|
432
454
|
|
|
433
455
|
const user = createMockUser()
|
|
@@ -435,15 +457,15 @@ const client = createMockClient()
|
|
|
435
457
|
const guild = createMockGuild()
|
|
436
458
|
const channel = createMockChannel(TextChannel)
|
|
437
459
|
|
|
438
|
-
// override nested manager methods per test
|
|
439
|
-
;(client.users as any).fetch =
|
|
460
|
+
// override nested manager methods per test (createMockFn works with vitest and jest matchers)
|
|
461
|
+
;(client.users as any).fetch = createMockFn(() => Promise.resolve(user))
|
|
440
462
|
await (client.users as any).fetch('user-123')
|
|
441
463
|
expect((client.users as any).fetch).toHaveBeenCalledWith('user-123')
|
|
442
464
|
```
|
|
443
465
|
|
|
444
466
|
### `createMockMessage`
|
|
445
467
|
|
|
446
|
-
Creates a smart mock `Message`. Tracks a `deleted` boolean — `delete()`, `edit()`, `reply()`, `react()`, `pin()`, and `unpin()` throw if the message has already been deleted. `edit()` and `reply()` resolve to a new mock `Message` instance. All methods are
|
|
468
|
+
Creates a smart mock `Message`. Tracks a `deleted` boolean — `delete()`, `edit()`, `reply()`, `react()`, `pin()`, and `unpin()` throw if the message has already been deleted. `edit()` and `reply()` resolve to a new mock `Message` instance. All methods are mock functions.
|
|
447
469
|
|
|
448
470
|
```typescript
|
|
449
471
|
import { createMockMessage } from 'meocord/testing'
|
|
@@ -458,9 +480,9 @@ await msg.edit({ content: 'x' }) // → throws (already deleted)
|
|
|
458
480
|
|
|
459
481
|
// edit() and reply() resolve to a new Message mock
|
|
460
482
|
const edited = await createMockMessage().edit({ content: 'updated' })
|
|
461
|
-
edited.delete // →
|
|
483
|
+
edited.delete // → a mock fn
|
|
462
484
|
|
|
463
|
-
// still
|
|
485
|
+
// still a mock fn — assertions work
|
|
464
486
|
expect(msg.delete).toHaveBeenCalledTimes(1)
|
|
465
487
|
```
|
|
466
488
|
|
|
@@ -483,8 +505,13 @@ const module = MeoCordTestingModule.create({
|
|
|
483
505
|
### Full example
|
|
484
506
|
|
|
485
507
|
```typescript
|
|
486
|
-
import {
|
|
487
|
-
|
|
508
|
+
import {
|
|
509
|
+
MeoCordTestingModule,
|
|
510
|
+
createMockFn,
|
|
511
|
+
createMockInteraction,
|
|
512
|
+
createChatInputOptions,
|
|
513
|
+
type MockedFunction,
|
|
514
|
+
} from 'meocord/testing'
|
|
488
515
|
import { ChatInputCommandInteraction } from 'discord.js'
|
|
489
516
|
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js'
|
|
490
517
|
import { GreetingService } from '@src/services/greeting.service.js'
|
|
@@ -492,10 +519,10 @@ import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js'
|
|
|
492
519
|
|
|
493
520
|
describe('GreetingSlashController', () => {
|
|
494
521
|
let controller: GreetingSlashController
|
|
495
|
-
let greetingService: { buildGreeting:
|
|
522
|
+
let greetingService: { buildGreeting: MockedFunction<GreetingService['buildGreeting']> }
|
|
496
523
|
|
|
497
524
|
beforeEach(() => {
|
|
498
|
-
greetingService = { buildGreeting:
|
|
525
|
+
greetingService = { buildGreeting: createMockFn() }
|
|
499
526
|
|
|
500
527
|
const module = MeoCordTestingModule.create({
|
|
501
528
|
controllers: [GreetingSlashController],
|
|
@@ -508,7 +535,7 @@ describe('GreetingSlashController', () => {
|
|
|
508
535
|
})
|
|
509
536
|
|
|
510
537
|
it('replies with a greeting for the provided name', async () => {
|
|
511
|
-
|
|
538
|
+
greetingService.buildGreeting.mockResolvedValue('Hello, Alice!')
|
|
512
539
|
|
|
513
540
|
const interaction = createMockInteraction(ChatInputCommandInteraction)
|
|
514
541
|
interaction.options = createChatInputOptions({ name: 'Alice' })
|
|
@@ -563,9 +590,10 @@ npx meocord start --prod
|
|
|
563
590
|
1. Fork the repository
|
|
564
591
|
2. Create a feature branch: `git checkout -b feat/your-feature`
|
|
565
592
|
3. Commit with conventional commits: `git commit -m "feat: add X"`
|
|
566
|
-
4.
|
|
593
|
+
4. Run `bun run lint` and `bun run test` before pushing
|
|
594
|
+
5. Push and open a pull request against `main`
|
|
567
595
|
|
|
568
|
-
Include a description of what changed and why, and add tests for any new behaviour.
|
|
596
|
+
Include a description of what changed and why, and add tests for any new behaviour. Use `fix:` / `feat:` / `docs:` prefixes so [semantic-release](https://semantic-release.gitbook.io/) can version correctly — `test:` commits do not trigger a release.
|
|
569
597
|
|
|
570
598
|
---
|
|
571
599
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require('reflect-metadata');
|
|
4
4
|
var inversify = require('inversify');
|
|
5
5
|
var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs');
|
|
6
|
-
var globals = require('@jest/globals');
|
|
7
6
|
var discord_js = require('discord.js');
|
|
8
7
|
|
|
9
8
|
function isValueProvider(p) {
|
|
@@ -98,10 +97,12 @@ function isValueProvider(p) {
|
|
|
98
97
|
*
|
|
99
98
|
* @example
|
|
100
99
|
* ```typescript
|
|
100
|
+
* import { MeoCordTestingModule, createMockFn } from 'meocord/testing'
|
|
101
|
+
*
|
|
101
102
|
* const module = MeoCordTestingModule.create({
|
|
102
103
|
* controllers: [PingController],
|
|
103
104
|
* providers: [
|
|
104
|
-
* { provide: PingService, useValue: { handlePing:
|
|
105
|
+
* { provide: PingService, useValue: { handlePing: createMockFn().mockResolvedValue('pong') } },
|
|
105
106
|
* ],
|
|
106
107
|
* }).compile()
|
|
107
108
|
*
|
|
@@ -113,8 +114,203 @@ function isValueProvider(p) {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* MeoCord Framework
|
|
119
|
+
* Copyright (c) 2025 Ukasyah Rahmatullah Zada
|
|
120
|
+
* SPDX-License-Identifier: MIT
|
|
121
|
+
*/ // ---------------------------------------------------------------------------
|
|
122
|
+
// Framework-agnostic mock function
|
|
123
|
+
//
|
|
124
|
+
// A minimal mock-fn implementation used internally by the testing utilities so
|
|
125
|
+
// that `meocord/testing` does not depend on jest OR vitest. Both jest and
|
|
126
|
+
// vitest detect a mock via the `_isMockFunction` flag, and both
|
|
127
|
+
// `expect(fn).toHaveBeenCalledWith(...)` / `toHaveBeenCalledTimes(...)` read
|
|
128
|
+
// `fn.mock.calls` — so a mock produced here is recognized by assertions in
|
|
129
|
+
// either framework.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/**
|
|
132
|
+
* Type guard that recognises mocks produced by `createMockFn` as well as
|
|
133
|
+
* native `jest.fn()` / `vi.fn()` mocks — all stamp `_isMockFunction = true`.
|
|
134
|
+
*/ function isMockFunction(fn) {
|
|
135
|
+
return typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction === true;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Creates a framework-agnostic mock function. Callable; records calls on
|
|
139
|
+
* `.mock.calls`; supports the mock-return/resolved/rejected/implementation
|
|
140
|
+
* API used by the testing utilities and by user assertions.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* const fn = createMockFn<(x: number) => number>((x) => x + 1)
|
|
144
|
+
* fn(2) // → 3
|
|
145
|
+
* fn.mockReturnValue(99)
|
|
146
|
+
* fn(2) // → 99
|
|
147
|
+
* fn.mock.calls // → [[2], [2]]
|
|
148
|
+
*/ function createMockFn(impl) {
|
|
149
|
+
let currentImpl = impl;
|
|
150
|
+
let returnOnce = [];
|
|
151
|
+
let returnValue = {
|
|
152
|
+
set: false,
|
|
153
|
+
value: undefined
|
|
154
|
+
};
|
|
155
|
+
let resolvedOnce = [];
|
|
156
|
+
let resolvedValue = {
|
|
157
|
+
set: false,
|
|
158
|
+
value: undefined
|
|
159
|
+
};
|
|
160
|
+
let rejectedOnce = [];
|
|
161
|
+
let rejectedValue = {
|
|
162
|
+
set: false,
|
|
163
|
+
value: undefined
|
|
164
|
+
};
|
|
165
|
+
const implOnce = [];
|
|
166
|
+
let name = 'vi.fn';
|
|
167
|
+
const calls = [];
|
|
168
|
+
const results = [];
|
|
169
|
+
const instances = [];
|
|
170
|
+
const mockFn = function(...args) {
|
|
171
|
+
calls.push(args);
|
|
172
|
+
instances.push(this);
|
|
173
|
+
let type = 'return';
|
|
174
|
+
let value;
|
|
175
|
+
try {
|
|
176
|
+
if (implOnce.length > 0) {
|
|
177
|
+
value = implOnce.shift().apply(this, args);
|
|
178
|
+
} else if (returnOnce.length > 0) {
|
|
179
|
+
value = returnOnce.shift();
|
|
180
|
+
} else if (returnValue.set) {
|
|
181
|
+
value = returnValue.value;
|
|
182
|
+
} else if (resolvedOnce.length > 0) {
|
|
183
|
+
value = Promise.resolve(resolvedOnce.shift());
|
|
184
|
+
} else if (resolvedValue.set) {
|
|
185
|
+
value = Promise.resolve(resolvedValue.value);
|
|
186
|
+
} else if (rejectedOnce.length > 0) {
|
|
187
|
+
value = Promise.reject(rejectedOnce.shift());
|
|
188
|
+
} else if (rejectedValue.set) {
|
|
189
|
+
value = Promise.reject(rejectedValue.value);
|
|
190
|
+
} else if (currentImpl !== undefined) {
|
|
191
|
+
value = currentImpl.apply(this, args);
|
|
192
|
+
} else {
|
|
193
|
+
value = undefined;
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
type = 'throw';
|
|
198
|
+
value = err;
|
|
199
|
+
throw err;
|
|
200
|
+
} finally{
|
|
201
|
+
results.push({
|
|
202
|
+
type,
|
|
203
|
+
value
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
// Stamp the marker both jest and vitest check via isMockFunction.
|
|
208
|
+
Object.defineProperty(mockFn, '_isMockFunction', {
|
|
209
|
+
value: true,
|
|
210
|
+
enumerable: false
|
|
211
|
+
});
|
|
212
|
+
const mock = {
|
|
213
|
+
get calls () {
|
|
214
|
+
return calls;
|
|
215
|
+
},
|
|
216
|
+
get results () {
|
|
217
|
+
return results;
|
|
218
|
+
},
|
|
219
|
+
get instances () {
|
|
220
|
+
return instances;
|
|
221
|
+
},
|
|
222
|
+
get lastCall () {
|
|
223
|
+
return calls.length > 0 ? calls[calls.length - 1] : undefined;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
Object.defineProperty(mockFn, 'mock', {
|
|
227
|
+
value: mock,
|
|
228
|
+
enumerable: false
|
|
229
|
+
});
|
|
230
|
+
mockFn.mockReturnValue = (v)=>{
|
|
231
|
+
returnValue = {
|
|
232
|
+
set: true,
|
|
233
|
+
value: v
|
|
234
|
+
};
|
|
235
|
+
return mockFn;
|
|
236
|
+
};
|
|
237
|
+
mockFn.mockReturnValueOnce = (v)=>{
|
|
238
|
+
returnOnce.push(v);
|
|
239
|
+
return mockFn;
|
|
240
|
+
};
|
|
241
|
+
mockFn.mockResolvedValue = (v)=>{
|
|
242
|
+
resolvedValue = {
|
|
243
|
+
set: true,
|
|
244
|
+
value: v
|
|
245
|
+
};
|
|
246
|
+
return mockFn;
|
|
247
|
+
};
|
|
248
|
+
mockFn.mockResolvedValueOnce = (v)=>{
|
|
249
|
+
resolvedOnce.push(v);
|
|
250
|
+
return mockFn;
|
|
251
|
+
};
|
|
252
|
+
mockFn.mockRejectedValue = (v)=>{
|
|
253
|
+
rejectedValue = {
|
|
254
|
+
set: true,
|
|
255
|
+
value: v
|
|
256
|
+
};
|
|
257
|
+
return mockFn;
|
|
258
|
+
};
|
|
259
|
+
mockFn.mockRejectedValueOnce = (v)=>{
|
|
260
|
+
rejectedOnce.push(v);
|
|
261
|
+
return mockFn;
|
|
262
|
+
};
|
|
263
|
+
mockFn.mockImplementation = (fn)=>{
|
|
264
|
+
currentImpl = fn;
|
|
265
|
+
return mockFn;
|
|
266
|
+
};
|
|
267
|
+
mockFn.mockImplementationOnce = (fn)=>{
|
|
268
|
+
implOnce.push(fn);
|
|
269
|
+
return mockFn;
|
|
270
|
+
};
|
|
271
|
+
mockFn.mockClear = ()=>{
|
|
272
|
+
calls.length = 0;
|
|
273
|
+
results.length = 0;
|
|
274
|
+
instances.length = 0;
|
|
275
|
+
return mockFn;
|
|
276
|
+
};
|
|
277
|
+
mockFn.mockReset = ()=>{
|
|
278
|
+
calls.length = 0;
|
|
279
|
+
results.length = 0;
|
|
280
|
+
instances.length = 0;
|
|
281
|
+
returnOnce = [];
|
|
282
|
+
returnValue = {
|
|
283
|
+
set: false,
|
|
284
|
+
value: undefined
|
|
285
|
+
};
|
|
286
|
+
resolvedOnce = [];
|
|
287
|
+
resolvedValue = {
|
|
288
|
+
set: false,
|
|
289
|
+
value: undefined
|
|
290
|
+
};
|
|
291
|
+
rejectedOnce = [];
|
|
292
|
+
rejectedValue = {
|
|
293
|
+
set: false,
|
|
294
|
+
value: undefined
|
|
295
|
+
};
|
|
296
|
+
implOnce.length = 0;
|
|
297
|
+
currentImpl = impl;
|
|
298
|
+
return mockFn;
|
|
299
|
+
};
|
|
300
|
+
mockFn.mockRestore = ()=>{
|
|
301
|
+
mockFn.mockReset();
|
|
302
|
+
return mockFn;
|
|
303
|
+
};
|
|
304
|
+
mockFn.getMockName = ()=>name;
|
|
305
|
+
mockFn.mockName = (n)=>{
|
|
306
|
+
name = n;
|
|
307
|
+
return mockFn;
|
|
308
|
+
};
|
|
309
|
+
return mockFn;
|
|
310
|
+
}
|
|
311
|
+
|
|
116
312
|
// ---------------------------------------------------------------------------
|
|
117
|
-
// stubDeep — Proxy that auto-creates
|
|
313
|
+
// stubDeep — Proxy that auto-creates a mock fn on any property access
|
|
118
314
|
// ---------------------------------------------------------------------------
|
|
119
315
|
const SKIP = new Set([
|
|
120
316
|
'constructor',
|
|
@@ -132,7 +328,7 @@ function stubDeep(instance, externalStubs) {
|
|
|
132
328
|
return Reflect.get(target, prop, target);
|
|
133
329
|
}
|
|
134
330
|
const key = prop;
|
|
135
|
-
// 'then' must be undefined — prevents
|
|
331
|
+
// 'then' must be undefined — prevents frameworks treating the mock as a Promise
|
|
136
332
|
if (key === 'then') return undefined;
|
|
137
333
|
// Own property writes take precedence (e.g. interaction.guildId = 'abc')
|
|
138
334
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
@@ -155,7 +351,7 @@ function stubDeep(instance, externalStubs) {
|
|
|
155
351
|
}
|
|
156
352
|
proto = Object.getPrototypeOf(proto);
|
|
157
353
|
}
|
|
158
|
-
const stub = typeof protoValue === 'function' ?
|
|
354
|
+
const stub = typeof protoValue === 'function' ? createMockFn() : stubDeep({});
|
|
159
355
|
stubs.set(key, stub);
|
|
160
356
|
return stub;
|
|
161
357
|
},
|
|
@@ -225,7 +421,7 @@ const CLASS_TYPE_FIELDS = {
|
|
|
225
421
|
}
|
|
226
422
|
};
|
|
227
423
|
// All known pure type-guard methods on BaseInteraction and its subclasses.
|
|
228
|
-
// These are wired as
|
|
424
|
+
// These are wired as a mock fn wrapping the real prototype logic so they return
|
|
229
425
|
// correct values by default and can still be overridden per test.
|
|
230
426
|
const TYPE_GUARD_METHODS = [
|
|
231
427
|
'isCommand',
|
|
@@ -265,14 +461,14 @@ function findPrototypeMethod(instance, name) {
|
|
|
265
461
|
*
|
|
266
462
|
* **Type guards** (`isButton()`, `isRepliable()`, etc.) run the real discord.js
|
|
267
463
|
* prototype logic — no manual `.mockReturnValue(true)` setup needed. They are
|
|
268
|
-
* still
|
|
464
|
+
* still mock functions so you can override them per test.
|
|
269
465
|
*
|
|
270
466
|
* **Reply state machine** — `replied` and `deferred` start as `false`. Calling
|
|
271
467
|
* `reply()` or `deferReply()` twice throws, just like a real interaction would.
|
|
272
468
|
* `followUp()`, `editReply()`, and `deleteReply()` throw if called before any
|
|
273
|
-
* reply. These are still
|
|
469
|
+
* reply. These are still mock functions so call assertions work normally.
|
|
274
470
|
*
|
|
275
|
-
* All other methods are auto-stubbed as
|
|
471
|
+
* All other methods are auto-stubbed as a mock fn via Proxy.
|
|
276
472
|
*
|
|
277
473
|
* @example
|
|
278
474
|
* ```ts
|
|
@@ -295,12 +491,12 @@ function findPrototypeMethod(instance, name) {
|
|
|
295
491
|
instance[key] = value;
|
|
296
492
|
}
|
|
297
493
|
}
|
|
298
|
-
// Wire each type guard as
|
|
494
|
+
// Wire each type guard as a mock fn calling the real prototype implementation.
|
|
299
495
|
// Correct by default; overridable per test via .mockReturnValue().
|
|
300
496
|
for (const name of TYPE_GUARD_METHODS){
|
|
301
497
|
const method = findPrototypeMethod(instance, name);
|
|
302
498
|
if (method !== null) {
|
|
303
|
-
stubs.set(name,
|
|
499
|
+
stubs.set(name, createMockFn().mockImplementation(()=>method.call(instance)));
|
|
304
500
|
}
|
|
305
501
|
}
|
|
306
502
|
// Set up reply state machine for repliable interactions
|
|
@@ -320,36 +516,36 @@ function findPrototypeMethod(instance, name) {
|
|
|
320
516
|
if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
|
|
321
517
|
return false;
|
|
322
518
|
};
|
|
323
|
-
stubs.set('reply',
|
|
519
|
+
stubs.set('reply', createMockFn(async (...args)=>{
|
|
324
520
|
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
325
521
|
instance.replied = true;
|
|
326
522
|
if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
|
|
327
523
|
}));
|
|
328
|
-
stubs.set('deferReply',
|
|
524
|
+
stubs.set('deferReply', createMockFn(async (...args)=>{
|
|
329
525
|
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
330
526
|
instance.deferred = true;
|
|
331
527
|
if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
|
|
332
528
|
}));
|
|
333
|
-
stubs.set('followUp',
|
|
529
|
+
stubs.set('followUp', createMockFn(async ()=>{
|
|
334
530
|
if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
|
|
335
531
|
instance.replied = true;
|
|
336
532
|
return createMockMessage();
|
|
337
533
|
}));
|
|
338
|
-
stubs.set('editReply',
|
|
534
|
+
stubs.set('editReply', createMockFn(async ()=>{
|
|
339
535
|
if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
|
|
340
536
|
instance.replied = true;
|
|
341
537
|
return createMockMessage();
|
|
342
538
|
}));
|
|
343
|
-
stubs.set('deleteReply',
|
|
539
|
+
stubs.set('deleteReply', createMockFn(async ()=>{
|
|
344
540
|
if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply');
|
|
345
541
|
}));
|
|
346
542
|
// deferUpdate / update — MessageComponentInteraction only (type === MessageComponent)
|
|
347
543
|
if (instance.type === discord_js.InteractionType.MessageComponent) {
|
|
348
|
-
stubs.set('update',
|
|
544
|
+
stubs.set('update', createMockFn(async ()=>{
|
|
349
545
|
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
350
546
|
instance.replied = true;
|
|
351
547
|
}));
|
|
352
|
-
stubs.set('deferUpdate',
|
|
548
|
+
stubs.set('deferUpdate', createMockFn(async ()=>{
|
|
353
549
|
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
354
550
|
instance.deferred = true;
|
|
355
551
|
}));
|
|
@@ -360,18 +556,18 @@ function findPrototypeMethod(instance, name) {
|
|
|
360
556
|
// ---------------------------------------------------------------------------
|
|
361
557
|
// Convenience wrappers for common discord.js classes
|
|
362
558
|
// ---------------------------------------------------------------------------
|
|
363
|
-
/** Creates a mock {@link User}. All methods are auto-stubbed as
|
|
559
|
+
/** Creates a mock {@link User}. All methods are auto-stubbed as a mock fn. */ const createMockUser = ()=>createMockInteraction(discord_js.User);
|
|
364
560
|
/**
|
|
365
561
|
* Creates a mock {@link Client}.
|
|
366
562
|
*
|
|
367
563
|
* Manager methods that are constructor-assigned (not on the prototype) are
|
|
368
|
-
* pre-initialized as
|
|
564
|
+
* pre-initialized as a mock fn so they work out of the box without manual
|
|
369
565
|
* setup: `users.fetch`, `channels.fetch`, `guilds.fetch`, and
|
|
370
566
|
* `application.commands.fetch`.
|
|
371
567
|
*/ function createMockClient() {
|
|
372
568
|
const instance = Object.create(discord_js.Client.prototype);
|
|
373
569
|
// Manager properties are constructor-assigned — pre-initialize as prototype-based
|
|
374
|
-
// stubs so ALL manager methods (not just fetch) are auto-stubbed as
|
|
570
|
+
// stubs so ALL manager methods (not just fetch) are auto-stubbed as a mock fn.
|
|
375
571
|
const appInstance = Object.create(null);
|
|
376
572
|
appInstance.commands = stubDeep(Object.create(discord_js.ApplicationCommandManager.prototype));
|
|
377
573
|
instance.users = stubDeep(Object.create(discord_js.UserManager.prototype));
|
|
@@ -386,7 +582,7 @@ function findPrototypeMethod(instance, name) {
|
|
|
386
582
|
*
|
|
387
583
|
* Manager properties are constructor-assigned in discord.js. This factory
|
|
388
584
|
* pre-initializes each as a prototype-based stub so all methods are
|
|
389
|
-
* auto-stubbed as
|
|
585
|
+
* auto-stubbed as a mock fn.
|
|
390
586
|
*
|
|
391
587
|
* Pre-initialized: `members`, `channels`, `roles`, `bans`.
|
|
392
588
|
*/ function createMockGuild() {
|
|
@@ -402,7 +598,7 @@ function findPrototypeMethod(instance, name) {
|
|
|
402
598
|
*
|
|
403
599
|
* Manager properties that are constructor-assigned in discord.js are
|
|
404
600
|
* pre-initialized as prototype-based stubs so all methods are auto-stubbed
|
|
405
|
-
* as
|
|
601
|
+
* as a mock fn:
|
|
406
602
|
* - Guild text channels (`TextChannel`, `NewsChannel`): `messages`, `threads`
|
|
407
603
|
* - `DMChannel`: `messages`
|
|
408
604
|
* - `ThreadChannel`: `messages`, `members`
|
|
@@ -437,7 +633,7 @@ function findPrototypeMethod(instance, name) {
|
|
|
437
633
|
* `msg.channel.send`, `msg.guild.members.fetch`, `msg.thread.fetch`,
|
|
438
634
|
* and `msg.mentions.has` all work out of the box.
|
|
439
635
|
*
|
|
440
|
-
* All methods remain
|
|
636
|
+
* All methods remain mock functions — overridable per test.
|
|
441
637
|
*
|
|
442
638
|
* Note: `createMockInteraction`'s `followUp()` and `editReply()` stubs return
|
|
443
639
|
* a `createMockMessage()` by default, matching the official return types.
|
|
@@ -460,7 +656,7 @@ function createMockMessage() {
|
|
|
460
656
|
// Constructor-assigned — set as prototype-based stubs
|
|
461
657
|
instance.author = stubDeep(Object.create(discord_js.User.prototype));
|
|
462
658
|
// Getters on the prototype — the proxy sees them as functions and returns
|
|
463
|
-
//
|
|
659
|
+
// a mock fn, which is wrong. Pre-initialize as own properties to shadow
|
|
464
660
|
// the prototype getters.
|
|
465
661
|
Object.defineProperty(instance, 'member', {
|
|
466
662
|
value: stubDeep(Object.create(discord_js.GuildMember.prototype)),
|
|
@@ -481,25 +677,25 @@ function createMockMessage() {
|
|
|
481
677
|
// MessageMentions — constructor-assigned, has methods like .has(), .members
|
|
482
678
|
instance.mentions = stubDeep(Object.create(discord_js.MessageMentions.prototype));
|
|
483
679
|
const alreadyDeleted = ()=>new Error('This message has already been deleted.');
|
|
484
|
-
stubs.set('delete',
|
|
680
|
+
stubs.set('delete', createMockFn(async ()=>{
|
|
485
681
|
if (instance.deleted) throw alreadyDeleted();
|
|
486
682
|
instance.deleted = true;
|
|
487
683
|
}));
|
|
488
|
-
stubs.set('edit',
|
|
684
|
+
stubs.set('edit', createMockFn(async ()=>{
|
|
489
685
|
if (instance.deleted) throw alreadyDeleted();
|
|
490
686
|
return createMockMessage();
|
|
491
687
|
}));
|
|
492
|
-
stubs.set('reply',
|
|
688
|
+
stubs.set('reply', createMockFn(async ()=>{
|
|
493
689
|
if (instance.deleted) throw alreadyDeleted();
|
|
494
690
|
return createMockMessage();
|
|
495
691
|
}));
|
|
496
|
-
stubs.set('react',
|
|
692
|
+
stubs.set('react', createMockFn(async ()=>{
|
|
497
693
|
if (instance.deleted) throw alreadyDeleted();
|
|
498
694
|
}));
|
|
499
|
-
stubs.set('pin',
|
|
695
|
+
stubs.set('pin', createMockFn(async ()=>{
|
|
500
696
|
if (instance.deleted) throw alreadyDeleted();
|
|
501
697
|
}));
|
|
502
|
-
stubs.set('unpin',
|
|
698
|
+
stubs.set('unpin', createMockFn(async ()=>{
|
|
503
699
|
if (instance.deleted) throw alreadyDeleted();
|
|
504
700
|
}));
|
|
505
701
|
return stubDeep(instance, stubs);
|
|
@@ -509,7 +705,7 @@ function createMockMessage() {
|
|
|
509
705
|
* `CommandInteractionOptionResolver` works: declare what options the command
|
|
510
706
|
* was invoked with, and the resolver finds them by name.
|
|
511
707
|
*
|
|
512
|
-
* All explicit methods are
|
|
708
|
+
* All explicit methods are mock functions — override per test with `.mockReturnValue()`.
|
|
513
709
|
* Methods not listed (e.g. `getAttachment`) are auto-stubbed by the Proxy.
|
|
514
710
|
*
|
|
515
711
|
* @example
|
|
@@ -541,20 +737,20 @@ function createMockMessage() {
|
|
|
541
737
|
}
|
|
542
738
|
const isObjectOption = (v)=>typeof v === 'object' && v !== null && 'id' in v;
|
|
543
739
|
// Use a real prototype instance so unlisted methods (e.g. getAttachment)
|
|
544
|
-
// are found on the prototype chain and auto-stubbed as
|
|
740
|
+
// are found on the prototype chain and auto-stubbed as a mock fn
|
|
545
741
|
const base = Object.create(discord_js.CommandInteractionOptionResolver.prototype);
|
|
546
|
-
base.getSubcommandGroup =
|
|
547
|
-
base.getSubcommand =
|
|
548
|
-
base.getString =
|
|
549
|
-
base.getNumber =
|
|
550
|
-
base.getInteger =
|
|
551
|
-
base.getBoolean =
|
|
742
|
+
base.getSubcommandGroup = createMockFn((required)=>resolveSubEntry(subcommandGroup, 'subcommand group', required));
|
|
743
|
+
base.getSubcommand = createMockFn((required)=>resolveSubEntry(subcommand, 'subcommand', required));
|
|
744
|
+
base.getString = createMockFn((name, required)=>resolveOrThrow(name, typeof values[name] === 'string' ? values[name] : null, required));
|
|
745
|
+
base.getNumber = createMockFn((name, required)=>resolveOrThrow(name, typeof values[name] === 'number' ? values[name] : null, required));
|
|
746
|
+
base.getInteger = createMockFn((name, required)=>resolveOrThrow(name, typeof values[name] === 'number' ? values[name] : null, required));
|
|
747
|
+
base.getBoolean = createMockFn((name, required)=>resolveOrThrow(name, typeof values[name] === 'boolean' ? values[name] : null, required));
|
|
552
748
|
const getObjectOption = (name, required)=>resolveOrThrow(name, isObjectOption(values[name]) ? values[name] : null, required);
|
|
553
|
-
base.getUser =
|
|
554
|
-
base.getRole =
|
|
555
|
-
base.getChannel =
|
|
556
|
-
base.getMember =
|
|
557
|
-
base.getMentionable =
|
|
749
|
+
base.getUser = createMockFn(getObjectOption);
|
|
750
|
+
base.getRole = createMockFn(getObjectOption);
|
|
751
|
+
base.getChannel = createMockFn(getObjectOption);
|
|
752
|
+
base.getMember = createMockFn(getObjectOption);
|
|
753
|
+
base.getMentionable = createMockFn(getObjectOption);
|
|
558
754
|
return stubDeep(base);
|
|
559
755
|
}
|
|
560
756
|
|
|
@@ -564,7 +760,9 @@ exports.TestingModuleBuilder = TestingModuleBuilder;
|
|
|
564
760
|
exports.createChatInputOptions = createChatInputOptions;
|
|
565
761
|
exports.createMockChannel = createMockChannel;
|
|
566
762
|
exports.createMockClient = createMockClient;
|
|
763
|
+
exports.createMockFn = createMockFn;
|
|
567
764
|
exports.createMockGuild = createMockGuild;
|
|
568
765
|
exports.createMockInteraction = createMockInteraction;
|
|
569
766
|
exports.createMockMessage = createMockMessage;
|
|
570
767
|
exports.createMockUser = createMockUser;
|
|
768
|
+
exports.isMockFunction = isMockFunction;
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { MeoCordTestingModule, TestingModule, TestingModuleBuilder } from './meocord-testing-module.js';
|
|
2
2
|
export { createChatInputOptions, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMessage, createMockUser } from './mock-interaction.js';
|
|
3
|
+
export { createMockFn, isMockFunction } from './mock-fn.js';
|
|
@@ -94,10 +94,12 @@ function isValueProvider(p) {
|
|
|
94
94
|
*
|
|
95
95
|
* @example
|
|
96
96
|
* ```typescript
|
|
97
|
+
* import { MeoCordTestingModule, createMockFn } from 'meocord/testing'
|
|
98
|
+
*
|
|
97
99
|
* const module = MeoCordTestingModule.create({
|
|
98
100
|
* controllers: [PingController],
|
|
99
101
|
* providers: [
|
|
100
|
-
* { provide: PingService, useValue: { handlePing:
|
|
102
|
+
* { provide: PingService, useValue: { handlePing: createMockFn().mockResolvedValue('pong') } },
|
|
101
103
|
* ],
|
|
102
104
|
* }).compile()
|
|
103
105
|
*
|