@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.
@@ -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
+ }