@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,14 @@
|
|
|
1
|
+
import { getEmailEntitiesByConfig, EmailCoreEntities, EmailCompanyEntities } from './index';
|
|
2
|
+
describe('getEmailEntitiesByConfig', ()=>{
|
|
3
|
+
it('returns the core entities when the company feature is disabled', ()=>{
|
|
4
|
+
expect(getEmailEntitiesByConfig(false)).toBe(EmailCoreEntities);
|
|
5
|
+
});
|
|
6
|
+
it('returns the company-scoped entities when the company feature is enabled', ()=>{
|
|
7
|
+
expect(getEmailEntitiesByConfig(true)).toBe(EmailCompanyEntities);
|
|
8
|
+
});
|
|
9
|
+
it('core and company entity sets are disjoint (never mixed)', ()=>{
|
|
10
|
+
const core = getEmailEntitiesByConfig(false);
|
|
11
|
+
const company = getEmailEntitiesByConfig(true);
|
|
12
|
+
expect(core.some((e)=>company.includes(e))).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
import { EmailModule } from './email.module';
|
|
15
|
+
import { EMAIL_MODULE_OPTIONS } from '../config/email.constants';
|
|
16
|
+
import { EmailConfigController, EmailSendController, EmailTemplateController } from '../controllers';
|
|
17
|
+
function providerTokens(providers) {
|
|
18
|
+
return providers.map((p)=>typeof p === 'function' ? p : p.provide);
|
|
19
|
+
}
|
|
20
|
+
describe('EmailModule', ()=>{
|
|
21
|
+
describe('forRoot', ()=>{
|
|
22
|
+
it('should default to a non-global module with all controllers registered', ()=>{
|
|
23
|
+
const dynamicModule = EmailModule.forRoot({});
|
|
24
|
+
expect(dynamicModule.module).toBe(EmailModule);
|
|
25
|
+
expect(dynamicModule.global).toBe(false);
|
|
26
|
+
expect(dynamicModule.controllers).toEqual([
|
|
27
|
+
EmailConfigController,
|
|
28
|
+
EmailTemplateController,
|
|
29
|
+
EmailSendController
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
it('should respect global: true', ()=>{
|
|
33
|
+
expect(EmailModule.forRoot({
|
|
34
|
+
global: true
|
|
35
|
+
}).global).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('should register no controllers when includeController is false', ()=>{
|
|
38
|
+
expect(EmailModule.forRoot({
|
|
39
|
+
includeController: false
|
|
40
|
+
}).controllers).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
it('should register a EMAIL_MODULE_OPTIONS provider carrying the given options', ()=>{
|
|
43
|
+
const options = {
|
|
44
|
+
bootstrapAppConfig: {
|
|
45
|
+
enableCompanyFeature: true
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const dynamicModule = EmailModule.forRoot(options);
|
|
49
|
+
const provider = dynamicModule.providers.find((p)=>p.provide === EMAIL_MODULE_OPTIONS);
|
|
50
|
+
expect(provider.useValue).toBe(options);
|
|
51
|
+
});
|
|
52
|
+
it('should always export the same fixed set of services', ()=>{
|
|
53
|
+
expect(EmailModule.forRoot({}).exports).toHaveLength(6);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('forRootAsync', ()=>{
|
|
57
|
+
it('should forward external imports alongside CacheModule/UtilsModule', ()=>{
|
|
58
|
+
let FakeImportedModule = class FakeImportedModule {
|
|
59
|
+
};
|
|
60
|
+
const dynamicModule = EmailModule.forRootAsync({
|
|
61
|
+
bootstrapAppConfig: {},
|
|
62
|
+
imports: [
|
|
63
|
+
FakeImportedModule
|
|
64
|
+
]
|
|
65
|
+
});
|
|
66
|
+
expect(dynamicModule.imports).toEqual(expect.arrayContaining([
|
|
67
|
+
FakeImportedModule
|
|
68
|
+
]));
|
|
69
|
+
});
|
|
70
|
+
it('useFactory: builds an EMAIL_MODULE_OPTIONS provider that merges resolved config into the static options', async ()=>{
|
|
71
|
+
const useFactory = jest.fn().mockResolvedValue({
|
|
72
|
+
defaultFrom: 'noreply@flusys.io'
|
|
73
|
+
});
|
|
74
|
+
const dynamicModule = EmailModule.forRootAsync({
|
|
75
|
+
bootstrapAppConfig: {},
|
|
76
|
+
useFactory,
|
|
77
|
+
inject: [
|
|
78
|
+
'SOME_TOKEN'
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
const provider = dynamicModule.providers.find((p)=>p.provide === EMAIL_MODULE_OPTIONS);
|
|
82
|
+
expect(provider.inject).toEqual([
|
|
83
|
+
'SOME_TOKEN'
|
|
84
|
+
]);
|
|
85
|
+
const resolved = await provider.useFactory('injected');
|
|
86
|
+
expect(useFactory).toHaveBeenCalledWith('injected');
|
|
87
|
+
expect(resolved.config).toEqual({
|
|
88
|
+
defaultFrom: 'noreply@flusys.io'
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
it('useClass: registers both the class provider and an options provider calling createEmailOptions()', async ()=>{
|
|
92
|
+
let MyOptionsFactory = class MyOptionsFactory {
|
|
93
|
+
constructor(){
|
|
94
|
+
_define_property(this, "createEmailOptions", jest.fn().mockResolvedValue({
|
|
95
|
+
defaultFrom: 'from-class@flusys.io'
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const dynamicModule = EmailModule.forRootAsync({
|
|
100
|
+
bootstrapAppConfig: {},
|
|
101
|
+
useClass: MyOptionsFactory
|
|
102
|
+
});
|
|
103
|
+
const classProvider = dynamicModule.providers.find((p)=>p.provide === MyOptionsFactory);
|
|
104
|
+
expect(classProvider.useClass).toBe(MyOptionsFactory);
|
|
105
|
+
const optionsProvider = dynamicModule.providers.find((p)=>p.provide === EMAIL_MODULE_OPTIONS);
|
|
106
|
+
const instance = new MyOptionsFactory();
|
|
107
|
+
const resolved = await optionsProvider.useFactory(instance);
|
|
108
|
+
expect(instance.createEmailOptions).toHaveBeenCalled();
|
|
109
|
+
expect(resolved.config).toEqual({
|
|
110
|
+
defaultFrom: 'from-class@flusys.io'
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
it('useExisting: registers a single options provider without a duplicate class registration', async ()=>{
|
|
114
|
+
let MyOptionsFactory = class MyOptionsFactory {
|
|
115
|
+
constructor(){
|
|
116
|
+
_define_property(this, "createEmailOptions", jest.fn().mockResolvedValue({
|
|
117
|
+
defaultFrom: 'from-existing@flusys.io'
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const dynamicModule = EmailModule.forRootAsync({
|
|
122
|
+
bootstrapAppConfig: {},
|
|
123
|
+
useExisting: MyOptionsFactory
|
|
124
|
+
});
|
|
125
|
+
expect(dynamicModule.providers.filter((p)=>p.provide === MyOptionsFactory)).toHaveLength(0);
|
|
126
|
+
const optionsProvider = dynamicModule.providers.find((p)=>p.provide === EMAIL_MODULE_OPTIONS);
|
|
127
|
+
expect(optionsProvider.inject).toEqual([
|
|
128
|
+
MyOptionsFactory
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
it('should register no EMAIL_MODULE_OPTIONS provider when neither useFactory, useClass, nor useExisting is given', ()=>{
|
|
132
|
+
const dynamicModule = EmailModule.forRootAsync({
|
|
133
|
+
bootstrapAppConfig: {}
|
|
134
|
+
});
|
|
135
|
+
expect(providerTokens(dynamicModule.providers)).not.toContain(EMAIL_MODULE_OPTIONS);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
import { InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
|
15
|
+
import { EmailFactoryService } from './email-factory.service';
|
|
16
|
+
import { EmailProviderRegistry } from './email-provider.registry';
|
|
17
|
+
let FakeSuccessProvider = class FakeSuccessProvider {
|
|
18
|
+
async initialize(_config) {
|
|
19
|
+
FakeSuccessProvider.initializeCalls += 1;
|
|
20
|
+
}
|
|
21
|
+
async sendEmail(_options) {
|
|
22
|
+
return {
|
|
23
|
+
success: true
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async sendBulkEmails(_options) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
async healthCheck() {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
async close() {
|
|
33
|
+
this.closeCalled = true;
|
|
34
|
+
}
|
|
35
|
+
constructor(){
|
|
36
|
+
_define_property(this, "closeCalled", false);
|
|
37
|
+
FakeSuccessProvider.instances.push(this);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
_define_property(FakeSuccessProvider, "initializeCalls", 0);
|
|
41
|
+
_define_property(FakeSuccessProvider, "instances", []);
|
|
42
|
+
let FakeFailingInitProvider = class FakeFailingInitProvider {
|
|
43
|
+
async initialize(_config) {
|
|
44
|
+
throw new Error('boom during init');
|
|
45
|
+
}
|
|
46
|
+
async sendEmail(_options) {
|
|
47
|
+
return {
|
|
48
|
+
success: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async sendBulkEmails(_options) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
async healthCheck() {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
let FakeNoInitializeProvider = class FakeNoInitializeProvider {
|
|
59
|
+
async sendEmail(_options) {
|
|
60
|
+
return {
|
|
61
|
+
success: true
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async sendBulkEmails(_options) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
async healthCheck() {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
describe('EmailFactoryService', ()=>{
|
|
72
|
+
let service;
|
|
73
|
+
beforeEach(()=>{
|
|
74
|
+
service = new EmailFactoryService();
|
|
75
|
+
EmailProviderRegistry.clear();
|
|
76
|
+
FakeSuccessProvider.initializeCalls = 0;
|
|
77
|
+
FakeSuccessProvider.instances = [];
|
|
78
|
+
});
|
|
79
|
+
afterEach(()=>{
|
|
80
|
+
EmailProviderRegistry.clear();
|
|
81
|
+
});
|
|
82
|
+
describe('createProvider', ()=>{
|
|
83
|
+
it('throws NotFoundException when the provider is not registered', async ()=>{
|
|
84
|
+
const config = {
|
|
85
|
+
provider: 'unknown',
|
|
86
|
+
config: {}
|
|
87
|
+
};
|
|
88
|
+
await expect(service.createProvider(config)).rejects.toThrow(NotFoundException);
|
|
89
|
+
});
|
|
90
|
+
it('throws NotFoundException with the config-not-found messageKey and available list', async ()=>{
|
|
91
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
92
|
+
const config = {
|
|
93
|
+
provider: 'unknown',
|
|
94
|
+
config: {}
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
await service.createProvider(config);
|
|
98
|
+
fail('expected createProvider to throw');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
expect(error).toBeInstanceOf(NotFoundException);
|
|
101
|
+
expect(error.getResponse()).toEqual(expect.objectContaining({
|
|
102
|
+
messageKey: 'email.send.config.not.found'
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
it('instantiates and initializes the provider on cache miss', async ()=>{
|
|
107
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
108
|
+
const config = {
|
|
109
|
+
provider: 'smtp',
|
|
110
|
+
config: {
|
|
111
|
+
host: 'localhost'
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const provider = await service.createProvider(config);
|
|
115
|
+
expect(provider).toBeInstanceOf(FakeSuccessProvider);
|
|
116
|
+
expect(FakeSuccessProvider.initializeCalls).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
it('returns the cached provider instance for an identical config (no re-initialization)', async ()=>{
|
|
119
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
120
|
+
const config = {
|
|
121
|
+
provider: 'smtp',
|
|
122
|
+
config: {
|
|
123
|
+
host: 'localhost'
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const first = await service.createProvider(config);
|
|
127
|
+
const second = await service.createProvider(config);
|
|
128
|
+
expect(second).toBe(first);
|
|
129
|
+
expect(FakeSuccessProvider.initializeCalls).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
it('creates a distinct cached instance for a different config on the same provider', async ()=>{
|
|
132
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
133
|
+
const first = await service.createProvider({
|
|
134
|
+
provider: 'smtp',
|
|
135
|
+
config: {
|
|
136
|
+
host: 'a'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const second = await service.createProvider({
|
|
140
|
+
provider: 'smtp',
|
|
141
|
+
config: {
|
|
142
|
+
host: 'b'
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
expect(second).not.toBe(first);
|
|
146
|
+
expect(FakeSuccessProvider.initializeCalls).toBe(2);
|
|
147
|
+
});
|
|
148
|
+
it('is insensitive to key ordering when hashing the config for the cache key', async ()=>{
|
|
149
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
150
|
+
const first = await service.createProvider({
|
|
151
|
+
provider: 'smtp',
|
|
152
|
+
config: {
|
|
153
|
+
a: 1,
|
|
154
|
+
b: 2
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const second = await service.createProvider({
|
|
158
|
+
provider: 'smtp',
|
|
159
|
+
config: {
|
|
160
|
+
b: 2,
|
|
161
|
+
a: 1
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
expect(second).toBe(first);
|
|
165
|
+
expect(FakeSuccessProvider.initializeCalls).toBe(1);
|
|
166
|
+
});
|
|
167
|
+
it('does not call initialize when the provider class has no initialize method', async ()=>{
|
|
168
|
+
EmailProviderRegistry.register('smtp', FakeNoInitializeProvider);
|
|
169
|
+
const config = {
|
|
170
|
+
provider: 'smtp',
|
|
171
|
+
config: {}
|
|
172
|
+
};
|
|
173
|
+
const provider = await service.createProvider(config);
|
|
174
|
+
expect(provider).toBeInstanceOf(FakeNoInitializeProvider);
|
|
175
|
+
});
|
|
176
|
+
it('wraps initialize() failures in InternalServerErrorException with SDK_NOT_INSTALLED messageKey', async ()=>{
|
|
177
|
+
EmailProviderRegistry.register('mailgun', FakeFailingInitProvider);
|
|
178
|
+
const config = {
|
|
179
|
+
provider: 'mailgun',
|
|
180
|
+
config: {}
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
await service.createProvider(config);
|
|
184
|
+
fail('expected createProvider to throw');
|
|
185
|
+
} catch (error) {
|
|
186
|
+
expect(error).toBeInstanceOf(InternalServerErrorException);
|
|
187
|
+
expect(error.getResponse()).toEqual(expect.objectContaining({
|
|
188
|
+
messageKey: 'system.service.not.available'
|
|
189
|
+
}));
|
|
190
|
+
expect(error.getResponse().message).toContain('boom during init');
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
it('does not cache a provider that failed to initialize', async ()=>{
|
|
194
|
+
EmailProviderRegistry.register('mailgun', FakeFailingInitProvider);
|
|
195
|
+
const config = {
|
|
196
|
+
provider: 'mailgun',
|
|
197
|
+
config: {}
|
|
198
|
+
};
|
|
199
|
+
await expect(service.createProvider(config)).rejects.toThrow();
|
|
200
|
+
// second attempt should also go through the (still failing) initialization path
|
|
201
|
+
await expect(service.createProvider(config)).rejects.toThrow();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('onModuleDestroy', ()=>{
|
|
205
|
+
it('closes every cached provider and clears the cache', async ()=>{
|
|
206
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
207
|
+
const provider = await service.createProvider({
|
|
208
|
+
provider: 'smtp',
|
|
209
|
+
config: {}
|
|
210
|
+
});
|
|
211
|
+
await service.onModuleDestroy();
|
|
212
|
+
expect(provider.closeCalled).toBe(true);
|
|
213
|
+
// cache cleared -> a subsequent createProvider call re-initializes
|
|
214
|
+
EmailProviderRegistry.register('smtp', FakeSuccessProvider);
|
|
215
|
+
await service.createProvider({
|
|
216
|
+
provider: 'smtp',
|
|
217
|
+
config: {}
|
|
218
|
+
});
|
|
219
|
+
expect(FakeSuccessProvider.initializeCalls).toBe(2);
|
|
220
|
+
});
|
|
221
|
+
it('does not throw when a provider close() call rejects', async ()=>{
|
|
222
|
+
let FakeThrowingCloseProvider = class FakeThrowingCloseProvider extends FakeSuccessProvider {
|
|
223
|
+
async close() {
|
|
224
|
+
throw new Error('close failed');
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
EmailProviderRegistry.register('smtp', FakeThrowingCloseProvider);
|
|
228
|
+
await service.createProvider({
|
|
229
|
+
provider: 'smtp',
|
|
230
|
+
config: {}
|
|
231
|
+
});
|
|
232
|
+
await expect(service.onModuleDestroy()).resolves.toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
it('is a no-op when nothing has been cached', async ()=>{
|
|
235
|
+
await expect(service.onModuleDestroy()).resolves.toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { EmailProviderRegistry } from './email-provider.registry';
|
|
2
|
+
let FakeProvider = class FakeProvider {
|
|
3
|
+
async sendEmail(_options) {
|
|
4
|
+
return {
|
|
5
|
+
success: true
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
async sendBulkEmails(_options) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
async healthCheck() {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
describe('EmailProviderRegistry', ()=>{
|
|
16
|
+
afterEach(()=>{
|
|
17
|
+
EmailProviderRegistry.clear();
|
|
18
|
+
});
|
|
19
|
+
it('registers and retrieves a provider by exact name', ()=>{
|
|
20
|
+
EmailProviderRegistry.register('smtp', FakeProvider);
|
|
21
|
+
expect(EmailProviderRegistry.get('smtp')).toBe(FakeProvider);
|
|
22
|
+
});
|
|
23
|
+
it('normalizes provider names to lowercase on register and lookup', ()=>{
|
|
24
|
+
EmailProviderRegistry.register('SendGrid', FakeProvider);
|
|
25
|
+
expect(EmailProviderRegistry.get('sendgrid')).toBe(FakeProvider);
|
|
26
|
+
expect(EmailProviderRegistry.get('SENDGRID')).toBe(FakeProvider);
|
|
27
|
+
expect(EmailProviderRegistry.has('sendgrid')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('returns undefined for an unregistered provider', ()=>{
|
|
30
|
+
expect(EmailProviderRegistry.get('unknown-provider')).toBeUndefined();
|
|
31
|
+
expect(EmailProviderRegistry.has('unknown-provider')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
it('lists all registered provider names', ()=>{
|
|
34
|
+
EmailProviderRegistry.register('smtp', FakeProvider);
|
|
35
|
+
EmailProviderRegistry.register('mailgun', FakeProvider);
|
|
36
|
+
expect(EmailProviderRegistry.getAll().sort()).toEqual([
|
|
37
|
+
'mailgun',
|
|
38
|
+
'smtp'
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
it('clears all registered providers', ()=>{
|
|
42
|
+
EmailProviderRegistry.register('smtp', FakeProvider);
|
|
43
|
+
EmailProviderRegistry.clear();
|
|
44
|
+
expect(EmailProviderRegistry.getAll()).toEqual([]);
|
|
45
|
+
expect(EmailProviderRegistry.has('smtp')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it('overwrites a previously registered provider for the same name', ()=>{
|
|
48
|
+
let OtherProvider = class OtherProvider extends FakeProvider {
|
|
49
|
+
};
|
|
50
|
+
EmailProviderRegistry.register('smtp', FakeProvider);
|
|
51
|
+
EmailProviderRegistry.register('smtp', OtherProvider);
|
|
52
|
+
expect(EmailProviderRegistry.get('smtp')).toBe(OtherProvider);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { InternalServerErrorException } from '@nestjs/common';
|
|
2
|
+
import { MailgunProvider } from './mailgun-provider';
|
|
3
|
+
// mailgun.js is not an installed dependency in this workspace, so the real dynamic
|
|
4
|
+
// import() inside `initialize()` fails — that's exercised directly (no mocking needed).
|
|
5
|
+
// For "happy path" behavior we bypass initialize() and inject a fake client, matching
|
|
6
|
+
// the private `client`/`domain` fields the provider mutates internally.
|
|
7
|
+
function injectClient(provider, client, domain = 'example.com') {
|
|
8
|
+
provider.client = client;
|
|
9
|
+
provider.domain = domain;
|
|
10
|
+
}
|
|
11
|
+
describe('MailgunProvider', ()=>{
|
|
12
|
+
let provider;
|
|
13
|
+
beforeEach(()=>{
|
|
14
|
+
provider = new MailgunProvider();
|
|
15
|
+
});
|
|
16
|
+
describe('initialize', ()=>{
|
|
17
|
+
it('throws InternalServerErrorException with SDK_NOT_INSTALLED when mailgun.js is unavailable', async ()=>{
|
|
18
|
+
await expect(provider.initialize({
|
|
19
|
+
apiKey: 'key',
|
|
20
|
+
domain: 'example.com'
|
|
21
|
+
})).rejects.toThrow(InternalServerErrorException);
|
|
22
|
+
});
|
|
23
|
+
it('surfaces the sdk name in the exception messageVariables', async ()=>{
|
|
24
|
+
try {
|
|
25
|
+
await provider.initialize({
|
|
26
|
+
apiKey: 'key',
|
|
27
|
+
domain: 'example.com'
|
|
28
|
+
});
|
|
29
|
+
fail('expected initialize to throw');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
expect(error.getResponse()).toEqual(expect.objectContaining({
|
|
32
|
+
messageKey: 'system.sdk.not.installed',
|
|
33
|
+
messageVariables: {
|
|
34
|
+
sdk: 'mailgun'
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('sendEmail', ()=>{
|
|
41
|
+
it('returns a failure result when the provider was never initialized', async ()=>{
|
|
42
|
+
const result = await provider.sendEmail({
|
|
43
|
+
to: 'a@example.com',
|
|
44
|
+
subject: 'Hi'
|
|
45
|
+
});
|
|
46
|
+
expect(result).toEqual({
|
|
47
|
+
success: false,
|
|
48
|
+
error: 'Mailgun provider not initialized'
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('sends via the mailgun client and returns the message id on success', async ()=>{
|
|
52
|
+
const create = jest.fn().mockResolvedValue({
|
|
53
|
+
id: '<msg-1@mailgun>'
|
|
54
|
+
});
|
|
55
|
+
injectClient(provider, {
|
|
56
|
+
messages: {
|
|
57
|
+
create
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const result = await provider.sendEmail({
|
|
61
|
+
to: 'a@example.com',
|
|
62
|
+
cc: [
|
|
63
|
+
'b@example.com',
|
|
64
|
+
'c@example.com'
|
|
65
|
+
],
|
|
66
|
+
bcc: 'd@example.com',
|
|
67
|
+
subject: 'Hello',
|
|
68
|
+
html: '<p>hi</p>',
|
|
69
|
+
text: 'hi',
|
|
70
|
+
from: 'sender@example.com',
|
|
71
|
+
fromName: 'Sender Name',
|
|
72
|
+
replyTo: 'reply@example.com'
|
|
73
|
+
});
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
success: true,
|
|
76
|
+
messageId: '<msg-1@mailgun>'
|
|
77
|
+
});
|
|
78
|
+
expect(create).toHaveBeenCalledWith('example.com', expect.objectContaining({
|
|
79
|
+
from: 'Sender Name <sender@example.com>',
|
|
80
|
+
to: 'a@example.com',
|
|
81
|
+
subject: 'Hello',
|
|
82
|
+
html: '<p>hi</p>',
|
|
83
|
+
text: 'hi',
|
|
84
|
+
cc: 'b@example.com, c@example.com',
|
|
85
|
+
bcc: 'd@example.com',
|
|
86
|
+
'h:Reply-To': 'reply@example.com'
|
|
87
|
+
}));
|
|
88
|
+
});
|
|
89
|
+
it('omits optional fields (cc/bcc/replyTo/html/text) when not provided', async ()=>{
|
|
90
|
+
const create = jest.fn().mockResolvedValue({
|
|
91
|
+
id: 'id-1'
|
|
92
|
+
});
|
|
93
|
+
injectClient(provider, {
|
|
94
|
+
messages: {
|
|
95
|
+
create
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
await provider.sendEmail({
|
|
99
|
+
to: 'a@example.com',
|
|
100
|
+
subject: 'Hi',
|
|
101
|
+
from: 'x@example.com'
|
|
102
|
+
});
|
|
103
|
+
const [, messageData] = create.mock.calls[0];
|
|
104
|
+
expect(messageData).not.toHaveProperty('cc');
|
|
105
|
+
expect(messageData).not.toHaveProperty('bcc');
|
|
106
|
+
expect(messageData).not.toHaveProperty('h:Reply-To');
|
|
107
|
+
expect(messageData).not.toHaveProperty('html');
|
|
108
|
+
expect(messageData).not.toHaveProperty('text');
|
|
109
|
+
});
|
|
110
|
+
it('base64-decodes attachment content before sending', async ()=>{
|
|
111
|
+
const create = jest.fn().mockResolvedValue({
|
|
112
|
+
id: 'id-1'
|
|
113
|
+
});
|
|
114
|
+
injectClient(provider, {
|
|
115
|
+
messages: {
|
|
116
|
+
create
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
await provider.sendEmail({
|
|
120
|
+
to: 'a@example.com',
|
|
121
|
+
subject: 'Hi',
|
|
122
|
+
attachments: [
|
|
123
|
+
{
|
|
124
|
+
filename: 'doc.txt',
|
|
125
|
+
content: Buffer.from('hello').toString('base64'),
|
|
126
|
+
contentType: 'text/plain'
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
const [, messageData] = create.mock.calls[0];
|
|
131
|
+
const attachment = messageData.attachment[0];
|
|
132
|
+
expect(attachment.filename).toBe('doc.txt');
|
|
133
|
+
expect(attachment.contentType).toBe('text/plain');
|
|
134
|
+
expect(Buffer.isBuffer(attachment.data)).toBe(true);
|
|
135
|
+
expect(attachment.data.toString('utf8')).toBe('hello');
|
|
136
|
+
});
|
|
137
|
+
it('returns the error message when the mailgun client rejects with an Error', async ()=>{
|
|
138
|
+
injectClient(provider, {
|
|
139
|
+
messages: {
|
|
140
|
+
create: jest.fn().mockRejectedValue(new Error('domain not found'))
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const result = await provider.sendEmail({
|
|
144
|
+
to: 'a@example.com',
|
|
145
|
+
subject: 'Hi'
|
|
146
|
+
});
|
|
147
|
+
expect(result).toEqual({
|
|
148
|
+
success: false,
|
|
149
|
+
error: 'domain not found'
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
it('returns "Unknown error" when the mailgun client rejects with a non-Error', async ()=>{
|
|
153
|
+
injectClient(provider, {
|
|
154
|
+
messages: {
|
|
155
|
+
create: jest.fn().mockRejectedValue('some string failure')
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const result = await provider.sendEmail({
|
|
159
|
+
to: 'a@example.com',
|
|
160
|
+
subject: 'Hi'
|
|
161
|
+
});
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
success: false,
|
|
164
|
+
error: 'Unknown error'
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('sendBulkEmails', ()=>{
|
|
169
|
+
it('sends each message individually and aggregates the results', async ()=>{
|
|
170
|
+
const create = jest.fn().mockResolvedValueOnce({
|
|
171
|
+
id: 'id-1'
|
|
172
|
+
}).mockRejectedValueOnce(new Error('failed-2'));
|
|
173
|
+
injectClient(provider, {
|
|
174
|
+
messages: {
|
|
175
|
+
create
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const results = await provider.sendBulkEmails([
|
|
179
|
+
{
|
|
180
|
+
to: 'a@example.com',
|
|
181
|
+
subject: 'A'
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
to: 'b@example.com',
|
|
185
|
+
subject: 'B'
|
|
186
|
+
}
|
|
187
|
+
]);
|
|
188
|
+
expect(results).toEqual([
|
|
189
|
+
{
|
|
190
|
+
success: true,
|
|
191
|
+
messageId: 'id-1'
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
success: false,
|
|
195
|
+
error: 'failed-2'
|
|
196
|
+
}
|
|
197
|
+
]);
|
|
198
|
+
expect(create).toHaveBeenCalledTimes(2);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe('healthCheck', ()=>{
|
|
202
|
+
it('returns false when not initialized', async ()=>{
|
|
203
|
+
await expect(provider.healthCheck()).resolves.toBe(false);
|
|
204
|
+
});
|
|
205
|
+
it('returns true when the mailgun domain lookup succeeds', async ()=>{
|
|
206
|
+
injectClient(provider, {
|
|
207
|
+
domains: {
|
|
208
|
+
get: jest.fn().mockResolvedValue({})
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
await expect(provider.healthCheck()).resolves.toBe(true);
|
|
212
|
+
});
|
|
213
|
+
it('returns false when the mailgun domain lookup fails', async ()=>{
|
|
214
|
+
injectClient(provider, {
|
|
215
|
+
domains: {
|
|
216
|
+
get: jest.fn().mockRejectedValue(new Error('nope'))
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
await expect(provider.healthCheck()).resolves.toBe(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
describe('close', ()=>{
|
|
223
|
+
it('clears the client reference', async ()=>{
|
|
224
|
+
injectClient(provider, {
|
|
225
|
+
messages: {
|
|
226
|
+
create: jest.fn()
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
await provider.close();
|
|
230
|
+
const result = await provider.sendEmail({
|
|
231
|
+
to: 'a@example.com',
|
|
232
|
+
subject: 'Hi'
|
|
233
|
+
});
|
|
234
|
+
expect(result).toEqual({
|
|
235
|
+
success: false,
|
|
236
|
+
error: 'Mailgun provider not initialized'
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|