@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.
- package/cjs/controllers/email-config.controller.spec.js +85 -0
- package/cjs/controllers/email-send.controller.spec.js +139 -0
- package/cjs/controllers/email-template.controller.spec.js +89 -0
- package/cjs/docs/email-swagger.config.spec.js +62 -0
- package/cjs/entities/index.spec.js +18 -0
- package/cjs/modules/email.module.spec.js +142 -0
- package/cjs/providers/email-factory.service.spec.js +242 -0
- package/cjs/providers/email-provider.registry.spec.js +58 -0
- package/cjs/providers/mailgun-provider.spec.js +244 -0
- package/cjs/providers/sendgrid-provider.spec.js +346 -0
- package/cjs/providers/smtp-provider.js +12 -5
- package/cjs/providers/smtp-provider.spec.js +327 -0
- package/cjs/services/email-config.service.spec.js +69 -0
- package/cjs/services/email-datasource.provider.spec.js +135 -0
- package/cjs/services/email-provider-config.service.spec.js +247 -0
- package/cjs/services/email-send.service.spec.js +546 -0
- package/cjs/services/email-template.service.spec.js +257 -0
- package/cjs/utils/email-templates.util.spec.js +61 -0
- package/fesm/controllers/email-config.controller.spec.js +81 -0
- package/fesm/controllers/email-send.controller.spec.js +135 -0
- package/fesm/controllers/email-template.controller.spec.js +85 -0
- package/fesm/docs/email-swagger.config.spec.js +58 -0
- package/fesm/entities/index.spec.js +14 -0
- package/fesm/modules/email.module.spec.js +138 -0
- package/fesm/providers/email-factory.service.spec.js +238 -0
- package/fesm/providers/email-provider.registry.spec.js +54 -0
- package/fesm/providers/mailgun-provider.spec.js +240 -0
- package/fesm/providers/sendgrid-provider.spec.js +342 -0
- package/fesm/providers/smtp-provider.js +12 -5
- package/fesm/providers/smtp-provider.spec.js +282 -0
- package/fesm/services/email-config.service.spec.js +65 -0
- package/fesm/services/email-datasource.provider.spec.js +131 -0
- package/fesm/services/email-provider-config.service.spec.js +243 -0
- package/fesm/services/email-send.service.spec.js +542 -0
- package/fesm/services/email-template.service.spec.js +253 -0
- package/fesm/utils/email-templates.util.spec.js +57 -0
- package/package.json +3 -3
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _testing = require("@nestjs/testing");
|
|
6
|
+
const _modules = require("@flusys/nestjs-shared/modules");
|
|
7
|
+
const _repositorymock = require("@test-utils/mocks/repository.mock");
|
|
8
|
+
const _loggedusermock = require("@test-utils/mocks/logged-user.mock");
|
|
9
|
+
const _entities = require("../entities");
|
|
10
|
+
const _emailconfigservice = require("./email-config.service");
|
|
11
|
+
const _emaildatasourceprovider = require("./email-datasource.provider");
|
|
12
|
+
const _emailtemplateservice = require("./email-template.service");
|
|
13
|
+
function buildTemplate(overrides = {}) {
|
|
14
|
+
return {
|
|
15
|
+
id: 'template-uuid-1',
|
|
16
|
+
name: 'Welcome Email',
|
|
17
|
+
slug: 'welcome-email',
|
|
18
|
+
description: null,
|
|
19
|
+
subject: 'Welcome {{firstName}}!',
|
|
20
|
+
schema: {
|
|
21
|
+
fields: []
|
|
22
|
+
},
|
|
23
|
+
htmlContent: '<h1>Welcome {{firstName}}!</h1>',
|
|
24
|
+
textContent: 'Welcome {{firstName}}!',
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
isActive: true,
|
|
27
|
+
isHtml: true,
|
|
28
|
+
createdAt: new Date(),
|
|
29
|
+
updatedAt: new Date(),
|
|
30
|
+
deletedAt: null,
|
|
31
|
+
createdById: null,
|
|
32
|
+
updatedById: null,
|
|
33
|
+
deletedById: null,
|
|
34
|
+
...overrides
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function buildQuery() {
|
|
38
|
+
return {
|
|
39
|
+
select: jest.fn().mockReturnThis(),
|
|
40
|
+
andWhere: jest.fn().mockReturnThis(),
|
|
41
|
+
orderBy: jest.fn().mockReturnThis()
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
describe('EmailTemplateService', ()=>{
|
|
45
|
+
let service;
|
|
46
|
+
let mockRepo;
|
|
47
|
+
let mockEmailConfig;
|
|
48
|
+
beforeEach(async ()=>{
|
|
49
|
+
mockRepo = (0, _repositorymock.createMockRepository)();
|
|
50
|
+
const mockDataSourceProvider = (0, _repositorymock.createMockDataSourceProvider)(mockRepo);
|
|
51
|
+
mockEmailConfig = {
|
|
52
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
|
|
53
|
+
};
|
|
54
|
+
const module = await _testing.Test.createTestingModule({
|
|
55
|
+
providers: [
|
|
56
|
+
_emailtemplateservice.EmailTemplateService,
|
|
57
|
+
{
|
|
58
|
+
provide: 'CACHE_INSTANCE',
|
|
59
|
+
useValue: {}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
provide: _modules.UtilsService,
|
|
63
|
+
useValue: {}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
provide: _emailconfigservice.EmailConfigService,
|
|
67
|
+
useValue: mockEmailConfig
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
provide: _emaildatasourceprovider.EmailDataSourceProvider,
|
|
71
|
+
useValue: mockDataSourceProvider
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}).compile();
|
|
75
|
+
service = await module.resolve(_emailtemplateservice.EmailTemplateService);
|
|
76
|
+
// convertSingleDtoToEntity accesses `this.repository` directly; normally it is
|
|
77
|
+
// lazily initialized by insert()/update() via ensureDataSourceRepository() first.
|
|
78
|
+
await service.ensureDataSourceRepository();
|
|
79
|
+
});
|
|
80
|
+
describe('resolveEntity', ()=>{
|
|
81
|
+
it('resolves the plain EmailTemplate entity when the company feature is disabled', ()=>{
|
|
82
|
+
expect(service.resolveEntity()).toBe(_entities.EmailTemplate);
|
|
83
|
+
});
|
|
84
|
+
it('resolves EmailTemplateWithCompany when the company feature is enabled', ()=>{
|
|
85
|
+
mockEmailConfig.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
86
|
+
expect(service.resolveEntity()).toBe(_entities.EmailTemplateWithCompany);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('convertSingleDtoToEntity', ()=>{
|
|
90
|
+
it('creates a new entity (no schema-version bump) for an insert without id', async ()=>{
|
|
91
|
+
mockRepo.create.mockReturnValue({});
|
|
92
|
+
const entity = await service.convertSingleDtoToEntity({
|
|
93
|
+
name: 'Welcome',
|
|
94
|
+
slug: 'welcome',
|
|
95
|
+
subject: 'Hi {{name}}',
|
|
96
|
+
schema: {
|
|
97
|
+
fields: []
|
|
98
|
+
},
|
|
99
|
+
htmlContent: '<p>Hi {{name}}</p>'
|
|
100
|
+
}, (0, _loggedusermock.buildMockUser)());
|
|
101
|
+
expect(entity.schemaVersion).toBeUndefined();
|
|
102
|
+
expect(mockRepo.findOne).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
it('increments schemaVersion on update when the schema payload changed', async ()=>{
|
|
105
|
+
// convertSingleDtoToEntity (ApiService base) mutates the fetched row in place via
|
|
106
|
+
// Object.assign, and incrementSchemaVersionIfChanged re-fetches by id afterwards —
|
|
107
|
+
// so the mock must return a fresh clone per call, like a real repository would.
|
|
108
|
+
mockRepo.findOne.mockImplementation(async ()=>buildTemplate({
|
|
109
|
+
schema: {
|
|
110
|
+
fields: []
|
|
111
|
+
},
|
|
112
|
+
schemaVersion: 3
|
|
113
|
+
}));
|
|
114
|
+
const entity = await service.convertSingleDtoToEntity({
|
|
115
|
+
id: 'template-uuid-1',
|
|
116
|
+
schema: {
|
|
117
|
+
fields: [
|
|
118
|
+
{
|
|
119
|
+
name: 'x'
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
}, (0, _loggedusermock.buildMockUser)());
|
|
124
|
+
expect(entity.schemaVersion).toBe(4);
|
|
125
|
+
});
|
|
126
|
+
it('does not increment schemaVersion on update when the schema payload is unchanged', async ()=>{
|
|
127
|
+
mockRepo.findOne.mockImplementation(async ()=>buildTemplate({
|
|
128
|
+
schema: {
|
|
129
|
+
fields: []
|
|
130
|
+
},
|
|
131
|
+
schemaVersion: 3
|
|
132
|
+
}));
|
|
133
|
+
const entity = await service.convertSingleDtoToEntity({
|
|
134
|
+
id: 'template-uuid-1',
|
|
135
|
+
schema: {
|
|
136
|
+
fields: []
|
|
137
|
+
}
|
|
138
|
+
}, (0, _loggedusermock.buildMockUser)());
|
|
139
|
+
expect(entity.schemaVersion).toBe(3);
|
|
140
|
+
});
|
|
141
|
+
it('does not touch schemaVersion when the update payload has no schema field', async ()=>{
|
|
142
|
+
const existing = buildTemplate({
|
|
143
|
+
schema: {
|
|
144
|
+
fields: []
|
|
145
|
+
},
|
|
146
|
+
schemaVersion: 3
|
|
147
|
+
});
|
|
148
|
+
mockRepo.findOne.mockResolvedValue(existing);
|
|
149
|
+
const entity = await service.convertSingleDtoToEntity({
|
|
150
|
+
id: 'template-uuid-1',
|
|
151
|
+
name: 'Renamed'
|
|
152
|
+
}, (0, _loggedusermock.buildMockUser)());
|
|
153
|
+
expect(entity.schemaVersion).toBe(3);
|
|
154
|
+
// ensureDataSourceRepository/findOne should only be hit once — by the base
|
|
155
|
+
// convertSingleDtoToEntity — not a second time by incrementSchemaVersionIfChanged.
|
|
156
|
+
expect(mockRepo.findOne).toHaveBeenCalledTimes(1);
|
|
157
|
+
});
|
|
158
|
+
it('attaches the current user companyId when the company feature is enabled', async ()=>{
|
|
159
|
+
mockEmailConfig.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
160
|
+
mockRepo.create.mockReturnValue({});
|
|
161
|
+
const entity = await service.convertSingleDtoToEntity({
|
|
162
|
+
name: 'Welcome',
|
|
163
|
+
slug: 'welcome',
|
|
164
|
+
subject: 'Hi',
|
|
165
|
+
schema: {},
|
|
166
|
+
htmlContent: '<p/>'
|
|
167
|
+
}, (0, _loggedusermock.buildMockUser)({
|
|
168
|
+
companyId: 'company-uuid-7'
|
|
169
|
+
}));
|
|
170
|
+
expect(entity.companyId).toBe('company-uuid-7');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('getSelectQuery', ()=>{
|
|
174
|
+
it('selects the default whitelisted fields (without companyId) when company feature is disabled', async ()=>{
|
|
175
|
+
const query = buildQuery();
|
|
176
|
+
await service.getSelectQuery(query, (0, _loggedusermock.buildMockUser)());
|
|
177
|
+
const [selected] = query.select.mock.calls[0];
|
|
178
|
+
expect(selected).toEqual(expect.arrayContaining([
|
|
179
|
+
'emailTemplate.id',
|
|
180
|
+
'emailTemplate.name',
|
|
181
|
+
'emailTemplate.slug',
|
|
182
|
+
'emailTemplate.subject',
|
|
183
|
+
'emailTemplate.htmlContent',
|
|
184
|
+
'emailTemplate.textContent',
|
|
185
|
+
'emailTemplate.schemaVersion',
|
|
186
|
+
'emailTemplate.isActive',
|
|
187
|
+
'emailTemplate.isHtml'
|
|
188
|
+
]));
|
|
189
|
+
expect(selected).not.toContain('emailTemplate.companyId');
|
|
190
|
+
});
|
|
191
|
+
it('appends companyId to the default field list when company feature is enabled', async ()=>{
|
|
192
|
+
mockEmailConfig.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
193
|
+
const query = buildQuery();
|
|
194
|
+
await service.getSelectQuery(query, (0, _loggedusermock.buildMockUser)());
|
|
195
|
+
const [selected] = query.select.mock.calls[0];
|
|
196
|
+
expect(selected).toContain('emailTemplate.companyId');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe('getExtraManipulateQuery', ()=>{
|
|
200
|
+
it('applies the company filter and orders by createdAt DESC when company feature is enabled', async ()=>{
|
|
201
|
+
mockEmailConfig.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
202
|
+
const query = buildQuery();
|
|
203
|
+
await service.getExtraManipulateQuery(query, {}, (0, _loggedusermock.buildMockUser)({
|
|
204
|
+
companyId: 'c-1'
|
|
205
|
+
}));
|
|
206
|
+
expect(query.andWhere).toHaveBeenCalledWith('emailTemplate.companyId = :companyId', {
|
|
207
|
+
companyId: 'c-1'
|
|
208
|
+
});
|
|
209
|
+
expect(query.orderBy).toHaveBeenCalledWith('emailTemplate.createdAt', 'DESC');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe('findByIdDirect', ()=>{
|
|
213
|
+
it('returns the matching template', async ()=>{
|
|
214
|
+
const template = buildTemplate();
|
|
215
|
+
mockRepo.findOne.mockResolvedValue(template);
|
|
216
|
+
const result = await service.findByIdDirect('template-uuid-1');
|
|
217
|
+
expect(result).toBe(template);
|
|
218
|
+
expect(mockRepo.findOne).toHaveBeenCalledWith({
|
|
219
|
+
where: {
|
|
220
|
+
id: 'template-uuid-1'
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe('findBySlug', ()=>{
|
|
226
|
+
it('finds an active template by slug', async ()=>{
|
|
227
|
+
const template = buildTemplate();
|
|
228
|
+
mockRepo.findOne.mockResolvedValue(template);
|
|
229
|
+
const result = await service.findBySlug('welcome-email', (0, _loggedusermock.buildMockUser)());
|
|
230
|
+
expect(result).toBe(template);
|
|
231
|
+
expect(mockRepo.findOne).toHaveBeenCalledWith({
|
|
232
|
+
where: {
|
|
233
|
+
slug: 'welcome-email',
|
|
234
|
+
isActive: true
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
it('scopes the slug lookup to the current company when the company feature is enabled', async ()=>{
|
|
239
|
+
mockEmailConfig.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
240
|
+
mockRepo.findOne.mockResolvedValue(null);
|
|
241
|
+
await service.findBySlug('welcome-email', (0, _loggedusermock.buildMockUser)({
|
|
242
|
+
companyId: 'company-uuid-3'
|
|
243
|
+
}));
|
|
244
|
+
expect(mockRepo.findOne).toHaveBeenCalledWith({
|
|
245
|
+
where: {
|
|
246
|
+
slug: 'welcome-email',
|
|
247
|
+
isActive: true,
|
|
248
|
+
companyId: 'company-uuid-3'
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
it('returns null when no template matches the slug', async ()=>{
|
|
253
|
+
mockRepo.findOne.mockResolvedValue(null);
|
|
254
|
+
await expect(service.findBySlug('missing-slug', (0, _loggedusermock.buildMockUser)())).resolves.toBeNull();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _emailtemplatesutil = require("./email-templates.util");
|
|
6
|
+
describe('email-templates.util', ()=>{
|
|
7
|
+
describe('getOtpEmailFormat', ()=>{
|
|
8
|
+
it('renders the OTP inside the generated HTML document', ()=>{
|
|
9
|
+
const html = (0, _emailtemplatesutil.getOtpEmailFormat)(123456, 'Jane');
|
|
10
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
11
|
+
expect(html).toContain('123456');
|
|
12
|
+
expect(html).toContain('Hi Jane,');
|
|
13
|
+
expect(html).toContain('Your OTP Code');
|
|
14
|
+
});
|
|
15
|
+
it('falls back to a generic greeting when userName is not provided', ()=>{
|
|
16
|
+
const html = (0, _emailtemplatesutil.getOtpEmailFormat)(654321);
|
|
17
|
+
expect(html).toContain('Hi Sir/Madam,');
|
|
18
|
+
});
|
|
19
|
+
it('falls back to a generic greeting when userName is null', ()=>{
|
|
20
|
+
const html = (0, _emailtemplatesutil.getOtpEmailFormat)(111111, null);
|
|
21
|
+
expect(html).toContain('Hi Sir/Madam,');
|
|
22
|
+
});
|
|
23
|
+
it('does not escape HTML special characters in userName (XSS caveat)', ()=>{
|
|
24
|
+
const html = (0, _emailtemplatesutil.getOtpEmailFormat)(999999, '<script>alert(1)</script>');
|
|
25
|
+
// Documents CURRENT behavior: userName is interpolated verbatim, unescaped.
|
|
26
|
+
expect(html).toContain('<script>alert(1)</script>');
|
|
27
|
+
expect(html).not.toContain('<script>');
|
|
28
|
+
});
|
|
29
|
+
it('includes styling for the OTP action box', ()=>{
|
|
30
|
+
const html = (0, _emailtemplatesutil.getOtpEmailFormat)(1, 'A');
|
|
31
|
+
expect(html).toContain('letter-spacing: 6px');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('getResetPasswordEmailFormat', ()=>{
|
|
35
|
+
it('renders the reset link as the href of the action button', ()=>{
|
|
36
|
+
const html = (0, _emailtemplatesutil.getResetPasswordEmailFormat)('https://app.example.com/reset?token=abc', 'Bob');
|
|
37
|
+
expect(html).toContain('href="https://app.example.com/reset?token=abc"');
|
|
38
|
+
expect(html).toContain('Hi Bob,');
|
|
39
|
+
expect(html).toContain('Reset Your Password');
|
|
40
|
+
});
|
|
41
|
+
it('falls back to a generic greeting when userName is omitted', ()=>{
|
|
42
|
+
const html = (0, _emailtemplatesutil.getResetPasswordEmailFormat)('https://app.example.com/reset?token=abc');
|
|
43
|
+
expect(html).toContain('Hi Sir/Madam,');
|
|
44
|
+
});
|
|
45
|
+
it('does not escape or validate the reset link (XSS/HTML-injection caveat)', ()=>{
|
|
46
|
+
const maliciousLink = '"><script>alert(1)</script>';
|
|
47
|
+
const html = (0, _emailtemplatesutil.getResetPasswordEmailFormat)(maliciousLink, 'Bob');
|
|
48
|
+
// Documents CURRENT behavior: resetLink is interpolated verbatim, unescaped,
|
|
49
|
+
// allowing HTML attribute breakout if the value is attacker-influenced.
|
|
50
|
+
expect(html).toContain(`href="${maliciousLink}"`);
|
|
51
|
+
});
|
|
52
|
+
it('does not escape HTML special characters in userName', ()=>{
|
|
53
|
+
const html = (0, _emailtemplatesutil.getResetPasswordEmailFormat)('https://x.test/reset', '<img src=x onerror=alert(1)>');
|
|
54
|
+
expect(html).toContain('<img src=x onerror=alert(1)>');
|
|
55
|
+
});
|
|
56
|
+
it('produces a single-line action-box style for the reset button', ()=>{
|
|
57
|
+
const html = (0, _emailtemplatesutil.getResetPasswordEmailFormat)('https://x.test/reset', 'A');
|
|
58
|
+
expect(html).toContain('font-size: 16px');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
2
|
+
import { EmailConfigController } from './email-config.controller';
|
|
3
|
+
function buildEmailConfig(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'email-config-1',
|
|
6
|
+
provider: 'smtp',
|
|
7
|
+
isActive: true,
|
|
8
|
+
isDefault: true,
|
|
9
|
+
readOnly: false,
|
|
10
|
+
createdAt: new Date(),
|
|
11
|
+
updatedAt: new Date(),
|
|
12
|
+
...overrides
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe('EmailConfigController', ()=>{
|
|
16
|
+
let controller;
|
|
17
|
+
let mockEmailConfigService;
|
|
18
|
+
beforeEach(()=>{
|
|
19
|
+
mockEmailConfigService = {
|
|
20
|
+
insert: jest.fn(),
|
|
21
|
+
findById: jest.fn(),
|
|
22
|
+
getAll: jest.fn(),
|
|
23
|
+
update: jest.fn(),
|
|
24
|
+
delete: jest.fn()
|
|
25
|
+
};
|
|
26
|
+
controller = new EmailConfigController(mockEmailConfigService);
|
|
27
|
+
});
|
|
28
|
+
it('wires the injected service onto the generated controller base', ()=>{
|
|
29
|
+
expect(controller.emailConfigService).toBe(mockEmailConfigService);
|
|
30
|
+
expect(controller.service).toBe(mockEmailConfigService);
|
|
31
|
+
});
|
|
32
|
+
describe('insert', ()=>{
|
|
33
|
+
it('creates an email config and returns the mapped response', async ()=>{
|
|
34
|
+
const user = buildMockUser();
|
|
35
|
+
mockEmailConfigService.insert.mockResolvedValue(buildEmailConfig());
|
|
36
|
+
const result = await controller.insert({
|
|
37
|
+
provider: 'smtp'
|
|
38
|
+
}, user);
|
|
39
|
+
expect(mockEmailConfigService.insert).toHaveBeenCalledWith({
|
|
40
|
+
provider: 'smtp'
|
|
41
|
+
}, user);
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(result.data).toEqual(expect.objectContaining({
|
|
44
|
+
id: 'email-config-1'
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('getAll', ()=>{
|
|
49
|
+
it('paginates email configs and maps the response list', async ()=>{
|
|
50
|
+
mockEmailConfigService.getAll.mockResolvedValue({
|
|
51
|
+
data: [
|
|
52
|
+
buildEmailConfig()
|
|
53
|
+
],
|
|
54
|
+
total: 1
|
|
55
|
+
});
|
|
56
|
+
const result = await controller.getAll({}, buildMockUser(), '');
|
|
57
|
+
expect(mockEmailConfigService.getAll).toHaveBeenCalledWith('', {}, buildMockUser());
|
|
58
|
+
expect(result.data).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('delete', ()=>{
|
|
62
|
+
it('delegates soft-delete to the service', async ()=>{
|
|
63
|
+
mockEmailConfigService.delete.mockResolvedValue({
|
|
64
|
+
count: 1
|
|
65
|
+
});
|
|
66
|
+
const result = await controller.delete({
|
|
67
|
+
id: [
|
|
68
|
+
'email-config-1'
|
|
69
|
+
],
|
|
70
|
+
type: 'delete'
|
|
71
|
+
}, buildMockUser());
|
|
72
|
+
expect(mockEmailConfigService.delete).toHaveBeenCalledWith({
|
|
73
|
+
id: [
|
|
74
|
+
'email-config-1'
|
|
75
|
+
],
|
|
76
|
+
type: 'delete'
|
|
77
|
+
}, buildMockUser());
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Test } from '@nestjs/testing';
|
|
2
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
3
|
+
import { EmailSendController } from './email-send.controller';
|
|
4
|
+
import { EmailSendService } from '../services';
|
|
5
|
+
describe('EmailSendController', ()=>{
|
|
6
|
+
let controller;
|
|
7
|
+
let mockService;
|
|
8
|
+
beforeEach(async ()=>{
|
|
9
|
+
mockService = {
|
|
10
|
+
sendEmail: jest.fn(),
|
|
11
|
+
sendTemplateEmail: jest.fn(),
|
|
12
|
+
sendTestEmail: jest.fn()
|
|
13
|
+
};
|
|
14
|
+
const module = await Test.createTestingModule({
|
|
15
|
+
controllers: [
|
|
16
|
+
EmailSendController
|
|
17
|
+
],
|
|
18
|
+
providers: [
|
|
19
|
+
{
|
|
20
|
+
provide: EmailSendService,
|
|
21
|
+
useValue: mockService
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}).compile();
|
|
25
|
+
controller = module.get(EmailSendController);
|
|
26
|
+
});
|
|
27
|
+
describe('sendEmail', ()=>{
|
|
28
|
+
it('delegates to the service and returns a success response', async ()=>{
|
|
29
|
+
mockService.sendEmail.mockResolvedValue({
|
|
30
|
+
success: true,
|
|
31
|
+
messageId: 'msg-1'
|
|
32
|
+
});
|
|
33
|
+
const dto = {
|
|
34
|
+
to: 'a@example.com',
|
|
35
|
+
subject: 'Hi',
|
|
36
|
+
html: '<p/>'
|
|
37
|
+
};
|
|
38
|
+
const user = buildMockUser();
|
|
39
|
+
const result = await controller.sendEmail(dto, user);
|
|
40
|
+
expect(mockService.sendEmail).toHaveBeenCalledWith(dto, user);
|
|
41
|
+
expect(result).toEqual({
|
|
42
|
+
success: true,
|
|
43
|
+
message: 'Email sent successfully',
|
|
44
|
+
messageKey: 'email.send.success',
|
|
45
|
+
data: {
|
|
46
|
+
success: true,
|
|
47
|
+
messageId: 'msg-1',
|
|
48
|
+
error: undefined
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('returns a failure response with the FAILED messageKey when the send fails', async ()=>{
|
|
53
|
+
mockService.sendEmail.mockResolvedValue({
|
|
54
|
+
success: false,
|
|
55
|
+
error: 'SMTP down'
|
|
56
|
+
});
|
|
57
|
+
const result = await controller.sendEmail({
|
|
58
|
+
to: 'a@example.com',
|
|
59
|
+
subject: 'Hi',
|
|
60
|
+
html: '<p/>'
|
|
61
|
+
}, buildMockUser());
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
success: false,
|
|
64
|
+
message: 'Failed to send email',
|
|
65
|
+
messageKey: 'email.send.failed',
|
|
66
|
+
data: {
|
|
67
|
+
success: false,
|
|
68
|
+
messageId: undefined,
|
|
69
|
+
error: 'SMTP down'
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('sendTemplateEmail', ()=>{
|
|
75
|
+
it('delegates to the service and returns a success response', async ()=>{
|
|
76
|
+
mockService.sendTemplateEmail.mockResolvedValue({
|
|
77
|
+
success: true,
|
|
78
|
+
messageId: 'msg-2'
|
|
79
|
+
});
|
|
80
|
+
const dto = {
|
|
81
|
+
to: 'a@example.com',
|
|
82
|
+
templateSlug: 'welcome'
|
|
83
|
+
};
|
|
84
|
+
const user = buildMockUser();
|
|
85
|
+
const result = await controller.sendTemplateEmail(dto, user);
|
|
86
|
+
expect(mockService.sendTemplateEmail).toHaveBeenCalledWith(dto, user);
|
|
87
|
+
expect(result.success).toBe(true);
|
|
88
|
+
expect(result.messageKey).toBe('email.send.success');
|
|
89
|
+
expect(result.message).toBe('Template email sent successfully');
|
|
90
|
+
});
|
|
91
|
+
it('returns a failure response when the template send fails', async ()=>{
|
|
92
|
+
mockService.sendTemplateEmail.mockResolvedValue({
|
|
93
|
+
success: false,
|
|
94
|
+
error: 'Template error'
|
|
95
|
+
});
|
|
96
|
+
const result = await controller.sendTemplateEmail({
|
|
97
|
+
to: 'a@example.com',
|
|
98
|
+
templateSlug: 'welcome'
|
|
99
|
+
}, buildMockUser());
|
|
100
|
+
expect(result.success).toBe(false);
|
|
101
|
+
expect(result.messageKey).toBe('email.send.failed');
|
|
102
|
+
expect(result.message).toBe('Failed to send template email');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('sendTestEmail', ()=>{
|
|
106
|
+
it('delegates to the service with the emailConfigId and recipient from the dto', async ()=>{
|
|
107
|
+
mockService.sendTestEmail.mockResolvedValue({
|
|
108
|
+
success: true,
|
|
109
|
+
messageId: 'msg-3'
|
|
110
|
+
});
|
|
111
|
+
const dto = {
|
|
112
|
+
emailConfigId: 'config-1',
|
|
113
|
+
recipient: 'a@example.com'
|
|
114
|
+
};
|
|
115
|
+
const user = buildMockUser();
|
|
116
|
+
const result = await controller.sendTestEmail(dto, user);
|
|
117
|
+
expect(mockService.sendTestEmail).toHaveBeenCalledWith('config-1', 'a@example.com', user);
|
|
118
|
+
expect(result.success).toBe(true);
|
|
119
|
+
expect(result.message).toBe('Test email sent successfully');
|
|
120
|
+
});
|
|
121
|
+
it('returns a failure response when the test send fails', async ()=>{
|
|
122
|
+
mockService.sendTestEmail.mockResolvedValue({
|
|
123
|
+
success: false,
|
|
124
|
+
error: 'Bad config'
|
|
125
|
+
});
|
|
126
|
+
const result = await controller.sendTestEmail({
|
|
127
|
+
emailConfigId: 'config-1',
|
|
128
|
+
recipient: 'a@example.com'
|
|
129
|
+
}, buildMockUser());
|
|
130
|
+
expect(result.success).toBe(false);
|
|
131
|
+
expect(result.message).toBe('Failed to send test email');
|
|
132
|
+
expect(result.messageKey).toBe('email.send.failed');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
3
|
+
import { EmailTemplateController } from './email-template.controller';
|
|
4
|
+
function buildTemplate(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'template-1',
|
|
7
|
+
slug: 'password-reset',
|
|
8
|
+
subject: 'Reset your password',
|
|
9
|
+
body: '<p>Hello {{name}}</p>',
|
|
10
|
+
isActive: true,
|
|
11
|
+
readOnly: false,
|
|
12
|
+
createdAt: new Date(),
|
|
13
|
+
updatedAt: new Date(),
|
|
14
|
+
...overrides
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('EmailTemplateController', ()=>{
|
|
18
|
+
let controller;
|
|
19
|
+
let mockEmailTemplateService;
|
|
20
|
+
beforeEach(()=>{
|
|
21
|
+
mockEmailTemplateService = {
|
|
22
|
+
insert: jest.fn(),
|
|
23
|
+
findById: jest.fn(),
|
|
24
|
+
getAll: jest.fn(),
|
|
25
|
+
update: jest.fn(),
|
|
26
|
+
delete: jest.fn(),
|
|
27
|
+
findBySlug: jest.fn()
|
|
28
|
+
};
|
|
29
|
+
controller = new EmailTemplateController(mockEmailTemplateService);
|
|
30
|
+
});
|
|
31
|
+
it('wires the injected service onto the generated controller base', ()=>{
|
|
32
|
+
expect(controller.emailTemplateService).toBe(mockEmailTemplateService);
|
|
33
|
+
expect(controller.service).toBe(mockEmailTemplateService);
|
|
34
|
+
});
|
|
35
|
+
describe('insert', ()=>{
|
|
36
|
+
it('creates an email template and returns the mapped response', async ()=>{
|
|
37
|
+
const user = buildMockUser();
|
|
38
|
+
mockEmailTemplateService.insert.mockResolvedValue(buildTemplate());
|
|
39
|
+
const result = await controller.insert({
|
|
40
|
+
slug: 'password-reset'
|
|
41
|
+
}, user);
|
|
42
|
+
expect(mockEmailTemplateService.insert).toHaveBeenCalledWith({
|
|
43
|
+
slug: 'password-reset'
|
|
44
|
+
}, user);
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
expect(result.data).toEqual(expect.objectContaining({
|
|
47
|
+
id: 'template-1'
|
|
48
|
+
}));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('getAll', ()=>{
|
|
52
|
+
it('paginates templates and maps the response list', async ()=>{
|
|
53
|
+
mockEmailTemplateService.getAll.mockResolvedValue({
|
|
54
|
+
data: [
|
|
55
|
+
buildTemplate()
|
|
56
|
+
],
|
|
57
|
+
total: 1
|
|
58
|
+
});
|
|
59
|
+
const result = await controller.getAll({}, buildMockUser(), '');
|
|
60
|
+
expect(mockEmailTemplateService.getAll).toHaveBeenCalledWith('', {}, buildMockUser());
|
|
61
|
+
expect(result.data).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('getBySlug', ()=>{
|
|
65
|
+
it('returns the template matching the given slug', async ()=>{
|
|
66
|
+
const user = buildMockUser();
|
|
67
|
+
mockEmailTemplateService.findBySlug.mockResolvedValue(buildTemplate());
|
|
68
|
+
const result = await controller.getBySlug({
|
|
69
|
+
slug: 'password-reset'
|
|
70
|
+
}, user);
|
|
71
|
+
expect(mockEmailTemplateService.findBySlug).toHaveBeenCalledWith('password-reset', user);
|
|
72
|
+
expect(result.success).toBe(true);
|
|
73
|
+
expect(result.data).toEqual(expect.objectContaining({
|
|
74
|
+
slug: 'password-reset'
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
it('throws NotFoundException when no template matches the slug', async ()=>{
|
|
78
|
+
const user = buildMockUser();
|
|
79
|
+
mockEmailTemplateService.findBySlug.mockResolvedValue(null);
|
|
80
|
+
await expect(controller.getBySlug({
|
|
81
|
+
slug: 'missing'
|
|
82
|
+
}, user)).rejects.toThrow(NotFoundException);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { emailSwaggerConfig } from './email-swagger.config';
|
|
2
|
+
describe('emailSwaggerConfig', ()=>{
|
|
3
|
+
it('should default to company feature enabled and single-database mode when no config is given', ()=>{
|
|
4
|
+
const config = emailSwaggerConfig();
|
|
5
|
+
expect(config.title).toBe('Email API');
|
|
6
|
+
expect(config.bearerAuth).toBe(true);
|
|
7
|
+
expect(config.excludeSchemaProperties).toBeUndefined();
|
|
8
|
+
expect(config.description).not.toContain('Multi-Tenant Mode');
|
|
9
|
+
});
|
|
10
|
+
it('should exclude companyId schema properties when the company feature is disabled', ()=>{
|
|
11
|
+
const config = emailSwaggerConfig({
|
|
12
|
+
enableCompanyFeature: false
|
|
13
|
+
});
|
|
14
|
+
expect(config.excludeSchemaProperties).toEqual([
|
|
15
|
+
{
|
|
16
|
+
schemaName: 'EmailConfigResponseDto',
|
|
17
|
+
properties: [
|
|
18
|
+
'companyId'
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
schemaName: 'EmailTemplateResponseDto',
|
|
23
|
+
properties: [
|
|
24
|
+
'companyId'
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
it('should not exclude any schema properties when the company feature is enabled', ()=>{
|
|
30
|
+
const config = emailSwaggerConfig({
|
|
31
|
+
enableCompanyFeature: true
|
|
32
|
+
});
|
|
33
|
+
expect(config.excludeSchemaProperties).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it('should describe company isolation and multi-tenant support only when the company feature is enabled', ()=>{
|
|
36
|
+
const withCompany = emailSwaggerConfig({
|
|
37
|
+
enableCompanyFeature: true
|
|
38
|
+
});
|
|
39
|
+
const withoutCompany = emailSwaggerConfig({
|
|
40
|
+
enableCompanyFeature: false
|
|
41
|
+
});
|
|
42
|
+
expect(withCompany.description).toContain('Company isolation');
|
|
43
|
+
expect(withCompany.description).toContain('## Multi-Tenant Support');
|
|
44
|
+
expect(withCompany.description).toContain('with multi-tenant and company support');
|
|
45
|
+
expect(withoutCompany.description).not.toContain('Company isolation');
|
|
46
|
+
expect(withoutCompany.description).not.toContain('## Multi-Tenant Support');
|
|
47
|
+
});
|
|
48
|
+
it('should mention multi-tenant mode in the description only for multi-tenant database mode', ()=>{
|
|
49
|
+
const multiTenant = emailSwaggerConfig({
|
|
50
|
+
databaseMode: 'multi-tenant'
|
|
51
|
+
});
|
|
52
|
+
const single = emailSwaggerConfig({
|
|
53
|
+
databaseMode: 'single'
|
|
54
|
+
});
|
|
55
|
+
expect(multiTenant.description).toContain('Multi-Tenant Mode');
|
|
56
|
+
expect(single.description).not.toContain('Multi-Tenant Mode');
|
|
57
|
+
});
|
|
58
|
+
});
|