@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -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/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -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/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -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
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createEmailService,
|
|
5
|
+
EmailProvider,
|
|
6
|
+
registerEmailProvider,
|
|
7
|
+
} from "./email.js";
|
|
8
|
+
|
|
9
|
+
test("Email Service", async (t) => {
|
|
10
|
+
await t.test(
|
|
11
|
+
"createEmailService returns object with sendEmail method",
|
|
12
|
+
() => {
|
|
13
|
+
const service = createEmailService();
|
|
14
|
+
assert.strictEqual(typeof service, "object");
|
|
15
|
+
assert.strictEqual(typeof service.sendEmail, "function");
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
await t.test(
|
|
20
|
+
"SMTP provider: uses mail defaults when no SMTP_HOST set",
|
|
21
|
+
async () => {
|
|
22
|
+
// Clear any explicit SMTP env vars
|
|
23
|
+
const origHost = process.env.SMTP_HOST;
|
|
24
|
+
const origPort = process.env.SMTP_PORT;
|
|
25
|
+
delete process.env.SMTP_HOST;
|
|
26
|
+
delete process.env.SMTP_PORT;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const service = createEmailService({ provider: "smtp" });
|
|
30
|
+
|
|
31
|
+
// Mock nodemailer at the transport level
|
|
32
|
+
const _capturedConfig = null;
|
|
33
|
+
const _mockTransport = {
|
|
34
|
+
sendMail: async (opts) => {
|
|
35
|
+
return { messageId: "test-id-123", ...opts };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// We test that sendEmail resolves without error
|
|
40
|
+
// The transport is lazily created, so sendEmail triggers creation
|
|
41
|
+
// We can't easily intercept the dynamic import, but we can verify
|
|
42
|
+
// the service was created correctly
|
|
43
|
+
assert.strictEqual(typeof service.sendEmail, "function");
|
|
44
|
+
} finally {
|
|
45
|
+
if (origHost !== undefined) process.env.SMTP_HOST = origHost;
|
|
46
|
+
else delete process.env.SMTP_HOST;
|
|
47
|
+
if (origPort !== undefined) process.env.SMTP_PORT = origPort;
|
|
48
|
+
else delete process.env.SMTP_PORT;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
await t.test(
|
|
54
|
+
"SMTP provider: uses explicit SMTP_HOST/SMTP_PORT when set",
|
|
55
|
+
() => {
|
|
56
|
+
const origHost = process.env.SMTP_HOST;
|
|
57
|
+
const origPort = process.env.SMTP_PORT;
|
|
58
|
+
process.env.SMTP_HOST = "mail.example.com";
|
|
59
|
+
process.env.SMTP_PORT = "587";
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const service = createEmailService({ provider: "smtp" });
|
|
63
|
+
assert.strictEqual(typeof service.sendEmail, "function");
|
|
64
|
+
} finally {
|
|
65
|
+
if (origHost !== undefined) process.env.SMTP_HOST = origHost;
|
|
66
|
+
else delete process.env.SMTP_HOST;
|
|
67
|
+
if (origPort !== undefined) process.env.SMTP_PORT = origPort;
|
|
68
|
+
else delete process.env.SMTP_PORT;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await t.test(
|
|
74
|
+
"Resend provider: throws at creation if RESEND_API_KEY missing",
|
|
75
|
+
async () => {
|
|
76
|
+
const origKey = process.env.RESEND_API_KEY;
|
|
77
|
+
delete process.env.RESEND_API_KEY;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => createEmailService({ provider: "resend" }),
|
|
82
|
+
(err) => err instanceof Error && /RESEND_API_KEY/.test(err.message),
|
|
83
|
+
);
|
|
84
|
+
} finally {
|
|
85
|
+
if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
|
|
86
|
+
else delete process.env.RESEND_API_KEY;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await t.test(
|
|
92
|
+
"Resend provider: calls fetch with correct URL, headers, and body",
|
|
93
|
+
async () => {
|
|
94
|
+
const origKey = process.env.RESEND_API_KEY;
|
|
95
|
+
process.env.RESEND_API_KEY = "re_test_key_123";
|
|
96
|
+
|
|
97
|
+
// Mock global fetch
|
|
98
|
+
const originalFetch = globalThis.fetch;
|
|
99
|
+
let capturedUrl = null;
|
|
100
|
+
let capturedOptions = null;
|
|
101
|
+
|
|
102
|
+
globalThis.fetch = async (url, opts) => {
|
|
103
|
+
capturedUrl = url;
|
|
104
|
+
capturedOptions = opts;
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
json: async () => ({ id: "resend-msg-id" }),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const service = createEmailService({
|
|
113
|
+
provider: "resend",
|
|
114
|
+
from: "Test <test@example.com>",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await service.sendEmail(
|
|
118
|
+
"recipient@example.com",
|
|
119
|
+
"Test Subject",
|
|
120
|
+
"<p>Hello</p>",
|
|
121
|
+
"Hello",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
assert.strictEqual(capturedUrl, "https://api.resend.com/emails");
|
|
125
|
+
assert.strictEqual(capturedOptions.method, "POST");
|
|
126
|
+
|
|
127
|
+
const headers = capturedOptions.headers;
|
|
128
|
+
assert.strictEqual(headers.Authorization, "Bearer re_test_key_123");
|
|
129
|
+
assert.strictEqual(headers["Content-Type"], "application/json");
|
|
130
|
+
|
|
131
|
+
const body = JSON.parse(capturedOptions.body);
|
|
132
|
+
assert.strictEqual(body.from, "Test <test@example.com>");
|
|
133
|
+
assert.deepStrictEqual(body.to, ["recipient@example.com"]);
|
|
134
|
+
assert.strictEqual(body.subject, "Test Subject");
|
|
135
|
+
assert.strictEqual(body.html, "<p>Hello</p>");
|
|
136
|
+
assert.strictEqual(body.text, "Hello");
|
|
137
|
+
|
|
138
|
+
assert.strictEqual(result.id, "resend-msg-id");
|
|
139
|
+
} finally {
|
|
140
|
+
globalThis.fetch = originalFetch;
|
|
141
|
+
if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
|
|
142
|
+
else delete process.env.RESEND_API_KEY;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await t.test("Resend provider: throws on non-ok response", async () => {
|
|
148
|
+
const origKey = process.env.RESEND_API_KEY;
|
|
149
|
+
process.env.RESEND_API_KEY = "re_test_key_123";
|
|
150
|
+
|
|
151
|
+
const originalFetch = globalThis.fetch;
|
|
152
|
+
globalThis.fetch = async () => ({
|
|
153
|
+
ok: false,
|
|
154
|
+
statusText: "Unprocessable Entity",
|
|
155
|
+
json: async () => ({ message: "Invalid email address" }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const service = createEmailService({ provider: "resend" });
|
|
160
|
+
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
() => service.sendEmail("bad@", "Subject", "<p>body</p>"),
|
|
163
|
+
(err) =>
|
|
164
|
+
err instanceof Error &&
|
|
165
|
+
/Resend API error/.test(err.message) &&
|
|
166
|
+
/Invalid email address/.test(err.message),
|
|
167
|
+
);
|
|
168
|
+
} finally {
|
|
169
|
+
globalThis.fetch = originalFetch;
|
|
170
|
+
if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
|
|
171
|
+
else delete process.env.RESEND_API_KEY;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await t.test("Resend provider: handles non-JSON error response", async () => {
|
|
176
|
+
const origKey = process.env.RESEND_API_KEY;
|
|
177
|
+
process.env.RESEND_API_KEY = "re_test_key_123";
|
|
178
|
+
|
|
179
|
+
const originalFetch = globalThis.fetch;
|
|
180
|
+
globalThis.fetch = async () => ({
|
|
181
|
+
ok: false,
|
|
182
|
+
statusText: "Internal Server Error",
|
|
183
|
+
json: async () => {
|
|
184
|
+
throw new Error("not json");
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const service = createEmailService({ provider: "resend" });
|
|
190
|
+
|
|
191
|
+
await assert.rejects(
|
|
192
|
+
() => service.sendEmail("test@example.com", "Subject", "<p>body</p>"),
|
|
193
|
+
(err) =>
|
|
194
|
+
err instanceof Error &&
|
|
195
|
+
/Resend API error/.test(err.message) &&
|
|
196
|
+
/Internal Server Error/.test(err.message),
|
|
197
|
+
);
|
|
198
|
+
} finally {
|
|
199
|
+
globalThis.fetch = originalFetch;
|
|
200
|
+
if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
|
|
201
|
+
else delete process.env.RESEND_API_KEY;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await t.test("default provider is smtp", () => {
|
|
206
|
+
const origProvider = process.env.EMAIL_PROVIDER;
|
|
207
|
+
delete process.env.EMAIL_PROVIDER;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const service = createEmailService();
|
|
211
|
+
// The service should be created without error (defaults to smtp)
|
|
212
|
+
assert.strictEqual(typeof service.sendEmail, "function");
|
|
213
|
+
} finally {
|
|
214
|
+
if (origProvider !== undefined) process.env.EMAIL_PROVIDER = origProvider;
|
|
215
|
+
else delete process.env.EMAIL_PROVIDER;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await t.test("respects options.from override", async () => {
|
|
220
|
+
const origKey = process.env.RESEND_API_KEY;
|
|
221
|
+
process.env.RESEND_API_KEY = "re_test_key_123";
|
|
222
|
+
|
|
223
|
+
const originalFetch = globalThis.fetch;
|
|
224
|
+
let capturedBody = null;
|
|
225
|
+
|
|
226
|
+
globalThis.fetch = async (_url, opts) => {
|
|
227
|
+
capturedBody = JSON.parse(opts.body);
|
|
228
|
+
return { ok: true, json: async () => ({ id: "msg-1" }) };
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const service = createEmailService({
|
|
233
|
+
provider: "resend",
|
|
234
|
+
from: "Custom <custom@example.com>",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await service.sendEmail("to@example.com", "Sub", "<p>Hi</p>");
|
|
238
|
+
assert.strictEqual(capturedBody.from, "Custom <custom@example.com>");
|
|
239
|
+
} finally {
|
|
240
|
+
globalThis.fetch = originalFetch;
|
|
241
|
+
if (origKey !== undefined) process.env.RESEND_API_KEY = origKey;
|
|
242
|
+
else delete process.env.RESEND_API_KEY;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await t.test(
|
|
247
|
+
"Zeptomail provider: throws at creation if ZEPTOMAIL_TOKEN missing",
|
|
248
|
+
async () => {
|
|
249
|
+
const origToken = process.env.ZEPTOMAIL_TOKEN;
|
|
250
|
+
const origUrl = process.env.ZEPTOMAIL_URL;
|
|
251
|
+
delete process.env.ZEPTOMAIL_TOKEN;
|
|
252
|
+
process.env.ZEPTOMAIL_URL = "https://api.zeptomail.com";
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
assert.throws(
|
|
256
|
+
() => createEmailService({ provider: "zeptomail" }),
|
|
257
|
+
(err) => err instanceof Error && /ZEPTOMAIL_TOKEN/.test(err.message),
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
if (origToken !== undefined) process.env.ZEPTOMAIL_TOKEN = origToken;
|
|
261
|
+
else delete process.env.ZEPTOMAIL_TOKEN;
|
|
262
|
+
if (origUrl !== undefined) process.env.ZEPTOMAIL_URL = origUrl;
|
|
263
|
+
else delete process.env.ZEPTOMAIL_URL;
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await t.test(
|
|
269
|
+
"Zeptomail provider: throws at creation if ZEPTOMAIL_URL missing",
|
|
270
|
+
async () => {
|
|
271
|
+
const origToken = process.env.ZEPTOMAIL_TOKEN;
|
|
272
|
+
const origUrl = process.env.ZEPTOMAIL_URL;
|
|
273
|
+
process.env.ZEPTOMAIL_TOKEN = "zep_test_token_123";
|
|
274
|
+
delete process.env.ZEPTOMAIL_URL;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
assert.throws(
|
|
278
|
+
() => createEmailService({ provider: "zeptomail" }),
|
|
279
|
+
(err) => err instanceof Error && /ZEPTOMAIL_URL/.test(err.message),
|
|
280
|
+
);
|
|
281
|
+
} finally {
|
|
282
|
+
if (origToken !== undefined) process.env.ZEPTOMAIL_TOKEN = origToken;
|
|
283
|
+
else delete process.env.ZEPTOMAIL_TOKEN;
|
|
284
|
+
if (origUrl !== undefined) process.env.ZEPTOMAIL_URL = origUrl;
|
|
285
|
+
else delete process.env.ZEPTOMAIL_URL;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await t.test(
|
|
291
|
+
"Zeptomail provider: calls fetch with correct URL, headers, and body",
|
|
292
|
+
async () => {
|
|
293
|
+
const origToken = process.env.ZEPTOMAIL_TOKEN;
|
|
294
|
+
const origUrl = process.env.ZEPTOMAIL_URL;
|
|
295
|
+
process.env.ZEPTOMAIL_TOKEN = "zep_test_token_123";
|
|
296
|
+
process.env.ZEPTOMAIL_URL = "https://api.zeptomail.com";
|
|
297
|
+
|
|
298
|
+
const originalFetch = globalThis.fetch;
|
|
299
|
+
let capturedUrl = null;
|
|
300
|
+
let capturedOptions = null;
|
|
301
|
+
|
|
302
|
+
globalThis.fetch = async (url, opts) => {
|
|
303
|
+
capturedUrl = url;
|
|
304
|
+
capturedOptions = opts;
|
|
305
|
+
return {
|
|
306
|
+
ok: true,
|
|
307
|
+
json: async () => ({ request_id: "zep-request-id" }),
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const service = createEmailService({
|
|
313
|
+
provider: "zeptomail",
|
|
314
|
+
from: "Test <test@example.com>",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const result = await service.sendEmail(
|
|
318
|
+
"recipient@example.com",
|
|
319
|
+
"Test Subject",
|
|
320
|
+
"<p>Hello</p>",
|
|
321
|
+
"Hello",
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
assert.strictEqual(capturedUrl, "https://api.zeptomail.com/v1.1/email");
|
|
325
|
+
assert.strictEqual(capturedOptions.method, "POST");
|
|
326
|
+
|
|
327
|
+
const headers = capturedOptions.headers;
|
|
328
|
+
// Token is used as-is; it already carries the "Zoho-enczapikey" scheme
|
|
329
|
+
assert.strictEqual(headers.Authorization, "zep_test_token_123");
|
|
330
|
+
assert.strictEqual(headers["Content-Type"], "application/json");
|
|
331
|
+
|
|
332
|
+
const body = JSON.parse(capturedOptions.body);
|
|
333
|
+
assert.deepStrictEqual(body.from, {
|
|
334
|
+
name: "Test",
|
|
335
|
+
address: "test@example.com",
|
|
336
|
+
});
|
|
337
|
+
assert.strictEqual(body.subject, "Test Subject");
|
|
338
|
+
assert.strictEqual(body.htmlbody, "<p>Hello</p>");
|
|
339
|
+
assert.strictEqual(body.textbody, "Hello");
|
|
340
|
+
|
|
341
|
+
assert.strictEqual(result.request_id, "zep-request-id");
|
|
342
|
+
// Normalized: id should equal request_id
|
|
343
|
+
assert.strictEqual(result.id, "zep-request-id");
|
|
344
|
+
} finally {
|
|
345
|
+
globalThis.fetch = originalFetch;
|
|
346
|
+
if (origToken !== undefined) process.env.ZEPTOMAIL_TOKEN = origToken;
|
|
347
|
+
else delete process.env.ZEPTOMAIL_TOKEN;
|
|
348
|
+
if (origUrl !== undefined) process.env.ZEPTOMAIL_URL = origUrl;
|
|
349
|
+
else delete process.env.ZEPTOMAIL_URL;
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
await t.test("input validation: rejects empty 'to'", async () => {
|
|
355
|
+
const service = createEmailService();
|
|
356
|
+
await assert.rejects(
|
|
357
|
+
() => service.sendEmail("", "Subject", "<p>body</p>"),
|
|
358
|
+
(err) => err instanceof Error && /to/.test(err.message),
|
|
359
|
+
);
|
|
360
|
+
await assert.rejects(
|
|
361
|
+
() => service.sendEmail(null, "Subject", "<p>body</p>"),
|
|
362
|
+
(err) => err instanceof Error && /to/.test(err.message),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await t.test("input validation: rejects empty 'subject'", async () => {
|
|
367
|
+
const service = createEmailService();
|
|
368
|
+
await assert.rejects(
|
|
369
|
+
() => service.sendEmail("a@b.com", "", "<p>body</p>"),
|
|
370
|
+
(err) => err instanceof Error && /subject/.test(err.message),
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await t.test("input validation: rejects empty 'html'", async () => {
|
|
375
|
+
const service = createEmailService();
|
|
376
|
+
await assert.rejects(
|
|
377
|
+
() => service.sendEmail("a@b.com", "Subject", ""),
|
|
378
|
+
(err) => err instanceof Error && /html/.test(err.message),
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// --- Strategy Pattern ---
|
|
383
|
+
|
|
384
|
+
await t.test("EmailProvider base class is exported", () => {
|
|
385
|
+
assert.strictEqual(typeof EmailProvider, "function");
|
|
386
|
+
const provider = new EmailProvider("test@example.com");
|
|
387
|
+
assert.strictEqual(provider.from, "test@example.com");
|
|
388
|
+
// validateConfig() is a no-op on base class - must not throw
|
|
389
|
+
assert.doesNotThrow(() => provider.validateConfig());
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await t.test(
|
|
393
|
+
"EmailProvider base class sendEmail throws not-implemented",
|
|
394
|
+
async () => {
|
|
395
|
+
const provider = new EmailProvider("test@example.com");
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
() => provider.sendEmail("to@example.com", "Subject", "<p>Hi</p>"),
|
|
398
|
+
(err) =>
|
|
399
|
+
err instanceof Error && /must implement sendEmail/.test(err.message),
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
await t.test("createEmailService throws for unknown provider", () => {
|
|
405
|
+
assert.throws(
|
|
406
|
+
() => createEmailService({ provider: "unknown-provider" }),
|
|
407
|
+
(err) =>
|
|
408
|
+
err instanceof Error &&
|
|
409
|
+
/Unknown email provider/.test(err.message) &&
|
|
410
|
+
/unknown-provider/.test(err.message),
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
await t.test(
|
|
415
|
+
"registerEmailProvider: validateConfig() is called at service creation",
|
|
416
|
+
() => {
|
|
417
|
+
class BrokenProvider extends EmailProvider {
|
|
418
|
+
validateConfig() {
|
|
419
|
+
throw new Error("missing MY_API_KEY");
|
|
420
|
+
}
|
|
421
|
+
async sendEmail() {
|
|
422
|
+
return { id: "x" };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
registerEmailProvider("test-broken", BrokenProvider);
|
|
426
|
+
|
|
427
|
+
assert.throws(
|
|
428
|
+
() => createEmailService({ provider: "test-broken" }),
|
|
429
|
+
(err) => err instanceof Error && /MY_API_KEY/.test(err.message),
|
|
430
|
+
);
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await t.test("registerEmailProvider: rejects non-subclass", () => {
|
|
435
|
+
class NotAProvider {
|
|
436
|
+
async sendEmail() {}
|
|
437
|
+
}
|
|
438
|
+
assert.throws(
|
|
439
|
+
() => registerEmailProvider("not-valid", NotAProvider),
|
|
440
|
+
(err) =>
|
|
441
|
+
err instanceof Error && /must extend EmailProvider/.test(err.message),
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await t.test(
|
|
446
|
+
"registerEmailProvider: custom provider is used by createEmailService",
|
|
447
|
+
async () => {
|
|
448
|
+
let capturedArgs = null;
|
|
449
|
+
|
|
450
|
+
class TestProvider extends EmailProvider {
|
|
451
|
+
async sendEmail(to, subject, html, text) {
|
|
452
|
+
capturedArgs = { to, subject, html, text, from: this.from };
|
|
453
|
+
return { id: "custom-provider-id" };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
registerEmailProvider("test-custom", TestProvider);
|
|
458
|
+
|
|
459
|
+
const service = createEmailService({
|
|
460
|
+
provider: "test-custom",
|
|
461
|
+
from: "Custom <custom@example.com>",
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const result = await service.sendEmail(
|
|
465
|
+
"to@example.com",
|
|
466
|
+
"Custom Subject",
|
|
467
|
+
"<p>Custom Body</p>",
|
|
468
|
+
"Custom Body",
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
assert.strictEqual(result.id, "custom-provider-id");
|
|
472
|
+
assert.strictEqual(capturedArgs.to, "to@example.com");
|
|
473
|
+
assert.strictEqual(capturedArgs.subject, "Custom Subject");
|
|
474
|
+
assert.strictEqual(capturedArgs.html, "<p>Custom Body</p>");
|
|
475
|
+
assert.strictEqual(capturedArgs.text, "Custom Body");
|
|
476
|
+
assert.strictEqual(capturedArgs.from, "Custom <custom@example.com>");
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
await t.test(
|
|
481
|
+
"registerEmailProvider: custom provider still runs input validation",
|
|
482
|
+
async () => {
|
|
483
|
+
class NoopProvider extends EmailProvider {
|
|
484
|
+
async sendEmail() {
|
|
485
|
+
return { id: "noop" };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
registerEmailProvider("test-noop", NoopProvider);
|
|
490
|
+
const service = createEmailService({ provider: "test-noop" });
|
|
491
|
+
|
|
492
|
+
await assert.rejects(
|
|
493
|
+
() => service.sendEmail("", "Subject", "<p>body</p>"),
|
|
494
|
+
(err) => err instanceof Error && /to/.test(err.message),
|
|
495
|
+
);
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
});
|