@step-func-emailer/handlers 0.2.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.
Files changed (136) hide show
  1. package/dist/handlers/__tests__/bounce-handler.test.d.ts +2 -0
  2. package/dist/handlers/__tests__/bounce-handler.test.d.ts.map +1 -0
  3. package/dist/handlers/__tests__/bounce-handler.test.js +126 -0
  4. package/dist/handlers/__tests__/bounce-handler.test.js.map +1 -0
  5. package/dist/handlers/__tests__/check-condition.test.d.ts +2 -0
  6. package/dist/handlers/__tests__/check-condition.test.d.ts.map +1 -0
  7. package/dist/handlers/__tests__/check-condition.test.js +140 -0
  8. package/dist/handlers/__tests__/check-condition.test.js.map +1 -0
  9. package/dist/handlers/__tests__/engagement-handler.test.d.ts +2 -0
  10. package/dist/handlers/__tests__/engagement-handler.test.d.ts.map +1 -0
  11. package/dist/handlers/__tests__/engagement-handler.test.js +139 -0
  12. package/dist/handlers/__tests__/engagement-handler.test.js.map +1 -0
  13. package/dist/handlers/__tests__/send-email.test.d.ts +2 -0
  14. package/dist/handlers/__tests__/send-email.test.d.ts.map +1 -0
  15. package/dist/handlers/__tests__/send-email.test.js +205 -0
  16. package/dist/handlers/__tests__/send-email.test.js.map +1 -0
  17. package/dist/handlers/__tests__/unsubscribe.test.d.ts +2 -0
  18. package/dist/handlers/__tests__/unsubscribe.test.d.ts.map +1 -0
  19. package/dist/handlers/__tests__/unsubscribe.test.js +79 -0
  20. package/dist/handlers/__tests__/unsubscribe.test.js.map +1 -0
  21. package/dist/handlers/bounce-handler.d.ts +3 -0
  22. package/dist/handlers/bounce-handler.d.ts.map +1 -0
  23. package/dist/handlers/bounce-handler.js +55 -0
  24. package/dist/handlers/bounce-handler.js.map +1 -0
  25. package/dist/handlers/check-condition.d.ts +3 -0
  26. package/dist/handlers/check-condition.d.ts.map +1 -0
  27. package/dist/handlers/check-condition.js +74 -0
  28. package/dist/handlers/check-condition.js.map +1 -0
  29. package/dist/handlers/engagement-handler.d.ts +3 -0
  30. package/dist/handlers/engagement-handler.d.ts.map +1 -0
  31. package/dist/handlers/engagement-handler.js +98 -0
  32. package/dist/handlers/engagement-handler.js.map +1 -0
  33. package/dist/handlers/send-email.d.ts +5 -0
  34. package/dist/handlers/send-email.d.ts.map +1 -0
  35. package/dist/handlers/send-email.js +137 -0
  36. package/dist/handlers/send-email.js.map +1 -0
  37. package/dist/handlers/unsubscribe.d.ts +5 -0
  38. package/dist/handlers/unsubscribe.d.ts.map +1 -0
  39. package/dist/handlers/unsubscribe.js +53 -0
  40. package/dist/handlers/unsubscribe.js.map +1 -0
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +50 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/__tests__/display-names.test.d.ts +2 -0
  46. package/dist/lib/__tests__/display-names.test.d.ts.map +1 -0
  47. package/dist/lib/__tests__/display-names.test.js +49 -0
  48. package/dist/lib/__tests__/display-names.test.js.map +1 -0
  49. package/dist/lib/__tests__/dynamo-client.test.d.ts +2 -0
  50. package/dist/lib/__tests__/dynamo-client.test.d.ts.map +1 -0
  51. package/dist/lib/__tests__/dynamo-client.test.js +229 -0
  52. package/dist/lib/__tests__/dynamo-client.test.js.map +1 -0
  53. package/dist/lib/__tests__/execution-stopper.test.d.ts +2 -0
  54. package/dist/lib/__tests__/execution-stopper.test.d.ts.map +1 -0
  55. package/dist/lib/__tests__/execution-stopper.test.js +56 -0
  56. package/dist/lib/__tests__/execution-stopper.test.js.map +1 -0
  57. package/dist/lib/__tests__/ses-sender.test.d.ts +2 -0
  58. package/dist/lib/__tests__/ses-sender.test.d.ts.map +1 -0
  59. package/dist/lib/__tests__/ses-sender.test.js +66 -0
  60. package/dist/lib/__tests__/ses-sender.test.js.map +1 -0
  61. package/dist/lib/__tests__/ssm-config.test.d.ts +2 -0
  62. package/dist/lib/__tests__/ssm-config.test.d.ts.map +1 -0
  63. package/dist/lib/__tests__/ssm-config.test.js +73 -0
  64. package/dist/lib/__tests__/ssm-config.test.js.map +1 -0
  65. package/dist/lib/__tests__/template-renderer.test.d.ts +2 -0
  66. package/dist/lib/__tests__/template-renderer.test.d.ts.map +1 -0
  67. package/dist/lib/__tests__/template-renderer.test.js +79 -0
  68. package/dist/lib/__tests__/template-renderer.test.js.map +1 -0
  69. package/dist/lib/__tests__/unsubscribe-token.test.d.ts +2 -0
  70. package/dist/lib/__tests__/unsubscribe-token.test.d.ts.map +1 -0
  71. package/dist/lib/__tests__/unsubscribe-token.test.js +74 -0
  72. package/dist/lib/__tests__/unsubscribe-token.test.js.map +1 -0
  73. package/dist/lib/display-names.d.ts +5 -0
  74. package/dist/lib/display-names.d.ts.map +1 -0
  75. package/dist/lib/display-names.js +46 -0
  76. package/dist/lib/display-names.js.map +1 -0
  77. package/dist/lib/dynamo-client.d.ts +13 -0
  78. package/dist/lib/dynamo-client.d.ts.map +1 -0
  79. package/dist/lib/dynamo-client.js +211 -0
  80. package/dist/lib/dynamo-client.js.map +1 -0
  81. package/dist/lib/execution-stopper.d.ts +2 -0
  82. package/dist/lib/execution-stopper.d.ts.map +1 -0
  83. package/dist/lib/execution-stopper.js +38 -0
  84. package/dist/lib/execution-stopper.js.map +1 -0
  85. package/dist/lib/logger.d.ts +8 -0
  86. package/dist/lib/logger.d.ts.map +1 -0
  87. package/dist/lib/logger.js +13 -0
  88. package/dist/lib/logger.js.map +1 -0
  89. package/dist/lib/ses-sender.d.ts +13 -0
  90. package/dist/lib/ses-sender.d.ts.map +1 -0
  91. package/dist/lib/ses-sender.js +86 -0
  92. package/dist/lib/ses-sender.js.map +1 -0
  93. package/dist/lib/ses-suppression.d.ts +2 -0
  94. package/dist/lib/ses-suppression.d.ts.map +1 -0
  95. package/dist/lib/ses-suppression.js +24 -0
  96. package/dist/lib/ses-suppression.js.map +1 -0
  97. package/dist/lib/ssm-config.d.ts +14 -0
  98. package/dist/lib/ssm-config.d.ts.map +1 -0
  99. package/dist/lib/ssm-config.js +54 -0
  100. package/dist/lib/ssm-config.js.map +1 -0
  101. package/dist/lib/template-renderer.d.ts +9 -0
  102. package/dist/lib/template-renderer.d.ts.map +1 -0
  103. package/dist/lib/template-renderer.js +36 -0
  104. package/dist/lib/template-renderer.js.map +1 -0
  105. package/dist/lib/unsubscribe-token.d.ts +13 -0
  106. package/dist/lib/unsubscribe-token.d.ts.map +1 -0
  107. package/dist/lib/unsubscribe-token.js +37 -0
  108. package/dist/lib/unsubscribe-token.js.map +1 -0
  109. package/package.json +41 -0
  110. package/src/handlers/__tests__/bounce-handler.test.ts +173 -0
  111. package/src/handlers/__tests__/check-condition.test.ts +172 -0
  112. package/src/handlers/__tests__/engagement-handler.test.ts +161 -0
  113. package/src/handlers/__tests__/send-email.test.ts +279 -0
  114. package/src/handlers/__tests__/unsubscribe.test.ts +111 -0
  115. package/src/handlers/bounce-handler.ts +91 -0
  116. package/src/handlers/check-condition.ts +77 -0
  117. package/src/handlers/engagement-handler.ts +169 -0
  118. package/src/handlers/send-email.ts +184 -0
  119. package/src/handlers/unsubscribe.ts +70 -0
  120. package/src/index.ts +10 -0
  121. package/src/lib/__tests__/display-names.test.ts +52 -0
  122. package/src/lib/__tests__/dynamo-client.test.ts +346 -0
  123. package/src/lib/__tests__/execution-stopper.test.ts +68 -0
  124. package/src/lib/__tests__/ses-sender.test.ts +85 -0
  125. package/src/lib/__tests__/ssm-config.test.ts +85 -0
  126. package/src/lib/__tests__/template-renderer.test.ts +96 -0
  127. package/src/lib/__tests__/unsubscribe-token.test.ts +79 -0
  128. package/src/lib/display-names.ts +54 -0
  129. package/src/lib/dynamo-client.ts +301 -0
  130. package/src/lib/execution-stopper.ts +40 -0
  131. package/src/lib/logger.ts +10 -0
  132. package/src/lib/ses-sender.ts +102 -0
  133. package/src/lib/ses-suppression.ts +30 -0
  134. package/src/lib/ssm-config.ts +81 -0
  135. package/src/lib/template-renderer.ts +52 -0
  136. package/src/lib/unsubscribe-token.ts +53 -0
