@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,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";