@buenojs/bueno 0.8.4 → 0.8.5

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.
Files changed (218) hide show
  1. package/README.md +136 -16
  2. package/dist/cli/{index.js → bin.js} +412 -331
  3. package/dist/container/index.js +250 -0
  4. package/dist/context/index.js +219 -0
  5. package/dist/database/index.js +493 -0
  6. package/dist/frontend/index.js +7697 -0
  7. package/dist/health/index.js +364 -0
  8. package/dist/i18n/index.js +345 -0
  9. package/dist/index.js +11043 -6482
  10. package/dist/jobs/index.js +819 -0
  11. package/dist/lock/index.js +367 -0
  12. package/dist/logger/index.js +281 -0
  13. package/dist/metrics/index.js +289 -0
  14. package/dist/middleware/index.js +77 -0
  15. package/dist/migrations/index.js +571 -0
  16. package/dist/modules/index.js +3346 -0
  17. package/dist/notification/index.js +484 -0
  18. package/dist/observability/index.js +331 -0
  19. package/dist/openapi/index.js +776 -0
  20. package/dist/orm/index.js +1356 -0
  21. package/dist/router/index.js +886 -0
  22. package/dist/rpc/index.js +691 -0
  23. package/dist/schema/index.js +400 -0
  24. package/dist/telemetry/index.js +595 -0
  25. package/dist/template/index.js +640 -0
  26. package/dist/templates/index.js +640 -0
  27. package/dist/testing/index.js +1111 -0
  28. package/dist/types/index.js +60 -0
  29. package/package.json +121 -27
  30. package/src/cache/index.ts +2 -1
  31. package/src/cli/bin.ts +2 -2
  32. package/src/cli/commands/build.ts +183 -165
  33. package/src/cli/commands/dev.ts +96 -89
  34. package/src/cli/commands/generate.ts +142 -111
  35. package/src/cli/commands/help.ts +20 -16
  36. package/src/cli/commands/index.ts +3 -6
  37. package/src/cli/commands/migration.ts +124 -105
  38. package/src/cli/commands/new.ts +294 -232
  39. package/src/cli/commands/start.ts +81 -79
  40. package/src/cli/core/args.ts +68 -50
  41. package/src/cli/core/console.ts +89 -95
  42. package/src/cli/core/index.ts +4 -4
  43. package/src/cli/core/prompt.ts +65 -62
  44. package/src/cli/core/spinner.ts +23 -20
  45. package/src/cli/index.ts +46 -38
  46. package/src/cli/templates/database/index.ts +37 -18
  47. package/src/cli/templates/database/mysql.ts +3 -3
  48. package/src/cli/templates/database/none.ts +2 -2
  49. package/src/cli/templates/database/postgresql.ts +3 -3
  50. package/src/cli/templates/database/sqlite.ts +3 -3
  51. package/src/cli/templates/deploy.ts +29 -26
  52. package/src/cli/templates/docker.ts +41 -30
  53. package/src/cli/templates/frontend/index.ts +33 -15
  54. package/src/cli/templates/frontend/none.ts +2 -2
  55. package/src/cli/templates/frontend/react.ts +18 -18
  56. package/src/cli/templates/frontend/solid.ts +15 -15
  57. package/src/cli/templates/frontend/svelte.ts +17 -17
  58. package/src/cli/templates/frontend/vue.ts +15 -15
  59. package/src/cli/templates/generators/index.ts +29 -29
  60. package/src/cli/templates/generators/types.ts +21 -21
  61. package/src/cli/templates/index.ts +6 -6
  62. package/src/cli/templates/project/api.ts +37 -36
  63. package/src/cli/templates/project/default.ts +25 -25
  64. package/src/cli/templates/project/fullstack.ts +28 -26
  65. package/src/cli/templates/project/index.ts +55 -16
  66. package/src/cli/templates/project/minimal.ts +17 -12
  67. package/src/cli/templates/project/types.ts +10 -5
  68. package/src/cli/templates/project/website.ts +14 -14
  69. package/src/cli/utils/fs.ts +55 -41
  70. package/src/cli/utils/index.ts +3 -3
  71. package/src/cli/utils/strings.ts +47 -33
  72. package/src/cli/utils/version.ts +14 -8
  73. package/src/config/env-validation.ts +100 -0
  74. package/src/config/env.ts +169 -41
  75. package/src/config/index.ts +28 -20
  76. package/src/config/loader.ts +25 -16
  77. package/src/config/merge.ts +21 -10
  78. package/src/config/types.ts +545 -25
  79. package/src/config/validation.ts +215 -7
  80. package/src/container/forward-ref.ts +22 -22
  81. package/src/container/index.ts +34 -12
  82. package/src/context/index.ts +11 -1
  83. package/src/database/index.ts +7 -190
  84. package/src/database/orm/builder.ts +457 -0
  85. package/src/database/orm/casts/index.ts +130 -0
  86. package/src/database/orm/casts/types.ts +25 -0
  87. package/src/database/orm/compiler.ts +304 -0
  88. package/src/database/orm/hooks/index.ts +114 -0
  89. package/src/database/orm/index.ts +61 -0
  90. package/src/database/orm/model-registry.ts +59 -0
  91. package/src/database/orm/model.ts +821 -0
  92. package/src/database/orm/relationships/base.ts +146 -0
  93. package/src/database/orm/relationships/belongs-to-many.ts +179 -0
  94. package/src/database/orm/relationships/belongs-to.ts +56 -0
  95. package/src/database/orm/relationships/has-many.ts +45 -0
  96. package/src/database/orm/relationships/has-one.ts +41 -0
  97. package/src/database/orm/relationships/index.ts +11 -0
  98. package/src/database/orm/scopes/index.ts +55 -0
  99. package/src/events/__tests__/event-system.test.ts +235 -0
  100. package/src/events/config.ts +238 -0
  101. package/src/events/example-usage.ts +185 -0
  102. package/src/events/index.ts +278 -0
  103. package/src/events/manager.ts +385 -0
  104. package/src/events/registry.ts +182 -0
  105. package/src/events/types.ts +124 -0
  106. package/src/frontend/api-routes.ts +65 -23
  107. package/src/frontend/bundler.ts +76 -34
  108. package/src/frontend/console-client.ts +2 -2
  109. package/src/frontend/console-stream.ts +94 -38
  110. package/src/frontend/dev-server.ts +94 -46
  111. package/src/frontend/file-router.ts +61 -19
  112. package/src/frontend/frameworks/index.ts +37 -10
  113. package/src/frontend/frameworks/react.ts +10 -8
  114. package/src/frontend/frameworks/solid.ts +11 -9
  115. package/src/frontend/frameworks/svelte.ts +15 -9
  116. package/src/frontend/frameworks/vue.ts +13 -11
  117. package/src/frontend/hmr-client.ts +12 -10
  118. package/src/frontend/hmr.ts +146 -103
  119. package/src/frontend/index.ts +14 -5
  120. package/src/frontend/islands.ts +41 -22
  121. package/src/frontend/isr.ts +59 -37
  122. package/src/frontend/layout.ts +36 -21
  123. package/src/frontend/ssr/react.ts +74 -27
  124. package/src/frontend/ssr/solid.ts +54 -20
  125. package/src/frontend/ssr/svelte.ts +48 -14
  126. package/src/frontend/ssr/vue.ts +50 -18
  127. package/src/frontend/ssr.ts +83 -39
  128. package/src/frontend/types.ts +91 -56
  129. package/src/health/index.ts +21 -9
  130. package/src/i18n/engine.ts +305 -0
  131. package/src/i18n/index.ts +38 -0
  132. package/src/i18n/loader.ts +218 -0
  133. package/src/i18n/middleware.ts +164 -0
  134. package/src/i18n/negotiator.ts +162 -0
  135. package/src/i18n/types.ts +158 -0
  136. package/src/index.ts +179 -27
  137. package/src/jobs/drivers/memory.ts +315 -0
  138. package/src/jobs/drivers/redis.ts +459 -0
  139. package/src/jobs/index.ts +30 -0
  140. package/src/jobs/queue.ts +281 -0
  141. package/src/jobs/types.ts +295 -0
  142. package/src/jobs/worker.ts +380 -0
  143. package/src/logger/index.ts +1 -3
  144. package/src/logger/transports/index.ts +62 -22
  145. package/src/metrics/index.ts +25 -16
  146. package/src/migrations/index.ts +9 -0
  147. package/src/modules/filters.ts +13 -17
  148. package/src/modules/guards.ts +49 -26
  149. package/src/modules/index.ts +409 -298
  150. package/src/modules/interceptors.ts +58 -20
  151. package/src/modules/lazy.ts +11 -19
  152. package/src/modules/lifecycle.ts +15 -7
  153. package/src/modules/metadata.ts +15 -5
  154. package/src/modules/pipes.ts +94 -72
  155. package/src/notification/channels/base.ts +68 -0
  156. package/src/notification/channels/email.ts +105 -0
  157. package/src/notification/channels/push.ts +104 -0
  158. package/src/notification/channels/sms.ts +105 -0
  159. package/src/notification/channels/whatsapp.ts +104 -0
  160. package/src/notification/index.ts +48 -0
  161. package/src/notification/service.ts +354 -0
  162. package/src/notification/types.ts +344 -0
  163. package/src/observability/__tests__/observability.test.ts +483 -0
  164. package/src/observability/breadcrumbs.ts +114 -0
  165. package/src/observability/index.ts +136 -0
  166. package/src/observability/interceptor.ts +85 -0
  167. package/src/observability/service.ts +303 -0
  168. package/src/observability/trace.ts +37 -0
  169. package/src/observability/types.ts +196 -0
  170. package/src/openapi/__tests__/decorators.test.ts +335 -0
  171. package/src/openapi/__tests__/document-builder.test.ts +285 -0
  172. package/src/openapi/__tests__/route-scanner.test.ts +334 -0
  173. package/src/openapi/__tests__/schema-generator.test.ts +275 -0
  174. package/src/openapi/decorators.ts +328 -0
  175. package/src/openapi/document-builder.ts +274 -0
  176. package/src/openapi/index.ts +112 -0
  177. package/src/openapi/metadata.ts +112 -0
  178. package/src/openapi/route-scanner.ts +289 -0
  179. package/src/openapi/schema-generator.ts +256 -0
  180. package/src/openapi/swagger-module.ts +166 -0
  181. package/src/openapi/types.ts +398 -0
  182. package/src/orm/index.ts +10 -0
  183. package/src/rpc/index.ts +3 -1
  184. package/src/schema/index.ts +9 -0
  185. package/src/security/index.ts +15 -6
  186. package/src/ssg/index.ts +9 -8
  187. package/src/telemetry/index.ts +76 -22
  188. package/src/template/index.ts +7 -0
  189. package/src/templates/engine.ts +224 -0
  190. package/src/templates/index.ts +9 -0
  191. package/src/templates/loader.ts +331 -0
  192. package/src/templates/renderers/markdown.ts +212 -0
  193. package/src/templates/renderers/simple.ts +269 -0
  194. package/src/templates/types.ts +154 -0
  195. package/src/testing/index.ts +100 -27
  196. package/src/types/optional-deps.d.ts +347 -187
  197. package/src/validation/index.ts +92 -2
  198. package/src/validation/schemas.ts +536 -0
  199. package/tests/integration/fullstack.test.ts +4 -4
  200. package/tests/unit/database.test.ts +2 -72
  201. package/tests/unit/env-validation.test.ts +166 -0
  202. package/tests/unit/events.test.ts +910 -0
  203. package/tests/unit/i18n.test.ts +455 -0
  204. package/tests/unit/jobs.test.ts +493 -0
  205. package/tests/unit/notification.test.ts +988 -0
  206. package/tests/unit/observability.test.ts +453 -0
  207. package/tests/unit/orm/builder.test.ts +323 -0
  208. package/tests/unit/orm/casts.test.ts +179 -0
  209. package/tests/unit/orm/compiler.test.ts +220 -0
  210. package/tests/unit/orm/eager-loading.test.ts +285 -0
  211. package/tests/unit/orm/hooks.test.ts +191 -0
  212. package/tests/unit/orm/model.test.ts +373 -0
  213. package/tests/unit/orm/relationships.test.ts +303 -0
  214. package/tests/unit/orm/scopes.test.ts +74 -0
  215. package/tests/unit/templates-simple.test.ts +53 -0
  216. package/tests/unit/templates.test.ts +454 -0
  217. package/tests/unit/validation.test.ts +18 -24
  218. package/tsconfig.json +11 -3
