@flusys/nestjs-email 6.0.2 → 6.1.1

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 (37) hide show
  1. package/cjs/controllers/email-config.controller.spec.js +85 -0
  2. package/cjs/controllers/email-send.controller.spec.js +139 -0
  3. package/cjs/controllers/email-template.controller.spec.js +89 -0
  4. package/cjs/docs/email-swagger.config.spec.js +62 -0
  5. package/cjs/entities/index.spec.js +18 -0
  6. package/cjs/modules/email.module.spec.js +142 -0
  7. package/cjs/providers/email-factory.service.spec.js +242 -0
  8. package/cjs/providers/email-provider.registry.spec.js +58 -0
  9. package/cjs/providers/mailgun-provider.spec.js +244 -0
  10. package/cjs/providers/sendgrid-provider.spec.js +346 -0
  11. package/cjs/providers/smtp-provider.js +12 -5
  12. package/cjs/providers/smtp-provider.spec.js +327 -0
  13. package/cjs/services/email-config.service.spec.js +69 -0
  14. package/cjs/services/email-datasource.provider.spec.js +135 -0
  15. package/cjs/services/email-provider-config.service.spec.js +247 -0
  16. package/cjs/services/email-send.service.spec.js +546 -0
  17. package/cjs/services/email-template.service.spec.js +257 -0
  18. package/cjs/utils/email-templates.util.spec.js +61 -0
  19. package/fesm/controllers/email-config.controller.spec.js +81 -0
  20. package/fesm/controllers/email-send.controller.spec.js +135 -0
  21. package/fesm/controllers/email-template.controller.spec.js +85 -0
  22. package/fesm/docs/email-swagger.config.spec.js +58 -0
  23. package/fesm/entities/index.spec.js +14 -0
  24. package/fesm/modules/email.module.spec.js +138 -0
  25. package/fesm/providers/email-factory.service.spec.js +238 -0
  26. package/fesm/providers/email-provider.registry.spec.js +54 -0
  27. package/fesm/providers/mailgun-provider.spec.js +240 -0
  28. package/fesm/providers/sendgrid-provider.spec.js +342 -0
  29. package/fesm/providers/smtp-provider.js +12 -5
  30. package/fesm/providers/smtp-provider.spec.js +282 -0
  31. package/fesm/services/email-config.service.spec.js +65 -0
  32. package/fesm/services/email-datasource.provider.spec.js +131 -0
  33. package/fesm/services/email-provider-config.service.spec.js +243 -0
  34. package/fesm/services/email-send.service.spec.js +542 -0
  35. package/fesm/services/email-template.service.spec.js +253 -0
  36. package/fesm/utils/email-templates.util.spec.js +57 -0
  37. package/package.json +3 -3
