@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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test data factories for creating realistic test objects with sensible defaults.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createTestUser, createTestPost, createTestSession } from "@techstream/quark-core/testing";
|
|
6
|
+
* const user = createTestUser({ name: "Custom Name" });
|
|
7
|
+
* const post = createTestPost({ authorId: user.id, published: true });
|
|
8
|
+
* const session = createTestSession({ user: { role: "admin" } });
|
|
9
|
+
*
|
|
10
|
+
* @module testing/factories
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a short random ID string.
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function randomId() {
|
|
18
|
+
return Math.random().toString(36).slice(2, 10);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a test User object matching the Prisma User model shape.
|
|
23
|
+
* All fields have sensible defaults that can be overridden.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} [overrides={}]
|
|
26
|
+
* @returns {Object}
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const user = createTestUser({ role: "admin" });
|
|
30
|
+
*/
|
|
31
|
+
export function createTestUser(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id: overrides.id || `user_${randomId()}`,
|
|
34
|
+
email: overrides.email || `test-${randomId()}@example.com`,
|
|
35
|
+
name: overrides.name || "Test User",
|
|
36
|
+
role: overrides.role || "viewer",
|
|
37
|
+
password: overrides.password || null,
|
|
38
|
+
image: overrides.image || null,
|
|
39
|
+
emailVerified: overrides.emailVerified || null,
|
|
40
|
+
createdAt: overrides.createdAt || new Date(),
|
|
41
|
+
updatedAt: overrides.updatedAt || new Date(),
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a test Post object matching the Prisma Post model shape.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} [overrides={}]
|
|
50
|
+
* @returns {Object}
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* const post = createTestPost({ title: "My Post", published: true });
|
|
54
|
+
*/
|
|
55
|
+
export function createTestPost(overrides = {}) {
|
|
56
|
+
return {
|
|
57
|
+
id: overrides.id || `post_${randomId()}`,
|
|
58
|
+
title: overrides.title || "Test Post",
|
|
59
|
+
content: overrides.content || "Test content",
|
|
60
|
+
published: overrides.published ?? false,
|
|
61
|
+
authorId: overrides.authorId || `user_${randomId()}`,
|
|
62
|
+
createdAt: overrides.createdAt || new Date(),
|
|
63
|
+
updatedAt: overrides.updatedAt || new Date(),
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a test session object compatible with NextAuth session shape.
|
|
70
|
+
* Optionally pass user overrides to customize the embedded user.
|
|
71
|
+
*
|
|
72
|
+
* @param {Object} [overrides={}]
|
|
73
|
+
* @returns {Object}
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* const session = createTestSession({ user: { role: "admin" } });
|
|
77
|
+
*/
|
|
78
|
+
export function createTestSession(overrides = {}) {
|
|
79
|
+
const user = createTestUser(overrides.user);
|
|
80
|
+
return {
|
|
81
|
+
user: {
|
|
82
|
+
id: user.id,
|
|
83
|
+
email: user.email,
|
|
84
|
+
name: user.name,
|
|
85
|
+
role: user.role,
|
|
86
|
+
...overrides.user,
|
|
87
|
+
},
|
|
88
|
+
expires:
|
|
89
|
+
overrides.expires ||
|
|
90
|
+
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper utilities for common testing patterns.
|
|
3
|
+
*
|
|
4
|
+
* @module testing/helpers
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} CapturedOutput
|
|
11
|
+
* @property {Array<{method: string, args: Array<*>}>} output - Captured console entries
|
|
12
|
+
* @property {() => void} restore - Restore original console methods
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Capture console output during test execution.
|
|
17
|
+
* Intercepts console.log, .warn, .error, .info, and .debug.
|
|
18
|
+
* Call `.restore()` when done to put the original methods back.
|
|
19
|
+
*
|
|
20
|
+
* @returns {CapturedOutput}
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const { output, restore } = captureConsole();
|
|
24
|
+
* console.log("hello");
|
|
25
|
+
* console.error("oops");
|
|
26
|
+
* restore();
|
|
27
|
+
* // output => [{ method: "log", args: ["hello"] }, { method: "error", args: ["oops"] }]
|
|
28
|
+
*/
|
|
29
|
+
export function captureConsole() {
|
|
30
|
+
/** @type {Array<{method: string, args: Array<*>}>} */
|
|
31
|
+
const output = [];
|
|
32
|
+
|
|
33
|
+
const original = {
|
|
34
|
+
log: console.log,
|
|
35
|
+
warn: console.warn,
|
|
36
|
+
error: console.error,
|
|
37
|
+
info: console.info,
|
|
38
|
+
debug: console.debug,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (const method of /** @type {const} */ ([
|
|
42
|
+
"log",
|
|
43
|
+
"warn",
|
|
44
|
+
"error",
|
|
45
|
+
"info",
|
|
46
|
+
"debug",
|
|
47
|
+
])) {
|
|
48
|
+
console[method] = (...args) => {
|
|
49
|
+
output.push({ method, args });
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function restore() {
|
|
54
|
+
for (const [method, fn] of Object.entries(original)) {
|
|
55
|
+
console[method] = fn;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { output, restore };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wait for an async assertion to pass, retrying at a regular interval until timeout.
|
|
64
|
+
* Useful for testing eventually-consistent behavior or polling.
|
|
65
|
+
*
|
|
66
|
+
* @param {() => void | Promise<void>} fn - Assertion function that throws on failure
|
|
67
|
+
* @param {Object} [options]
|
|
68
|
+
* @param {number} [options.timeout=5000] - Maximum time to wait in milliseconds
|
|
69
|
+
* @param {number} [options.interval=50] - Time between retries in milliseconds
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
* @throws {Error} The last error thrown by `fn` if timeout is reached
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* await waitFor(() => {
|
|
75
|
+
* assert.strictEqual(getStatus(), "ready");
|
|
76
|
+
* }, { timeout: 2000 });
|
|
77
|
+
*/
|
|
78
|
+
export async function waitFor(fn, { timeout = 5000, interval = 50 } = {}) {
|
|
79
|
+
const start = Date.now();
|
|
80
|
+
let lastError;
|
|
81
|
+
|
|
82
|
+
while (Date.now() - start < timeout) {
|
|
83
|
+
try {
|
|
84
|
+
await fn();
|
|
85
|
+
return;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
lastError = err;
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw lastError;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {Object} TestContext
|
|
97
|
+
* @property {(key: string, value: string | undefined) => void} setEnv - Set an environment variable (original is saved for restore)
|
|
98
|
+
* @property {() => void} restoreEnv - Restore all modified environment variables to their original values
|
|
99
|
+
* @property {(fn: () => void | Promise<void>) => void} onCleanup - Register a cleanup function
|
|
100
|
+
* @property {() => Promise<void>} cleanup - Run all registered cleanup functions and restore env
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a temporary test context that tracks environment variable changes
|
|
105
|
+
* and cleanup functions. Call `.cleanup()` at the end of your test to restore everything.
|
|
106
|
+
*
|
|
107
|
+
* @returns {TestContext}
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* const ctx = createTestContext();
|
|
111
|
+
* ctx.setEnv("NODE_ENV", "test");
|
|
112
|
+
* ctx.setEnv("API_KEY", "secret");
|
|
113
|
+
* // ... run your test ...
|
|
114
|
+
* await ctx.cleanup(); // restores NODE_ENV and API_KEY to original values
|
|
115
|
+
*/
|
|
116
|
+
export function createTestContext() {
|
|
117
|
+
/** @type {Map<string, string | undefined>} */
|
|
118
|
+
const savedEnv = new Map();
|
|
119
|
+
/** @type {Array<() => void | Promise<void>>} */
|
|
120
|
+
const cleanupFns = [];
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
/**
|
|
124
|
+
* Set an environment variable. The original value is saved for later restore.
|
|
125
|
+
* @param {string} key
|
|
126
|
+
* @param {string | undefined} value - Pass `undefined` to delete the variable
|
|
127
|
+
*/
|
|
128
|
+
setEnv(key, value) {
|
|
129
|
+
if (!savedEnv.has(key)) {
|
|
130
|
+
savedEnv.set(key, process.env[key]);
|
|
131
|
+
}
|
|
132
|
+
if (value === undefined) {
|
|
133
|
+
delete process.env[key];
|
|
134
|
+
} else {
|
|
135
|
+
process.env[key] = value;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/** Restore all modified environment variables to their original values. */
|
|
140
|
+
restoreEnv() {
|
|
141
|
+
for (const [key, value] of savedEnv) {
|
|
142
|
+
if (value === undefined) {
|
|
143
|
+
delete process.env[key];
|
|
144
|
+
} else {
|
|
145
|
+
process.env[key] = value;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
savedEnv.clear();
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Register a cleanup function to be called during `.cleanup()`.
|
|
153
|
+
* @param {() => void | Promise<void>} fn
|
|
154
|
+
*/
|
|
155
|
+
onCleanup(fn) {
|
|
156
|
+
cleanupFns.push(fn);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/** Run all registered cleanup functions (LIFO) and restore environment variables. */
|
|
160
|
+
async cleanup() {
|
|
161
|
+
// Run cleanups in reverse order (LIFO)
|
|
162
|
+
for (let i = cleanupFns.length - 1; i >= 0; i--) {
|
|
163
|
+
await cleanupFns[i]();
|
|
164
|
+
}
|
|
165
|
+
cleanupFns.length = 0;
|
|
166
|
+
|
|
167
|
+
// Restore env
|
|
168
|
+
for (const [key, value] of savedEnv) {
|
|
169
|
+
if (value === undefined) {
|
|
170
|
+
delete process.env[key];
|
|
171
|
+
} else {
|
|
172
|
+
process.env[key] = value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
savedEnv.clear();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Assert that an async function throws an error matching the given class and/or message pattern.
|
|
182
|
+
*
|
|
183
|
+
* @param {() => Promise<*>} fn - Async function expected to throw
|
|
184
|
+
* @param {Function} [ErrorClass] - Expected error constructor (e.g. `TypeError`, `AppError`)
|
|
185
|
+
* @param {string | RegExp} [messagePattern] - String or regex to match against the error message
|
|
186
|
+
* @returns {Promise<Error>} The caught error (for further assertions)
|
|
187
|
+
* @throws {import("node:assert").AssertionError} If `fn` does not throw, or the error doesn't match
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* await assertThrows(
|
|
191
|
+
* () => someAsyncFunction(),
|
|
192
|
+
* ValidationError,
|
|
193
|
+
* /invalid email/i,
|
|
194
|
+
* );
|
|
195
|
+
*/
|
|
196
|
+
export async function assertThrows(fn, ErrorClass, messagePattern) {
|
|
197
|
+
let threw = false;
|
|
198
|
+
let caughtError;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await fn();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
threw = true;
|
|
204
|
+
caughtError = err;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
assert.ok(threw, "Expected function to throw, but it did not");
|
|
208
|
+
|
|
209
|
+
if (ErrorClass) {
|
|
210
|
+
assert.ok(
|
|
211
|
+
caughtError instanceof ErrorClass,
|
|
212
|
+
`Expected error to be instance of ${ErrorClass.name}, got ${caughtError?.constructor?.name}: ${caughtError?.message}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (messagePattern) {
|
|
217
|
+
const message = caughtError?.message || "";
|
|
218
|
+
if (typeof messagePattern === "string") {
|
|
219
|
+
assert.ok(
|
|
220
|
+
message.includes(messagePattern),
|
|
221
|
+
`Expected error message to include "${messagePattern}", got "${message}"`,
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
assert.match(message, messagePattern);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return caughtError;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Assert that a response object has the expected status code and body content.
|
|
233
|
+
* Works with mock responses and real fetch Response objects.
|
|
234
|
+
*
|
|
235
|
+
* @param {Object} response - Response object with `status` property
|
|
236
|
+
* @param {Object} expected
|
|
237
|
+
* @param {number} [expected.status] - Expected HTTP status code
|
|
238
|
+
* @param {Record<string, *>} [expected.bodyIncludes] - Keys/values expected in the response body
|
|
239
|
+
* @returns {void}
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* const res = await handler(req);
|
|
243
|
+
* assertApiResponse(res, {
|
|
244
|
+
* status: 200,
|
|
245
|
+
* bodyIncludes: { success: true },
|
|
246
|
+
* });
|
|
247
|
+
*/
|
|
248
|
+
export function assertApiResponse(response, { status, bodyIncludes } = {}) {
|
|
249
|
+
if (status !== undefined) {
|
|
250
|
+
assert.strictEqual(
|
|
251
|
+
response.status,
|
|
252
|
+
status,
|
|
253
|
+
`Expected status ${status}, got ${response.status}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (bodyIncludes && response.body) {
|
|
258
|
+
for (const [key, value] of Object.entries(bodyIncludes)) {
|
|
259
|
+
assert.deepStrictEqual(
|
|
260
|
+
response.body[key],
|
|
261
|
+
value,
|
|
262
|
+
`Expected response.body.${key} to equal ${JSON.stringify(value)}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quark test utilities — zero-dependency helpers for testing Quark applications.
|
|
3
|
+
*
|
|
4
|
+
* Import from `@techstream/quark-core/testing` in your test files:
|
|
5
|
+
*
|
|
6
|
+
* ```js
|
|
7
|
+
* import {
|
|
8
|
+
* createTestUser,
|
|
9
|
+
* createTestPost,
|
|
10
|
+
* createTestSession,
|
|
11
|
+
* createMockPrisma,
|
|
12
|
+
* createMockRequest,
|
|
13
|
+
* createMockResponse,
|
|
14
|
+
* createMockRedis,
|
|
15
|
+
* captureConsole,
|
|
16
|
+
* waitFor,
|
|
17
|
+
* createTestContext,
|
|
18
|
+
* assertThrows,
|
|
19
|
+
* assertApiResponse,
|
|
20
|
+
* } from "@techstream/quark-core/testing";
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @module testing
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Factories — test data creation
|
|
27
|
+
export {
|
|
28
|
+
createTestPost,
|
|
29
|
+
createTestSession,
|
|
30
|
+
createTestUser,
|
|
31
|
+
} from "./factories.js";
|
|
32
|
+
// Helpers — test utilities
|
|
33
|
+
export {
|
|
34
|
+
assertApiResponse,
|
|
35
|
+
assertThrows,
|
|
36
|
+
captureConsole,
|
|
37
|
+
createTestContext,
|
|
38
|
+
waitFor,
|
|
39
|
+
} from "./helpers.js";
|
|
40
|
+
// Mocks — service stand-ins
|
|
41
|
+
export {
|
|
42
|
+
createMockPrisma,
|
|
43
|
+
createMockRedis,
|
|
44
|
+
createMockRequest,
|
|
45
|
+
createMockResponse,
|
|
46
|
+
} from "./mocks.js";
|