@@ -0,0 +1,988 @@
1
+ /**
2
+ * Notification System Unit Tests
3
+ *
4
+ * Tests for notification service, channels, and core functionality
5
+ */
6
+
7
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
8
+ import { mkdirSync, rmSync, writeFileSync } from "fs";
9
+ import { resolve } from "path";
10
+ import {
11
+ NotificationService,
12
+ EmailChannelService,
13
+ SMSChannelService,
14
+ WhatsAppChannelService,
15
+ PushNotificationChannelService,
16
+ type EmailMessage,
17
+ type SMSMessage,
18
+ type WhatsAppMessage,
19
+ type PushNotificationMessage,
20
+ type TemplateRef,
21
+ } from "../../src/notification";
22
+ import { TemplateEngine } from "../../src/templates";
23
+
24
+ // ============= Test Fixtures =============
25
+
26
+ const testEmailMessage: EmailMessage = {
27
+ channel: "email",
28
+ recipient: "test@example.com",
29
+ subject: "Test Email",
30
+ html: "<p>This is a test</p>",
31
+ text: "This is a test",
32
+ };
33
+
34
+ const testSMSMessage: SMSMessage = {
35
+ channel: "sms",
36
+ recipient: "+1234567890",
37
+ message: "This is a test SMS message",
38
+ };
39
+
40
+ const testWhatsAppMessage: WhatsAppMessage = {
41
+ channel: "whatsapp",
42
+ recipient: "+1234567890",
43
+ templateId: "welcome_template",
44
+ parameters: { name: "John" },
45
+ };
46
+
47
+ const testPushMessage: PushNotificationMessage = {
48
+ channel: "push",
49
+ recipient: "device_token_123",
50
+ title: "Test Notification",
51
+ body: "This is a test push notification",
52
+ };
53
+
54
+ // ============= Email Channel Tests =============
55
+
56
+ describe("EmailChannelService", () => {
57
+ let channel: EmailChannelService;
58
+
59
+ beforeEach(() => {
60
+ channel = new EmailChannelService({
61
+ driver: "smtp",
62
+ from: "noreply@example.com",
63
+ dryRun: true,
64
+ });
65
+ });
66
+
67
+ test("should validate email message", () => {
68
+ expect(() => {
69
+ channel.validate(testEmailMessage);
70
+ }).not.toThrow();
71
+ });
72
+
73
+ test("should reject email without subject", () => {
74
+ expect(() => {
75
+ channel.validate({
76
+ channel: "email",
77
+ recipient: "test@example.com",
78
+ html: "<p>Test</p>",
79
+ });
80
+ }).toThrow();
81
+ });
82
+
83
+ test("should reject email without recipient", () => {
84
+ expect(() => {
85
+ channel.validate({
86
+ channel: "email",
87
+ subject: "Test",
88
+ html: "<p>Test</p>",
89
+ });
90
+ }).toThrow();
91
+ });
92
+
93
+ test("should reject email without html or text", () => {
94
+ expect(() => {
95
+ channel.validate({
96
+ channel: "email",
97
+ recipient: "test@example.com",
98
+ subject: "Test",
99
+ });
100
+ }).toThrow();
101
+ });
102
+
103
+ test("should send email in dry-run mode", async () => {
104
+ const messageId = await channel.send(testEmailMessage);
105
+ expect(messageId).toBeDefined();
106
+ expect(typeof messageId).toBe("string");
107
+ });
108
+
109
+ test("should track metrics", async () => {
110
+ await channel.send(testEmailMessage);
111
+ await channel.send(testEmailMessage);
112
+
113
+ const metrics = channel.getMetrics();
114
+ expect(metrics.sent).toBe(2);
115
+ expect(metrics.failed).toBe(0);
116
+ expect(metrics.successRate).toBe(1);
117
+ });
118
+ });
119
+
120
+ // ============= SMS Channel Tests =============
121
+
122
+ describe("SMSChannelService", () => {
123
+ let channel: SMSChannelService;
124
+
125
+ beforeEach(() => {
126
+ channel = new SMSChannelService({
127
+ driver: "twilio",
128
+ dryRun: true,
129
+ });
130
+ });
131
+
132
+ test("should validate SMS message", () => {
133
+ expect(() => {
134
+ channel.validate(testSMSMessage);
135
+ }).not.toThrow();
136
+ });
137
+
138
+ test("should reject SMS without recipient", () => {
139
+ expect(() => {
140
+ channel.validate({
141
+ channel: "sms",
142
+ message: "Test message",
143
+ });
144
+ }).toThrow();
145
+ });
146
+
147
+ test("should reject SMS without message", () => {
148
+ expect(() => {
149
+ channel.validate({
150
+ channel: "sms",
151
+ recipient: "+1234567890",
152
+ });
153
+ }).toThrow();
154
+ });
155
+
156
+ test("should send SMS in dry-run mode", async () => {
157
+ const messageId = await channel.send(testSMSMessage);
158
+ expect(messageId).toBeDefined();
159
+ expect(typeof messageId).toBe("string");
160
+ });
161
+
162
+ test("should warn about long messages", async () => {
163
+ const longMessage: SMSMessage = {
164
+ ...testSMSMessage,
165
+ message: "A".repeat(200),
166
+ };
167
+
168
+ await expect(channel.send(longMessage)).resolves.toBeDefined();
169
+ });
170
+ });
171
+
172
+ // ============= WhatsApp Channel Tests =============
173
+
174
+ describe("WhatsAppChannelService", () => {
175
+ let channel: WhatsAppChannelService;
176
+
177
+ beforeEach(() => {
178
+ channel = new WhatsAppChannelService({
179
+ driver: "twilio",
180
+ dryRun: true,
181
+ });
182
+ });
183
+
184
+ test("should validate WhatsApp message", () => {
185
+ expect(() => {
186
+ channel.validate(testWhatsAppMessage);
187
+ }).not.toThrow();
188
+ });
189
+
190
+ test("should reject WhatsApp without templateId", () => {
191
+ expect(() => {
192
+ channel.validate({
193
+ channel: "whatsapp",
194
+ recipient: "+1234567890",
195
+ });
196
+ }).toThrow();
197
+ });
198
+
199
+ test("should send WhatsApp in dry-run mode", async () => {
200
+ const messageId = await channel.send(testWhatsAppMessage);
201
+ expect(messageId).toBeDefined();
202
+ expect(typeof messageId).toBe("string");
203
+ });
204
+ });
205
+
206
+ // ============= Push Notification Channel Tests =============
207
+
208
+ describe("PushNotificationChannelService", () => {
209
+ let channel: PushNotificationChannelService;
210
+
211
+ beforeEach(() => {
212
+ channel = new PushNotificationChannelService({
213
+ driver: "firebase",
214
+ dryRun: true,
215
+ });
216
+ });
217
+
218
+ test("should validate push message", () => {
219
+ expect(() => {
220
+ channel.validate(testPushMessage);
221
+ }).not.toThrow();
222
+ });
223
+
224
+ test("should reject push without title", () => {
225
+ expect(() => {
226
+ channel.validate({
227
+ channel: "push",
228
+ recipient: "device_token",
229
+ body: "Test",
230
+ });
231
+ }).toThrow();
232
+ });
233
+
234
+ test("should reject push without body", () => {
235
+ expect(() => {
236
+ channel.validate({
237
+ channel: "push",
238
+ recipient: "device_token",
239
+ title: "Test",
240
+ });
241
+ }).toThrow();
242
+ });
243
+
244
+ test("should send push in dry-run mode", async () => {
245
+ const messageId = await channel.send(testPushMessage);
246
+ expect(messageId).toBeDefined();
247
+ expect(typeof messageId).toBe("string");
248
+ });
249
+ });
250
+
251
+ // ============= Notification Service Tests =============
252
+
253
+ describe("NotificationService", () => {
254
+ let service: NotificationService;
255
+
256
+ beforeEach(() => {
257
+ service = new NotificationService({ enableMetrics: true });
258
+ service.registerChannel(
259
+ new EmailChannelService({
260
+ driver: "smtp",
261
+ from: "noreply@example.com",
262
+ dryRun: true,
263
+ }),
264
+ );
265
+ service.registerChannel(
266
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
267
+ );
268
+ });
269
+
270
+ test("should create service", () => {
271
+ expect(service).toBeDefined();
272
+ });
273
+
274
+ test("should register channels", () => {
275
+ expect(service.hasChannel("email")).toBe(true);
276
+ expect(service.hasChannel("sms")).toBe(true);
277
+ expect(service.hasChannel("push")).toBe(false);
278
+ });
279
+
280
+ test("should get registered channels", () => {
281
+ const channels = service.getChannels();
282
+ expect(channels).toContain("email");
283
+ expect(channels).toContain("sms");
284
+ });
285
+
286
+ test("should get channel by name", () => {
287
+ const emailChannel = service.getChannel("email");
288
+ expect(emailChannel).toBeDefined();
289
+ expect(emailChannel?.name).toBe("email");
290
+ });
291
+
292
+ test("should send via email channel", async () => {
293
+ const messageId = await service.send(testEmailMessage);
294
+ expect(messageId).toBeDefined();
295
+ });
296
+
297
+ test("should send via SMS channel", async () => {
298
+ const messageId = await service.send(testSMSMessage);
299
+ expect(messageId).toBeDefined();
300
+ });
301
+
302
+ test("should throw when sending to unregistered channel", async () => {
303
+ const message: PushNotificationMessage = {
304
+ ...testPushMessage,
305
+ };
306
+
307
+ await expect(service.send(message)).rejects.toThrow("Channel not registered");
308
+ });
309
+
310
+ test("should send batch messages", async () => {
311
+ const messages = [testEmailMessage, testSMSMessage];
312
+ const results = await service.sendBatch(messages);
313
+
314
+ expect(results.length).toBe(2);
315
+ expect(results[0]).toBeDefined();
316
+ expect(results[1]).toBeDefined();
317
+ });
318
+
319
+ test("should handle batch errors gracefully", async () => {
320
+ const validMessage = testEmailMessage;
321
+ const invalidMessage: EmailMessage = {
322
+ ...testEmailMessage,
323
+ subject: "", // Invalid
324
+ };
325
+
326
+ const results = await service.sendBatch([validMessage, invalidMessage]);
327
+ expect(results.length).toBe(2);
328
+ expect(results[0]).toBeDefined(); // Valid message succeeded
329
+ expect(results[1]).toBeUndefined(); // Invalid message failed
330
+ });
331
+
332
+ test("should get channel metrics", async () => {
333
+ await service.send(testEmailMessage);
334
+
335
+ const metrics = service.getChannelMetrics("email");
336
+ expect(metrics).toBeDefined();
337
+ expect(metrics?.sent).toBe(1);
338
+ });
339
+
340
+ test("should get all metrics", async () => {
341
+ await service.send(testEmailMessage);
342
+ await service.send(testSMSMessage);
343
+
344
+ const allMetrics = service.getAllMetrics();
345
+ expect(allMetrics.email).toBeDefined();
346
+ expect(allMetrics.sms).toBeDefined();
347
+ });
348
+
349
+ test("should get channel health", async () => {
350
+ const health = await service.getChannelHealth("email");
351
+ expect(health).toBeDefined();
352
+ expect(health?.status).toBe("healthy");
353
+ });
354
+
355
+ test("should get all health statuses", async () => {
356
+ const allHealth = await service.getHealthStatus();
357
+ expect(allHealth.email).toBeDefined();
358
+ expect(allHealth.sms).toBeDefined();
359
+ });
360
+
361
+ test("should unregister channel", () => {
362
+ expect(service.hasChannel("email")).toBe(true);
363
+ service.unregisterChannel("email");
364
+ expect(service.hasChannel("email")).toBe(false);
365
+ });
366
+
367
+ test("should throw when registering duplicate channel", () => {
368
+ const emailChannel = new EmailChannelService({
369
+ driver: "smtp",
370
+ from: "noreply@example.com",
371
+ dryRun: true,
372
+ });
373
+
374
+ expect(() => {
375
+ service.registerChannel(emailChannel);
376
+ }).toThrow("Channel already registered");
377
+ });
378
+ });
379
+
380
+ // ============= Notifiable Interface Tests =============
381
+
382
+ describe("Notifiable Interface", () => {
383
+ test("should build single notification", async () => {
384
+ const notifiable = {
385
+ build: async () => testEmailMessage,
386
+ };
387
+
388
+ const service = new NotificationService();
389
+ service.registerChannel(
390
+ new EmailChannelService({
391
+ driver: "smtp",
392
+ from: "noreply@example.com",
393
+ dryRun: true,
394
+ }),
395
+ );
396
+
397
+ const messageId = await service.sendNotifiable(notifiable);
398
+ expect(messageId).toBeDefined();
399
+ });
400
+
401
+ test("should build multiple notifications", async () => {
402
+ const notifiable = {
403
+ buildAll: async () => ({
404
+ email: testEmailMessage,
405
+ sms: testSMSMessage,
406
+ }),
407
+ };
408
+
409
+ const service = new NotificationService();
410
+ service.registerChannel(
411
+ new EmailChannelService({
412
+ driver: "smtp",
413
+ from: "noreply@example.com",
414
+ dryRun: true,
415
+ }),
416
+ );
417
+ service.registerChannel(
418
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
419
+ );
420
+
421
+ const messageId = await service.sendNotifiable(notifiable);
422
+ expect(messageId).toBeDefined();
423
+ });
424
+
425
+ test("should send to specific channel", async () => {
426
+ const notifiable = {
427
+ build: async (channel?: string) => {
428
+ if (channel === "sms") return testSMSMessage;
429
+ return testEmailMessage;
430
+ },
431
+ };
432
+
433
+ const service = new NotificationService();
434
+ service.registerChannel(
435
+ new EmailChannelService({
436
+ driver: "smtp",
437
+ from: "noreply@example.com",
438
+ dryRun: true,
439
+ }),
440
+ );
441
+ service.registerChannel(
442
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
443
+ );
444
+
445
+ const messageId = await service.sendNotifiable(notifiable, "sms");
446
+ expect(messageId).toBeDefined();
447
+ });
448
+ });
449
+
450
+ // ============= Multi-Channel Tests =============
451
+
452
+ describe("Multi-Channel Notifications", () => {
453
+ test("should support all built-in channels", () => {
454
+ const service = new NotificationService({ enableMetrics: true });
455
+
456
+ service.registerChannel(
457
+ new EmailChannelService({
458
+ driver: "smtp",
459
+ from: "noreply@example.com",
460
+ dryRun: true,
461
+ }),
462
+ );
463
+ service.registerChannel(
464
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
465
+ );
466
+ service.registerChannel(
467
+ new WhatsAppChannelService({ driver: "twilio", dryRun: true }),
468
+ );
469
+ service.registerChannel(
470
+ new PushNotificationChannelService({ driver: "firebase", dryRun: true }),
471
+ );
472
+
473
+ const channels = service.getChannels();
474
+ expect(channels).toContain("email");
475
+ expect(channels).toContain("sms");
476
+ expect(channels).toContain("whatsapp");
477
+ expect(channels).toContain("push");
478
+ });
479
+
480
+ test("should send to all channels", async () => {
481
+ const service = new NotificationService({ enableMetrics: true });
482
+
483
+ service.registerChannel(
484
+ new EmailChannelService({
485
+ driver: "smtp",
486
+ from: "noreply@example.com",
487
+ dryRun: true,
488
+ }),
489
+ );
490
+ service.registerChannel(
491
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
492
+ );
493
+ service.registerChannel(
494
+ new WhatsAppChannelService({ driver: "twilio", dryRun: true }),
495
+ );
496
+ service.registerChannel(
497
+ new PushNotificationChannelService({ driver: "firebase", dryRun: true }),
498
+ );
499
+
500
+ const results = await service.sendBatch([
501
+ testEmailMessage,
502
+ testSMSMessage,
503
+ testWhatsAppMessage,
504
+ testPushMessage,
505
+ ]);
506
+
507
+ expect(results.length).toBe(4);
508
+ expect(results.every((r) => r !== undefined)).toBe(true);
509
+ });
510
+ });
511
+
512
+ // ============= Template Integration Tests =============
513
+
514
+ describe("Template Integration", () => {
515
+ const TEST_DIR = resolve("./tests/.template-notif");
516
+
517
+ beforeEach(() => {
518
+ // Create test template directory
519
+ mkdirSync(TEST_DIR, { recursive: true });
520
+ });
521
+
522
+ afterEach(() => {
523
+ // Cleanup
524
+ try {
525
+ rmSync(TEST_DIR, { recursive: true, force: true });
526
+ } catch {
527
+ // Ignore errors
528
+ }
529
+ });
530
+
531
+ const createTemplate = (
532
+ templateId: string,
533
+ content: string,
534
+ metadata?: Record<string, unknown>
535
+ ) => {
536
+ const [dir, ...nameParts] = templateId.split("/");
537
+ const dirPath = resolve(TEST_DIR, dir);
538
+ mkdirSync(dirPath, { recursive: true });
539
+
540
+ let frontMatter = "";
541
+ if (metadata) {
542
+ const lines = Object.entries(metadata).map(([k, v]) => {
543
+ if (Array.isArray(v)) {
544
+ const formatted = v.map((item) => `"${item}"`).join(", ");
545
+ return `${k}: [${formatted}]`;
546
+ } else if (typeof v === "string") {
547
+ return `${k}: ${v}`;
548
+ } else {
549
+ return `${k}: ${JSON.stringify(v)}`;
550
+ }
551
+ });
552
+ frontMatter = `---\n${lines.join("\n")}\n---\n`;
553
+ }
554
+
555
+ const filePath = resolve(TEST_DIR, templateId + ".md");
556
+ writeFileSync(filePath, frontMatter + content);
557
+ };
558
+
559
+ test("should resolve email html TemplateRef to HTML", async () => {
560
+ createTemplate("emails/welcome", "## Email\nWelcome {{ name }}!");
561
+
562
+ const engine = new TemplateEngine({
563
+ basePath: TEST_DIR,
564
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
565
+ });
566
+
567
+ const service = new NotificationService({
568
+ enableMetrics: true,
569
+ templateEngine: engine,
570
+ });
571
+
572
+ service.registerChannel(
573
+ new EmailChannelService({
574
+ driver: "smtp",
575
+ from: "noreply@example.com",
576
+ dryRun: true,
577
+ }),
578
+ );
579
+
580
+ const emailRef: TemplateRef = {
581
+ templateId: "emails/welcome",
582
+ data: { name: "Alice" },
583
+ };
584
+
585
+ const message: EmailMessage = {
586
+ channel: "email",
587
+ recipient: "alice@example.com",
588
+ subject: "Welcome",
589
+ html: emailRef,
590
+ };
591
+
592
+ const messageId = await service.send(message);
593
+ expect(messageId).toBeDefined();
594
+
595
+ const metrics = service.getChannelMetrics("email");
596
+ expect(metrics?.sent).toBe(1);
597
+ });
598
+
599
+ test("should resolve email text TemplateRef to plain text", async () => {
600
+ createTemplate("emails/reset", "## Email\nReset link: {{ link }}");
601
+
602
+ const engine = new TemplateEngine({
603
+ basePath: TEST_DIR,
604
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
605
+ });
606
+
607
+ const service = new NotificationService({
608
+ enableMetrics: true,
609
+ templateEngine: engine,
610
+ });
611
+
612
+ service.registerChannel(
613
+ new EmailChannelService({
614
+ driver: "smtp",
615
+ from: "noreply@example.com",
616
+ dryRun: true,
617
+ }),
618
+ );
619
+
620
+ const textRef: TemplateRef = {
621
+ templateId: "emails/reset",
622
+ data: { link: "https://example.com/reset?token=xyz" },
623
+ };
624
+
625
+ const message: EmailMessage = {
626
+ channel: "email",
627
+ recipient: "user@example.com",
628
+ subject: "Reset Password",
629
+ text: textRef,
630
+ };
631
+
632
+ const messageId = await service.send(message);
633
+ expect(messageId).toBeDefined();
634
+ });
635
+
636
+ test("should resolve SMS message TemplateRef to text", async () => {
637
+ createTemplate("sms/verify", "## SMS\nCode: {{ code }}");
638
+
639
+ const engine = new TemplateEngine({
640
+ basePath: TEST_DIR,
641
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
642
+ });
643
+
644
+ const service = new NotificationService({
645
+ enableMetrics: true,
646
+ templateEngine: engine,
647
+ });
648
+
649
+ service.registerChannel(
650
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
651
+ );
652
+
653
+ const smsRef: TemplateRef = {
654
+ templateId: "sms/verify",
655
+ data: { code: "123456" },
656
+ };
657
+
658
+ const message: SMSMessage = {
659
+ channel: "sms",
660
+ recipient: "+1234567890",
661
+ message: smsRef,
662
+ };
663
+
664
+ const messageId = await service.send(message);
665
+ expect(messageId).toBeDefined();
666
+ });
667
+
668
+ test("should resolve push title TemplateRef to text", async () => {
669
+ createTemplate("push/order", "## Push\nOrder {{ orderId }} confirmed");
670
+
671
+ const engine = new TemplateEngine({
672
+ basePath: TEST_DIR,
673
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
674
+ });
675
+
676
+ const service = new NotificationService({
677
+ enableMetrics: true,
678
+ templateEngine: engine,
679
+ });
680
+
681
+ service.registerChannel(
682
+ new PushNotificationChannelService({
683
+ driver: "firebase",
684
+ dryRun: true,
685
+ }),
686
+ );
687
+
688
+ const titleRef: TemplateRef = {
689
+ templateId: "push/order",
690
+ data: { orderId: "ORD123" },
691
+ };
692
+
693
+ const message: PushNotificationMessage = {
694
+ channel: "push",
695
+ recipient: "device_token_123",
696
+ title: titleRef,
697
+ body: "Your order has been confirmed",
698
+ };
699
+
700
+ const messageId = await service.send(message);
701
+ expect(messageId).toBeDefined();
702
+ });
703
+
704
+ test("should resolve push body TemplateRef to text", async () => {
705
+ createTemplate("push/delivery", "## Push\nEst. delivery: {{ date }}");
706
+
707
+ const engine = new TemplateEngine({
708
+ basePath: TEST_DIR,
709
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
710
+ });
711
+
712
+ const service = new NotificationService({
713
+ enableMetrics: true,
714
+ templateEngine: engine,
715
+ });
716
+
717
+ service.registerChannel(
718
+ new PushNotificationChannelService({
719
+ driver: "firebase",
720
+ dryRun: true,
721
+ }),
722
+ );
723
+
724
+ const bodyRef: TemplateRef = {
725
+ templateId: "push/delivery",
726
+ data: { date: "Feb 28, 2026" },
727
+ };
728
+
729
+ const message: PushNotificationMessage = {
730
+ channel: "push",
731
+ recipient: "device_token_456",
732
+ title: "Delivery Update",
733
+ body: bodyRef,
734
+ };
735
+
736
+ const messageId = await service.send(message);
737
+ expect(messageId).toBeDefined();
738
+ });
739
+
740
+ test("should use variant override when provided", async () => {
741
+ createTemplate("emails/notify", "## SMS\nSMS variant: {{ msg }}\n\n---\n\n## Email\nEmail variant: {{ msg }}");
742
+
743
+ const engine = new TemplateEngine({
744
+ basePath: TEST_DIR,
745
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
746
+ });
747
+
748
+ const service = new NotificationService({
749
+ enableMetrics: true,
750
+ templateEngine: engine,
751
+ });
752
+
753
+ service.registerChannel(
754
+ new EmailChannelService({
755
+ driver: "smtp",
756
+ from: "noreply@example.com",
757
+ dryRun: true,
758
+ }),
759
+ );
760
+
761
+ // Force SMS variant even though channel is email
762
+ const htmlRef: TemplateRef = {
763
+ templateId: "emails/notify",
764
+ data: { msg: "Hello" },
765
+ variant: "sms", // Override!
766
+ };
767
+
768
+ const message: EmailMessage = {
769
+ channel: "email",
770
+ recipient: "test@example.com",
771
+ subject: "Test",
772
+ html: htmlRef,
773
+ };
774
+
775
+ const messageId = await service.send(message);
776
+ expect(messageId).toBeDefined();
777
+ });
778
+
779
+ test("should auto-detect variant from channel", async () => {
780
+ createTemplate("shared/notify", "## SMS\nSMS: {{ msg }}\n\n---\n\n## Email\nEmail: {{ msg }}");
781
+
782
+ const engine = new TemplateEngine({
783
+ basePath: TEST_DIR,
784
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
785
+ });
786
+
787
+ const service = new NotificationService({
788
+ enableMetrics: true,
789
+ templateEngine: engine,
790
+ });
791
+
792
+ service.registerChannel(
793
+ new SMSChannelService({ driver: "twilio", dryRun: true }),
794
+ );
795
+
796
+ const smsRef: TemplateRef = {
797
+ templateId: "shared/notify",
798
+ data: { msg: "Test message" },
799
+ // No variant specified - should auto-detect "sms"
800
+ };
801
+
802
+ const message: SMSMessage = {
803
+ channel: "sms",
804
+ recipient: "+1234567890",
805
+ message: smsRef,
806
+ };
807
+
808
+ const messageId = await service.send(message);
809
+ expect(messageId).toBeDefined();
810
+ });
811
+
812
+ test("should throw when template not found", async () => {
813
+ const engine = new TemplateEngine({
814
+ basePath: TEST_DIR,
815
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
816
+ });
817
+
818
+ const service = new NotificationService({
819
+ enableMetrics: true,
820
+ templateEngine: engine,
821
+ });
822
+
823
+ service.registerChannel(
824
+ new EmailChannelService({
825
+ driver: "smtp",
826
+ from: "noreply@example.com",
827
+ dryRun: true,
828
+ }),
829
+ );
830
+
831
+ const missingRef: TemplateRef = {
832
+ templateId: "emails/nonexistent",
833
+ data: { name: "Alice" },
834
+ };
835
+
836
+ const message: EmailMessage = {
837
+ channel: "email",
838
+ recipient: "test@example.com",
839
+ subject: "Test",
840
+ html: missingRef,
841
+ };
842
+
843
+ await expect(service.send(message)).rejects.toThrow(
844
+ "Template not found"
845
+ );
846
+ });
847
+
848
+ test("should throw when TemplateRef used without engine", async () => {
849
+ const service = new NotificationService({
850
+ enableMetrics: true,
851
+ // No templateEngine configured!
852
+ });
853
+
854
+ service.registerChannel(
855
+ new EmailChannelService({
856
+ driver: "smtp",
857
+ from: "noreply@example.com",
858
+ dryRun: true,
859
+ }),
860
+ );
861
+
862
+ const htmlRef: TemplateRef = {
863
+ templateId: "emails/welcome",
864
+ data: { name: "Alice" },
865
+ };
866
+
867
+ const message: EmailMessage = {
868
+ channel: "email",
869
+ recipient: "test@example.com",
870
+ subject: "Welcome",
871
+ html: htmlRef,
872
+ };
873
+
874
+ await expect(service.send(message)).rejects.toThrow(
875
+ "TemplateRef found in field"
876
+ );
877
+ });
878
+
879
+ test("should pass through plain string fields unchanged", async () => {
880
+ const engine = new TemplateEngine({
881
+ basePath: TEST_DIR,
882
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
883
+ });
884
+
885
+ const service = new NotificationService({
886
+ enableMetrics: true,
887
+ templateEngine: engine,
888
+ });
889
+
890
+ service.registerChannel(
891
+ new EmailChannelService({
892
+ driver: "smtp",
893
+ from: "noreply@example.com",
894
+ dryRun: true,
895
+ }),
896
+ );
897
+
898
+ const message: EmailMessage = {
899
+ channel: "email",
900
+ recipient: "test@example.com",
901
+ subject: "Hello World",
902
+ html: "<p>Plain HTML string</p>",
903
+ text: "Plain text string",
904
+ };
905
+
906
+ const messageId = await service.send(message);
907
+ expect(messageId).toBeDefined();
908
+ });
909
+
910
+ test("should support mixed TemplateRef and plain string fields", async () => {
911
+ createTemplate("emails/mixed", "## Email\nBody from template: {{ content }}");
912
+
913
+ const engine = new TemplateEngine({
914
+ basePath: TEST_DIR,
915
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
916
+ });
917
+
918
+ const service = new NotificationService({
919
+ enableMetrics: true,
920
+ templateEngine: engine,
921
+ });
922
+
923
+ service.registerChannel(
924
+ new EmailChannelService({
925
+ driver: "smtp",
926
+ from: "noreply@example.com",
927
+ dryRun: true,
928
+ }),
929
+ );
930
+
931
+ const htmlRef: TemplateRef = {
932
+ templateId: "emails/mixed",
933
+ data: { content: "Important update" },
934
+ };
935
+
936
+ const message: EmailMessage = {
937
+ channel: "email",
938
+ recipient: "test@example.com",
939
+ subject: "Mixed message",
940
+ html: htmlRef,
941
+ text: "Plain text fallback", // Plain string, not TemplateRef
942
+ };
943
+
944
+ const messageId = await service.send(message);
945
+ expect(messageId).toBeDefined();
946
+ });
947
+
948
+ test("should update metrics after template resolution", async () => {
949
+ createTemplate("emails/stats", "## Email\nMetrics test: {{ test }}");
950
+
951
+ const engine = new TemplateEngine({
952
+ basePath: TEST_DIR,
953
+ cache: { enabled: true, ttl: 3600, maxSize: 100 },
954
+ });
955
+
956
+ const service = new NotificationService({
957
+ enableMetrics: true,
958
+ templateEngine: engine,
959
+ });
960
+
961
+ service.registerChannel(
962
+ new EmailChannelService({
963
+ driver: "smtp",
964
+ from: "noreply@example.com",
965
+ dryRun: true,
966
+ }),
967
+ );
968
+
969
+ const ref: TemplateRef = {
970
+ templateId: "emails/stats",
971
+ data: { test: "value" },
972
+ };
973
+
974
+ const message: EmailMessage = {
975
+ channel: "email",
976
+ recipient: "test@example.com",
977
+ subject: "Metrics test",
978
+ html: ref,
979
+ };
980
+
981
+ await service.send(message);
982
+
983
+ const metrics = service.getChannelMetrics("email");
984
+ expect(metrics).toBeDefined();
985
+ expect(metrics?.sent).toBe(1);
986
+ expect(metrics?.successRate).toBe(1);
987
+ });
988
+ });