@@ -0,0 +1,346 @@
1
+ import { vi } from "vitest";
2
+ import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
3
+
4
+ const mockSend = vi.fn();
5
+ class MockCommand {
6
+ _type: string;
7
+ input: unknown;
8
+ constructor(type: string, input: unknown) {
9
+ this._type = type;
10
+ this.input = input;
11
+ }
12
+ }
13
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
14
+ DynamoDBClient: class {
15
+ send = mockSend;
16
+ },
17
+ GetItemCommand: class extends MockCommand {
18
+ constructor(input: unknown) {
19
+ super("GetItem", input);
20
+ }
21
+ },
22
+ PutItemCommand: class extends MockCommand {
23
+ constructor(input: unknown) {
24
+ super("PutItem", input);
25
+ }
26
+ },
27
+ UpdateItemCommand: class extends MockCommand {
28
+ constructor(input: unknown) {
29
+ super("UpdateItem", input);
30
+ }
31
+ },
32
+ DeleteItemCommand: class extends MockCommand {
33
+ constructor(input: unknown) {
34
+ super("DeleteItem", input);
35
+ }
36
+ },
37
+ QueryCommand: class extends MockCommand {
38
+ constructor(input: unknown) {
39
+ super("Query", input);
40
+ }
41
+ },
42
+ }));
43
+
44
+ const {
45
+ getSubscriberProfile,
46
+ upsertSubscriberProfile,
47
+ extractAttributes,
48
+ getExecution,
49
+ putExecution,
50
+ deleteExecution,
51
+ getAllExecutions,
52
+ writeSendLog,
53
+ hasBeenSent,
54
+ writeSuppression,
55
+ setProfileFlag,
56
+ } = await import("../dynamo-client.js");
57
+
58
+ beforeEach(() => {
59
+ mockSend.mockReset();
60
+ });
61
+
62
+ describe("getSubscriberProfile", () => {
63
+ it("returns profile when item exists", async () => {
64
+ const profile = {
65
+ PK: "SUB#user@example.com",
66
+ SK: "PROFILE",
67
+ email: "user@example.com",
68
+ firstName: "Jane",
69
+ unsubscribed: false,
70
+ suppressed: false,
71
+ createdAt: "2026-01-01T00:00:00.000Z",
72
+ updatedAt: "2026-01-01T00:00:00.000Z",
73
+ };
74
+ mockSend.mockResolvedValueOnce({ Item: marshall(profile) });
75
+
76
+ const result = await getSubscriberProfile("TestTable", "user@example.com");
77
+ expect(result).toEqual(profile);
78
+ });
79
+
80
+ it("returns null when item does not exist", async () => {
81
+ mockSend.mockResolvedValueOnce({});
82
+
83
+ const result = await getSubscriberProfile("TestTable", "nobody@example.com");
84
+ expect(result).toBeNull();
85
+ });
86
+ });
87
+
88
+ describe("upsertSubscriberProfile", () => {
89
+ it("sends UpdateItemCommand with correct key", async () => {
90
+ mockSend.mockResolvedValueOnce({});
91
+
92
+ await upsertSubscriberProfile("TestTable", {
93
+ email: "user@example.com",
94
+ firstName: "Jane",
95
+ });
96
+
97
+ expect(mockSend).toHaveBeenCalledOnce();
98
+ const cmd = mockSend.mock.calls[0][0];
99
+ expect(cmd._type).toBe("UpdateItem");
100
+ expect(cmd.input.TableName).toBe("TestTable");
101
+ // Verify key contains the correct PK
102
+ const key = unmarshall(cmd.input.Key);
103
+ expect(key.PK).toBe("SUB#user@example.com");
104
+ expect(key.SK).toBe("PROFILE");
105
+ });
106
+
107
+ it("includes attribute expressions for subscriber attributes", async () => {
108
+ mockSend.mockResolvedValueOnce({});
109
+
110
+ await upsertSubscriberProfile("TestTable", {
111
+ email: "user@example.com",
112
+ firstName: "Jane",
113
+ attributes: { platform: "web", plan: "pro" },
114
+ });
115
+
116
+ const cmd = mockSend.mock.calls[0][0];
117
+ expect(cmd.input.UpdateExpression).toContain("#attr_platform = :attr_platform");
118
+ expect(cmd.input.UpdateExpression).toContain("#attr_plan = :attr_plan");
119
+ expect(cmd.input.UpdateExpression).not.toContain("attributes.#attr_");
120
+ expect(cmd.input.ExpressionAttributeNames?.["#attr_platform"]).toBe("platform");
121
+ });
122
+ });
123
+
124
+ describe("extractAttributes", () => {
125
+ it("returns only non-system keys", () => {
126
+ const result = extractAttributes({
127
+ PK: "SUB#user@example.com",
128
+ SK: "PROFILE",
129
+ email: "user@example.com",
130
+ firstName: "Jane",
131
+ unsubscribed: false,
132
+ suppressed: false,
133
+ createdAt: "2026-01-01T00:00:00.000Z",
134
+ updatedAt: "2026-01-01T00:00:00.000Z",
135
+ platform: "web",
136
+ country: "US",
137
+ });
138
+ expect(result).toEqual({ platform: "web", country: "US" });
139
+ });
140
+
141
+ it("returns empty object when no custom attributes", () => {
142
+ const result = extractAttributes({
143
+ PK: "SUB#user@example.com",
144
+ SK: "PROFILE",
145
+ email: "user@example.com",
146
+ firstName: "Jane",
147
+ unsubscribed: false,
148
+ suppressed: false,
149
+ createdAt: "2026-01-01T00:00:00.000Z",
150
+ updatedAt: "2026-01-01T00:00:00.000Z",
151
+ });
152
+ expect(result).toEqual({});
153
+ });
154
+ });
155
+
156
+ describe("getExecution", () => {
157
+ it("returns execution when found", async () => {
158
+ const exec = {
159
+ PK: "SUB#user@example.com",
160
+ SK: "EXEC#onboarding",
161
+ executionArn: "arn:aws:states:us-east-1:123:execution:abc",
162
+ sequenceId: "onboarding",
163
+ startedAt: "2026-01-01T00:00:00.000Z",
164
+ };
165
+ mockSend.mockResolvedValueOnce({ Item: marshall(exec) });
166
+
167
+ const result = await getExecution("TestTable", "user@example.com", "onboarding");
168
+ expect(result).toEqual(exec);
169
+ });
170
+
171
+ it("returns null when not found", async () => {
172
+ mockSend.mockResolvedValueOnce({});
173
+
174
+ const result = await getExecution("TestTable", "user@example.com", "onboarding");
175
+ expect(result).toBeNull();
176
+ });
177
+ });
178
+
179
+ describe("putExecution", () => {
180
+ it("stores execution with correct keys", async () => {
181
+ mockSend.mockResolvedValueOnce({});
182
+
183
+ await putExecution(
184
+ "TestTable",
185
+ "user@example.com",
186
+ "onboarding",
187
+ "arn:aws:states:us-east-1:123:execution:abc",
188
+ );
189
+
190
+ const cmd = mockSend.mock.calls[0][0];
191
+ expect(cmd._type).toBe("PutItem");
192
+ const item = unmarshall(cmd.input.Item);
193
+ expect(item.PK).toBe("SUB#user@example.com");
194
+ expect(item.SK).toBe("EXEC#onboarding");
195
+ expect(item.executionArn).toBe("arn:aws:states:us-east-1:123:execution:abc");
196
+ expect(item.sequenceId).toBe("onboarding");
197
+ expect(item.startedAt).toBeTruthy();
198
+ });
199
+ });
200
+
201
+ describe("deleteExecution", () => {
202
+ it("deletes with correct key", async () => {
203
+ mockSend.mockResolvedValueOnce({});
204
+
205
+ await deleteExecution("TestTable", "user@example.com", "onboarding");
206
+
207
+ const cmd = mockSend.mock.calls[0][0];
208
+ expect(cmd._type).toBe("DeleteItem");
209
+ const key = unmarshall(cmd.input.Key);
210
+ expect(key.PK).toBe("SUB#user@example.com");
211
+ expect(key.SK).toBe("EXEC#onboarding");
212
+ });
213
+ });
214
+
215
+ describe("getAllExecutions", () => {
216
+ it("returns all executions for subscriber", async () => {
217
+ const execs = [
218
+ {
219
+ PK: "SUB#user@example.com",
220
+ SK: "EXEC#onboarding",
221
+ executionArn: "arn:1",
222
+ sequenceId: "onboarding",
223
+ startedAt: "2026-01-01T00:00:00.000Z",
224
+ },
225
+ {
226
+ PK: "SUB#user@example.com",
227
+ SK: "EXEC#win-back",
228
+ executionArn: "arn:2",
229
+ sequenceId: "win-back",
230
+ startedAt: "2026-01-02T00:00:00.000Z",
231
+ },
232
+ ];
233
+ mockSend.mockResolvedValueOnce({
234
+ Items: execs.map((e) => marshall(e)),
235
+ });
236
+
237
+ const result = await getAllExecutions("TestTable", "user@example.com");
238
+ expect(result).toHaveLength(2);
239
+ expect(result[0].sequenceId).toBe("onboarding");
240
+ expect(result[1].sequenceId).toBe("win-back");
241
+ });
242
+
243
+ it("returns empty array when no executions exist", async () => {
244
+ mockSend.mockResolvedValueOnce({ Items: [] });
245
+
246
+ const result = await getAllExecutions("TestTable", "user@example.com");
247
+ expect(result).toEqual([]);
248
+ });
249
+ });
250
+
251
+ describe("writeSendLog", () => {
252
+ it("writes send log with TTL", async () => {
253
+ mockSend.mockResolvedValueOnce({});
254
+
255
+ await writeSendLog("TestTable", "user@example.com", {
256
+ templateKey: "onboarding/welcome",
257
+ sequenceId: "onboarding",
258
+ subject: "Welcome!",
259
+ sesMessageId: "msg-123",
260
+ });
261
+
262
+ const cmd = mockSend.mock.calls[0][0];
263
+ expect(cmd._type).toBe("PutItem");
264
+ const item = unmarshall(cmd.input.Item);
265
+ expect(item.PK).toBe("SUB#user@example.com");
266
+ expect(item.SK).toMatch(/^SENT#/);
267
+ expect(item.templateKey).toBe("onboarding/welcome");
268
+ expect(item.ttl).toBeGreaterThan(0);
269
+ });
270
+ });
271
+
272
+ describe("hasBeenSent", () => {
273
+ it("returns true when matching send log exists", async () => {
274
+ mockSend.mockResolvedValueOnce({ Count: 1, Items: [{}] });
275
+
276
+ const result = await hasBeenSent("TestTable", "user@example.com", "onboarding/welcome");
277
+ expect(result).toBe(true);
278
+ });
279
+
280
+ it("returns false when no matching send log", async () => {
281
+ mockSend.mockResolvedValueOnce({ Count: 0, Items: [] });
282
+
283
+ const result = await hasBeenSent("TestTable", "user@example.com", "onboarding/welcome");
284
+ expect(result).toBe(false);
285
+ });
286
+ });
287
+
288
+ describe("writeSuppression", () => {
289
+ it("writes bounce suppression record", async () => {
290
+ mockSend.mockResolvedValueOnce({});
291
+
292
+ await writeSuppression(
293
+ "TestTable",
294
+ "user@example.com",
295
+ "bounce",
296
+ "Permanent",
297
+ "feedback-id-123",
298
+ );
299
+
300
+ const cmd = mockSend.mock.calls[0][0];
301
+ const item = unmarshall(cmd.input.Item);
302
+ expect(item.PK).toBe("SUB#user@example.com");
303
+ expect(item.SK).toBe("SUPPRESSION");
304
+ expect(item.reason).toBe("bounce");
305
+ expect(item.bounceType).toBe("Permanent");
306
+ expect(item.sesNotificationId).toBe("feedback-id-123");
307
+ });
308
+
309
+ it("writes complaint suppression without bounceType", async () => {
310
+ mockSend.mockResolvedValueOnce({});
311
+
312
+ await writeSuppression(
313
+ "TestTable",
314
+ "user@example.com",
315
+ "complaint",
316
+ undefined,
317
+ "feedback-id-456",
318
+ );
319
+
320
+ const cmd = mockSend.mock.calls[0][0];
321
+ const item = unmarshall(cmd.input.Item);
322
+ expect(item.reason).toBe("complaint");
323
+ expect(item.bounceType).toBeUndefined();
324
+ });
325
+ });
326
+
327
+ describe("setProfileFlag", () => {
328
+ it("sets unsubscribed flag", async () => {
329
+ mockSend.mockResolvedValueOnce({});
330
+
331
+ await setProfileFlag("TestTable", "user@example.com", "unsubscribed");
332
+
333
+ const cmd = mockSend.mock.calls[0][0];
334
+ expect(cmd._type).toBe("UpdateItem");
335
+ expect(cmd.input.ExpressionAttributeNames?.["#flag"]).toBe("unsubscribed");
336
+ });
337
+
338
+ it("sets suppressed flag", async () => {
339
+ mockSend.mockResolvedValueOnce({});
340
+
341
+ await setProfileFlag("TestTable", "user@example.com", "suppressed");
342
+
343
+ const cmd = mockSend.mock.calls[0][0];
344
+ expect(cmd.input.ExpressionAttributeNames?.["#flag"]).toBe("suppressed");
345
+ });
346
+ });
@@ -0,0 +1,68 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockSfnSend = vi.fn();
4
+ vi.mock("@aws-sdk/client-sfn", () => ({
5
+ SFNClient: class {
6
+ send = mockSfnSend;
7
+ },
8
+ StopExecutionCommand: class {
9
+ input: unknown;
10
+ constructor(input: unknown) {
11
+ this.input = input;
12
+ }
13
+ },
14
+ }));
15
+
16
+ const mockGetAllExecutions = vi.fn();
17
+ const mockDeleteExecution = vi.fn();
18
+ vi.mock("../dynamo-client.js", () => ({
19
+ getAllExecutions: (...args: unknown[]) => mockGetAllExecutions(...args),
20
+ deleteExecution: (...args: unknown[]) => mockDeleteExecution(...args),
21
+ }));
22
+
23
+ const { stopAllExecutions } = await import("../execution-stopper.js");
24
+
25
+ beforeEach(() => {
26
+ mockSfnSend.mockReset();
27
+ mockGetAllExecutions.mockReset();
28
+ mockDeleteExecution.mockReset();
29
+ });
30
+
31
+ describe("stopAllExecutions", () => {
32
+ it("stops and deletes all active executions", async () => {
33
+ mockGetAllExecutions.mockResolvedValueOnce([
34
+ { executionArn: "arn:1", sequenceId: "onboarding" },
35
+ { executionArn: "arn:2", sequenceId: "win-back" },
36
+ ]);
37
+ mockSfnSend.mockResolvedValue({});
38
+ mockDeleteExecution.mockResolvedValue(undefined);
39
+
40
+ await stopAllExecutions("TestTable", "user@example.com");
41
+
42
+ expect(mockSfnSend).toHaveBeenCalledTimes(2);
43
+ expect(mockDeleteExecution).toHaveBeenCalledTimes(2);
44
+ expect(mockDeleteExecution).toHaveBeenCalledWith("TestTable", "user@example.com", "onboarding");
45
+ expect(mockDeleteExecution).toHaveBeenCalledWith("TestTable", "user@example.com", "win-back");
46
+ });
47
+
48
+ it("does nothing when no executions exist", async () => {
49
+ mockGetAllExecutions.mockResolvedValueOnce([]);
50
+
51
+ await stopAllExecutions("TestTable", "user@example.com");
52
+
53
+ expect(mockSfnSend).not.toHaveBeenCalled();
54
+ expect(mockDeleteExecution).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it("continues when SFN stop fails (already stopped)", async () => {
58
+ mockGetAllExecutions.mockResolvedValueOnce([
59
+ { executionArn: "arn:1", sequenceId: "onboarding" },
60
+ ]);
61
+ mockSfnSend.mockRejectedValueOnce(new Error("ExecutionDoesNotExist"));
62
+ mockDeleteExecution.mockResolvedValue(undefined);
63
+
64
+ await expect(stopAllExecutions("TestTable", "user@example.com")).resolves.toBeUndefined();
65
+
66
+ expect(mockDeleteExecution).toHaveBeenCalledOnce();
67
+ });
68
+ });
@@ -0,0 +1,85 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockSend = vi.fn();
4
+ vi.mock("@aws-sdk/client-sesv2", () => ({
5
+ SESv2Client: class {
6
+ send = mockSend;
7
+ },
8
+ SendEmailCommand: class {
9
+ input: unknown;
10
+ constructor(input: unknown) {
11
+ this.input = input;
12
+ }
13
+ },
14
+ }));
15
+
16
+ const { sendEmail } = await import("../ses-sender.js");
17
+
18
+ beforeEach(() => {
19
+ mockSend.mockReset();
20
+ });
21
+
22
+ describe("sendEmail", () => {
23
+ const defaultParams = {
24
+ from: "Test <noreply@example.com>",
25
+ to: "user@example.com",
26
+ subject: "Welcome!",
27
+ htmlBody: "<h1>Hello</h1>",
28
+ configurationSetName: "my-config-set",
29
+ unsubscribeUrl: "https://unsub.example.com?token=abc",
30
+ templateKey: "onboarding/welcome",
31
+ sequenceId: "onboarding",
32
+ };
33
+
34
+ it("sends email and returns messageId", async () => {
35
+ mockSend.mockResolvedValueOnce({ MessageId: "ses-msg-123" });
36
+
37
+ const messageId = await sendEmail(defaultParams);
38
+
39
+ expect(messageId).toBe("ses-msg-123");
40
+ expect(mockSend).toHaveBeenCalledOnce();
41
+ });
42
+
43
+ it("returns 'unknown' when MessageId is missing", async () => {
44
+ mockSend.mockResolvedValueOnce({});
45
+
46
+ const messageId = await sendEmail(defaultParams);
47
+ expect(messageId).toBe("unknown");
48
+ });
49
+
50
+ it("includes List-Unsubscribe headers", async () => {
51
+ mockSend.mockResolvedValueOnce({ MessageId: "msg-1" });
52
+
53
+ await sendEmail(defaultParams);
54
+
55
+ const cmd = mockSend.mock.calls[0][0];
56
+ const headers = cmd.input.Content.Simple.Headers;
57
+ expect(headers).toContainEqual({
58
+ Name: "List-Unsubscribe",
59
+ Value: `<${defaultParams.unsubscribeUrl}>`,
60
+ });
61
+ expect(headers).toContainEqual({
62
+ Name: "List-Unsubscribe-Post",
63
+ Value: "List-Unsubscribe=One-Click",
64
+ });
65
+ });
66
+
67
+ it("sets correct destination and from address", async () => {
68
+ mockSend.mockResolvedValueOnce({ MessageId: "msg-1" });
69
+
70
+ await sendEmail(defaultParams);
71
+
72
+ const cmd = mockSend.mock.calls[0][0];
73
+ expect(cmd.input.FromEmailAddress).toBe(defaultParams.from);
74
+ expect(cmd.input.Destination.ToAddresses).toEqual([defaultParams.to]);
75
+ });
76
+
77
+ it("uses the configuration set name", async () => {
78
+ mockSend.mockResolvedValueOnce({ MessageId: "msg-1" });
79
+
80
+ await sendEmail(defaultParams);
81
+
82
+ const cmd = mockSend.mock.calls[0][0];
83
+ expect(cmd.input.ConfigurationSetName).toBe("my-config-set");
84
+ });
85
+ });
@@ -0,0 +1,85 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockSend = vi.fn();
4
+ vi.mock("@aws-sdk/client-ssm", () => ({
5
+ SSMClient: class {
6
+ send = mockSend;
7
+ },
8
+ GetParameterCommand: class {
9
+ input: unknown;
10
+ constructor(input: unknown) {
11
+ this.input = input;
12
+ }
13
+ },
14
+ }));
15
+
16
+ // Must import after mock setup
17
+ const { getParameter, resolveConfig } = await import("../ssm-config.js");
18
+
19
+ beforeEach(() => {
20
+ mockSend.mockReset();
21
+ });
22
+
23
+ describe("getParameter", () => {
24
+ it("fetches from SSM and returns value", async () => {
25
+ mockSend.mockResolvedValueOnce({
26
+ Parameter: { Value: "my-table" },
27
+ });
28
+
29
+ const value = await getParameter("/test/param");
30
+ expect(value).toBe("my-table");
31
+ expect(mockSend).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ it("throws when parameter not found", async () => {
35
+ mockSend.mockResolvedValueOnce({ Parameter: {} });
36
+
37
+ await expect(getParameter("/test/missing")).rejects.toThrow("SSM parameter not found");
38
+ });
39
+
40
+ it("caches values for subsequent calls", async () => {
41
+ mockSend.mockResolvedValueOnce({
42
+ Parameter: { Value: "cached-value" },
43
+ });
44
+
45
+ const v1 = await getParameter("/test/cached");
46
+ const v2 = await getParameter("/test/cached");
47
+
48
+ expect(v1).toBe("cached-value");
49
+ expect(v2).toBe("cached-value");
50
+ expect(mockSend).toHaveBeenCalledOnce();
51
+ });
52
+ });
53
+
54
+ describe("resolveConfig", () => {
55
+ it("resolves all 8 SSM parameters", async () => {
56
+ const params: Record<string, string> = {
57
+ "table-name": "MainTable",
58
+ "events-table-name": "EventsTable",
59
+ "template-bucket": "my-bucket",
60
+ "default-from-email": "noreply@example.com",
61
+ "default-from-name": "Example",
62
+ "ses-config-set": "my-config-set",
63
+ "unsubscribe-base-url": "https://unsub.example.com",
64
+ "unsubscribe-secret": "secret123",
65
+ };
66
+
67
+ mockSend.mockImplementation(async (cmd: { input: { Name: string } }) => {
68
+ const suffix = cmd.input.Name.split("/").pop()!;
69
+ const value = params[suffix];
70
+ if (!value) throw new Error(`Unexpected param: ${cmd.input.Name}`);
71
+ return { Parameter: { Value: value } };
72
+ });
73
+
74
+ const config = await resolveConfig();
75
+
76
+ expect(config.tableName).toBe("MainTable");
77
+ expect(config.eventsTableName).toBe("EventsTable");
78
+ expect(config.templateBucket).toBe("my-bucket");
79
+ expect(config.defaultFromEmail).toBe("noreply@example.com");
80
+ expect(config.defaultFromName).toBe("Example");
81
+ expect(config.sesConfigSet).toBe("my-config-set");
82
+ expect(config.unsubscribeBaseUrl).toBe("https://unsub.example.com");
83
+ expect(config.unsubscribeSecret).toBe("secret123");
84
+ });
85
+ });
@@ -0,0 +1,96 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockSend = vi.fn();
4
+ vi.mock("@aws-sdk/client-s3", () => ({
5
+ S3Client: class {
6
+ send = mockSend;
7
+ },
8
+ GetObjectCommand: class {
9
+ input: unknown;
10
+ constructor(input: unknown) {
11
+ this.input = input;
12
+ }
13
+ },
14
+ }));
15
+
16
+ const { renderTemplate } = await import("../template-renderer.js");
17
+
18
+ beforeEach(() => {
19
+ mockSend.mockReset();
20
+ });
21
+
22
+ describe("renderTemplate", () => {
23
+ it("fetches template from S3 and renders with LiquidJS", async () => {
24
+ mockSend.mockResolvedValueOnce({
25
+ Body: {
26
+ transformToString: () => Promise.resolve("<h1>Hello {{ firstName }}</h1>"),
27
+ },
28
+ });
29
+
30
+ const result = await renderTemplate("my-bucket", "welcome", {
31
+ email: "user@example.com",
32
+ firstName: "Jane",
33
+ unsubscribeUrl: "https://unsub.example.com",
34
+ currentYear: 2026,
35
+ });
36
+
37
+ expect(result).toBe("<h1>Hello Jane</h1>");
38
+ });
39
+
40
+ it("fetches template with .html extension", async () => {
41
+ mockSend.mockResolvedValueOnce({
42
+ Body: {
43
+ transformToString: () => Promise.resolve("<p>Hi</p>"),
44
+ },
45
+ });
46
+
47
+ await renderTemplate("my-bucket", "onboarding/step1", {
48
+ email: "user@example.com",
49
+ firstName: "Jane",
50
+ unsubscribeUrl: "https://unsub.example.com",
51
+ currentYear: 2026,
52
+ });
53
+
54
+ const cmd = mockSend.mock.calls[0][0];
55
+ expect(cmd.input.Bucket).toBe("my-bucket");
56
+ expect(cmd.input.Key).toBe("onboarding/step1.html");
57
+ });
58
+
59
+ it("renders multiple Liquid variables", async () => {
60
+ mockSend.mockResolvedValueOnce({
61
+ Body: {
62
+ transformToString: () =>
63
+ Promise.resolve(
64
+ "Hi {{ firstName }}, your email is {{ email }}. Year: {{ currentYear }}. <a href='{{ unsubscribeUrl }}'>Unsub</a>",
65
+ ),
66
+ },
67
+ });
68
+
69
+ const result = await renderTemplate("my-bucket", "multi-var", {
70
+ email: "jane@example.com",
71
+ firstName: "Jane",
72
+ unsubscribeUrl: "https://unsub.example.com?token=abc",
73
+ currentYear: 2026,
74
+ });
75
+
76
+ expect(result).toContain("Jane");
77
+ expect(result).toContain("jane@example.com");
78
+ expect(result).toContain("2026");
79
+ expect(result).toContain("https://unsub.example.com?token=abc");
80
+ });
81
+
82
+ it("handles empty template body gracefully", async () => {
83
+ mockSend.mockResolvedValueOnce({
84
+ Body: { transformToString: () => Promise.resolve("") },
85
+ });
86
+
87
+ const result = await renderTemplate("my-bucket", "empty", {
88
+ email: "user@example.com",
89
+ firstName: "Jane",
90
+ unsubscribeUrl: "https://unsub.example.com",
91
+ currentYear: 2026,
92
+ });
93
+
94
+ expect(result).toBe("");
95
+ });
96
+ });