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