@techstream/quark-core 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-lint.log +7 -0
- package/.turbo/turbo-test.log +1376 -0
- package/README.md +419 -0
- package/package.json +29 -0
- package/src/auth/index.js +127 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +90 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/csrf.js +118 -0
- package/src/csrf.test.js +157 -0
- package/src/email.js +140 -0
- package/src/email.test.js +259 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/index.js +32 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mailhog.js +43 -0
- package/src/queue/index.js +214 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +96 -0
- package/src/testing/factories.js +93 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
- package/test-imports.mjs +21 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable mock objects for testing without external services.
|
|
3
|
+
* All mocks use plain JavaScript objects and closures — no complex proxy chains.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/mocks
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} MockCall
|
|
10
|
+
* @property {string} model - The model name (e.g. "user", "post")
|
|
11
|
+
* @property {string} method - The method name (e.g. "findUnique", "create")
|
|
12
|
+
* @property {Array<*>} args - Arguments passed to the method
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} MockPrisma
|
|
17
|
+
* @property {MockCall[]} calls - Recorded method calls
|
|
18
|
+
* @property {() => void} reset - Clear all recorded calls
|
|
19
|
+
* @property {(model: string, method: string, value: *) => void} mockReturn - Set return value for model.method
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a mock Prisma client that records all calls and returns configurable results.
|
|
24
|
+
*
|
|
25
|
+
* Supports any model/method combination. Unknown models and methods return `null` by default.
|
|
26
|
+
* Use `.mockReturn(model, method, value)` to configure return values.
|
|
27
|
+
* Use `.calls` to inspect recorded calls.
|
|
28
|
+
* Use `.reset()` to clear recorded calls and return values.
|
|
29
|
+
*
|
|
30
|
+
* @param {Record<string, Record<string, *>>} [overrides={}] - Initial return values keyed by model.method
|
|
31
|
+
* @returns {MockPrisma & Record<string, Record<string, Function>>}
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const prisma = createMockPrisma();
|
|
35
|
+
* prisma.mockReturn("user", "findUnique", { id: "1", name: "Test" });
|
|
36
|
+
* const user = await prisma.user.findUnique({ where: { id: "1" } });
|
|
37
|
+
* // user => { id: "1", name: "Test" }
|
|
38
|
+
* // prisma.calls => [{ model: "user", method: "findUnique", args: [{ where: { id: "1" } }] }]
|
|
39
|
+
*/
|
|
40
|
+
export function createMockPrisma(overrides = {}) {
|
|
41
|
+
/** @type {MockCall[]} */
|
|
42
|
+
const calls = [];
|
|
43
|
+
|
|
44
|
+
/** @type {Record<string, Record<string, *>>} */
|
|
45
|
+
const returnValues = {};
|
|
46
|
+
|
|
47
|
+
// Initialize with overrides
|
|
48
|
+
for (const [model, methods] of Object.entries(overrides)) {
|
|
49
|
+
returnValues[model] = { ...methods };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set a return value for a specific model.method combination.
|
|
54
|
+
* The value can be a plain value or a function that receives the call args.
|
|
55
|
+
* @param {string} model
|
|
56
|
+
* @param {string} method
|
|
57
|
+
* @param {*} value
|
|
58
|
+
*/
|
|
59
|
+
function mockReturn(model, method, value) {
|
|
60
|
+
if (!returnValues[model]) {
|
|
61
|
+
returnValues[model] = {};
|
|
62
|
+
}
|
|
63
|
+
returnValues[model][method] = value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Clear all recorded calls and return values. */
|
|
67
|
+
function reset() {
|
|
68
|
+
calls.length = 0;
|
|
69
|
+
for (const key of Object.keys(returnValues)) {
|
|
70
|
+
delete returnValues[key];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @type {Record<string, Record<string, Function>>} */
|
|
75
|
+
const modelCache = {};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get or create a model accessor with method recording.
|
|
79
|
+
* @param {string} model
|
|
80
|
+
* @returns {Record<string, Function>}
|
|
81
|
+
*/
|
|
82
|
+
function getModel(model) {
|
|
83
|
+
if (!modelCache[model]) {
|
|
84
|
+
modelCache[model] = new Proxy(
|
|
85
|
+
{},
|
|
86
|
+
{
|
|
87
|
+
get(_target, method) {
|
|
88
|
+
if (typeof method !== "string") return undefined;
|
|
89
|
+
return async (...args) => {
|
|
90
|
+
calls.push({ model, method, args });
|
|
91
|
+
const modelReturns = returnValues[model];
|
|
92
|
+
if (modelReturns && method in modelReturns) {
|
|
93
|
+
const val = modelReturns[method];
|
|
94
|
+
return typeof val === "function" ? val(...args) : val;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return modelCache[model];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Proxy(
|
|
106
|
+
/** @type {any} */ ({
|
|
107
|
+
calls,
|
|
108
|
+
reset,
|
|
109
|
+
mockReturn,
|
|
110
|
+
}),
|
|
111
|
+
{
|
|
112
|
+
get(target, prop) {
|
|
113
|
+
if (prop === "calls" || prop === "reset" || prop === "mockReturn") {
|
|
114
|
+
return target[prop];
|
|
115
|
+
}
|
|
116
|
+
if (typeof prop === "string") {
|
|
117
|
+
return getModel(prop);
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @typedef {Object} MockRequestOptions
|
|
127
|
+
* @property {string} [method="GET"] - HTTP method
|
|
128
|
+
* @property {string} [url="http://localhost/api/test"] - Request URL
|
|
129
|
+
* @property {Record<string, string>} [headers={}] - Headers (lowercase keys)
|
|
130
|
+
* @property {*} [body=null] - Request body
|
|
131
|
+
* @property {Record<string, string>} [cookies={}] - Cookie key-value pairs
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a mock Request object compatible with Next.js API routes and middleware.
|
|
136
|
+
*
|
|
137
|
+
* @param {MockRequestOptions} [overrides={}]
|
|
138
|
+
* @returns {Object} A mock request with method, url, headers, body, cookies, and nextUrl
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* const req = createMockRequest({
|
|
142
|
+
* method: "POST",
|
|
143
|
+
* headers: { "content-type": "application/json" },
|
|
144
|
+
* body: { name: "Test" },
|
|
145
|
+
* });
|
|
146
|
+
*/
|
|
147
|
+
export function createMockRequest(overrides = {}) {
|
|
148
|
+
const method = overrides.method || "GET";
|
|
149
|
+
const url = overrides.url || "http://localhost/api/test";
|
|
150
|
+
const parsedUrl = new URL(url);
|
|
151
|
+
const headerMap = new Map(
|
|
152
|
+
Object.entries(overrides.headers || {}).map(([k, v]) => [
|
|
153
|
+
k.toLowerCase(),
|
|
154
|
+
v,
|
|
155
|
+
]),
|
|
156
|
+
);
|
|
157
|
+
const cookieMap = new Map(Object.entries(overrides.cookies || {}));
|
|
158
|
+
const body = overrides.body ?? null;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
method,
|
|
162
|
+
url,
|
|
163
|
+
nextUrl: parsedUrl,
|
|
164
|
+
headers: {
|
|
165
|
+
/** @param {string} key */
|
|
166
|
+
get(key) {
|
|
167
|
+
return headerMap.get(key.toLowerCase()) ?? null;
|
|
168
|
+
},
|
|
169
|
+
/** @param {string} key */
|
|
170
|
+
has(key) {
|
|
171
|
+
return headerMap.has(key.toLowerCase());
|
|
172
|
+
},
|
|
173
|
+
/** @param {string} key @param {string} value */
|
|
174
|
+
set(key, value) {
|
|
175
|
+
headerMap.set(key.toLowerCase(), value);
|
|
176
|
+
},
|
|
177
|
+
/** Iterate over all headers */
|
|
178
|
+
entries() {
|
|
179
|
+
return headerMap.entries();
|
|
180
|
+
},
|
|
181
|
+
forEach(fn) {
|
|
182
|
+
headerMap.forEach((value, key) => {
|
|
183
|
+
fn(value, key);
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
cookies: {
|
|
188
|
+
/** @param {string} name */
|
|
189
|
+
get(name) {
|
|
190
|
+
const value = cookieMap.get(name);
|
|
191
|
+
return value !== undefined ? { name, value } : undefined;
|
|
192
|
+
},
|
|
193
|
+
/** @param {string} name */
|
|
194
|
+
has(name) {
|
|
195
|
+
return cookieMap.has(name);
|
|
196
|
+
},
|
|
197
|
+
getAll() {
|
|
198
|
+
return [...cookieMap.entries()].map(([name, value]) => ({
|
|
199
|
+
name,
|
|
200
|
+
value,
|
|
201
|
+
}));
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
async json() {
|
|
205
|
+
return body;
|
|
206
|
+
},
|
|
207
|
+
async text() {
|
|
208
|
+
return typeof body === "string" ? body : JSON.stringify(body);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @typedef {Object} MockResponse
|
|
215
|
+
* @property {number} status - HTTP status code
|
|
216
|
+
* @property {Map<string, string>} headers - Response headers
|
|
217
|
+
* @property {*} body - Response body (set via json())
|
|
218
|
+
* @property {Map<string, string>} cookies - Response cookies
|
|
219
|
+
* @property {(data: *) => MockResponse} json - Set JSON body and return self
|
|
220
|
+
* @property {(url: string) => MockResponse} redirect - Create a redirect response
|
|
221
|
+
*/
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create a mock NextResponse object for testing middleware.
|
|
225
|
+
*
|
|
226
|
+
* @returns {MockResponse}
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* const res = createMockResponse();
|
|
230
|
+
* res.json({ ok: true });
|
|
231
|
+
* assert.deepStrictEqual(res.body, { ok: true });
|
|
232
|
+
*/
|
|
233
|
+
export function createMockResponse() {
|
|
234
|
+
const headers = new Map();
|
|
235
|
+
const cookies = new Map();
|
|
236
|
+
|
|
237
|
+
const response = {
|
|
238
|
+
status: 200,
|
|
239
|
+
headers: {
|
|
240
|
+
/** @param {string} key */
|
|
241
|
+
get(key) {
|
|
242
|
+
return headers.get(key.toLowerCase()) ?? null;
|
|
243
|
+
},
|
|
244
|
+
/** @param {string} key @param {string} value */
|
|
245
|
+
set(key, value) {
|
|
246
|
+
headers.set(key.toLowerCase(), value);
|
|
247
|
+
},
|
|
248
|
+
/** @param {string} key */
|
|
249
|
+
has(key) {
|
|
250
|
+
return headers.has(key.toLowerCase());
|
|
251
|
+
},
|
|
252
|
+
entries() {
|
|
253
|
+
return headers.entries();
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
body: null,
|
|
257
|
+
cookies: {
|
|
258
|
+
/** @param {string} name @param {string} value */
|
|
259
|
+
set(name, value) {
|
|
260
|
+
cookies.set(name, value);
|
|
261
|
+
},
|
|
262
|
+
/** @param {string} name */
|
|
263
|
+
get(name) {
|
|
264
|
+
return cookies.get(name) ?? null;
|
|
265
|
+
},
|
|
266
|
+
/** @param {string} name */
|
|
267
|
+
has(name) {
|
|
268
|
+
return cookies.has(name);
|
|
269
|
+
},
|
|
270
|
+
/** @param {string} name */
|
|
271
|
+
delete(name) {
|
|
272
|
+
cookies.delete(name);
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
/**
|
|
276
|
+
* Set JSON body and content-type header.
|
|
277
|
+
* @param {*} data
|
|
278
|
+
* @returns {MockResponse}
|
|
279
|
+
*/
|
|
280
|
+
json(data) {
|
|
281
|
+
response.body = data;
|
|
282
|
+
headers.set("content-type", "application/json");
|
|
283
|
+
return response;
|
|
284
|
+
},
|
|
285
|
+
/**
|
|
286
|
+
* Create a redirect-like response.
|
|
287
|
+
* @param {string} url
|
|
288
|
+
* @returns {MockResponse}
|
|
289
|
+
*/
|
|
290
|
+
redirect(url) {
|
|
291
|
+
response.status = 302;
|
|
292
|
+
headers.set("location", url);
|
|
293
|
+
return response;
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return response;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @typedef {Object} MockRedis
|
|
302
|
+
* @property {Array<{method: string, args: Array<*>}>} calls - Recorded method calls
|
|
303
|
+
* @property {() => void} reset - Clear all stored data and recorded calls
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Create a mock Redis client backed by an in-memory Map.
|
|
308
|
+
* Records all method calls for assertion. Supports common Redis commands:
|
|
309
|
+
* get, set, del, keys, expire, exists, incr, pipeline, ping, quit, disconnect.
|
|
310
|
+
*
|
|
311
|
+
* @param {Record<string, string>} [initialData={}] - Pre-populate the store
|
|
312
|
+
* @returns {MockRedis & Record<string, Function>}
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* const redis = createMockRedis({ "session:1": '{"user":"alice"}' });
|
|
316
|
+
* await redis.set("key", "value");
|
|
317
|
+
* const val = await redis.get("key");
|
|
318
|
+
* // val => "value"
|
|
319
|
+
* // redis.calls => [{ method: "set", args: ["key", "value"] }, { method: "get", args: ["key"] }]
|
|
320
|
+
*/
|
|
321
|
+
export function createMockRedis(initialData = {}) {
|
|
322
|
+
/** @type {Map<string, string>} */
|
|
323
|
+
const store = new Map(Object.entries(initialData));
|
|
324
|
+
/** @type {Map<string, number>} */
|
|
325
|
+
const ttls = new Map();
|
|
326
|
+
/** @type {Array<{method: string, args: Array<*>}>} */
|
|
327
|
+
const calls = [];
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Record a method call.
|
|
331
|
+
* @param {string} method
|
|
332
|
+
* @param {Array<*>} args
|
|
333
|
+
*/
|
|
334
|
+
function record(method, args) {
|
|
335
|
+
calls.push({ method, args: [...args] });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Clear all stored data and recorded calls. */
|
|
339
|
+
function reset() {
|
|
340
|
+
store.clear();
|
|
341
|
+
ttls.clear();
|
|
342
|
+
calls.length = 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
calls,
|
|
347
|
+
reset,
|
|
348
|
+
|
|
349
|
+
/** @param {string} key */
|
|
350
|
+
async get(key) {
|
|
351
|
+
record("get", [key]);
|
|
352
|
+
return store.get(key) ?? null;
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @param {string} key
|
|
357
|
+
* @param {string} value
|
|
358
|
+
* @param {...*} args - Optional args like "EX", seconds
|
|
359
|
+
*/
|
|
360
|
+
async set(key, value, ...args) {
|
|
361
|
+
record("set", [key, value, ...args]);
|
|
362
|
+
store.set(key, value);
|
|
363
|
+
// Handle EX/PX TTL args
|
|
364
|
+
const exIdx = args.indexOf("EX");
|
|
365
|
+
if (exIdx !== -1 && args[exIdx + 1] !== undefined) {
|
|
366
|
+
ttls.set(key, Number(args[exIdx + 1]));
|
|
367
|
+
}
|
|
368
|
+
return "OK";
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/** @param {...string} keys */
|
|
372
|
+
async del(...keys) {
|
|
373
|
+
record("del", keys);
|
|
374
|
+
let count = 0;
|
|
375
|
+
for (const key of keys) {
|
|
376
|
+
if (store.delete(key)) count++;
|
|
377
|
+
ttls.delete(key);
|
|
378
|
+
}
|
|
379
|
+
return count;
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
/** @param {string} pattern */
|
|
383
|
+
async keys(pattern) {
|
|
384
|
+
record("keys", [pattern]);
|
|
385
|
+
// Simple glob: only supports trailing *
|
|
386
|
+
const prefix = pattern.replace(/\*$/, "");
|
|
387
|
+
return [...store.keys()].filter((k) => k.startsWith(prefix));
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {string} key
|
|
392
|
+
* @param {number} seconds
|
|
393
|
+
*/
|
|
394
|
+
async expire(key, seconds) {
|
|
395
|
+
record("expire", [key, seconds]);
|
|
396
|
+
if (store.has(key)) {
|
|
397
|
+
ttls.set(key, seconds);
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
return 0;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/** @param {...string} keys */
|
|
404
|
+
async exists(...keys) {
|
|
405
|
+
record("exists", keys);
|
|
406
|
+
return keys.filter((k) => store.has(k)).length;
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/** @param {string} key */
|
|
410
|
+
async incr(key) {
|
|
411
|
+
record("incr", [key]);
|
|
412
|
+
const current = Number.parseInt(store.get(key) || "0", 10);
|
|
413
|
+
const next = current + 1;
|
|
414
|
+
store.set(key, String(next));
|
|
415
|
+
return next;
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Create a pipeline that batches commands and executes them together.
|
|
420
|
+
* @returns {Object} A chainable pipeline with .exec()
|
|
421
|
+
*/
|
|
422
|
+
pipeline() {
|
|
423
|
+
record("pipeline", []);
|
|
424
|
+
/** @type {Array<() => Promise<*>>} */
|
|
425
|
+
const queue = [];
|
|
426
|
+
|
|
427
|
+
const pipe = {
|
|
428
|
+
/** @param {string} key */
|
|
429
|
+
get(key) {
|
|
430
|
+
queue.push(async () => store.get(key) ?? null);
|
|
431
|
+
return pipe;
|
|
432
|
+
},
|
|
433
|
+
/** @param {string} key @param {string} value */
|
|
434
|
+
set(key, value) {
|
|
435
|
+
queue.push(async () => {
|
|
436
|
+
store.set(key, value);
|
|
437
|
+
return "OK";
|
|
438
|
+
});
|
|
439
|
+
return pipe;
|
|
440
|
+
},
|
|
441
|
+
/** @param {...string} keys */
|
|
442
|
+
del(...keys) {
|
|
443
|
+
queue.push(async () => {
|
|
444
|
+
let count = 0;
|
|
445
|
+
for (const k of keys) {
|
|
446
|
+
if (store.delete(k)) count++;
|
|
447
|
+
}
|
|
448
|
+
return count;
|
|
449
|
+
});
|
|
450
|
+
return pipe;
|
|
451
|
+
},
|
|
452
|
+
/** Execute all queued commands and return results. */
|
|
453
|
+
async exec() {
|
|
454
|
+
const results = [];
|
|
455
|
+
for (const fn of queue) {
|
|
456
|
+
results.push([null, await fn()]);
|
|
457
|
+
}
|
|
458
|
+
return results;
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
return pipe;
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
async ping() {
|
|
466
|
+
record("ping", []);
|
|
467
|
+
return "PONG";
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
async quit() {
|
|
471
|
+
record("quit", []);
|
|
472
|
+
return "OK";
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
async disconnect() {
|
|
476
|
+
record("disconnect", []);
|
|
477
|
+
return "OK";
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|