@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,236 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import {
4
+ createSentryAdapter,
5
+ ErrorReporter,
6
+ errorReporter,
7
+ } from "./error-reporter.js";
8
+
9
+ describe("ErrorReporter", () => {
10
+ /** @type {ErrorReporter} */
11
+ let reporter;
12
+
13
+ beforeEach(() => {
14
+ reporter = new ErrorReporter();
15
+ });
16
+
17
+ describe("default instance", () => {
18
+ it("has console adapter registered by default", () => {
19
+ assert.equal(reporter.adapters.length, 1);
20
+ assert.equal(reporter.adapters[0].name, "console");
21
+ });
22
+
23
+ it("global singleton has console adapter", () => {
24
+ const names = errorReporter.adapters.map((a) => a.name);
25
+ assert.ok(names.includes("console"));
26
+ });
27
+ });
28
+
29
+ describe("use()", () => {
30
+ it("adds an adapter", () => {
31
+ const adapter = { name: "test", report: () => {} };
32
+ reporter.use(adapter);
33
+ assert.equal(reporter.adapters.length, 2);
34
+ assert.equal(reporter.adapters[1].name, "test");
35
+ });
36
+
37
+ it("throws if adapter has no name", () => {
38
+ assert.throws(() => reporter.use({ report: () => {} }), /name/);
39
+ });
40
+
41
+ it("throws if adapter has no report function", () => {
42
+ assert.throws(() => reporter.use({ name: "bad" }), /report/);
43
+ });
44
+ });
45
+
46
+ describe("report()", () => {
47
+ it("calls all registered adapters", () => {
48
+ const calls = [];
49
+ const adapter1 = {
50
+ name: "a1",
51
+ report: (err, ctx) => calls.push({ adapter: "a1", err, ctx }),
52
+ };
53
+ const adapter2 = {
54
+ name: "a2",
55
+ report: (err, ctx) => calls.push({ adapter: "a2", err, ctx }),
56
+ };
57
+
58
+ reporter.use(adapter1);
59
+ reporter.use(adapter2);
60
+
61
+ const error = new Error("test error");
62
+ reporter.report(error, { requestId: "123" });
63
+
64
+ // console adapter + 2 custom = 3 calls
65
+ assert.equal(calls.length, 2);
66
+ assert.equal(calls[0].adapter, "a1");
67
+ assert.equal(calls[1].adapter, "a2");
68
+ assert.equal(calls[0].err, error);
69
+ assert.equal(calls[0].ctx.requestId, "123");
70
+ });
71
+
72
+ it("swallows adapter errors without throwing", () => {
73
+ const failAdapter = {
74
+ name: "fail",
75
+ report: () => {
76
+ throw new Error("adapter crash");
77
+ },
78
+ };
79
+ reporter.use(failAdapter);
80
+
81
+ assert.doesNotThrow(() => reporter.report(new Error("test")));
82
+ });
83
+ });
84
+
85
+ describe("captureMessage()", () => {
86
+ it("calls adapters that support captureMessage", () => {
87
+ const messages = [];
88
+ const adapter = {
89
+ name: "msg",
90
+ report: () => {},
91
+ captureMessage: (msg, level, ctx) => messages.push({ msg, level, ctx }),
92
+ };
93
+ reporter.use(adapter);
94
+
95
+ reporter.captureMessage("hello", "warn", { extra: true });
96
+
97
+ assert.equal(messages.length, 1);
98
+ assert.equal(messages[0].msg, "hello");
99
+ assert.equal(messages[0].level, "warn");
100
+ assert.equal(messages[0].ctx.extra, true);
101
+ });
102
+
103
+ it("skips adapters without captureMessage", () => {
104
+ const adapter = { name: "no-msg", report: () => {} };
105
+ reporter.use(adapter);
106
+
107
+ assert.doesNotThrow(() => reporter.captureMessage("hello"));
108
+ });
109
+ });
110
+
111
+ describe("setUser()", () => {
112
+ it("includes user in subsequent reports", () => {
113
+ const reported = [];
114
+ const adapter = {
115
+ name: "user-test",
116
+ report: (_err, ctx) => reported.push(ctx),
117
+ };
118
+ reporter.use(adapter);
119
+
120
+ reporter.setUser({ id: "u1", email: "a@b.com" });
121
+ reporter.report(new Error("test"));
122
+
123
+ assert.equal(reported.length, 1);
124
+ assert.deepEqual(reported[0].user, { id: "u1", email: "a@b.com" });
125
+ });
126
+
127
+ it("forwards user to adapters with setUser", () => {
128
+ let receivedUser = null;
129
+ const adapter = {
130
+ name: "user-fwd",
131
+ report: () => {},
132
+ setUser: (u) => {
133
+ receivedUser = u;
134
+ },
135
+ };
136
+ reporter.use(adapter);
137
+
138
+ reporter.setUser({ id: "u2" });
139
+ assert.deepEqual(receivedUser, { id: "u2" });
140
+ });
141
+ });
142
+
143
+ describe("addBreadcrumb()", () => {
144
+ it("stores breadcrumbs and attaches them to report context", () => {
145
+ const reported = [];
146
+ const adapter = {
147
+ name: "bc-test",
148
+ report: (_err, ctx) => reported.push(ctx),
149
+ };
150
+ reporter.use(adapter);
151
+
152
+ reporter.addBreadcrumb("clicked button", "ui", { buttonId: "submit" });
153
+ reporter.addBreadcrumb("navigated", "navigation");
154
+ reporter.report(new Error("test"));
155
+
156
+ assert.equal(reported.length, 1);
157
+ assert.equal(reported[0].breadcrumbs.length, 2);
158
+ assert.equal(reported[0].breadcrumbs[0].message, "clicked button");
159
+ assert.equal(reported[0].breadcrumbs[0].category, "ui");
160
+ assert.deepEqual(reported[0].breadcrumbs[0].data, { buttonId: "submit" });
161
+ assert.equal(reported[0].breadcrumbs[1].message, "navigated");
162
+ assert.equal(reported[0].breadcrumbs[1].category, "navigation");
163
+ assert.ok(reported[0].breadcrumbs[0].timestamp);
164
+ });
165
+
166
+ it("circular buffer keeps max 50 breadcrumbs", () => {
167
+ for (let i = 0; i < 60; i++) {
168
+ reporter.addBreadcrumb(`crumb-${i}`, "test");
169
+ }
170
+
171
+ const crumbs = reporter.breadcrumbs;
172
+ assert.equal(crumbs.length, 50);
173
+ // First 10 should have been evicted, first remaining is crumb-10
174
+ assert.equal(crumbs[0].message, "crumb-10");
175
+ assert.equal(crumbs[49].message, "crumb-59");
176
+ });
177
+
178
+ it("forwards breadcrumbs to adapters that support it", () => {
179
+ const received = [];
180
+ const adapter = {
181
+ name: "bc-fwd",
182
+ report: () => {},
183
+ addBreadcrumb: (msg, cat, data) => received.push({ msg, cat, data }),
184
+ };
185
+ reporter.use(adapter);
186
+
187
+ reporter.addBreadcrumb("test", "cat", { key: "val" });
188
+
189
+ assert.equal(received.length, 1);
190
+ assert.equal(received[0].msg, "test");
191
+ assert.equal(received[0].cat, "cat");
192
+ assert.deepEqual(received[0].data, { key: "val" });
193
+ });
194
+ });
195
+
196
+ describe("custom adapter receives correct data", () => {
197
+ it("passes error and enriched context", () => {
198
+ const reported = [];
199
+ const adapter = {
200
+ name: "custom",
201
+ report: (err, ctx) => reported.push({ err, ctx }),
202
+ };
203
+ reporter.use(adapter);
204
+
205
+ reporter.setUser({ id: "u1" });
206
+ reporter.addBreadcrumb("init", "app");
207
+
208
+ const error = new Error("custom test");
209
+ reporter.report(error, { route: "/api/test" });
210
+
211
+ assert.equal(reported.length, 1);
212
+ assert.equal(reported[0].err, error);
213
+ assert.equal(reported[0].ctx.route, "/api/test");
214
+ assert.deepEqual(reported[0].ctx.user, { id: "u1" });
215
+ assert.equal(reported[0].ctx.breadcrumbs.length, 1);
216
+ assert.equal(reported[0].ctx.breadcrumbs[0].message, "init");
217
+ });
218
+ });
219
+
220
+ describe("createSentryAdapter()", () => {
221
+ it("returns a valid adapter shape", () => {
222
+ const adapter = createSentryAdapter();
223
+ assert.equal(adapter.name, "sentry");
224
+ assert.equal(typeof adapter.report, "function");
225
+ assert.equal(typeof adapter.captureMessage, "function");
226
+ assert.equal(typeof adapter.setUser, "function");
227
+ assert.equal(typeof adapter.addBreadcrumb, "function");
228
+ });
229
+
230
+ it("can be registered without errors", () => {
231
+ const adapter = createSentryAdapter();
232
+ assert.doesNotThrow(() => reporter.use(adapter));
233
+ assert.doesNotThrow(() => reporter.report(new Error("test")));
234
+ });
235
+ });
236
+ });
package/src/errors.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @techstream/quark-core - Error Handling Module
3
+ * Standardized error types and utilities for consistent error handling
4
+ */
5
+
6
+ import { errorReporter } from "./error-reporter.js";
7
+
8
+ /**
9
+ * Base application error class
10
+ */
11
+ export class AppError extends Error {
12
+ constructor(message, statusCode = 500, code = "INTERNAL_ERROR") {
13
+ super(message);
14
+ this.name = this.constructor.name;
15
+ this.statusCode = statusCode;
16
+ this.code = code;
17
+ this.timestamp = new Date().toISOString();
18
+ Error.captureStackTrace(this, this.constructor);
19
+ }
20
+
21
+ toJSON() {
22
+ return {
23
+ name: this.name,
24
+ message: this.message,
25
+ code: this.code,
26
+ statusCode: this.statusCode,
27
+ timestamp: this.timestamp,
28
+ };
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 400 Bad Request Error
34
+ */
35
+ export class ValidationError extends AppError {
36
+ constructor(message, details = null) {
37
+ super(message, 400, "VALIDATION_ERROR");
38
+ this.details = details;
39
+ }
40
+
41
+ toJSON() {
42
+ return {
43
+ ...super.toJSON(),
44
+ details: this.details,
45
+ };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 401 Unauthorized Error
51
+ */
52
+ export class UnauthorizedError extends AppError {
53
+ constructor(message = "Authentication required") {
54
+ super(message, 401, "UNAUTHORIZED");
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 403 Forbidden Error
60
+ */
61
+ export class ForbiddenError extends AppError {
62
+ constructor(message = "Access denied") {
63
+ super(message, 403, "FORBIDDEN");
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 404 Not Found Error
69
+ */
70
+ export class NotFoundError extends AppError {
71
+ constructor(message = "Resource not found") {
72
+ super(message, 404, "NOT_FOUND");
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 409 Conflict Error
78
+ */
79
+ export class ConflictError extends AppError {
80
+ constructor(message = "Resource conflict") {
81
+ super(message, 409, "CONFLICT");
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 429 Rate Limit Error
87
+ */
88
+ export class RateLimitError extends AppError {
89
+ constructor(message = "Too many requests", retryAfter = 60) {
90
+ super(message, 429, "RATE_LIMIT");
91
+ this.retryAfter = retryAfter;
92
+ }
93
+
94
+ toJSON() {
95
+ return {
96
+ ...super.toJSON(),
97
+ retryAfter: this.retryAfter,
98
+ };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Database Error
104
+ */
105
+ export class DatabaseError extends AppError {
106
+ constructor(message = "Database error", originalError = null) {
107
+ super(message, 500, "DATABASE_ERROR");
108
+ this.originalError = originalError;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * External Service Error
114
+ */
115
+ export class ServiceError extends AppError {
116
+ constructor(serviceName, message = "Service error", statusCode = 502) {
117
+ super(message, statusCode, "SERVICE_ERROR");
118
+ this.serviceName = serviceName;
119
+ }
120
+
121
+ toJSON() {
122
+ return {
123
+ ...super.toJSON(),
124
+ serviceName: this.serviceName,
125
+ };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Safely extracts error message from various error types
131
+ * @param {Error|string|Object} error - The error to extract from
132
+ * @returns {string} Extracted error message
133
+ */
134
+ export const getErrorMessage = (error) => {
135
+ if (typeof error === "string") return error;
136
+ if (error instanceof Error) return error.message;
137
+ if (error && typeof error === "object" && error.message) return error.message;
138
+ return "An unknown error occurred";
139
+ };
140
+
141
+ /**
142
+ * Safely extracts status code from error
143
+ * @param {Error|AppError} error - The error to extract from
144
+ * @returns {number} HTTP status code
145
+ */
146
+ export const getStatusCode = (error) => {
147
+ if (error instanceof AppError) return error.statusCode;
148
+ if (error.statusCode) return error.statusCode;
149
+ return 500;
150
+ };
151
+
152
+ /**
153
+ * Converts any error to an AppError instance
154
+ * @param {Error|AppError|any} error - The error to normalize
155
+ * @returns {AppError} Normalized error
156
+ */
157
+ export const normalizeError = (error) => {
158
+ if (error instanceof AppError) return error;
159
+ if (error instanceof Error) {
160
+ return new AppError(error.message, 500, "INTERNAL_ERROR");
161
+ }
162
+ return new AppError(
163
+ typeof error === "string" ? error : "An unknown error occurred",
164
+ 500,
165
+ "INTERNAL_ERROR",
166
+ );
167
+ };
168
+
169
+ /**
170
+ * Logs error with context
171
+ * @param {Error} error - The error to log
172
+ * @param {Object} context - Additional context
173
+ */
174
+ export const logError = (error, context = {}) => {
175
+ errorReporter.report(error, context);
176
+ };
177
+
178
+ /**
179
+ * Wraps async function and handles errors consistently
180
+ * @param {Function} fn - Async function to wrap
181
+ * @returns {Function} Wrapped function
182
+ */
183
+ export const withErrorHandling = (fn) => {
184
+ return async (...args) => {
185
+ try {
186
+ return await fn(...args);
187
+ } catch (error) {
188
+ errorReporter.report(error);
189
+ throw normalizeError(error);
190
+ }
191
+ };
192
+ };
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ AppError,
5
+ ConflictError,
6
+ DatabaseError,
7
+ ForbiddenError,
8
+ getErrorMessage,
9
+ getStatusCode,
10
+ logError,
11
+ NotFoundError,
12
+ normalizeError,
13
+ RateLimitError,
14
+ ServiceError,
15
+ UnauthorizedError,
16
+ ValidationError,
17
+ } from "../src/errors.js";
18
+
19
+ test("Error Module", async (t) => {
20
+ await t.test("AppError has correct properties", () => {
21
+ const error = new AppError("Test error", 500, "TEST_ERROR");
22
+ assert(error.message === "Test error");
23
+ assert(error.statusCode === 500);
24
+ assert(error.code === "TEST_ERROR");
25
+ assert(error.name === "AppError");
26
+ });
27
+
28
+ await t.test("AppError.toJSON() serializes correctly", () => {
29
+ const error = new AppError("Test", 500, "TEST");
30
+ const json = error.toJSON();
31
+ assert(json.message === "Test");
32
+ assert(json.statusCode === 500);
33
+ assert(json.code === "TEST");
34
+ assert(json.timestamp);
35
+ });
36
+
37
+ await t.test("ValidationError has status 400", () => {
38
+ const error = new ValidationError("Invalid input");
39
+ assert(error.statusCode === 400);
40
+ assert(error.code === "VALIDATION_ERROR");
41
+ });
42
+
43
+ await t.test("UnauthorizedError has status 401", () => {
44
+ const error = new UnauthorizedError();
45
+ assert(error.statusCode === 401);
46
+ assert(error.code === "UNAUTHORIZED");
47
+ });
48
+
49
+ await t.test("ForbiddenError has status 403", () => {
50
+ const error = new ForbiddenError();
51
+ assert(error.statusCode === 403);
52
+ assert(error.code === "FORBIDDEN");
53
+ });
54
+
55
+ await t.test("NotFoundError has status 404", () => {
56
+ const error = new NotFoundError();
57
+ assert(error.statusCode === 404);
58
+ assert(error.code === "NOT_FOUND");
59
+ });
60
+
61
+ await t.test("ConflictError has status 409", () => {
62
+ const error = new ConflictError();
63
+ assert(error.statusCode === 409);
64
+ assert(error.code === "CONFLICT");
65
+ });
66
+
67
+ await t.test("RateLimitError has status 429", () => {
68
+ const error = new RateLimitError("Too many requests", 60);
69
+ assert(error.statusCode === 429);
70
+ assert(error.code === "RATE_LIMIT");
71
+ assert(error.retryAfter === 60);
72
+ });
73
+
74
+ await t.test("DatabaseError wraps database errors", () => {
75
+ const originalError = new Error("Connection failed");
76
+ const error = new DatabaseError("DB error", originalError);
77
+ assert(error.statusCode === 500);
78
+ assert(error.originalError === originalError);
79
+ });
80
+
81
+ await t.test("ServiceError includes service name", () => {
82
+ const error = new ServiceError("PaymentAPI", "Payment failed");
83
+ assert(error.serviceName === "PaymentAPI");
84
+ assert(error.statusCode === 502);
85
+ const json = error.toJSON();
86
+ assert(json.serviceName === "PaymentAPI");
87
+ });
88
+
89
+ await t.test("getErrorMessage extracts from various types", () => {
90
+ assert(getErrorMessage("string error") === "string error");
91
+ assert(getErrorMessage(new Error("error message")) === "error message");
92
+ assert(getErrorMessage({ message: "obj error" }) === "obj error");
93
+ assert(getErrorMessage({}).includes("unknown")); // Changed from null to empty object
94
+ });
95
+
96
+ await t.test("getStatusCode returns correct code", () => {
97
+ assert(getStatusCode(new ValidationError("")) === 400);
98
+ assert(getStatusCode(new UnauthorizedError()) === 401);
99
+ assert(getStatusCode(new NotFoundError()) === 404);
100
+ assert(getStatusCode(new Error()) === 500);
101
+ });
102
+
103
+ await t.test("normalizeError converts to AppError", () => {
104
+ const error = normalizeError(new Error("test"));
105
+ assert(error instanceof AppError);
106
+ assert(error.message === "test");
107
+ assert(error.statusCode === 500);
108
+ });
109
+
110
+ await t.test("normalizeError handles string errors", () => {
111
+ const error = normalizeError("string error");
112
+ assert(error instanceof AppError);
113
+ assert(error.message === "string error");
114
+ });
115
+
116
+ await t.test("normalizeError handles non-Error objects", () => {
117
+ const error = normalizeError({ some: "object" });
118
+ assert(error instanceof AppError);
119
+ assert(error.message.includes("unknown"));
120
+ });
121
+
122
+ await t.test("logError handles errors with context", () => {
123
+ // This should not throw
124
+ const error = new ValidationError("test");
125
+ logError(error, { userId: "123", action: "test" });
126
+ assert(true);
127
+ });
128
+ });
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ // Authorization exports
2
+
3
+ // Auth exports
4
+ export * from "./auth/index.js";
5
+ export * from "./authorization.js";
6
+ // Cache exports
7
+ export * from "./cache.js";
8
+ // CSRF protection exports
9
+ export * from "./csrf.js";
10
+ // Error Reporter exports
11
+ export * from "./error-reporter.js";
12
+ // Error exports
13
+ export * from "./errors.js";
14
+ // Logger exports
15
+ export * from "./logger.js";
16
+ // Mailhog exports
17
+ export * from "./mailhog.js";
18
+ // Email service exports
19
+ export * from "./email.js";
20
+ // Queue exports
21
+ export * from "./queue/index.js";
22
+ // Rate limiting exports
23
+ export * from "./rate-limiter.js";
24
+ // Redis exports
25
+ export * from "./redis.js";
26
+
27
+ // Utility exports
28
+ export * from "./utils.js";
29
+ export * from "./validation.js";
30
+
31
+ // Testing utilities (import from "@techstream/quark-core/testing" in test files)
32
+ // Not re-exported from main to avoid polluting production imports