@airhornjs/aws 5.0.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.
@@ -0,0 +1,486 @@
1
+ import { AirhornSendType } from "airhorn";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { AirhornAws } from "../src/index";
4
+ import { mockSesSend, mockSnsPublish } from "./setup";
5
+
6
+ describe("AirhornAws", () => {
7
+ let provider: AirhornAws;
8
+ const mockOptions = {
9
+ region: "us-east-1",
10
+ accessKeyId: "test-access-key",
11
+ secretAccessKey: "test-secret-key",
12
+ };
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ provider = new AirhornAws(mockOptions);
17
+ });
18
+
19
+ describe("constructor", () => {
20
+ it("should create instance with SMS, MobilePush, and Email capabilities by default", () => {
21
+ expect(provider).toBeDefined();
22
+ expect(provider.name).toBe("aws");
23
+ expect(provider.capabilities).toContain(AirhornSendType.SMS);
24
+ expect(provider.capabilities).toContain(AirhornSendType.MobilePush);
25
+ expect(provider.capabilities).toContain(AirhornSendType.Email);
26
+ });
27
+
28
+ it("should handle credentials from environment when not provided", () => {
29
+ const providerWithoutCreds = new AirhornAws({
30
+ region: "us-east-1",
31
+ });
32
+ expect(providerWithoutCreds.capabilities).toContain(AirhornSendType.SMS);
33
+ expect(providerWithoutCreds.capabilities).toContain(
34
+ AirhornSendType.MobilePush,
35
+ );
36
+ expect(providerWithoutCreds.capabilities).toContain(
37
+ AirhornSendType.Email,
38
+ );
39
+ });
40
+
41
+ it("should accept custom capabilities", () => {
42
+ const smsOnlyProvider = new AirhornAws({
43
+ ...mockOptions,
44
+ capabilities: [AirhornSendType.SMS],
45
+ });
46
+ expect(smsOnlyProvider.capabilities).toEqual([AirhornSendType.SMS]);
47
+ expect(smsOnlyProvider.capabilities).not.toContain(AirhornSendType.Email);
48
+
49
+ const emailOnlyProvider = new AirhornAws({
50
+ ...mockOptions,
51
+ capabilities: [AirhornSendType.Email],
52
+ });
53
+ expect(emailOnlyProvider.capabilities).toEqual([AirhornSendType.Email]);
54
+ expect(emailOnlyProvider.capabilities).not.toContain(AirhornSendType.SMS);
55
+ });
56
+
57
+ it("should throw error when region is missing", () => {
58
+ expect(() => {
59
+ new AirhornAws({
60
+ region: "",
61
+ accessKeyId: "key",
62
+ secretAccessKey: "secret",
63
+ });
64
+ }).toThrowError("AirhornAws requires region");
65
+ });
66
+ });
67
+
68
+ describe("SMS sending", () => {
69
+ const mockMessage = {
70
+ to: "+0987654321",
71
+ from: "+1234567890",
72
+ content: "Test SMS message",
73
+ type: AirhornSendType.SMS,
74
+ };
75
+
76
+ it("should send SMS successfully", async () => {
77
+ mockSnsPublish.mockResolvedValueOnce({
78
+ MessageId: "msg-123",
79
+ SequenceNumber: "seq-123",
80
+ $metadata: { httpStatusCode: 200 },
81
+ });
82
+
83
+ const result = await provider.send(mockMessage);
84
+
85
+ expect(result.success).toBe(true);
86
+ expect(result.response).toHaveProperty("messageId", "msg-123");
87
+ expect(result.response).toHaveProperty("sequenceNumber", "seq-123");
88
+ expect(result.errors).toHaveLength(0);
89
+ });
90
+
91
+ it("should require from phone number in message", async () => {
92
+ const messageWithoutFrom = { ...mockMessage, from: "" };
93
+ const result = await provider.send(messageWithoutFrom);
94
+
95
+ expect(result.success).toBe(false);
96
+ expect(result.errors).toHaveLength(1);
97
+ expect(result.errors[0].message).toContain(
98
+ "From identifier is required for SMS/MobilePush messages",
99
+ );
100
+ });
101
+
102
+ it("should handle SMS send errors", async () => {
103
+ const errorMessage = "Invalid phone number";
104
+ mockSnsPublish.mockRejectedValueOnce(new Error(errorMessage));
105
+
106
+ const result = await provider.send(mockMessage);
107
+
108
+ expect(result.success).toBe(false);
109
+ expect(result.errors).toHaveLength(1);
110
+ expect(result.errors[0].message).toBe(errorMessage);
111
+ });
112
+
113
+ it("should pass custom SMS options", async () => {
114
+ mockSnsPublish.mockResolvedValueOnce({
115
+ MessageId: "msg-456",
116
+ $metadata: { httpStatusCode: 200 },
117
+ });
118
+
119
+ const customOptions = {
120
+ smsType: "Promotional",
121
+ maxPrice: "0.50",
122
+ };
123
+
124
+ await provider.send(mockMessage, customOptions);
125
+
126
+ expect(mockSnsPublish).toHaveBeenCalledWith(
127
+ expect.objectContaining({
128
+ PhoneNumber: mockMessage.to,
129
+ Message: mockMessage.content,
130
+ MessageAttributes: expect.objectContaining({
131
+ "AWS.SNS.SMS.SMSType": {
132
+ DataType: "String",
133
+ StringValue: "Promotional",
134
+ },
135
+ }),
136
+ maxPrice: "0.50",
137
+ }),
138
+ );
139
+ });
140
+ });
141
+
142
+ describe("Email sending", () => {
143
+ const mockEmailMessage = {
144
+ to: "recipient@example.com",
145
+ from: "sender@example.com",
146
+ subject: "Test Email",
147
+ content: "<p>Test email content</p>",
148
+ type: AirhornSendType.Email,
149
+ };
150
+
151
+ it("should send email successfully via SES", async () => {
152
+ mockSesSend.mockResolvedValueOnce({
153
+ MessageId: "ses-msg-123",
154
+ $metadata: { httpStatusCode: 200 },
155
+ });
156
+
157
+ const result = await provider.send(mockEmailMessage);
158
+
159
+ expect(result.success).toBe(true);
160
+ expect(result.response).toHaveProperty("messageId", "ses-msg-123");
161
+ expect(result.errors).toHaveLength(0);
162
+
163
+ expect(mockSesSend).toHaveBeenCalledWith(
164
+ expect.objectContaining({
165
+ Source: mockEmailMessage.from,
166
+ Destination: {
167
+ ToAddresses: [mockEmailMessage.to],
168
+ },
169
+ Message: {
170
+ Subject: {
171
+ Data: mockEmailMessage.subject,
172
+ Charset: "UTF-8",
173
+ },
174
+ Body: {
175
+ Text: {
176
+ Data: mockEmailMessage.content,
177
+ Charset: "UTF-8",
178
+ },
179
+ Html: {
180
+ Data: mockEmailMessage.content,
181
+ Charset: "UTF-8",
182
+ },
183
+ },
184
+ },
185
+ }),
186
+ );
187
+ });
188
+
189
+ it("should require from email in message", async () => {
190
+ const messageWithoutFrom = { ...mockEmailMessage, from: "" };
191
+ const result = await provider.send(messageWithoutFrom);
192
+
193
+ expect(result.success).toBe(false);
194
+ expect(result.errors).toHaveLength(1);
195
+ expect(result.errors[0].message).toContain(
196
+ "From email address is required for email messages",
197
+ );
198
+ });
199
+
200
+ it("should use default subject when not provided", async () => {
201
+ mockSesSend.mockResolvedValueOnce({
202
+ MessageId: "ses-msg-456",
203
+ $metadata: { httpStatusCode: 200 },
204
+ });
205
+
206
+ const messageWithoutSubject = { ...mockEmailMessage, subject: undefined };
207
+ const result = await provider.send(messageWithoutSubject);
208
+
209
+ expect(result.success).toBe(true);
210
+ expect(mockSesSend).toHaveBeenCalledWith(
211
+ expect.objectContaining({
212
+ Message: expect.objectContaining({
213
+ Subject: {
214
+ Data: "Notification",
215
+ Charset: "UTF-8",
216
+ },
217
+ }),
218
+ }),
219
+ );
220
+ });
221
+
222
+ it("should handle email send errors", async () => {
223
+ const errorMessage = "Invalid email address";
224
+ mockSesSend.mockRejectedValueOnce(new Error(errorMessage));
225
+
226
+ const result = await provider.send(mockEmailMessage);
227
+
228
+ expect(result.success).toBe(false);
229
+ expect(result.errors).toHaveLength(1);
230
+ expect(result.errors[0].message).toBe(errorMessage);
231
+ });
232
+
233
+ it("should pass additional email options", async () => {
234
+ mockSesSend.mockResolvedValueOnce({
235
+ MessageId: "ses-msg-789",
236
+ $metadata: { httpStatusCode: 200 },
237
+ });
238
+
239
+ const additionalOptions = {
240
+ ccAddresses: ["cc@example.com"],
241
+ bccAddresses: ["bcc@example.com"],
242
+ replyToAddresses: ["reply@example.com"],
243
+ configurationSetName: "my-config-set",
244
+ };
245
+
246
+ await provider.send(mockEmailMessage, additionalOptions);
247
+
248
+ expect(mockSesSend).toHaveBeenCalledWith(
249
+ expect.objectContaining({
250
+ Destination: expect.objectContaining({
251
+ CcAddresses: ["cc@example.com"],
252
+ BccAddresses: ["bcc@example.com"],
253
+ }),
254
+ ReplyToAddresses: ["reply@example.com"],
255
+ ConfigurationSetName: "my-config-set",
256
+ }),
257
+ );
258
+ });
259
+ });
260
+
261
+ describe("MobilePush sending", () => {
262
+ const mockPushMessage = {
263
+ to: "arn:aws:sns:us-east-1:123456789012:endpoint/APNS/MyApp/abc123",
264
+ from: "MyApp",
265
+ content: JSON.stringify({
266
+ aps: {
267
+ alert: "Test push notification",
268
+ },
269
+ }),
270
+ type: AirhornSendType.MobilePush,
271
+ };
272
+
273
+ it("should send mobile push notification successfully", async () => {
274
+ mockSnsPublish.mockResolvedValueOnce({
275
+ MessageId: "push-123",
276
+ $metadata: { httpStatusCode: 200 },
277
+ });
278
+
279
+ const result = await provider.send(mockPushMessage);
280
+
281
+ expect(result.success).toBe(true);
282
+ expect(result.response).toHaveProperty("messageId", "push-123");
283
+ expect(result.errors).toHaveLength(0);
284
+
285
+ expect(mockSnsPublish).toHaveBeenCalledWith(
286
+ expect.objectContaining({
287
+ TargetArn: mockPushMessage.to,
288
+ Message: mockPushMessage.content,
289
+ }),
290
+ );
291
+ });
292
+
293
+ it("should send to topic ARN for broadcasts", async () => {
294
+ mockSnsPublish.mockResolvedValueOnce({
295
+ MessageId: "topic-123",
296
+ $metadata: { httpStatusCode: 200 },
297
+ });
298
+
299
+ const topicMessage = {
300
+ ...mockPushMessage,
301
+ to: "arn:aws:sns:us-east-1:123456789012:MyTopic",
302
+ };
303
+
304
+ const result = await provider.send(topicMessage, {
305
+ MessageStructure: "json",
306
+ });
307
+
308
+ expect(result.success).toBe(true);
309
+ expect(mockSnsPublish).toHaveBeenCalledWith(
310
+ expect.objectContaining({
311
+ TargetArn: topicMessage.to,
312
+ MessageStructure: "json",
313
+ }),
314
+ );
315
+ });
316
+
317
+ it("should require from identifier for mobile push", async () => {
318
+ const messageWithoutFrom = { ...mockPushMessage, from: "" };
319
+ const result = await provider.send(messageWithoutFrom);
320
+
321
+ expect(result.success).toBe(false);
322
+ expect(result.errors).toHaveLength(1);
323
+ expect(result.errors[0].message).toContain(
324
+ "From identifier is required for SMS/MobilePush messages",
325
+ );
326
+ });
327
+
328
+ it("should throw error when MobilePush is not in capabilities", async () => {
329
+ const emailOnlyProvider = new AirhornAws({
330
+ ...mockOptions,
331
+ capabilities: [AirhornSendType.Email],
332
+ });
333
+
334
+ const result = await emailOnlyProvider.send(mockPushMessage);
335
+
336
+ expect(result.success).toBe(false);
337
+ expect(result.errors).toHaveLength(1);
338
+ expect(result.errors[0].message).toContain("SNS is not configured");
339
+ });
340
+
341
+ it("should detect ARN-based destinations for SMS type", async () => {
342
+ mockSnsPublish.mockResolvedValueOnce({
343
+ MessageId: "arn-sms-123",
344
+ $metadata: { httpStatusCode: 200 },
345
+ });
346
+
347
+ // Using SMS type but with ARN destination (for backward compatibility)
348
+ const arnSmsMessage = {
349
+ to: "arn:aws:sns:us-east-1:123456789012:endpoint/APNS/MyApp/xyz789",
350
+ from: "MyApp",
351
+ content: "Test content",
352
+ type: AirhornSendType.SMS,
353
+ };
354
+
355
+ const result = await provider.send(arnSmsMessage);
356
+
357
+ expect(result.success).toBe(true);
358
+ expect(mockSnsPublish).toHaveBeenCalledWith(
359
+ expect.objectContaining({
360
+ TargetArn: arnSmsMessage.to,
361
+ Message: arnSmsMessage.content,
362
+ }),
363
+ );
364
+ });
365
+ });
366
+
367
+ describe("Unsupported message types", () => {
368
+ it("should reject unsupported message types", async () => {
369
+ const unsupportedMessage = {
370
+ to: "test",
371
+ from: "test",
372
+ content: "test",
373
+ // biome-ignore lint/suspicious/noExplicitAny: testing unsupported type
374
+ type: "unsupported" as any,
375
+ };
376
+
377
+ const result = await provider.send(unsupportedMessage);
378
+
379
+ expect(result.success).toBe(false);
380
+ expect(result.errors).toHaveLength(1);
381
+ expect(result.errors[0].message).toContain(
382
+ "AirhornAws does not support message type",
383
+ );
384
+ });
385
+ });
386
+
387
+ describe("Additional options", () => {
388
+ it("should pass additional options to SNS", async () => {
389
+ mockSnsPublish.mockResolvedValueOnce({
390
+ MessageId: "msg-custom",
391
+ $metadata: { httpStatusCode: 200 },
392
+ });
393
+
394
+ const message = {
395
+ to: "+0987654321",
396
+ from: "+1234567890",
397
+ content: "Test",
398
+ type: AirhornSendType.SMS,
399
+ };
400
+
401
+ const additionalOptions = {
402
+ smsType: "Promotional",
403
+ maxPrice: "1.00",
404
+ };
405
+
406
+ await provider.send(message, additionalOptions);
407
+
408
+ expect(mockSnsPublish).toHaveBeenCalledWith(
409
+ expect.objectContaining({
410
+ maxPrice: "1.00",
411
+ }),
412
+ );
413
+ });
414
+
415
+ it("should pass additional options to SES", async () => {
416
+ mockSesSend.mockResolvedValueOnce({
417
+ MessageId: "ses-custom",
418
+ $metadata: { httpStatusCode: 200 },
419
+ });
420
+
421
+ const message = {
422
+ to: "recipient@example.com",
423
+ from: "sender@example.com",
424
+ subject: "Test",
425
+ content: "Test content",
426
+ type: AirhornSendType.Email,
427
+ };
428
+
429
+ const additionalOptions = {
430
+ tags: [
431
+ { Name: "campaign", Value: "test" },
432
+ { Name: "type", Value: "transactional" },
433
+ ],
434
+ };
435
+
436
+ await provider.send(message, additionalOptions);
437
+
438
+ expect(mockSesSend).toHaveBeenCalledWith(
439
+ expect.objectContaining({
440
+ Tags: additionalOptions.tags,
441
+ }),
442
+ );
443
+ });
444
+
445
+ it("should throw error when SMS is not in capabilities", async () => {
446
+ const emailOnlyProvider = new AirhornAws({
447
+ ...mockOptions,
448
+ capabilities: [AirhornSendType.Email],
449
+ });
450
+
451
+ const message = {
452
+ to: "+0987654321",
453
+ from: "+1234567890",
454
+ content: "Test",
455
+ type: AirhornSendType.SMS,
456
+ };
457
+
458
+ const result = await emailOnlyProvider.send(message);
459
+
460
+ expect(result.success).toBe(false);
461
+ expect(result.errors).toHaveLength(1);
462
+ expect(result.errors[0].message).toContain("SNS is not configured");
463
+ });
464
+
465
+ it("should throw error when Email is not in capabilities", async () => {
466
+ const smsOnlyProvider = new AirhornAws({
467
+ ...mockOptions,
468
+ capabilities: [AirhornSendType.SMS],
469
+ });
470
+
471
+ const message = {
472
+ to: "recipient@example.com",
473
+ from: "sender@example.com",
474
+ subject: "Test",
475
+ content: "Test content",
476
+ type: AirhornSendType.Email,
477
+ };
478
+
479
+ const result = await smsOnlyProvider.send(message);
480
+
481
+ expect(result.success).toBe(false);
482
+ expect(result.errors).toHaveLength(1);
483
+ expect(result.errors[0].message).toContain("SES is not configured");
484
+ });
485
+ });
486
+ });
package/test/setup.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { vi } from "vitest";
2
+
3
+ // Mock SNS Client
4
+ export const mockSnsPublish = vi.fn();
5
+ export const mockSnsClient = vi.fn(() => ({
6
+ send: mockSnsPublish,
7
+ }));
8
+
9
+ vi.mock("@aws-sdk/client-sns", () => ({
10
+ SNSClient: mockSnsClient,
11
+ PublishCommand: vi.fn((params) => params),
12
+ }));
13
+
14
+ // Mock SES Client
15
+ export const mockSesSend = vi.fn();
16
+ export const mockSesClient = vi.fn(() => ({
17
+ send: mockSesSend,
18
+ }));
19
+
20
+ vi.mock("@aws-sdk/client-ses", () => ({
21
+ SESClient: mockSesClient,
22
+ SendEmailCommand: vi.fn((params) => params),
23
+ }));
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "node",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "allowSyntheticDefaultImports": true,
17
+ "noEmit": false,
18
+ "composite": true,
19
+ "types": ["node", "vitest/globals"]
20
+ },
21
+ "include": ["src/**/*"]
22
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ setupFiles: ["./test/setup.ts"],
7
+ coverage: {
8
+ reporter: ["text", "json", "lcov"],
9
+ exclude: ["**/test/**", "**/dist/**", "**/*.config.ts"],
10
+ },
11
+ },
12
+ });