@bernierllc/sender-identity-verification 1.0.0
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/.env.test +8 -0
- package/.eslintrc.js +30 -0
- package/README.md +376 -0
- package/__tests__/SenderIdentityVerification.test.ts +461 -0
- package/__tests__/__mocks__/fetch-mock.ts +156 -0
- package/__tests__/additional-coverage.test.ts +129 -0
- package/__tests__/additional-error-coverage.test.ts +483 -0
- package/__tests__/branch-coverage.test.ts +509 -0
- package/__tests__/config.test.ts +119 -0
- package/__tests__/error-handling.test.ts +321 -0
- package/__tests__/final-branch-coverage.test.ts +372 -0
- package/__tests__/integration.real-api.test.ts +295 -0
- package/__tests__/providers.test.ts +331 -0
- package/__tests__/service-coverage.test.ts +412 -0
- package/dist/SenderIdentityVerification.d.ts +72 -0
- package/dist/SenderIdentityVerification.js +643 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +38 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +61 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +21 -0
- package/dist/providers/MailgunProvider.d.ts +13 -0
- package/dist/providers/MailgunProvider.js +35 -0
- package/dist/providers/SESProvider.d.ts +12 -0
- package/dist/providers/SESProvider.js +47 -0
- package/dist/providers/SMTPProvider.d.ts +12 -0
- package/dist/providers/SMTPProvider.js +30 -0
- package/dist/providers/SendGridProvider.d.ts +19 -0
- package/dist/providers/SendGridProvider.js +98 -0
- package/dist/templates/verification-email.d.ts +9 -0
- package/dist/templates/verification-email.js +67 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.js +33 -0
- package/dist/utils/domain-extractor.d.ts +4 -0
- package/dist/utils/domain-extractor.js +20 -0
- package/jest.config.cjs +33 -0
- package/package.json +60 -0
- package/src/SenderIdentityVerification.ts +796 -0
- package/src/config.ts +81 -0
- package/src/errors.ts +64 -0
- package/src/global.d.ts +24 -0
- package/src/index.ts +24 -0
- package/src/providers/MailgunProvider.ts +35 -0
- package/src/providers/SESProvider.ts +51 -0
- package/src/providers/SMTPProvider.ts +29 -0
- package/src/providers/SendGridProvider.ts +108 -0
- package/src/templates/verification-email.ts +67 -0
- package/src/types.ts +163 -0
- package/src/utils/domain-extractor.ts +18 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
SenderIdentityVerification,
|
|
11
|
+
EmailProvider,
|
|
12
|
+
SenderStatus,
|
|
13
|
+
CreateSenderInput,
|
|
14
|
+
UpdateSenderInput
|
|
15
|
+
} from '../src/index';
|
|
16
|
+
|
|
17
|
+
describe('SenderIdentityVerification', () => {
|
|
18
|
+
let service: SenderIdentityVerification;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
service = new SenderIdentityVerification({
|
|
22
|
+
verificationBaseUrl: 'https://test.example.com',
|
|
23
|
+
verificationFromEmail: 'noreply@test.com',
|
|
24
|
+
verificationFromName: 'Test Verification',
|
|
25
|
+
sendgridApiKey: 'test-key',
|
|
26
|
+
emailSenderConfig: {},
|
|
27
|
+
domainVerificationConfig: {}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await service.initialize();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('createSender', () => {
|
|
34
|
+
it('should create sender successfully with valid input', async () => {
|
|
35
|
+
const input: CreateSenderInput = {
|
|
36
|
+
email: 'sender@example.com',
|
|
37
|
+
name: 'Test Sender',
|
|
38
|
+
provider: EmailProvider.SENDGRID,
|
|
39
|
+
skipVerification: true
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const result = await service.createSender(input);
|
|
43
|
+
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
expect(result.data).toBeDefined();
|
|
46
|
+
expect(result.data?.email).toBe('sender@example.com');
|
|
47
|
+
expect(result.data?.name).toBe('Test Sender');
|
|
48
|
+
expect(result.data?.status).toBe(SenderStatus.VERIFIED);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should create sender with pending status when not skipping verification', async () => {
|
|
52
|
+
const input: CreateSenderInput = {
|
|
53
|
+
email: 'pending@example.com',
|
|
54
|
+
name: 'Pending Sender',
|
|
55
|
+
provider: EmailProvider.SENDGRID,
|
|
56
|
+
skipVerification: false
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await service.createSender(input);
|
|
60
|
+
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
expect(result.data?.status).toBe(SenderStatus.VERIFICATION_SENT);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should fail when creating duplicate sender', async () => {
|
|
66
|
+
const input: CreateSenderInput = {
|
|
67
|
+
email: 'duplicate@example.com',
|
|
68
|
+
name: 'Duplicate Sender',
|
|
69
|
+
provider: EmailProvider.SENDGRID,
|
|
70
|
+
skipVerification: true
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Create first sender
|
|
74
|
+
await service.createSender(input);
|
|
75
|
+
|
|
76
|
+
// Try to create duplicate
|
|
77
|
+
const result = await service.createSender(input);
|
|
78
|
+
|
|
79
|
+
expect(result.success).toBe(false);
|
|
80
|
+
expect(result.error).toContain('already exists');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should fail with invalid email format', async () => {
|
|
84
|
+
const input: CreateSenderInput = {
|
|
85
|
+
email: 'invalid-email',
|
|
86
|
+
name: 'Invalid Sender',
|
|
87
|
+
provider: EmailProvider.SENDGRID
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const result = await service.createSender(input);
|
|
91
|
+
|
|
92
|
+
expect(result.success).toBe(false);
|
|
93
|
+
expect(result.error).toContain('Invalid email');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should set default reply-to email if not provided', async () => {
|
|
97
|
+
const input: CreateSenderInput = {
|
|
98
|
+
email: 'sender@example.com',
|
|
99
|
+
name: 'Test Sender',
|
|
100
|
+
provider: EmailProvider.SENDGRID,
|
|
101
|
+
skipVerification: true
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = await service.createSender(input);
|
|
105
|
+
|
|
106
|
+
expect(result.success).toBe(true);
|
|
107
|
+
expect(result.data?.replyToEmail).toBe('sender@example.com');
|
|
108
|
+
expect(result.data?.replyToName).toBe('Test Sender');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should use custom reply-to if provided', async () => {
|
|
112
|
+
const input: CreateSenderInput = {
|
|
113
|
+
email: 'sender@example.com',
|
|
114
|
+
name: 'Test Sender',
|
|
115
|
+
replyToEmail: 'support@example.com',
|
|
116
|
+
replyToName: 'Support Team',
|
|
117
|
+
provider: EmailProvider.SENDGRID,
|
|
118
|
+
skipVerification: true
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const result = await service.createSender(input);
|
|
122
|
+
|
|
123
|
+
expect(result.success).toBe(true);
|
|
124
|
+
expect(result.data?.replyToEmail).toBe('support@example.com');
|
|
125
|
+
expect(result.data?.replyToName).toBe('Support Team');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should set isDefault correctly', async () => {
|
|
129
|
+
const input: CreateSenderInput = {
|
|
130
|
+
email: 'default@example.com',
|
|
131
|
+
name: 'Default Sender',
|
|
132
|
+
provider: EmailProvider.SENDGRID,
|
|
133
|
+
isDefault: true,
|
|
134
|
+
skipVerification: true
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await service.createSender(input);
|
|
138
|
+
|
|
139
|
+
expect(result.success).toBe(true);
|
|
140
|
+
expect(result.data?.isDefault).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('getSender', () => {
|
|
145
|
+
it('should retrieve sender by ID', async () => {
|
|
146
|
+
const createResult = await service.createSender({
|
|
147
|
+
email: 'get@example.com',
|
|
148
|
+
name: 'Get Sender',
|
|
149
|
+
provider: EmailProvider.SENDGRID,
|
|
150
|
+
skipVerification: true
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const senderId = createResult.data!.id;
|
|
154
|
+
const getResult = await service.getSender(senderId);
|
|
155
|
+
|
|
156
|
+
expect(getResult.success).toBe(true);
|
|
157
|
+
expect(getResult.data?.id).toBe(senderId);
|
|
158
|
+
expect(getResult.data?.email).toBe('get@example.com');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should fail when sender not found', async () => {
|
|
162
|
+
const result = await service.getSender('non-existent-id');
|
|
163
|
+
|
|
164
|
+
expect(result.success).toBe(false);
|
|
165
|
+
expect(result.error).toContain('not found');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('listSenders', () => {
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
// Create multiple senders
|
|
172
|
+
await service.createSender({
|
|
173
|
+
email: 'sendgrid1@example.com',
|
|
174
|
+
name: 'SendGrid 1',
|
|
175
|
+
provider: EmailProvider.SENDGRID,
|
|
176
|
+
skipVerification: true
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await service.createSender({
|
|
180
|
+
email: 'sendgrid2@example.com',
|
|
181
|
+
name: 'SendGrid 2',
|
|
182
|
+
provider: EmailProvider.SENDGRID,
|
|
183
|
+
skipVerification: true
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await service.createSender({
|
|
187
|
+
email: 'mailgun1@example.com',
|
|
188
|
+
name: 'Mailgun 1',
|
|
189
|
+
provider: EmailProvider.MAILGUN,
|
|
190
|
+
skipVerification: true
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should list all senders', async () => {
|
|
195
|
+
const result = await service.listSenders();
|
|
196
|
+
|
|
197
|
+
expect(result.success).toBe(true);
|
|
198
|
+
expect(result.data?.length).toBeGreaterThanOrEqual(3);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should filter by provider', async () => {
|
|
202
|
+
const result = await service.listSenders({
|
|
203
|
+
provider: EmailProvider.SENDGRID
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(result.success).toBe(true);
|
|
207
|
+
expect(result.data?.every(s => s.provider === EmailProvider.SENDGRID)).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should filter by status', async () => {
|
|
211
|
+
const result = await service.listSenders({
|
|
212
|
+
status: SenderStatus.VERIFIED
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.success).toBe(true);
|
|
216
|
+
expect(result.data?.every(s => s.status === SenderStatus.VERIFIED)).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should filter by active status', async () => {
|
|
220
|
+
const result = await service.listSenders({
|
|
221
|
+
isActive: true
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result.success).toBe(true);
|
|
225
|
+
expect(result.data?.every(s => s.isActive === true)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should handle pagination', async () => {
|
|
229
|
+
const result = await service.listSenders({
|
|
230
|
+
limit: 2,
|
|
231
|
+
offset: 0
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.success).toBe(true);
|
|
235
|
+
expect(result.data?.length).toBeLessThanOrEqual(2);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('updateSender', () => {
|
|
240
|
+
it('should update sender name', async () => {
|
|
241
|
+
const createResult = await service.createSender({
|
|
242
|
+
email: 'update@example.com',
|
|
243
|
+
name: 'Original Name',
|
|
244
|
+
provider: EmailProvider.SENDGRID,
|
|
245
|
+
skipVerification: true
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const senderId = createResult.data!.id;
|
|
249
|
+
const updateInput: UpdateSenderInput = {
|
|
250
|
+
name: 'Updated Name'
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const updateResult = await service.updateSender(senderId, updateInput);
|
|
254
|
+
|
|
255
|
+
expect(updateResult.success).toBe(true);
|
|
256
|
+
expect(updateResult.data?.name).toBe('Updated Name');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should update reply-to email', async () => {
|
|
260
|
+
const createResult = await service.createSender({
|
|
261
|
+
email: 'update@example.com',
|
|
262
|
+
name: 'Test',
|
|
263
|
+
provider: EmailProvider.SENDGRID,
|
|
264
|
+
skipVerification: true
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const senderId = createResult.data!.id;
|
|
268
|
+
const updateInput: UpdateSenderInput = {
|
|
269
|
+
replyToEmail: 'new-reply@example.com'
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const updateResult = await service.updateSender(senderId, updateInput);
|
|
273
|
+
|
|
274
|
+
expect(updateResult.success).toBe(true);
|
|
275
|
+
expect(updateResult.data?.replyToEmail).toBe('new-reply@example.com');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should set isDefault and unset other defaults', async () => {
|
|
279
|
+
// Create two senders
|
|
280
|
+
const sender1 = await service.createSender({
|
|
281
|
+
email: 'sender1@example.com',
|
|
282
|
+
name: 'Sender 1',
|
|
283
|
+
provider: EmailProvider.SENDGRID,
|
|
284
|
+
isDefault: true,
|
|
285
|
+
skipVerification: true
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const sender2 = await service.createSender({
|
|
289
|
+
email: 'sender2@example.com',
|
|
290
|
+
name: 'Sender 2',
|
|
291
|
+
provider: EmailProvider.SENDGRID,
|
|
292
|
+
skipVerification: true
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Set sender2 as default
|
|
296
|
+
await service.updateSender(sender2.data!.id, { isDefault: true });
|
|
297
|
+
|
|
298
|
+
// Check that sender1 is no longer default
|
|
299
|
+
const getSender1 = await service.getSender(sender1.data!.id);
|
|
300
|
+
expect(getSender1.data?.isDefault).toBe(false);
|
|
301
|
+
|
|
302
|
+
// Check that sender2 is default
|
|
303
|
+
const getSender2 = await service.getSender(sender2.data!.id);
|
|
304
|
+
expect(getSender2.data?.isDefault).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should fail when updating non-existent sender', async () => {
|
|
308
|
+
const result = await service.updateSender('non-existent', { name: 'Test' });
|
|
309
|
+
|
|
310
|
+
expect(result.success).toBe(false);
|
|
311
|
+
expect(result.error).toContain('not found');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('deleteSender', () => {
|
|
316
|
+
it('should soft delete sender', async () => {
|
|
317
|
+
const createResult = await service.createSender({
|
|
318
|
+
email: 'delete@example.com',
|
|
319
|
+
name: 'Delete Sender',
|
|
320
|
+
provider: EmailProvider.SENDGRID,
|
|
321
|
+
skipVerification: true
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const senderId = createResult.data!.id;
|
|
325
|
+
const deleteResult = await service.deleteSender(senderId);
|
|
326
|
+
|
|
327
|
+
expect(deleteResult.success).toBe(true);
|
|
328
|
+
|
|
329
|
+
// Should not be able to get deleted sender
|
|
330
|
+
const getResult = await service.getSender(senderId);
|
|
331
|
+
expect(getResult.success).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should fail when deleting non-existent sender', async () => {
|
|
335
|
+
const result = await service.deleteSender('non-existent');
|
|
336
|
+
|
|
337
|
+
expect(result.success).toBe(false);
|
|
338
|
+
expect(result.error).toContain('not found');
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('verifySender', () => {
|
|
343
|
+
it('should verify sender with valid token', async () => {
|
|
344
|
+
const createResult = await service.createSender({
|
|
345
|
+
email: 'verify@example.com',
|
|
346
|
+
name: 'Verify Sender',
|
|
347
|
+
provider: EmailProvider.SMTP, // Use SMTP to avoid provider API calls
|
|
348
|
+
skipVerification: false
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const sender = createResult.data!;
|
|
352
|
+
|
|
353
|
+
// Get the verification token from the sender
|
|
354
|
+
const senderResult = await service.getSender(sender.id);
|
|
355
|
+
const token = senderResult.data!.verificationToken!;
|
|
356
|
+
|
|
357
|
+
// Verify with token
|
|
358
|
+
const verifyResult = await service.verifySender(token);
|
|
359
|
+
|
|
360
|
+
expect(verifyResult.success).toBe(true);
|
|
361
|
+
expect(verifyResult.status).toBe(SenderStatus.VERIFIED);
|
|
362
|
+
expect(verifyResult.verifiedAt).toBeDefined();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should return already verified for already verified sender', async () => {
|
|
366
|
+
await service.createSender({
|
|
367
|
+
email: 'already@example.com',
|
|
368
|
+
name: 'Already Verified',
|
|
369
|
+
provider: EmailProvider.SMTP,
|
|
370
|
+
skipVerification: true
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Try to verify with a fake token (sender is already verified)
|
|
374
|
+
const result = await service.verifySender('fake-token');
|
|
375
|
+
|
|
376
|
+
expect(result.success).toBe(false);
|
|
377
|
+
expect(result.message).toContain('Invalid');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should fail with invalid token', async () => {
|
|
381
|
+
const result = await service.verifySender('invalid-token');
|
|
382
|
+
|
|
383
|
+
expect(result.success).toBe(false);
|
|
384
|
+
expect(result.errors).toContain('Token not found or already used');
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('getDefaultSender', () => {
|
|
389
|
+
it('should return default sender for provider', async () => {
|
|
390
|
+
await service.createSender({
|
|
391
|
+
email: 'default@example.com',
|
|
392
|
+
name: 'Default Sender',
|
|
393
|
+
provider: EmailProvider.SENDGRID,
|
|
394
|
+
isDefault: true,
|
|
395
|
+
skipVerification: true
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const result = await service.getDefaultSender(EmailProvider.SENDGRID);
|
|
399
|
+
|
|
400
|
+
expect(result.success).toBe(true);
|
|
401
|
+
expect(result.data?.email).toBe('default@example.com');
|
|
402
|
+
expect(result.data?.isDefault).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should fail when no default sender exists', async () => {
|
|
406
|
+
const result = await service.getDefaultSender(EmailProvider.MAILGUN);
|
|
407
|
+
|
|
408
|
+
expect(result.success).toBe(false);
|
|
409
|
+
expect(result.error).toContain('No default sender found');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('checkCompliance', () => {
|
|
414
|
+
it('should check compliance for sender', async () => {
|
|
415
|
+
const createResult = await service.createSender({
|
|
416
|
+
email: 'compliance@example.com',
|
|
417
|
+
name: 'Compliance Sender',
|
|
418
|
+
provider: EmailProvider.SENDGRID,
|
|
419
|
+
skipVerification: true
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const senderId = createResult.data!.id;
|
|
423
|
+
const complianceResult = await service.checkCompliance(senderId);
|
|
424
|
+
|
|
425
|
+
expect(complianceResult.success).toBe(true);
|
|
426
|
+
expect(complianceResult.data?.checks).toBeDefined();
|
|
427
|
+
expect(complianceResult.data?.checks.emailFormat).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should fail when checking compliance for non-existent sender', async () => {
|
|
431
|
+
const result = await service.checkCompliance('non-existent');
|
|
432
|
+
|
|
433
|
+
expect(result.success).toBe(false);
|
|
434
|
+
expect(result.error).toContain('not found');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe('resendVerification', () => {
|
|
439
|
+
it('should fail when resending for verified sender', async () => {
|
|
440
|
+
const createResult = await service.createSender({
|
|
441
|
+
email: 'resend@example.com',
|
|
442
|
+
name: 'Resend Sender',
|
|
443
|
+
provider: EmailProvider.SENDGRID,
|
|
444
|
+
skipVerification: true
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const senderId = createResult.data!.id;
|
|
448
|
+
const result = await service.resendVerification(senderId);
|
|
449
|
+
|
|
450
|
+
expect(result.success).toBe(false);
|
|
451
|
+
expect(result.error).toContain('already verified');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should fail when resending for non-existent sender', async () => {
|
|
455
|
+
const result = await service.resendVerification('non-existent');
|
|
456
|
+
|
|
457
|
+
expect(result.success).toBe(false);
|
|
458
|
+
expect(result.error).toContain('not found');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch mock utilities for testing HTTP calls
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface MockResponse {
|
|
14
|
+
status: number;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
statusText: string;
|
|
17
|
+
data: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FetchMock {
|
|
21
|
+
private responses: Map<string, MockResponse[]> = new Map();
|
|
22
|
+
private callHistory: Array<{ url: string; method: string; body?: string }> = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mock a successful response
|
|
26
|
+
*/
|
|
27
|
+
mockSuccess(url: string, data: unknown, status = 200): void {
|
|
28
|
+
const existing = this.responses.get(url) || [];
|
|
29
|
+
existing.push({
|
|
30
|
+
status,
|
|
31
|
+
ok: true,
|
|
32
|
+
statusText: 'OK',
|
|
33
|
+
data
|
|
34
|
+
});
|
|
35
|
+
this.responses.set(url, existing);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mock an error response
|
|
40
|
+
*/
|
|
41
|
+
mockError(url: string, status: number, data: unknown): void {
|
|
42
|
+
const existing = this.responses.get(url) || [];
|
|
43
|
+
existing.push({
|
|
44
|
+
status,
|
|
45
|
+
ok: false,
|
|
46
|
+
statusText: this.getStatusText(status),
|
|
47
|
+
data
|
|
48
|
+
});
|
|
49
|
+
this.responses.set(url, existing);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mock a network error
|
|
54
|
+
*/
|
|
55
|
+
mockNetworkError(url: string, message = 'Network error'): void {
|
|
56
|
+
const existing = this.responses.get(url) || [];
|
|
57
|
+
existing.push({
|
|
58
|
+
status: 0,
|
|
59
|
+
ok: false,
|
|
60
|
+
statusText: 'Network Error',
|
|
61
|
+
data: { error: message }
|
|
62
|
+
});
|
|
63
|
+
this.responses.set(url, existing);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the mock fetch function
|
|
68
|
+
*/
|
|
69
|
+
getFetchMock(): typeof fetch {
|
|
70
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
71
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
72
|
+
const method = init?.method || 'GET';
|
|
73
|
+
const body = init?.body?.toString();
|
|
74
|
+
|
|
75
|
+
// Record the call
|
|
76
|
+
this.callHistory.push({ url, method, body });
|
|
77
|
+
|
|
78
|
+
// Get response for this URL
|
|
79
|
+
const responses = this.responses.get(url);
|
|
80
|
+
if (!responses || responses.length === 0) {
|
|
81
|
+
throw new Error(`No mock response configured for ${method} ${url}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Get and remove first response (FIFO)
|
|
85
|
+
const response = responses.shift()!;
|
|
86
|
+
|
|
87
|
+
// Network error case
|
|
88
|
+
if (response.status === 0) {
|
|
89
|
+
throw new Error(response.data && typeof response.data === 'object' && 'error' in response.data
|
|
90
|
+
? String(response.data.error)
|
|
91
|
+
: 'Network error');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create mock Response object
|
|
95
|
+
return {
|
|
96
|
+
ok: response.ok,
|
|
97
|
+
status: response.status,
|
|
98
|
+
statusText: response.statusText,
|
|
99
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
100
|
+
json: async () => response.data,
|
|
101
|
+
text: async () => JSON.stringify(response.data),
|
|
102
|
+
blob: async () => new Blob(),
|
|
103
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
104
|
+
formData: async () => new FormData(),
|
|
105
|
+
clone: function() { return this; },
|
|
106
|
+
body: null,
|
|
107
|
+
bodyUsed: false,
|
|
108
|
+
url,
|
|
109
|
+
redirected: false,
|
|
110
|
+
type: 'basic'
|
|
111
|
+
} as Response;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get call history
|
|
117
|
+
*/
|
|
118
|
+
getCallHistory(): Array<{ url: string; method: string; body?: string }> {
|
|
119
|
+
return [...this.callHistory];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Clear all mocks
|
|
124
|
+
*/
|
|
125
|
+
clear(): void {
|
|
126
|
+
this.responses.clear();
|
|
127
|
+
this.callHistory = [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get HTTP status text
|
|
132
|
+
*/
|
|
133
|
+
private getStatusText(status: number): string {
|
|
134
|
+
const statusTexts: Record<number, string> = {
|
|
135
|
+
200: 'OK',
|
|
136
|
+
201: 'Created',
|
|
137
|
+
204: 'No Content',
|
|
138
|
+
400: 'Bad Request',
|
|
139
|
+
401: 'Unauthorized',
|
|
140
|
+
403: 'Forbidden',
|
|
141
|
+
404: 'Not Found',
|
|
142
|
+
429: 'Too Many Requests',
|
|
143
|
+
500: 'Internal Server Error',
|
|
144
|
+
502: 'Bad Gateway',
|
|
145
|
+
503: 'Service Unavailable'
|
|
146
|
+
};
|
|
147
|
+
return statusTexts[status] || 'Unknown';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a new fetch mock instance
|
|
153
|
+
*/
|
|
154
|
+
export function createFetchMock(): FetchMock {
|
|
155
|
+
return new FetchMock();
|
|
156
|
+
}
|