@@ -0,0 +1,546 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _testing = require("@nestjs/testing");
6
+ const _common = require("@nestjs/common");
7
+ const _loggedusermock = require("@test-utils/mocks/logged-user.mock");
8
+ const _providers = require("../providers");
9
+ const _emailconfigservice = require("./email-config.service");
10
+ const _emailproviderconfigservice = require("./email-provider-config.service");
11
+ const _emailsendservice = require("./email-send.service");
12
+ const _emailtemplateservice = require("./email-template.service");
13
+ function buildConfig(overrides = {}) {
14
+ return {
15
+ id: 'config-uuid-1',
16
+ name: 'Primary SMTP',
17
+ provider: 'smtp',
18
+ config: {},
19
+ fromEmail: 'noreply@example.com',
20
+ fromName: 'FLUSYS',
21
+ isActive: true,
22
+ isDefault: true,
23
+ createdAt: new Date(),
24
+ updatedAt: new Date(),
25
+ deletedAt: null,
26
+ createdById: null,
27
+ updatedById: null,
28
+ deletedById: null,
29
+ ...overrides
30
+ };
31
+ }
32
+ function buildTemplate(overrides = {}) {
33
+ return {
34
+ id: 'template-uuid-1',
35
+ name: 'Welcome Email',
36
+ slug: 'welcome-email',
37
+ description: null,
38
+ subject: 'Welcome {{firstName}}!',
39
+ schema: {},
40
+ htmlContent: '<h1>Welcome {{firstName}}!</h1>',
41
+ textContent: 'Welcome {{firstName}}!',
42
+ schemaVersion: 1,
43
+ isActive: true,
44
+ isHtml: true,
45
+ createdAt: new Date(),
46
+ updatedAt: new Date(),
47
+ deletedAt: null,
48
+ createdById: null,
49
+ updatedById: null,
50
+ deletedById: null,
51
+ ...overrides
52
+ };
53
+ }
54
+ describe('EmailSendService', ()=>{
55
+ let service;
56
+ let mockEmailFactory;
57
+ let mockEmailConfigService;
58
+ let mockEmailProviderConfigService;
59
+ let mockEmailTemplateService;
60
+ let mockProvider;
61
+ beforeEach(async ()=>{
62
+ mockProvider = {
63
+ sendEmail: jest.fn().mockResolvedValue({
64
+ success: true,
65
+ messageId: 'msg-1'
66
+ }),
67
+ sendBulkEmails: jest.fn(),
68
+ healthCheck: jest.fn()
69
+ };
70
+ mockEmailFactory = {
71
+ createProvider: jest.fn().mockResolvedValue(mockProvider)
72
+ };
73
+ mockEmailConfigService = {
74
+ isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
75
+ getDefaultFromName: jest.fn().mockReturnValue('FLUSYS')
76
+ };
77
+ mockEmailProviderConfigService = {
78
+ findByIdDirect: jest.fn(),
79
+ getDefaultConfig: jest.fn()
80
+ };
81
+ mockEmailTemplateService = {
82
+ findByIdDirect: jest.fn(),
83
+ findBySlug: jest.fn()
84
+ };
85
+ const module = await _testing.Test.createTestingModule({
86
+ providers: [
87
+ _emailsendservice.EmailSendService,
88
+ {
89
+ provide: _providers.EmailFactoryService,
90
+ useValue: mockEmailFactory
91
+ },
92
+ {
93
+ provide: _emailconfigservice.EmailConfigService,
94
+ useValue: mockEmailConfigService
95
+ },
96
+ {
97
+ provide: _emailproviderconfigservice.EmailProviderConfigService,
98
+ useValue: mockEmailProviderConfigService
99
+ },
100
+ {
101
+ provide: _emailtemplateservice.EmailTemplateService,
102
+ useValue: mockEmailTemplateService
103
+ }
104
+ ]
105
+ }).compile();
106
+ service = await module.resolve(_emailsendservice.EmailSendService);
107
+ });
108
+ describe('sendEmail', ()=>{
109
+ it('resolves the config by id and forwards the payload to the provider', async ()=>{
110
+ const config = buildConfig();
111
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
112
+ const dto = {
113
+ to: 'user@example.com',
114
+ subject: 'Hello',
115
+ html: '<p>Hi</p>',
116
+ emailConfigId: 'config-uuid-1'
117
+ };
118
+ const result = await service.sendEmail(dto, (0, _loggedusermock.buildMockUser)());
119
+ expect(result).toEqual({
120
+ success: true,
121
+ messageId: 'msg-1'
122
+ });
123
+ expect(mockEmailFactory.createProvider).toHaveBeenCalledWith({
124
+ provider: config.provider,
125
+ config: config.config
126
+ });
127
+ expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
128
+ to: 'user@example.com',
129
+ subject: 'Hello',
130
+ html: '<p>Hi</p>',
131
+ from: 'noreply@example.com',
132
+ fromName: 'FLUSYS'
133
+ }));
134
+ });
135
+ it('falls back to the default config when no emailConfigId is supplied', async ()=>{
136
+ const config = buildConfig();
137
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(config);
138
+ await service.sendEmail({
139
+ to: 'a@example.com',
140
+ subject: 'Hi',
141
+ html: '<p/>'
142
+ }, (0, _loggedusermock.buildMockUser)());
143
+ expect(mockEmailProviderConfigService.getDefaultConfig).toHaveBeenCalledWith((0, _loggedusermock.buildMockUser)());
144
+ expect(mockEmailProviderConfigService.findByIdDirect).not.toHaveBeenCalled();
145
+ });
146
+ it('prefers dto.from/fromName over the config defaults', async ()=>{
147
+ const config = buildConfig({
148
+ fromEmail: 'config@example.com',
149
+ fromName: 'Config Name'
150
+ });
151
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(config);
152
+ await service.sendEmail({
153
+ to: 'a@example.com',
154
+ subject: 'Hi',
155
+ html: '<p/>',
156
+ from: 'override@example.com',
157
+ fromName: 'Override Name'
158
+ }, (0, _loggedusermock.buildMockUser)());
159
+ expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
160
+ from: 'override@example.com',
161
+ fromName: 'Override Name'
162
+ }));
163
+ });
164
+ it('base64-decodes attachment content before forwarding to the provider', async ()=>{
165
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
166
+ await service.sendEmail({
167
+ to: 'a@example.com',
168
+ subject: 'Hi',
169
+ html: '<p/>',
170
+ attachments: [
171
+ {
172
+ filename: 'a.txt',
173
+ content: Buffer.from('hello').toString('base64')
174
+ }
175
+ ]
176
+ }, (0, _loggedusermock.buildMockUser)());
177
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
178
+ expect(payload.attachments?.[0].content.toString('utf8')).toBe('hello');
179
+ });
180
+ it('throws NotFoundException with CONFIG_NOT_FOUND when the referenced config does not exist', async ()=>{
181
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(null);
182
+ await expect(service.sendEmail({
183
+ to: 'a@example.com',
184
+ subject: 'Hi',
185
+ html: '<p/>',
186
+ emailConfigId: 'missing'
187
+ }, (0, _loggedusermock.buildMockUser)())).rejects.toMatchObject({
188
+ response: expect.objectContaining({
189
+ messageKey: 'email.send.config.not.found'
190
+ })
191
+ });
192
+ });
193
+ it('throws BadRequestException with CONFIG_INACTIVE when the config is inactive', async ()=>{
194
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(buildConfig({
195
+ isActive: false
196
+ }));
197
+ await expect(service.sendEmail({
198
+ to: 'a@example.com',
199
+ subject: 'Hi',
200
+ html: '<p/>',
201
+ emailConfigId: 'c-1'
202
+ }, (0, _loggedusermock.buildMockUser)())).rejects.toMatchObject({
203
+ response: expect.objectContaining({
204
+ messageKey: 'email.send.config.inactive'
205
+ })
206
+ });
207
+ });
208
+ it('rejects a config belonging to another company when the company feature is enabled', async ()=>{
209
+ mockEmailConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
210
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(buildConfig({
211
+ companyId: 'other-company'
212
+ }));
213
+ await expect(service.sendEmail({
214
+ to: 'a@example.com',
215
+ subject: 'Hi',
216
+ html: '<p/>',
217
+ emailConfigId: 'c-1'
218
+ }, (0, _loggedusermock.buildMockUser)({
219
+ companyId: 'my-company'
220
+ }))).rejects.toThrow(_common.BadRequestException);
221
+ });
222
+ it('throws NotFoundException with CONFIG_DEFAULT_NOT_FOUND when no default config exists', async ()=>{
223
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(null);
224
+ await expect(service.sendEmail({
225
+ to: 'a@example.com',
226
+ subject: 'Hi',
227
+ html: '<p/>'
228
+ }, (0, _loggedusermock.buildMockUser)())).rejects.toMatchObject({
229
+ response: expect.objectContaining({
230
+ messageKey: 'email.send.config.default.not.found'
231
+ })
232
+ });
233
+ });
234
+ });
235
+ describe('sendTemplateEmail — template resolution', ()=>{
236
+ beforeEach(()=>{
237
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
238
+ });
239
+ it('resolves the template by templateId when provided', async ()=>{
240
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate());
241
+ await service.sendTemplateEmail({
242
+ to: 'a@example.com',
243
+ templateId: 'template-uuid-1'
244
+ }, (0, _loggedusermock.buildMockUser)());
245
+ expect(mockEmailTemplateService.findByIdDirect).toHaveBeenCalledWith('template-uuid-1');
246
+ expect(mockEmailTemplateService.findBySlug).not.toHaveBeenCalled();
247
+ });
248
+ it('resolves the template by templateSlug when templateId is absent', async ()=>{
249
+ mockEmailTemplateService.findBySlug.mockResolvedValue(buildTemplate());
250
+ const user = (0, _loggedusermock.buildMockUser)();
251
+ await service.sendTemplateEmail({
252
+ to: 'a@example.com',
253
+ templateSlug: 'welcome-email'
254
+ }, user);
255
+ expect(mockEmailTemplateService.findBySlug).toHaveBeenCalledWith('welcome-email', user);
256
+ });
257
+ it('throws BadRequestException with TEMPLATE_ID_OR_SLUG_REQUIRED when neither is provided', async ()=>{
258
+ await expect(service.sendTemplateEmail({
259
+ to: 'a@example.com'
260
+ }, (0, _loggedusermock.buildMockUser)())).rejects.toMatchObject({
261
+ response: expect.objectContaining({
262
+ messageKey: 'email.send.template.id.or.slug.required'
263
+ })
264
+ });
265
+ });
266
+ it('throws NotFoundException with TEMPLATE_NOT_FOUND when the template does not exist', async ()=>{
267
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(null);
268
+ await expect(service.sendTemplateEmail({
269
+ to: 'a@example.com',
270
+ templateId: 'missing'
271
+ }, (0, _loggedusermock.buildMockUser)())).rejects.toMatchObject({
272
+ response: expect.objectContaining({
273
+ messageKey: 'email.send.template.not.found'
274
+ })
275
+ });
276
+ });
277
+ it('throws BadRequestException with TEMPLATE_INACTIVE when the template is inactive', async ()=>{
278
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
279
+ isActive: false
280
+ }));
281
+ await expect(service.sendTemplateEmail({
282
+ to: 'a@example.com',
283
+ templateId: 'template-uuid-1'
284
+ }, (0, _loggedusermock.buildMockUser)())).rejects.toMatchObject({
285
+ response: expect.objectContaining({
286
+ messageKey: 'email.send.template.inactive'
287
+ })
288
+ });
289
+ });
290
+ it('rejects a template belonging to another company when the company feature is enabled', async ()=>{
291
+ mockEmailConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
292
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
293
+ companyId: 'other-company'
294
+ }));
295
+ await expect(service.sendTemplateEmail({
296
+ to: 'a@example.com',
297
+ templateId: 'template-uuid-1'
298
+ }, (0, _loggedusermock.buildMockUser)({
299
+ companyId: 'my-company'
300
+ }))).rejects.toThrow(_common.BadRequestException);
301
+ });
302
+ });
303
+ describe('sendTemplateEmail — variable interpolation & XSS safety', ()=>{
304
+ beforeEach(()=>{
305
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
306
+ });
307
+ it('HTML-escapes untrusted variables in the html body', async ()=>{
308
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
309
+ subject: 'Hi {{name}}',
310
+ htmlContent: '<p>Hi {{name}}</p>',
311
+ isHtml: true
312
+ }));
313
+ await service.sendTemplateEmail({
314
+ to: 'a@example.com',
315
+ templateId: 'template-uuid-1',
316
+ variables: {
317
+ name: '<script>alert(1)</script>'
318
+ }
319
+ }, (0, _loggedusermock.buildMockUser)());
320
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
321
+ expect(payload.html).toBe('<p>Hi &lt;script&gt;alert(1)&lt;&#x2F;script&gt;</p>');
322
+ expect(payload.html).not.toContain('<script>');
323
+ });
324
+ it('does NOT escape variables interpolated into the subject line', async ()=>{
325
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
326
+ subject: 'Hi {{name}}',
327
+ isHtml: true
328
+ }));
329
+ await service.sendTemplateEmail({
330
+ to: 'a@example.com',
331
+ templateId: 'template-uuid-1',
332
+ variables: {
333
+ name: '<b>Bob</b>'
334
+ }
335
+ }, (0, _loggedusermock.buildMockUser)());
336
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
337
+ expect(payload.subject).toBe('Hi <b>Bob</b>');
338
+ });
339
+ it('does not escape variables interpolated into plain-text content', async ()=>{
340
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
341
+ isHtml: false,
342
+ textContent: 'Hi {{name}}'
343
+ }));
344
+ await service.sendTemplateEmail({
345
+ to: 'a@example.com',
346
+ templateId: 'template-uuid-1',
347
+ variables: {
348
+ name: '<script>x</script>'
349
+ }
350
+ }, (0, _loggedusermock.buildMockUser)());
351
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
352
+ expect(payload.text).toBe('Hi <script>x</script>');
353
+ expect(payload.html).toBeUndefined();
354
+ });
355
+ it('falls back to htmlContent as text when isHtml is false and textContent is empty', async ()=>{
356
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
357
+ isHtml: false,
358
+ textContent: null,
359
+ htmlContent: 'Plain {{name}}'
360
+ }));
361
+ await service.sendTemplateEmail({
362
+ to: 'a@example.com',
363
+ templateId: 'template-uuid-1',
364
+ variables: {
365
+ name: 'Bob'
366
+ }
367
+ }, (0, _loggedusermock.buildMockUser)());
368
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
369
+ expect(payload.text).toBe('Plain Bob');
370
+ });
371
+ it('leaves unresolved {{variable}} tokens untouched when the variable is missing', async ()=>{
372
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
373
+ htmlContent: '<p>Hi {{missing}}</p>',
374
+ isHtml: true
375
+ }));
376
+ await service.sendTemplateEmail({
377
+ to: 'a@example.com',
378
+ templateId: 'template-uuid-1',
379
+ variables: {
380
+ unrelated: 'value'
381
+ }
382
+ }, (0, _loggedusermock.buildMockUser)());
383
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
384
+ expect(payload.html).toBe('<p>Hi {{missing}}</p>');
385
+ });
386
+ it('substitutes a repeated variable token every time it appears', async ()=>{
387
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
388
+ htmlContent: '<p>{{name}} and {{name}} again</p>',
389
+ isHtml: true
390
+ }));
391
+ await service.sendTemplateEmail({
392
+ to: 'a@example.com',
393
+ templateId: 'template-uuid-1',
394
+ variables: {
395
+ name: 'Bob'
396
+ }
397
+ }, (0, _loggedusermock.buildMockUser)());
398
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
399
+ expect(payload.html).toBe('<p>Bob and Bob again</p>');
400
+ });
401
+ it('interpolates multiple distinct variables in the same content string', async ()=>{
402
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
403
+ htmlContent: '<p>{{greeting}}, {{name}}!</p>',
404
+ isHtml: true
405
+ }));
406
+ await service.sendTemplateEmail({
407
+ to: 'a@example.com',
408
+ templateId: 'template-uuid-1',
409
+ variables: {
410
+ greeting: 'Hello',
411
+ name: 'Bob'
412
+ }
413
+ }, (0, _loggedusermock.buildMockUser)());
414
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
415
+ expect(payload.html).toBe('<p>Hello, Bob!</p>');
416
+ });
417
+ it('stringifies and escapes non-string variables (numbers, objects)', async ()=>{
418
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
419
+ htmlContent: '<p>Count: {{count}}, Meta: {{meta}}</p>',
420
+ isHtml: true
421
+ }));
422
+ await service.sendTemplateEmail({
423
+ to: 'a@example.com',
424
+ templateId: 'template-uuid-1',
425
+ variables: {
426
+ count: 5,
427
+ meta: {
428
+ a: '<b>'
429
+ }
430
+ }
431
+ }, (0, _loggedusermock.buildMockUser)());
432
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
433
+ expect(payload.html).toContain('Count: 5');
434
+ expect(payload.html).toContain('&quot;a&quot;');
435
+ expect(payload.html).not.toContain('<b>');
436
+ });
437
+ it('treats null/undefined variables as empty strings', async ()=>{
438
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
439
+ htmlContent: '<p>[{{missingVar}}]</p>',
440
+ isHtml: true
441
+ }));
442
+ await service.sendTemplateEmail({
443
+ to: 'a@example.com',
444
+ templateId: 'template-uuid-1',
445
+ variables: {
446
+ missingVar: null
447
+ }
448
+ }, (0, _loggedusermock.buildMockUser)());
449
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
450
+ expect(payload.html).toBe('<p>[]</p>');
451
+ });
452
+ it('returns the content unchanged when no variables are supplied', async ()=>{
453
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
454
+ htmlContent: '<p>Hi {{name}}</p>',
455
+ isHtml: true
456
+ }));
457
+ await service.sendTemplateEmail({
458
+ to: 'a@example.com',
459
+ templateId: 'template-uuid-1'
460
+ }, (0, _loggedusermock.buildMockUser)());
461
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
462
+ expect(payload.html).toBe('<p>Hi {{name}}</p>');
463
+ });
464
+ });
465
+ describe('sendTemplateEmail — provider dispatch', ()=>{
466
+ it('resolves the provider from the resolved config and sends the interpolated content', async ()=>{
467
+ const config = buildConfig({
468
+ id: 'config-uuid-9',
469
+ provider: 'sendgrid',
470
+ config: {
471
+ apiKey: 'x'
472
+ }
473
+ });
474
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
475
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate());
476
+ const result = await service.sendTemplateEmail({
477
+ to: 'a@example.com',
478
+ templateId: 'template-uuid-1',
479
+ emailConfigId: 'config-uuid-9',
480
+ variables: {
481
+ firstName: 'Bob'
482
+ }
483
+ }, (0, _loggedusermock.buildMockUser)());
484
+ expect(result).toEqual({
485
+ success: true,
486
+ messageId: 'msg-1'
487
+ });
488
+ expect(mockEmailFactory.createProvider).toHaveBeenCalledWith({
489
+ provider: 'sendgrid',
490
+ config: {
491
+ apiKey: 'x'
492
+ }
493
+ });
494
+ expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
495
+ subject: 'Welcome Bob!',
496
+ html: '<h1>Welcome Bob!</h1>'
497
+ }));
498
+ });
499
+ it('propagates a provider-level send failure result without throwing', async ()=>{
500
+ mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
501
+ mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate());
502
+ mockProvider.sendEmail.mockResolvedValue({
503
+ success: false,
504
+ error: 'Connection refused'
505
+ });
506
+ const result = await service.sendTemplateEmail({
507
+ to: 'a@example.com',
508
+ templateId: 'template-uuid-1'
509
+ }, (0, _loggedusermock.buildMockUser)());
510
+ expect(result).toEqual({
511
+ success: false,
512
+ error: 'Connection refused'
513
+ });
514
+ });
515
+ });
516
+ describe('sendTestEmail', ()=>{
517
+ it('sends a test email using the resolved configuration and returns the provider result', async ()=>{
518
+ const config = buildConfig({
519
+ name: 'Prod SMTP',
520
+ provider: 'smtp'
521
+ });
522
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
523
+ const result = await service.sendTestEmail('config-uuid-1', 'recipient@example.com', (0, _loggedusermock.buildMockUser)());
524
+ expect(result).toEqual({
525
+ success: true,
526
+ messageId: 'msg-1'
527
+ });
528
+ expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
529
+ to: 'recipient@example.com',
530
+ subject: 'Test Email from FLUSYS',
531
+ from: 'noreply@example.com'
532
+ }));
533
+ });
534
+ it('HTML-escapes the config name/provider before embedding them in the test email body', async ()=>{
535
+ const config = buildConfig({
536
+ name: '<script>alert(1)</script>',
537
+ provider: 'smtp'
538
+ });
539
+ mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
540
+ await service.sendTestEmail('config-uuid-1', 'recipient@example.com', (0, _loggedusermock.buildMockUser)());
541
+ const [payload] = mockProvider.sendEmail.mock.calls[0];
542
+ expect(payload.html).toContain('&lt;script&gt;alert(1)&lt;&#x2F;script&gt;');
543
+ expect(payload.html).not.toContain('<script>alert(1)</script>');
544
+ });
545
+ });
546
+ });