@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,259 @@
1
+ import assert from "node:assert";
2
+ import { mock, test } from "node:test";
3
+ import { createEmailService } from "./email.js";
4
+
5
+ test("Email Service", async (t) => {
6
+ await t.test("createEmailService returns object with sendEmail method", () => {
7
+ const service = createEmailService();
8
+ assert.strictEqual(typeof service, "object");
9
+ assert.strictEqual(typeof service.sendEmail, "function");
10
+ });
11
+
12
+ await t.test("SMTP provider: uses Mailhog defaults when no SMTP_HOST set", async () => {
13
+ // Clear any explicit SMTP env vars
14
+ const origHost = process.env.SMTP_HOST;
15
+ const origPort = process.env.SMTP_PORT;
16
+ delete process.env.SMTP_HOST;
17
+ delete process.env.SMTP_PORT;
18
+
19
+ try {
20
+ const service = createEmailService({ provider: "smtp" });
21
+
22
+ // Mock nodemailer at the transport level
23
+ let capturedConfig = null;
24
+ const mockTransport = {
25
+ sendMail: async (opts) => {
26
+ return { messageId: "test-id-123", ...opts };
27
+ },
28
+ };
29
+
30
+ // We test that sendEmail resolves without error
31
+ // The transport is lazily created, so sendEmail triggers creation
32
+ // We can't easily intercept the dynamic import, but we can verify
33
+ // the service was created correctly
34
+ assert.strictEqual(typeof service.sendEmail, "function");
35
+ } finally {
36
+ if (origHost !== undefined) process.env.SMTP_HOST = origHost;
37
+ else delete process.env.SMTP_HOST;
38
+ if (origPort !== undefined) process.env.SMTP_PORT = origPort;
39
+ else delete process.env.SMTP_PORT;
40
+ }
41
+ });
42
+
43
+ await t.test("SMTP provider: uses explicit SMTP_HOST/SMTP_PORT when set", () => {
44
+ const origHost = process.env.SMTP_HOST;
45
+ const origPort = process.env.SMTP_PORT;
46
+ process.env.SMTP_HOST = "mail.example.com";
47
+ process.env.SMTP_PORT = "587";
48
+
49
+ try {
50
+ const service = createEmailService({ provider: "smtp" });
51
+ assert.strictEqual(typeof service.sendEmail, "function");
52
+ } finally {
53
+ if (origHost !== undefined) process.env.SMTP_HOST = origHost;
54
+ else delete process.env.SMTP_HOST;
55
+ if (origPort !== undefined) process.env.SMTP_PORT = origPort;
56
+ else delete process.env.SMTP_PORT;
57
+ }
58
+ });
59
+
60
+ await t.test("Resend provider: throws if RESEND_API_KEY missing", async () => {
61
+ const origKey = process.env.RESEND_API_KEY;
62
+ delete process.env.RESEND_API_KEY;
63
+
64
+ try {
65
+ const service = createEmailService({ provider: "resend" });
66
+
67
+ await assert.rejects(
68
+ () => service.sendEmail("test@example.com", "Subject", "<p>body</p>"),
69
+ (err) =>
70
+ err instanceof Error &&
71
+ /RESEND_API_KEY/.test(err.message),
72
+ );
73
+ } finally {
74
+ if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
75
+ else delete process.env.RESEND_API_KEY;
76
+ }
77
+ });
78
+
79
+ await t.test("Resend provider: calls fetch with correct URL, headers, and body", async () => {
80
+ const origKey = process.env.RESEND_API_KEY;
81
+ process.env.RESEND_API_KEY = "re_test_key_123";
82
+
83
+ // Mock global fetch
84
+ const originalFetch = globalThis.fetch;
85
+ let capturedUrl = null;
86
+ let capturedOptions = null;
87
+
88
+ globalThis.fetch = async (url, opts) => {
89
+ capturedUrl = url;
90
+ capturedOptions = opts;
91
+ return {
92
+ ok: true,
93
+ json: async () => ({ id: "resend-msg-id" }),
94
+ };
95
+ };
96
+
97
+ try {
98
+ const service = createEmailService({
99
+ provider: "resend",
100
+ from: "Test <test@example.com>",
101
+ });
102
+
103
+ const result = await service.sendEmail(
104
+ "recipient@example.com",
105
+ "Test Subject",
106
+ "<p>Hello</p>",
107
+ "Hello",
108
+ );
109
+
110
+ assert.strictEqual(capturedUrl, "https://api.resend.com/emails");
111
+ assert.strictEqual(capturedOptions.method, "POST");
112
+
113
+ const headers = capturedOptions.headers;
114
+ assert.strictEqual(headers.Authorization, "Bearer re_test_key_123");
115
+ assert.strictEqual(headers["Content-Type"], "application/json");
116
+
117
+ const body = JSON.parse(capturedOptions.body);
118
+ assert.strictEqual(body.from, "Test <test@example.com>");
119
+ assert.deepStrictEqual(body.to, ["recipient@example.com"]);
120
+ assert.strictEqual(body.subject, "Test Subject");
121
+ assert.strictEqual(body.html, "<p>Hello</p>");
122
+ assert.strictEqual(body.text, "Hello");
123
+
124
+ assert.strictEqual(result.id, "resend-msg-id");
125
+ } finally {
126
+ globalThis.fetch = originalFetch;
127
+ if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
128
+ else delete process.env.RESEND_API_KEY;
129
+ }
130
+ });
131
+
132
+ await t.test("Resend provider: throws on non-ok response", async () => {
133
+ const origKey = process.env.RESEND_API_KEY;
134
+ process.env.RESEND_API_KEY = "re_test_key_123";
135
+
136
+ const originalFetch = globalThis.fetch;
137
+ globalThis.fetch = async () => ({
138
+ ok: false,
139
+ statusText: "Unprocessable Entity",
140
+ json: async () => ({ message: "Invalid email address" }),
141
+ });
142
+
143
+ try {
144
+ const service = createEmailService({ provider: "resend" });
145
+
146
+ await assert.rejects(
147
+ () => service.sendEmail("bad@", "Subject", "<p>body</p>"),
148
+ (err) =>
149
+ err instanceof Error &&
150
+ /Resend API error/.test(err.message) &&
151
+ /Invalid email address/.test(err.message),
152
+ );
153
+ } finally {
154
+ globalThis.fetch = originalFetch;
155
+ if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
156
+ else delete process.env.RESEND_API_KEY;
157
+ }
158
+ });
159
+
160
+ await t.test("Resend provider: handles non-JSON error response", async () => {
161
+ const origKey = process.env.RESEND_API_KEY;
162
+ process.env.RESEND_API_KEY = "re_test_key_123";
163
+
164
+ const originalFetch = globalThis.fetch;
165
+ globalThis.fetch = async () => ({
166
+ ok: false,
167
+ statusText: "Internal Server Error",
168
+ json: async () => {
169
+ throw new Error("not json");
170
+ },
171
+ });
172
+
173
+ try {
174
+ const service = createEmailService({ provider: "resend" });
175
+
176
+ await assert.rejects(
177
+ () => service.sendEmail("test@example.com", "Subject", "<p>body</p>"),
178
+ (err) =>
179
+ err instanceof Error &&
180
+ /Resend API error/.test(err.message) &&
181
+ /Internal Server Error/.test(err.message),
182
+ );
183
+ } finally {
184
+ globalThis.fetch = originalFetch;
185
+ if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
186
+ else delete process.env.RESEND_API_KEY;
187
+ }
188
+ });
189
+
190
+ await t.test("default provider is smtp", () => {
191
+ const origProvider = process.env.EMAIL_PROVIDER;
192
+ delete process.env.EMAIL_PROVIDER;
193
+
194
+ try {
195
+ const service = createEmailService();
196
+ // The service should be created without error (defaults to smtp)
197
+ assert.strictEqual(typeof service.sendEmail, "function");
198
+ } finally {
199
+ if (origProvider !== undefined)
200
+ process.env.EMAIL_PROVIDER = origProvider;
201
+ else delete process.env.EMAIL_PROVIDER;
202
+ }
203
+ });
204
+
205
+ await t.test("respects options.from override", async () => {
206
+ const origKey = process.env.RESEND_API_KEY;
207
+ process.env.RESEND_API_KEY = "re_test_key_123";
208
+
209
+ const originalFetch = globalThis.fetch;
210
+ let capturedBody = null;
211
+
212
+ globalThis.fetch = async (url, opts) => {
213
+ capturedBody = JSON.parse(opts.body);
214
+ return { ok: true, json: async () => ({ id: "msg-1" }) };
215
+ };
216
+
217
+ try {
218
+ const service = createEmailService({
219
+ provider: "resend",
220
+ from: "Custom <custom@example.com>",
221
+ });
222
+
223
+ await service.sendEmail("to@example.com", "Sub", "<p>Hi</p>");
224
+ assert.strictEqual(capturedBody.from, "Custom <custom@example.com>");
225
+ } finally {
226
+ globalThis.fetch = originalFetch;
227
+ if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
228
+ else delete process.env.RESEND_API_KEY;
229
+ }
230
+ });
231
+
232
+ await t.test("input validation: rejects empty 'to'", async () => {
233
+ const service = createEmailService();
234
+ await assert.rejects(
235
+ () => service.sendEmail("", "Subject", "<p>body</p>"),
236
+ (err) => err instanceof Error && /to/.test(err.message),
237
+ );
238
+ await assert.rejects(
239
+ () => service.sendEmail(null, "Subject", "<p>body</p>"),
240
+ (err) => err instanceof Error && /to/.test(err.message),
241
+ );
242
+ });
243
+
244
+ await t.test("input validation: rejects empty 'subject'", async () => {
245
+ const service = createEmailService();
246
+ await assert.rejects(
247
+ () => service.sendEmail("a@b.com", "", "<p>body</p>"),
248
+ (err) => err instanceof Error && /subject/.test(err.message),
249
+ );
250
+ });
251
+
252
+ await t.test("input validation: rejects empty 'html'", async () => {
253
+ const service = createEmailService();
254
+ await assert.rejects(
255
+ () => service.sendEmail("a@b.com", "Subject", ""),
256
+ (err) => err instanceof Error && /html/.test(err.message),
257
+ );
258
+ });
259
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * @techstream/quark-core - Error Reporter Module
3
+ * Adapter/hook system for pluggable error reporting
4
+ */
5
+
6
+ import { normalizeError } from "./errors.js";
7
+ import { logger } from "./logger.js";
8
+
9
+ const MAX_BREADCRUMBS = 50;
10
+
11
+ /**
12
+ * @typedef {Object} ErrorAdapter
13
+ * @property {string} name - Unique adapter identifier
14
+ * @property {(error: Error, context: Object) => void} report - Called when an error is reported
15
+ * @property {(message: string, level: string, context: Object) => void} [captureMessage] - For non-error events
16
+ * @property {(user: Object) => void} [setUser] - Sets user context
17
+ * @property {(message: string, category: string, data: Object) => void} [addBreadcrumb] - Adds a breadcrumb
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} Breadcrumb
22
+ * @property {string} message - Breadcrumb message
23
+ * @property {string} category - Breadcrumb category
24
+ * @property {Object} data - Associated data
25
+ * @property {string} timestamp - ISO timestamp
26
+ */
27
+
28
+ /**
29
+ * Built-in adapter that logs errors via the Quark logger.
30
+ * @type {ErrorAdapter}
31
+ */
32
+ export const consoleAdapter = {
33
+ name: "console",
34
+
35
+ report(error, context) {
36
+ const appError = normalizeError(error);
37
+ logger.error(appError.message, {
38
+ error: appError.toJSON(),
39
+ context,
40
+ stack: error.stack,
41
+ });
42
+ },
43
+
44
+ captureMessage(message, level = "info", context = {}) {
45
+ const logFn = logger[level] || logger.info;
46
+ logFn.call(logger, message, context);
47
+ },
48
+
49
+ setUser(_user) {
50
+ // Console adapter does not persist user context
51
+ },
52
+
53
+ addBreadcrumb(_message, _category, _data) {
54
+ // Console adapter does not store breadcrumbs
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Error reporter with adapter registration.
60
+ * Distributes error reports to all registered adapters.
61
+ */
62
+ export class ErrorReporter {
63
+ /** @type {ErrorAdapter[]} */
64
+ #adapters = [];
65
+
66
+ /** @type {Breadcrumb[]} */
67
+ #breadcrumbs = [];
68
+
69
+ /** @type {Object|null} */
70
+ #user = null;
71
+
72
+ constructor() {
73
+ this.#adapters = [consoleAdapter];
74
+ this.#breadcrumbs = [];
75
+ this.#user = null;
76
+ }
77
+
78
+ /**
79
+ * Register an adapter
80
+ * @param {ErrorAdapter} adapter - The adapter to register
81
+ * @returns {void}
82
+ */
83
+ use(adapter) {
84
+ if (
85
+ !adapter ||
86
+ typeof adapter.name !== "string" ||
87
+ typeof adapter.report !== "function"
88
+ ) {
89
+ throw new Error(
90
+ "Adapter must have a name (string) and a report (function)",
91
+ );
92
+ }
93
+ this.#adapters.push(adapter);
94
+ }
95
+
96
+ /**
97
+ * Report an error to all registered adapters
98
+ * @param {Error} error - The error to report
99
+ * @param {Object} [context={}] - Additional context
100
+ * @returns {void}
101
+ */
102
+ report(error, context = {}) {
103
+ const enrichedContext = {
104
+ ...context,
105
+ ...(this.#user ? { user: this.#user } : {}),
106
+ breadcrumbs: [...this.#breadcrumbs],
107
+ };
108
+
109
+ for (const adapter of this.#adapters) {
110
+ try {
111
+ adapter.report(error, enrichedContext);
112
+ } catch (_adapterError) {
113
+ // Swallow adapter errors to avoid cascading failures
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Capture a non-error message event
120
+ * @param {string} message - The message to capture
121
+ * @param {string} [level="info"] - Log level
122
+ * @param {Object} [context={}] - Additional context
123
+ * @returns {void}
124
+ */
125
+ captureMessage(message, level = "info", context = {}) {
126
+ const enrichedContext = {
127
+ ...context,
128
+ ...(this.#user ? { user: this.#user } : {}),
129
+ };
130
+
131
+ for (const adapter of this.#adapters) {
132
+ try {
133
+ if (typeof adapter.captureMessage === "function") {
134
+ adapter.captureMessage(message, level, enrichedContext);
135
+ }
136
+ } catch (_adapterError) {
137
+ // Swallow adapter errors
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Set user context for subsequent reports
144
+ * @param {Object} user - User information
145
+ * @returns {void}
146
+ */
147
+ setUser(user) {
148
+ this.#user = user;
149
+
150
+ for (const adapter of this.#adapters) {
151
+ try {
152
+ if (typeof adapter.setUser === "function") {
153
+ adapter.setUser(user);
154
+ }
155
+ } catch (_adapterError) {
156
+ // Swallow adapter errors
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Add a breadcrumb to the trail (circular buffer, max 50)
163
+ * @param {string} message - Breadcrumb message
164
+ * @param {string} [category="default"] - Breadcrumb category
165
+ * @param {Object} [data={}] - Associated data
166
+ * @returns {void}
167
+ */
168
+ addBreadcrumb(message, category = "default", data = {}) {
169
+ /** @type {Breadcrumb} */
170
+ const breadcrumb = {
171
+ message,
172
+ category,
173
+ data,
174
+ timestamp: new Date().toISOString(),
175
+ };
176
+
177
+ if (this.#breadcrumbs.length >= MAX_BREADCRUMBS) {
178
+ this.#breadcrumbs.shift();
179
+ }
180
+ this.#breadcrumbs.push(breadcrumb);
181
+
182
+ for (const adapter of this.#adapters) {
183
+ try {
184
+ if (typeof adapter.addBreadcrumb === "function") {
185
+ adapter.addBreadcrumb(message, category, data);
186
+ }
187
+ } catch (_adapterError) {
188
+ // Swallow adapter errors
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Returns the list of registered adapters (for inspection/testing)
195
+ * @returns {ErrorAdapter[]}
196
+ */
197
+ get adapters() {
198
+ return [...this.#adapters];
199
+ }
200
+
201
+ /**
202
+ * Returns the current breadcrumbs (for inspection/testing)
203
+ * @returns {Breadcrumb[]}
204
+ */
205
+ get breadcrumbs() {
206
+ return [...this.#breadcrumbs];
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Creates a Sentry adapter factory (stub/example).
212
+ * Downstream projects can use this as a template to integrate Sentry.
213
+ *
214
+ * @param {Object} _options - Sentry configuration options
215
+ * @returns {ErrorAdapter} A Sentry adapter (stubbed)
216
+ *
217
+ * @example
218
+ * ```js
219
+ * import * as Sentry from "@sentry/node";
220
+ * import { errorReporter, createSentryAdapter } from "@techstream/quark-core";
221
+ *
222
+ * Sentry.init({ dsn: "https://example@sentry.io/123" });
223
+ * errorReporter.use(createSentryAdapter({ Sentry }));
224
+ * ```
225
+ */
226
+ export const createSentryAdapter = (_options = {}) => {
227
+ return {
228
+ name: "sentry",
229
+
230
+ report(_error, _context) {
231
+ // Example Sentry integration:
232
+ // const { Sentry } = options;
233
+ // Sentry.withScope((scope) => {
234
+ // if (context.user) scope.setUser(context.user);
235
+ // if (context.breadcrumbs) {
236
+ // for (const b of context.breadcrumbs) {
237
+ // Sentry.addBreadcrumb({ message: b.message, category: b.category, data: b.data });
238
+ // }
239
+ // }
240
+ // scope.setExtras(context);
241
+ // Sentry.captureException(error);
242
+ // });
243
+ },
244
+
245
+ captureMessage(_message, _level, _context) {
246
+ // Example:
247
+ // const { Sentry } = options;
248
+ // Sentry.captureMessage(message, level);
249
+ },
250
+
251
+ setUser(_user) {
252
+ // Example:
253
+ // const { Sentry } = options;
254
+ // Sentry.setUser(user);
255
+ },
256
+
257
+ addBreadcrumb(_message, _category, _data) {
258
+ // Example:
259
+ // const { Sentry } = options;
260
+ // Sentry.addBreadcrumb({ message, category, data });
261
+ },
262
+ };
263
+ };
264
+
265
+ /** Global singleton error reporter instance */
266
+ export const errorReporter = new ErrorReporter();