@digitaldefiance/express-suite-test-utils 1.0.10 → 1.0.12
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/LICENSE +21 -0
- package/README.md +182 -0
- package/package.json +8 -4
- package/src/index.ts +7 -0
- package/src/lib/bson-mock.ts +36 -0
- package/src/lib/console.ts +69 -0
- package/src/lib/direct-log.ts +105 -0
- package/src/lib/localStorage-mock.ts +40 -0
- package/src/lib/mongoose-memory.d.ts +1 -1
- package/src/lib/mongoose-memory.d.ts.map +1 -1
- package/src/lib/mongoose-memory.js +7 -7
- package/src/lib/mongoose-memory.ts +64 -0
- package/src/lib/react-mocks.ts +29 -0
- package/src/lib/to-throw-type.ts +200 -0
- package/src/lib/types.ts +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Digital Defiance
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -157,12 +157,194 @@ describe('User model', () => {
|
|
|
157
157
|
|
|
158
158
|
**Note:** Requires `mongoose` as a peer dependency and `mongodb-memory-server` as a dependency (already included).
|
|
159
159
|
|
|
160
|
+
## Testing Approach
|
|
161
|
+
|
|
162
|
+
This package provides comprehensive testing utilities for Express Suite projects, including custom Jest matchers, console mocks, database helpers, and more.
|
|
163
|
+
|
|
164
|
+
### Test Utilities Overview
|
|
165
|
+
|
|
166
|
+
**Custom Matchers**: `toThrowType` for type-safe error testing
|
|
167
|
+
**Console Mocks**: Mock and spy on console methods
|
|
168
|
+
**Direct Log Mocks**: Mock `fs.writeSync` for stdout/stderr testing
|
|
169
|
+
**Database Helpers**: MongoDB Memory Server integration
|
|
170
|
+
**React Mocks**: Mock React components and hooks
|
|
171
|
+
|
|
172
|
+
### Usage Patterns
|
|
173
|
+
|
|
174
|
+
#### Using toThrowType Matcher
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import '@digitaldefiance/express-suite-test-utils';
|
|
178
|
+
|
|
179
|
+
class CustomError extends Error {
|
|
180
|
+
constructor(public code: number) {
|
|
181
|
+
super('Custom error');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe('Error Testing', () => {
|
|
186
|
+
it('should throw specific error type', () => {
|
|
187
|
+
expect(() => {
|
|
188
|
+
throw new CustomError(404);
|
|
189
|
+
}).toThrowType(CustomError);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should validate error properties', () => {
|
|
193
|
+
expect(() => {
|
|
194
|
+
throw new CustomError(404);
|
|
195
|
+
}).toThrowType(CustomError, (error) => {
|
|
196
|
+
expect(error.code).toBe(404);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### Using Console Mocks
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import { withConsoleMocks, spyContains } from '@digitaldefiance/express-suite-test-utils';
|
|
206
|
+
|
|
207
|
+
describe('Console Output', () => {
|
|
208
|
+
it('should capture console.log', async () => {
|
|
209
|
+
await withConsoleMocks({ mute: true }, async (spies) => {
|
|
210
|
+
console.log('test message');
|
|
211
|
+
|
|
212
|
+
expect(spies.log).toHaveBeenCalledWith('test message');
|
|
213
|
+
expect(spyContains(spies.log, 'test', 'message')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should capture console.error', async () => {
|
|
218
|
+
await withConsoleMocks({ mute: true }, async (spies) => {
|
|
219
|
+
console.error('error message');
|
|
220
|
+
|
|
221
|
+
expect(spies.error).toHaveBeenCalledWith('error message');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
#### Using Direct Log Mocks
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { withDirectLogMocks, directLogContains, getDirectLogMessages } from '@digitaldefiance/express-suite-test-utils';
|
|
231
|
+
import * as fs from 'fs';
|
|
232
|
+
|
|
233
|
+
// Mock fs at module level
|
|
234
|
+
jest.mock('fs', () => ({
|
|
235
|
+
...jest.requireActual('fs'),
|
|
236
|
+
writeSync: jest.fn(),
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
describe('Direct Logging', () => {
|
|
240
|
+
it('should capture stdout writes', async () => {
|
|
241
|
+
await withDirectLogMocks({ mute: true }, async (spies) => {
|
|
242
|
+
const buffer = Buffer.from('hello world\n', 'utf8');
|
|
243
|
+
fs.writeSync(1, buffer); // stdout
|
|
244
|
+
|
|
245
|
+
expect(directLogContains(spies.writeSync, 1, 'hello', 'world')).toBe(true);
|
|
246
|
+
expect(getDirectLogMessages(spies.writeSync, 1)).toEqual(['hello world\n']);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### Using MongoDB Memory Server
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import { connectMemoryDB, disconnectMemoryDB, clearMemoryDB } from '@digitaldefiance/express-suite-test-utils';
|
|
256
|
+
import { User } from './models/user';
|
|
257
|
+
|
|
258
|
+
describe('Database Tests', () => {
|
|
259
|
+
beforeAll(async () => {
|
|
260
|
+
await connectMemoryDB();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterAll(async () => {
|
|
264
|
+
await disconnectMemoryDB();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
afterEach(async () => {
|
|
268
|
+
await clearMemoryDB();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should validate user schema', async () => {
|
|
272
|
+
const user = new User({
|
|
273
|
+
username: 'test',
|
|
274
|
+
email: 'test@example.com'
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await user.validate(); // Real Mongoose validation!
|
|
278
|
+
await user.save();
|
|
279
|
+
|
|
280
|
+
const found = await User.findOne({ username: 'test' });
|
|
281
|
+
expect(found).toBeDefined();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should reject invalid data', async () => {
|
|
285
|
+
const invalid = new User({ username: 'ab' }); // too short
|
|
286
|
+
|
|
287
|
+
await expect(invalid.validate()).rejects.toThrow();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Testing Best Practices
|
|
293
|
+
|
|
294
|
+
1. **Always clean up** after tests (disconnect DB, restore mocks)
|
|
295
|
+
2. **Use memory database** for fast, isolated tests
|
|
296
|
+
3. **Mock external dependencies** to avoid side effects
|
|
297
|
+
4. **Test error conditions** with `toThrowType` matcher
|
|
298
|
+
5. **Capture console output** when testing logging behavior
|
|
299
|
+
|
|
300
|
+
### Cross-Package Testing
|
|
301
|
+
|
|
302
|
+
These utilities are designed to work seamlessly with all Express Suite packages:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { connectMemoryDB, disconnectMemoryDB } from '@digitaldefiance/express-suite-test-utils';
|
|
306
|
+
import { Application } from '@digitaldefiance/node-express-suite';
|
|
307
|
+
import { UserService } from '@digitaldefiance/node-express-suite';
|
|
308
|
+
|
|
309
|
+
describe('Integration Tests', () => {
|
|
310
|
+
let app: Application;
|
|
311
|
+
|
|
312
|
+
beforeAll(async () => {
|
|
313
|
+
await connectMemoryDB();
|
|
314
|
+
app = new Application({
|
|
315
|
+
mongoUri: global.__MONGO_URI__,
|
|
316
|
+
jwtSecret: 'test-secret'
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterAll(async () => {
|
|
321
|
+
await app.stop();
|
|
322
|
+
await disconnectMemoryDB();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should create and find user', async () => {
|
|
326
|
+
const userService = new UserService(app);
|
|
327
|
+
const user = await userService.create({
|
|
328
|
+
username: 'alice',
|
|
329
|
+
email: 'alice@example.com'
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const found = await userService.findByUsername('alice');
|
|
333
|
+
expect(found).toBeDefined();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
160
338
|
## License
|
|
161
339
|
|
|
162
340
|
MIT
|
|
163
341
|
|
|
164
342
|
## ChangeLog
|
|
165
343
|
|
|
344
|
+
### v1.0.11
|
|
345
|
+
|
|
346
|
+
- Fix mongoose to use @digitaldefiance/mongoose-types
|
|
347
|
+
|
|
166
348
|
### v1.0.10
|
|
167
349
|
|
|
168
350
|
- Fix direct-log mocks to work with non-configurable fs.writeSync in newer Node.js versions
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digitaldefiance/express-suite-test-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
|
+
"homepage": "https://github.com/Digital-Defiance/test-utils",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Digital-Defiance/test-utils.git"
|
|
8
|
+
},
|
|
4
9
|
"description": "Test utilities for Digital Defiance Express Suite",
|
|
5
10
|
"main": "src/index.js",
|
|
6
11
|
"types": "src/index.d.ts",
|
|
@@ -46,6 +51,5 @@
|
|
|
46
51
|
"devDependencies": {
|
|
47
52
|
"@types/node": "^22.0.0",
|
|
48
53
|
"mongoose": "^8.9.3"
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
}
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock bson and crypto modules for Jest tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ObjectId {
|
|
6
|
+
constructor(public id: string = '000000000000000000000000') {}
|
|
7
|
+
toString(): string {
|
|
8
|
+
return this.id;
|
|
9
|
+
}
|
|
10
|
+
toHexString(): string {
|
|
11
|
+
return this.id;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Binary {}
|
|
16
|
+
export class Code {}
|
|
17
|
+
export class DBRef {}
|
|
18
|
+
export class Decimal128 {}
|
|
19
|
+
export class Double {}
|
|
20
|
+
export class Int32 {}
|
|
21
|
+
export class Long {}
|
|
22
|
+
export class MaxKey {}
|
|
23
|
+
export class MinKey {}
|
|
24
|
+
export class Timestamp {}
|
|
25
|
+
export class UUID {}
|
|
26
|
+
|
|
27
|
+
// Mock secp256k1 for ethereum crypto
|
|
28
|
+
export const secp256k1 = {
|
|
29
|
+
CURVE: {
|
|
30
|
+
p: BigInt(0),
|
|
31
|
+
n: BigInt(0),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Default export for ethereum-cryptography
|
|
36
|
+
export default { secp256k1 };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Reusable console mock helpers for e2e tests */
|
|
2
|
+
|
|
3
|
+
export type ConsoleSpies = {
|
|
4
|
+
log: jest.SpyInstance;
|
|
5
|
+
info: jest.SpyInstance;
|
|
6
|
+
warn: jest.SpyInstance;
|
|
7
|
+
error: jest.SpyInstance;
|
|
8
|
+
debug: jest.SpyInstance;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface WithConsoleOptions {
|
|
12
|
+
mute?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a test body with console spies that are always restored.
|
|
17
|
+
* By default mutes console output to keep test logs clean.
|
|
18
|
+
*/
|
|
19
|
+
export async function withConsoleMocks<T = unknown>(
|
|
20
|
+
options: WithConsoleOptions,
|
|
21
|
+
fn: (spies: ConsoleSpies) => Promise<T> | T,
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const mute = options?.mute !== false; // default true
|
|
24
|
+
const noop = () => undefined;
|
|
25
|
+
const spies: ConsoleSpies = {
|
|
26
|
+
log: jest
|
|
27
|
+
.spyOn(console, 'log')
|
|
28
|
+
.mockImplementation(mute ? noop : console.log),
|
|
29
|
+
info: jest
|
|
30
|
+
.spyOn(console, 'info')
|
|
31
|
+
.mockImplementation(mute ? noop : console.info),
|
|
32
|
+
warn: jest
|
|
33
|
+
.spyOn(console, 'warn')
|
|
34
|
+
.mockImplementation(mute ? noop : console.warn),
|
|
35
|
+
error: jest
|
|
36
|
+
.spyOn(console, 'error')
|
|
37
|
+
.mockImplementation(mute ? noop : console.error),
|
|
38
|
+
debug: jest
|
|
39
|
+
.spyOn(console, 'debug')
|
|
40
|
+
.mockImplementation(
|
|
41
|
+
mute
|
|
42
|
+
? noop
|
|
43
|
+
: (console.debug as ((...args: unknown[]) => void) | undefined) ??
|
|
44
|
+
noop,
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
return await fn(spies);
|
|
49
|
+
} finally {
|
|
50
|
+
spies.log.mockRestore();
|
|
51
|
+
spies.info.mockRestore();
|
|
52
|
+
spies.warn.mockRestore();
|
|
53
|
+
spies.error.mockRestore();
|
|
54
|
+
spies.debug.mockRestore();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True if any call to the spy contains all provided substrings, in order-agnostic check.
|
|
60
|
+
*/
|
|
61
|
+
export function spyContains(
|
|
62
|
+
spy: jest.SpyInstance,
|
|
63
|
+
...needles: string[]
|
|
64
|
+
): boolean {
|
|
65
|
+
return spy.mock.calls.some((args: unknown[]) => {
|
|
66
|
+
const text = args.map((a) => (typeof a === 'string' ? a : '')).join(' ');
|
|
67
|
+
return needles.every((n) => text.includes(n));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spies returned from withDirectLogMocks
|
|
5
|
+
*/
|
|
6
|
+
export interface DirectLogSpies {
|
|
7
|
+
writeSync: jest.Mock;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for withDirectLogMocks
|
|
12
|
+
*/
|
|
13
|
+
export interface WithDirectLogOptions {
|
|
14
|
+
/**
|
|
15
|
+
* If false, do not mute the output (let it pass through).
|
|
16
|
+
* By default (true), mutes output.
|
|
17
|
+
*/
|
|
18
|
+
mute?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wrap a test body with fs.writeSync spy for directLog testing.
|
|
23
|
+
* By default mutes output (does nothing on write).
|
|
24
|
+
* The spy will capture calls with file descriptor and buffer.
|
|
25
|
+
*
|
|
26
|
+
* Note: Requires fs module to be mocked at module level with jest.mock('fs')
|
|
27
|
+
*/
|
|
28
|
+
export async function withDirectLogMocks<T = unknown>(
|
|
29
|
+
options: WithDirectLogOptions,
|
|
30
|
+
fn: (spies: DirectLogSpies) => Promise<T> | T,
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
const mute = options?.mute !== false; // default true
|
|
33
|
+
|
|
34
|
+
// Get the mocked writeSync function
|
|
35
|
+
const writeSync = fs.writeSync as unknown as jest.Mock;
|
|
36
|
+
|
|
37
|
+
// Store previous mock implementation if any
|
|
38
|
+
const previousImpl = writeSync.getMockImplementation();
|
|
39
|
+
|
|
40
|
+
// Set implementation based on mute option
|
|
41
|
+
if (mute) {
|
|
42
|
+
writeSync.mockImplementation(() => undefined);
|
|
43
|
+
} else {
|
|
44
|
+
// Pass through - requires original implementation to be available
|
|
45
|
+
writeSync.mockImplementation((...args: any[]) => {
|
|
46
|
+
// In test environment, we can't easily call the real fs.writeSync
|
|
47
|
+
// so we just no-op but track the calls
|
|
48
|
+
return args[1]?.length || 0;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const spies: DirectLogSpies = { writeSync };
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return await fn(spies);
|
|
56
|
+
} finally {
|
|
57
|
+
// Clear calls and restore previous implementation
|
|
58
|
+
writeSync.mockClear();
|
|
59
|
+
if (previousImpl) {
|
|
60
|
+
writeSync.mockImplementation(previousImpl);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Helper to check if writeSync was called with a specific file descriptor and message.
|
|
67
|
+
* @param spy The writeSync mock
|
|
68
|
+
* @param fd The file descriptor (1 for stdout, 2 for stderr)
|
|
69
|
+
* @param needles Substrings to search for in the buffer content
|
|
70
|
+
*/
|
|
71
|
+
export function directLogContains(
|
|
72
|
+
spy: jest.Mock,
|
|
73
|
+
fd: number,
|
|
74
|
+
...needles: string[]
|
|
75
|
+
): boolean {
|
|
76
|
+
return spy.mock.calls.some((args: unknown[]) => {
|
|
77
|
+
const [calledFd, buffer] = args;
|
|
78
|
+
if (calledFd !== fd) return false;
|
|
79
|
+
|
|
80
|
+
const text = buffer instanceof Buffer
|
|
81
|
+
? buffer.toString('utf8')
|
|
82
|
+
: String(buffer);
|
|
83
|
+
|
|
84
|
+
return needles.every((n) => text.includes(n));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get all messages written to a specific file descriptor.
|
|
90
|
+
* @param spy The writeSync mock
|
|
91
|
+
* @param fd The file descriptor (1 for stdout, 2 for stderr)
|
|
92
|
+
*/
|
|
93
|
+
export function getDirectLogMessages(
|
|
94
|
+
spy: jest.Mock,
|
|
95
|
+
fd: number,
|
|
96
|
+
): string[] {
|
|
97
|
+
return spy.mock.calls
|
|
98
|
+
.filter((args: unknown[]) => args[0] === fd)
|
|
99
|
+
.map((args: unknown[]) => {
|
|
100
|
+
const buffer = args[1];
|
|
101
|
+
return buffer instanceof Buffer
|
|
102
|
+
? buffer.toString('utf8')
|
|
103
|
+
: String(buffer);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple localStorage mock for Node.js test environment
|
|
3
|
+
*/
|
|
4
|
+
export class LocalStorageMock implements Storage {
|
|
5
|
+
private store: Map<string, string> = new Map();
|
|
6
|
+
|
|
7
|
+
get length(): number {
|
|
8
|
+
return this.store.size;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
clear(): void {
|
|
12
|
+
this.store.clear();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getItem(key: string): string | null {
|
|
16
|
+
return this.store.get(key) ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
key(index: number): string | null {
|
|
20
|
+
const keys = Array.from(this.store.keys());
|
|
21
|
+
return keys[index] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
removeItem(key: string): void {
|
|
25
|
+
this.store.delete(key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setItem(key: string, value: string): void {
|
|
29
|
+
this.store.set(key, value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Set up global localStorage mock
|
|
34
|
+
if (typeof globalThis.localStorage === 'undefined') {
|
|
35
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
36
|
+
value: new LocalStorageMock(),
|
|
37
|
+
writable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mongoose-memory.d.ts","sourceRoot":"","sources":["../../../../../packages/digitaldefiance-express-suite-test-utils/src/lib/mongoose-memory.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"mongoose-memory.d.ts","sourceRoot":"","sources":["../../../../../packages/digitaldefiance-express-suite-test-utils/src/lib/mongoose-memory.ts"],"names":[],"mappings":"AAAA,OAAiB,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAMvE;;;GAGG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC;IAC/C,UAAU,EAAE,UAAU,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC,CAsBD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAWxD;AAED;;GAEG;AACH,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAOnD"}
|
|
@@ -4,8 +4,8 @@ exports.connectMemoryDB = connectMemoryDB;
|
|
|
4
4
|
exports.disconnectMemoryDB = disconnectMemoryDB;
|
|
5
5
|
exports.clearMemoryDB = clearMemoryDB;
|
|
6
6
|
const tslib_1 = require("tslib");
|
|
7
|
+
const mongoose_types_1 = tslib_1.__importDefault(require("@digitaldefiance/mongoose-types"));
|
|
7
8
|
const mongodb_memory_server_1 = require("mongodb-memory-server");
|
|
8
|
-
const mongoose_1 = tslib_1.__importDefault(require("mongoose"));
|
|
9
9
|
let mongoServer;
|
|
10
10
|
let connection;
|
|
11
11
|
/**
|
|
@@ -14,8 +14,8 @@ let connection;
|
|
|
14
14
|
*/
|
|
15
15
|
async function connectMemoryDB() {
|
|
16
16
|
// If mongoose is connected but we don't have our server, disconnect first
|
|
17
|
-
if (
|
|
18
|
-
await
|
|
17
|
+
if (mongoose_types_1.default.connection.readyState !== 0 && !mongoServer) {
|
|
18
|
+
await mongoose_types_1.default.disconnect();
|
|
19
19
|
connection = undefined;
|
|
20
20
|
}
|
|
21
21
|
// Create new server if needed
|
|
@@ -24,10 +24,10 @@ async function connectMemoryDB() {
|
|
|
24
24
|
}
|
|
25
25
|
const uri = mongoServer.getUri();
|
|
26
26
|
// Connect if not already connected
|
|
27
|
-
if (
|
|
28
|
-
await
|
|
27
|
+
if (mongoose_types_1.default.connection.readyState === 0) {
|
|
28
|
+
await mongoose_types_1.default.connect(uri);
|
|
29
29
|
}
|
|
30
|
-
connection =
|
|
30
|
+
connection = mongoose_types_1.default.connection;
|
|
31
31
|
return { connection, uri };
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
@@ -36,7 +36,7 @@ async function connectMemoryDB() {
|
|
|
36
36
|
async function disconnectMemoryDB() {
|
|
37
37
|
if (connection) {
|
|
38
38
|
await connection.dropDatabase();
|
|
39
|
-
await
|
|
39
|
+
await mongoose_types_1.default.disconnect();
|
|
40
40
|
connection = undefined;
|
|
41
41
|
}
|
|
42
42
|
if (mongoServer) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import mongoose, { Connection } from '@digitaldefiance/mongoose-types';
|
|
2
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
3
|
+
|
|
4
|
+
let mongoServer: MongoMemoryServer | undefined;
|
|
5
|
+
let connection: Connection | undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Connect to in-memory MongoDB for testing
|
|
9
|
+
* @returns Object with both the connection and URI
|
|
10
|
+
*/
|
|
11
|
+
export async function connectMemoryDB(): Promise<{
|
|
12
|
+
connection: Connection;
|
|
13
|
+
uri: string;
|
|
14
|
+
}> {
|
|
15
|
+
// If mongoose is connected but we don't have our server, disconnect first
|
|
16
|
+
if (mongoose.connection.readyState !== 0 && !mongoServer) {
|
|
17
|
+
await mongoose.disconnect();
|
|
18
|
+
connection = undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Create new server if needed
|
|
22
|
+
if (!mongoServer) {
|
|
23
|
+
mongoServer = await MongoMemoryServer.create();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const uri = mongoServer.getUri();
|
|
27
|
+
|
|
28
|
+
// Connect if not already connected
|
|
29
|
+
if (mongoose.connection.readyState === 0) {
|
|
30
|
+
await mongoose.connect(uri);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connection = mongoose.connection;
|
|
34
|
+
|
|
35
|
+
return { connection, uri };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drop all collections and disconnect
|
|
40
|
+
*/
|
|
41
|
+
export async function disconnectMemoryDB(): Promise<void> {
|
|
42
|
+
if (connection) {
|
|
43
|
+
await connection.dropDatabase();
|
|
44
|
+
await mongoose.disconnect();
|
|
45
|
+
connection = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (mongoServer) {
|
|
49
|
+
await mongoServer.stop();
|
|
50
|
+
mongoServer = undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Clear all collections without disconnecting
|
|
56
|
+
*/
|
|
57
|
+
export async function clearMemoryDB(): Promise<void> {
|
|
58
|
+
if (connection) {
|
|
59
|
+
const collections = connection.collections;
|
|
60
|
+
for (const key in collections) {
|
|
61
|
+
await collections[key].deleteMany({});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock utilities for React component testing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mock timezones for testing
|
|
7
|
+
*/
|
|
8
|
+
export const mockTimezones = [
|
|
9
|
+
'America/New_York',
|
|
10
|
+
'America/Los_Angeles',
|
|
11
|
+
'Europe/London',
|
|
12
|
+
'Europe/Paris',
|
|
13
|
+
'Asia/Tokyo',
|
|
14
|
+
'UTC',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get initial timezone mock
|
|
19
|
+
*/
|
|
20
|
+
export const mockGetInitialTimezone = (): string => 'UTC';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mock RegisterForm props
|
|
24
|
+
*/
|
|
25
|
+
export const mockRegisterFormProps = {
|
|
26
|
+
timezones: mockTimezones,
|
|
27
|
+
getInitialTimezone: mockGetInitialTimezone,
|
|
28
|
+
onSubmit: jest.fn().mockResolvedValue({ success: true, message: 'Success' }),
|
|
29
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { expect } from '@jest/globals';
|
|
2
|
+
import type { MatcherContext } from 'expect';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
export type ErrorClass<E extends Error> = new (...args: any[]) => E;
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
namespace jest {
|
|
10
|
+
interface Matchers<R> {
|
|
11
|
+
toThrowType<E extends Error>(
|
|
12
|
+
errorType: ErrorClass<E>,
|
|
13
|
+
validator?: (error: E) => void,
|
|
14
|
+
): R;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MatcherError extends Error {
|
|
20
|
+
matcherResult?: {
|
|
21
|
+
expected: unknown;
|
|
22
|
+
received: unknown;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMatcherError(error: unknown): error is MatcherError {
|
|
27
|
+
return error instanceof Error && 'matcherResult' in error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractTestInfo(stackTrace: string) {
|
|
31
|
+
const stackLines = stackTrace.split('\n');
|
|
32
|
+
const anonymousLine = stackLines.find((line) =>
|
|
33
|
+
line.includes('Object.<anonymous>'),
|
|
34
|
+
);
|
|
35
|
+
const match = anonymousLine?.match(/\((.+?\.spec\.ts):(\d+):(\d+)\)/);
|
|
36
|
+
|
|
37
|
+
if (!match) {
|
|
38
|
+
return { testHierarchy: ['Unknown Test'], location: '' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fullTestPath = match[1];
|
|
42
|
+
const lineNumber = parseInt(match[2]);
|
|
43
|
+
const testFile = fullTestPath.split('/').pop() || '';
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const fileContent = readFileSync(fullTestPath, 'utf8');
|
|
47
|
+
const lines = fileContent.split('\n');
|
|
48
|
+
const testLineContent = lines[lineNumber - 1];
|
|
49
|
+
const testNameMatch = testLineContent.match(/it\(['"](.+?)['"]/);
|
|
50
|
+
const testName = testNameMatch?.[1];
|
|
51
|
+
|
|
52
|
+
const testHierarchy = ['Test'];
|
|
53
|
+
if (testName) {
|
|
54
|
+
testHierarchy.push(testName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
testHierarchy,
|
|
59
|
+
location: ` (${testFile}:${lineNumber})`,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return {
|
|
63
|
+
testHierarchy: ['Test'],
|
|
64
|
+
location: ` (${testFile}:${lineNumber})`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const toThrowType = async function <E extends Error>(
|
|
70
|
+
this: MatcherContext,
|
|
71
|
+
received: (() => unknown | Promise<unknown>) | Promise<unknown>,
|
|
72
|
+
errorType: ErrorClass<E>,
|
|
73
|
+
validator?: (error: E) => void,
|
|
74
|
+
): Promise<{ pass: boolean; message: () => string }> {
|
|
75
|
+
const matcherName = 'toThrowType';
|
|
76
|
+
const options = {
|
|
77
|
+
isNot: this.isNot,
|
|
78
|
+
promise: this.promise,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let error: unknown;
|
|
82
|
+
let pass = false;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (this.promise) {
|
|
86
|
+
try {
|
|
87
|
+
await received;
|
|
88
|
+
pass = false;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
error = e;
|
|
91
|
+
if (
|
|
92
|
+
error instanceof Error &&
|
|
93
|
+
(error instanceof errorType || error.constructor === errorType)
|
|
94
|
+
) {
|
|
95
|
+
pass = true;
|
|
96
|
+
if (validator) {
|
|
97
|
+
await validator(error as E);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
if (typeof received !== 'function') {
|
|
103
|
+
throw new Error(
|
|
104
|
+
this.utils.matcherHint(matcherName, undefined, undefined, options) +
|
|
105
|
+
'\n\n' +
|
|
106
|
+
'Received value must be a function',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await (received as () => unknown | Promise<unknown>)();
|
|
111
|
+
pass = false;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
error = e;
|
|
114
|
+
if (
|
|
115
|
+
error instanceof Error &&
|
|
116
|
+
(error instanceof errorType || error.constructor === errorType)
|
|
117
|
+
) {
|
|
118
|
+
pass = true;
|
|
119
|
+
if (validator) {
|
|
120
|
+
await validator(error as E);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (validatorError) {
|
|
126
|
+
const error =
|
|
127
|
+
validatorError instanceof Error
|
|
128
|
+
? validatorError
|
|
129
|
+
: new Error(String(validatorError));
|
|
130
|
+
const message = error.message;
|
|
131
|
+
const stack = error.stack || '';
|
|
132
|
+
|
|
133
|
+
let diffString: string;
|
|
134
|
+
if (isMatcherError(error) && error.matcherResult) {
|
|
135
|
+
diffString =
|
|
136
|
+
this.utils.diff(
|
|
137
|
+
error.matcherResult.expected,
|
|
138
|
+
error.matcherResult.received,
|
|
139
|
+
) || '';
|
|
140
|
+
} else {
|
|
141
|
+
diffString = this.utils.diff('Error to match assertions', message) || '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { testHierarchy, location } = extractTestInfo(stack);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
pass: false,
|
|
148
|
+
message: () =>
|
|
149
|
+
`\n\n${this.utils.RECEIVED_COLOR(
|
|
150
|
+
`● ${testHierarchy.join(' › ')}${location ? ` ${location}` : ''}`,
|
|
151
|
+
)}\n\n` +
|
|
152
|
+
this.utils.matcherHint(matcherName, undefined, undefined, options) +
|
|
153
|
+
'\n\n' +
|
|
154
|
+
diffString +
|
|
155
|
+
'\n\n' +
|
|
156
|
+
(stack
|
|
157
|
+
? this.utils.RECEIVED_COLOR(stack.split('\n').slice(1).join('\n'))
|
|
158
|
+
: ''),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const testHeader =
|
|
163
|
+
error instanceof Error && error.stack
|
|
164
|
+
? (() => {
|
|
165
|
+
const { testHierarchy, location } = extractTestInfo(error.stack);
|
|
166
|
+
return `\n\n${this.utils.RECEIVED_COLOR(
|
|
167
|
+
`● ${testHierarchy.join(' › ')}${location}`,
|
|
168
|
+
)}\n\n`;
|
|
169
|
+
})()
|
|
170
|
+
: '\n';
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
pass,
|
|
174
|
+
message: () =>
|
|
175
|
+
testHeader +
|
|
176
|
+
this.utils.matcherHint(matcherName, undefined, undefined, options) +
|
|
177
|
+
'\n\n' +
|
|
178
|
+
(pass
|
|
179
|
+
? `Expected function not to throw ${this.utils.printExpected(
|
|
180
|
+
errorType.name,
|
|
181
|
+
)}`
|
|
182
|
+
: this.promise
|
|
183
|
+
? this.utils.matcherErrorMessage(
|
|
184
|
+
this.utils.matcherHint(matcherName, undefined, undefined, options),
|
|
185
|
+
'Expected promise to reject',
|
|
186
|
+
'Promise resolved successfully',
|
|
187
|
+
)
|
|
188
|
+
: this.utils.matcherErrorMessage(
|
|
189
|
+
this.utils.matcherHint(matcherName, undefined, undefined, options),
|
|
190
|
+
`Expected function to throw ${this.utils.printExpected(
|
|
191
|
+
errorType.name,
|
|
192
|
+
)}`,
|
|
193
|
+
`Received: ${this.utils.printReceived(
|
|
194
|
+
error instanceof Error ? error.constructor.name : typeof error,
|
|
195
|
+
)}`,
|
|
196
|
+
)),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
expect.extend({ toThrowType });
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
|
|
3
|
+
export {};
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
namespace jest {
|
|
7
|
+
interface Matchers<R> {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
toThrowType<E extends Error, T extends new (...args: any[]) => E>(
|
|
10
|
+
errorType: T,
|
|
11
|
+
validator?: (error: E) => void,
|
|
12
|
+
): R;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare module 'expect' {
|
|
18
|
+
interface ExpectExtendMap {
|
|
19
|
+
toThrowType: {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
<E extends Error, T extends new (...args: any[]) => E>(
|
|
22
|
+
this: jest.MatcherContext,
|
|
23
|
+
received: () => unknown,
|
|
24
|
+
errorType: T,
|
|
25
|
+
validator?: (error: E) => void,
|
|
26
|
+
): jest.CustomMatcherResult;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|