resend-cli 1.0.2 → 1.1.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 (180) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/.github/scripts/pr-title-check.js +34 -0
  3. package/.github/workflows/ci.yml +32 -0
  4. package/.github/workflows/pr-title-check.yml +13 -0
  5. package/.github/workflows/release.yml +93 -0
  6. package/CHANGELOG.md +31 -0
  7. package/LICENSE +21 -21
  8. package/README.md +416 -19
  9. package/biome.json +36 -0
  10. package/bun.lock +76 -0
  11. package/bunfig.toml +2 -0
  12. package/install.ps1 +140 -0
  13. package/install.sh +294 -0
  14. package/package.json +43 -22
  15. package/src/cli.ts +65 -0
  16. package/src/commands/api-keys/create.ts +114 -0
  17. package/src/commands/api-keys/delete.ts +47 -0
  18. package/src/commands/api-keys/index.ts +26 -0
  19. package/src/commands/api-keys/list.ts +35 -0
  20. package/src/commands/api-keys/utils.ts +8 -0
  21. package/src/commands/auth/index.ts +20 -0
  22. package/src/commands/auth/login.ts +211 -0
  23. package/src/commands/auth/logout.ts +105 -0
  24. package/src/commands/broadcasts/create.ts +196 -0
  25. package/src/commands/broadcasts/delete.ts +46 -0
  26. package/src/commands/broadcasts/get.ts +59 -0
  27. package/src/commands/broadcasts/index.ts +43 -0
  28. package/src/commands/broadcasts/list.ts +60 -0
  29. package/src/commands/broadcasts/send.ts +56 -0
  30. package/src/commands/broadcasts/update.ts +95 -0
  31. package/src/commands/broadcasts/utils.ts +35 -0
  32. package/src/commands/contact-properties/create.ts +118 -0
  33. package/src/commands/contact-properties/delete.ts +48 -0
  34. package/src/commands/contact-properties/get.ts +46 -0
  35. package/src/commands/contact-properties/index.ts +48 -0
  36. package/src/commands/contact-properties/list.ts +68 -0
  37. package/src/commands/contact-properties/update.ts +88 -0
  38. package/src/commands/contact-properties/utils.ts +17 -0
  39. package/src/commands/contacts/add-segment.ts +78 -0
  40. package/src/commands/contacts/create.ts +122 -0
  41. package/src/commands/contacts/delete.ts +49 -0
  42. package/src/commands/contacts/get.ts +53 -0
  43. package/src/commands/contacts/index.ts +58 -0
  44. package/src/commands/contacts/list.ts +57 -0
  45. package/src/commands/contacts/remove-segment.ts +48 -0
  46. package/src/commands/contacts/segments.ts +39 -0
  47. package/src/commands/contacts/topics.ts +45 -0
  48. package/src/commands/contacts/update-topics.ts +90 -0
  49. package/src/commands/contacts/update.ts +77 -0
  50. package/src/commands/contacts/utils.ts +119 -0
  51. package/src/commands/doctor.ts +298 -0
  52. package/src/commands/domains/create.ts +83 -0
  53. package/src/commands/domains/delete.ts +42 -0
  54. package/src/commands/domains/get.ts +47 -0
  55. package/src/commands/domains/index.ts +35 -0
  56. package/src/commands/domains/list.ts +53 -0
  57. package/src/commands/domains/update.ts +75 -0
  58. package/src/commands/domains/utils.ts +44 -0
  59. package/src/commands/domains/verify.ts +38 -0
  60. package/src/commands/emails/batch.ts +140 -0
  61. package/src/commands/emails/index.ts +24 -0
  62. package/src/commands/emails/receiving/attachment.ts +55 -0
  63. package/src/commands/emails/receiving/attachments.ts +68 -0
  64. package/src/commands/emails/receiving/get.ts +58 -0
  65. package/src/commands/emails/receiving/index.ts +28 -0
  66. package/src/commands/emails/receiving/list.ts +59 -0
  67. package/src/commands/emails/receiving/utils.ts +38 -0
  68. package/src/commands/emails/send.ts +189 -0
  69. package/src/commands/segments/create.ts +50 -0
  70. package/src/commands/segments/delete.ts +47 -0
  71. package/src/commands/segments/get.ts +38 -0
  72. package/src/commands/segments/index.ts +36 -0
  73. package/src/commands/segments/list.ts +58 -0
  74. package/src/commands/segments/utils.ts +7 -0
  75. package/src/commands/teams/index.ts +10 -0
  76. package/src/commands/teams/list.ts +35 -0
  77. package/src/commands/teams/remove.ts +83 -0
  78. package/src/commands/teams/switch.ts +73 -0
  79. package/src/commands/topics/create.ts +73 -0
  80. package/src/commands/topics/delete.ts +47 -0
  81. package/src/commands/topics/get.ts +42 -0
  82. package/src/commands/topics/index.ts +42 -0
  83. package/src/commands/topics/list.ts +34 -0
  84. package/src/commands/topics/update.ts +59 -0
  85. package/src/commands/topics/utils.ts +16 -0
  86. package/src/commands/webhooks/create.ts +128 -0
  87. package/src/commands/webhooks/delete.ts +49 -0
  88. package/src/commands/webhooks/get.ts +42 -0
  89. package/src/commands/webhooks/index.ts +44 -0
  90. package/src/commands/webhooks/list.ts +55 -0
  91. package/src/commands/webhooks/update.ts +83 -0
  92. package/src/commands/webhooks/utils.ts +36 -0
  93. package/src/commands/whoami.ts +71 -0
  94. package/src/lib/actions.ts +157 -0
  95. package/src/lib/client.ts +29 -0
  96. package/src/lib/config.ts +211 -0
  97. package/src/lib/files.ts +15 -0
  98. package/src/lib/help-text.ts +36 -0
  99. package/src/lib/output.ts +54 -0
  100. package/src/lib/pagination.ts +36 -0
  101. package/src/lib/prompts.ts +149 -0
  102. package/src/lib/spinner.ts +89 -0
  103. package/src/lib/table.ts +57 -0
  104. package/src/lib/tty.ts +28 -0
  105. package/src/lib/version.ts +4 -0
  106. package/tests/commands/api-keys/create.test.ts +195 -0
  107. package/tests/commands/api-keys/delete.test.ts +156 -0
  108. package/tests/commands/api-keys/list.test.ts +133 -0
  109. package/tests/commands/auth/login.test.ts +119 -0
  110. package/tests/commands/auth/logout.test.ts +146 -0
  111. package/tests/commands/broadcasts/create.test.ts +447 -0
  112. package/tests/commands/broadcasts/delete.test.ts +182 -0
  113. package/tests/commands/broadcasts/get.test.ts +146 -0
  114. package/tests/commands/broadcasts/list.test.ts +196 -0
  115. package/tests/commands/broadcasts/send.test.ts +161 -0
  116. package/tests/commands/broadcasts/update.test.ts +283 -0
  117. package/tests/commands/contact-properties/create.test.ts +250 -0
  118. package/tests/commands/contact-properties/delete.test.ts +183 -0
  119. package/tests/commands/contact-properties/get.test.ts +144 -0
  120. package/tests/commands/contact-properties/list.test.ts +180 -0
  121. package/tests/commands/contact-properties/update.test.ts +216 -0
  122. package/tests/commands/contacts/add-segment.test.ts +188 -0
  123. package/tests/commands/contacts/create.test.ts +270 -0
  124. package/tests/commands/contacts/delete.test.ts +192 -0
  125. package/tests/commands/contacts/get.test.ts +148 -0
  126. package/tests/commands/contacts/list.test.ts +175 -0
  127. package/tests/commands/contacts/remove-segment.test.ts +166 -0
  128. package/tests/commands/contacts/segments.test.ts +167 -0
  129. package/tests/commands/contacts/topics.test.ts +163 -0
  130. package/tests/commands/contacts/update-topics.test.ts +247 -0
  131. package/tests/commands/contacts/update.test.ts +205 -0
  132. package/tests/commands/doctor.test.ts +165 -0
  133. package/tests/commands/domains/create.test.ts +192 -0
  134. package/tests/commands/domains/delete.test.ts +156 -0
  135. package/tests/commands/domains/get.test.ts +137 -0
  136. package/tests/commands/domains/list.test.ts +164 -0
  137. package/tests/commands/domains/update.test.ts +223 -0
  138. package/tests/commands/domains/verify.test.ts +117 -0
  139. package/tests/commands/emails/batch.test.ts +313 -0
  140. package/tests/commands/emails/receiving/attachment.test.ts +140 -0
  141. package/tests/commands/emails/receiving/attachments.test.ts +168 -0
  142. package/tests/commands/emails/receiving/get.test.ts +140 -0
  143. package/tests/commands/emails/receiving/list.test.ts +181 -0
  144. package/tests/commands/emails/send.test.ts +309 -0
  145. package/tests/commands/segments/create.test.ts +163 -0
  146. package/tests/commands/segments/delete.test.ts +182 -0
  147. package/tests/commands/segments/get.test.ts +137 -0
  148. package/tests/commands/segments/list.test.ts +173 -0
  149. package/tests/commands/teams/list.test.ts +63 -0
  150. package/tests/commands/teams/remove.test.ts +103 -0
  151. package/tests/commands/teams/switch.test.ts +96 -0
  152. package/tests/commands/topics/create.test.ts +191 -0
  153. package/tests/commands/topics/delete.test.ts +156 -0
  154. package/tests/commands/topics/get.test.ts +125 -0
  155. package/tests/commands/topics/list.test.ts +124 -0
  156. package/tests/commands/topics/update.test.ts +177 -0
  157. package/tests/commands/webhooks/create.test.ts +224 -0
  158. package/tests/commands/webhooks/delete.test.ts +156 -0
  159. package/tests/commands/webhooks/get.test.ts +125 -0
  160. package/tests/commands/webhooks/list.test.ts +177 -0
  161. package/tests/commands/webhooks/update.test.ts +206 -0
  162. package/tests/commands/whoami.test.ts +99 -0
  163. package/tests/helpers.ts +93 -0
  164. package/tests/lib/client.test.ts +71 -0
  165. package/tests/lib/config.test.ts +414 -0
  166. package/tests/lib/files.test.ts +65 -0
  167. package/tests/lib/help-text.test.ts +96 -0
  168. package/tests/lib/output.test.ts +127 -0
  169. package/tests/lib/prompts.test.ts +178 -0
  170. package/tests/lib/spinner.test.ts +146 -0
  171. package/tests/lib/table.test.ts +63 -0
  172. package/tests/lib/tty.test.ts +85 -0
  173. package/tsconfig.json +14 -0
  174. package/src/index.js +0 -72
  175. package/src/routes.js +0 -37
  176. package/src/sections/apikeys.js +0 -99
  177. package/src/sections/audiences.js +0 -84
  178. package/src/sections/contacts.js +0 -177
  179. package/src/sections/domain.js +0 -195
  180. package/src/sections/email.js +0 -132
@@ -0,0 +1,447 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from 'bun:test';
10
+ import * as files from '../../../src/lib/files';
11
+ import {
12
+ captureTestEnv,
13
+ expectExit1,
14
+ mockExitThrow,
15
+ mockSdkError,
16
+ setNonInteractive,
17
+ setupOutputSpies,
18
+ } from '../../helpers';
19
+
20
+ const mockCreate = mock(async () => ({
21
+ data: { id: 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6' },
22
+ error: null,
23
+ }));
24
+
25
+ mock.module('resend', () => ({
26
+ Resend: class MockResend {
27
+ constructor(public key: string) {}
28
+ broadcasts = { create: mockCreate };
29
+ },
30
+ }));
31
+
32
+ describe('broadcasts create command', () => {
33
+ const restoreEnv = captureTestEnv();
34
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
35
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
36
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
37
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
38
+ let readFileSpy: ReturnType<typeof spyOn> | undefined;
39
+
40
+ beforeEach(() => {
41
+ process.env.RESEND_API_KEY = 're_test_key';
42
+ mockCreate.mockClear();
43
+ });
44
+
45
+ afterEach(() => {
46
+ restoreEnv();
47
+ spies?.restore();
48
+ errorSpy?.mockRestore();
49
+ stderrSpy?.mockRestore();
50
+ exitSpy?.mockRestore();
51
+ readFileSpy?.mockRestore();
52
+ spies = undefined;
53
+ errorSpy = undefined;
54
+ stderrSpy = undefined;
55
+ exitSpy = undefined;
56
+ readFileSpy = undefined;
57
+ });
58
+
59
+ test('creates broadcast with required flags', async () => {
60
+ spies = setupOutputSpies();
61
+
62
+ const { createBroadcastCommand } = await import(
63
+ '../../../src/commands/broadcasts/create'
64
+ );
65
+ await createBroadcastCommand.parseAsync(
66
+ [
67
+ '--from',
68
+ 'hello@domain.com',
69
+ '--subject',
70
+ 'Weekly Update',
71
+ '--segment-id',
72
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
73
+ '--html',
74
+ '<p>Hi</p>',
75
+ ],
76
+ { from: 'user' },
77
+ );
78
+
79
+ expect(mockCreate).toHaveBeenCalledTimes(1);
80
+ const args = mockCreate.mock.calls[0][0] as Record<string, unknown>;
81
+ expect(args.from).toBe('hello@domain.com');
82
+ expect(args.subject).toBe('Weekly Update');
83
+ expect(args.segmentId).toBe('7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d');
84
+ expect(args.html).toBe('<p>Hi</p>');
85
+ });
86
+
87
+ test('outputs JSON id when non-interactive', async () => {
88
+ spies = setupOutputSpies();
89
+
90
+ const { createBroadcastCommand } = await import(
91
+ '../../../src/commands/broadcasts/create'
92
+ );
93
+ await createBroadcastCommand.parseAsync(
94
+ [
95
+ '--from',
96
+ 'hello@domain.com',
97
+ '--subject',
98
+ 'News',
99
+ '--segment-id',
100
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
101
+ '--text',
102
+ 'Hello!',
103
+ ],
104
+ { from: 'user' },
105
+ );
106
+
107
+ const output = spies.logSpy.mock.calls[0][0] as string;
108
+ const parsed = JSON.parse(output);
109
+ expect(parsed.id).toBe('d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6');
110
+ });
111
+
112
+ test('passes --send flag to SDK', async () => {
113
+ spies = setupOutputSpies();
114
+
115
+ const { createBroadcastCommand } = await import(
116
+ '../../../src/commands/broadcasts/create'
117
+ );
118
+ await createBroadcastCommand.parseAsync(
119
+ [
120
+ '--from',
121
+ 'hello@domain.com',
122
+ '--subject',
123
+ 'Go',
124
+ '--segment-id',
125
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
126
+ '--text',
127
+ 'Hi',
128
+ '--send',
129
+ ],
130
+ { from: 'user' },
131
+ );
132
+
133
+ const args = mockCreate.mock.calls[0][0] as Record<string, unknown>;
134
+ expect(args.send).toBe(true);
135
+ });
136
+
137
+ test('passes --scheduled-at with --send to SDK', async () => {
138
+ spies = setupOutputSpies();
139
+
140
+ const { createBroadcastCommand } = await import(
141
+ '../../../src/commands/broadcasts/create'
142
+ );
143
+ await createBroadcastCommand.parseAsync(
144
+ [
145
+ '--from',
146
+ 'hello@domain.com',
147
+ '--subject',
148
+ 'Go',
149
+ '--segment-id',
150
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
151
+ '--text',
152
+ 'Hi',
153
+ '--send',
154
+ '--scheduled-at',
155
+ 'in 1 hour',
156
+ ],
157
+ { from: 'user' },
158
+ );
159
+
160
+ const args = mockCreate.mock.calls[0][0] as Record<string, unknown>;
161
+ expect(args.scheduledAt).toBe('in 1 hour');
162
+ });
163
+
164
+ test('passes optional flags to SDK', async () => {
165
+ spies = setupOutputSpies();
166
+
167
+ const { createBroadcastCommand } = await import(
168
+ '../../../src/commands/broadcasts/create'
169
+ );
170
+ await createBroadcastCommand.parseAsync(
171
+ [
172
+ '--from',
173
+ 'hello@domain.com',
174
+ '--subject',
175
+ 'News',
176
+ '--segment-id',
177
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
178
+ '--html',
179
+ '<p>Hi</p>',
180
+ '--name',
181
+ 'Q1 Newsletter',
182
+ '--reply-to',
183
+ 'reply@domain.com',
184
+ '--preview-text',
185
+ 'Read the news',
186
+ '--topic-id',
187
+ 'topic_xyz',
188
+ ],
189
+ { from: 'user' },
190
+ );
191
+
192
+ const args = mockCreate.mock.calls[0][0] as Record<string, unknown>;
193
+ expect(args.name).toBe('Q1 Newsletter');
194
+ expect(args.replyTo).toBe('reply@domain.com');
195
+ expect(args.previewText).toBe('Read the news');
196
+ expect(args.topicId).toBe('topic_xyz');
197
+ });
198
+
199
+ test('errors with missing_from when --from absent in non-interactive mode', async () => {
200
+ setNonInteractive();
201
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
202
+ exitSpy = mockExitThrow();
203
+
204
+ const { createBroadcastCommand } = await import(
205
+ '../../../src/commands/broadcasts/create'
206
+ );
207
+ await expectExit1(() =>
208
+ createBroadcastCommand.parseAsync(
209
+ [
210
+ '--subject',
211
+ 'News',
212
+ '--segment-id',
213
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
214
+ '--html',
215
+ '<p>Hi</p>',
216
+ ],
217
+ { from: 'user' },
218
+ ),
219
+ );
220
+
221
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
222
+ expect(output).toContain('missing_from');
223
+ });
224
+
225
+ test('errors with missing_subject when --subject absent in non-interactive mode', async () => {
226
+ setNonInteractive();
227
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
228
+ exitSpy = mockExitThrow();
229
+
230
+ const { createBroadcastCommand } = await import(
231
+ '../../../src/commands/broadcasts/create'
232
+ );
233
+ await expectExit1(() =>
234
+ createBroadcastCommand.parseAsync(
235
+ [
236
+ '--from',
237
+ 'hello@domain.com',
238
+ '--segment-id',
239
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
240
+ '--html',
241
+ '<p>Hi</p>',
242
+ ],
243
+ { from: 'user' },
244
+ ),
245
+ );
246
+
247
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
248
+ expect(output).toContain('missing_subject');
249
+ });
250
+
251
+ test('errors with missing_segment when --segment-id absent in non-interactive mode', async () => {
252
+ setNonInteractive();
253
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
254
+ exitSpy = mockExitThrow();
255
+
256
+ const { createBroadcastCommand } = await import(
257
+ '../../../src/commands/broadcasts/create'
258
+ );
259
+ await expectExit1(() =>
260
+ createBroadcastCommand.parseAsync(
261
+ [
262
+ '--from',
263
+ 'hello@domain.com',
264
+ '--subject',
265
+ 'News',
266
+ '--html',
267
+ '<p>Hi</p>',
268
+ ],
269
+ { from: 'user' },
270
+ ),
271
+ );
272
+
273
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
274
+ expect(output).toContain('missing_segment');
275
+ });
276
+
277
+ test('errors with missing_body when no body flag in non-interactive mode', async () => {
278
+ setNonInteractive();
279
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
280
+ exitSpy = mockExitThrow();
281
+
282
+ const { createBroadcastCommand } = await import(
283
+ '../../../src/commands/broadcasts/create'
284
+ );
285
+ await expectExit1(() =>
286
+ createBroadcastCommand.parseAsync(
287
+ [
288
+ '--from',
289
+ 'hello@domain.com',
290
+ '--subject',
291
+ 'News',
292
+ '--segment-id',
293
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
294
+ ],
295
+ { from: 'user' },
296
+ ),
297
+ );
298
+
299
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
300
+ expect(output).toContain('missing_body');
301
+ });
302
+
303
+ test('errors with auth_error when no API key', async () => {
304
+ setNonInteractive();
305
+ delete process.env.RESEND_API_KEY;
306
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
307
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
308
+ exitSpy = mockExitThrow();
309
+
310
+ const { createBroadcastCommand } = await import(
311
+ '../../../src/commands/broadcasts/create'
312
+ );
313
+ await expectExit1(() =>
314
+ createBroadcastCommand.parseAsync(
315
+ [
316
+ '--from',
317
+ 'hello@domain.com',
318
+ '--subject',
319
+ 'News',
320
+ '--segment-id',
321
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
322
+ '--html',
323
+ '<p>Hi</p>',
324
+ ],
325
+ { from: 'user' },
326
+ ),
327
+ );
328
+
329
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
330
+ expect(output).toContain('auth_error');
331
+ });
332
+
333
+ test('errors with create_error when SDK returns an error', async () => {
334
+ setNonInteractive();
335
+ mockCreate.mockResolvedValueOnce(
336
+ mockSdkError('Segment not found', 'not_found'),
337
+ );
338
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
339
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
340
+ exitSpy = mockExitThrow();
341
+
342
+ const { createBroadcastCommand } = await import(
343
+ '../../../src/commands/broadcasts/create'
344
+ );
345
+ await expectExit1(() =>
346
+ createBroadcastCommand.parseAsync(
347
+ [
348
+ '--from',
349
+ 'hello@domain.com',
350
+ '--subject',
351
+ 'News',
352
+ '--segment-id',
353
+ '00000000-0000-0000-0000-000000000bad',
354
+ '--html',
355
+ '<p>Hi</p>',
356
+ ],
357
+ { from: 'user' },
358
+ ),
359
+ );
360
+
361
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
362
+ expect(output).toContain('create_error');
363
+ });
364
+
365
+ test('does not call SDK when validation fails', async () => {
366
+ setNonInteractive();
367
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
368
+ exitSpy = mockExitThrow();
369
+
370
+ const { createBroadcastCommand } = await import(
371
+ '../../../src/commands/broadcasts/create'
372
+ );
373
+ await expectExit1(() =>
374
+ createBroadcastCommand.parseAsync([], { from: 'user' }),
375
+ );
376
+
377
+ expect(mockCreate).not.toHaveBeenCalled();
378
+ });
379
+
380
+ test('reads html body from --html-file and passes it to SDK', async () => {
381
+ spies = setupOutputSpies();
382
+ readFileSpy = spyOn(files, 'readFile').mockReturnValue('<p>From file</p>');
383
+
384
+ const { createBroadcastCommand } = await import(
385
+ '../../../src/commands/broadcasts/create'
386
+ );
387
+ await createBroadcastCommand.parseAsync(
388
+ [
389
+ '--from',
390
+ 'hello@domain.com',
391
+ '--subject',
392
+ 'News',
393
+ '--segment-id',
394
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
395
+ '--html-file',
396
+ '/fake/email.html',
397
+ ],
398
+ { from: 'user' },
399
+ );
400
+
401
+ expect(readFileSpy).toHaveBeenCalledTimes(1);
402
+ const args = mockCreate.mock.calls[0][0] as Record<string, unknown>;
403
+ expect(args.html).toBe('<p>From file</p>');
404
+ });
405
+
406
+ test('errors with file_read_error when --html-file path is unreadable', async () => {
407
+ setNonInteractive();
408
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
409
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
410
+ exitSpy = mockExitThrow();
411
+
412
+ const { outputError } = await import('../../../src/lib/output');
413
+ readFileSpy = spyOn(files, 'readFile').mockImplementation(
414
+ (filePath: string, globalOpts: { json?: boolean }) => {
415
+ outputError(
416
+ {
417
+ message: `Failed to read file: ${filePath}`,
418
+ code: 'file_read_error',
419
+ },
420
+ { json: globalOpts.json },
421
+ );
422
+ },
423
+ );
424
+
425
+ const { createBroadcastCommand } = await import(
426
+ '../../../src/commands/broadcasts/create'
427
+ );
428
+ await expectExit1(() =>
429
+ createBroadcastCommand.parseAsync(
430
+ [
431
+ '--from',
432
+ 'hello@domain.com',
433
+ '--subject',
434
+ 'News',
435
+ '--segment-id',
436
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
437
+ '--html-file',
438
+ '/nonexistent/file.html',
439
+ ],
440
+ { from: 'user' },
441
+ ),
442
+ );
443
+
444
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
445
+ expect(output).toContain('file_read_error');
446
+ });
447
+ });
@@ -0,0 +1,182 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from 'bun:test';
10
+ import {
11
+ captureTestEnv,
12
+ expectExit1,
13
+ mockExitThrow,
14
+ mockSdkError,
15
+ setNonInteractive,
16
+ setupOutputSpies,
17
+ } from '../../helpers';
18
+
19
+ const mockRemove = mock(async () => ({
20
+ data: {
21
+ object: 'broadcast' as const,
22
+ id: 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
23
+ deleted: true,
24
+ },
25
+ error: null,
26
+ }));
27
+
28
+ mock.module('resend', () => ({
29
+ Resend: class MockResend {
30
+ constructor(public key: string) {}
31
+ broadcasts = { remove: mockRemove };
32
+ },
33
+ }));
34
+
35
+ describe('broadcasts delete command', () => {
36
+ const restoreEnv = captureTestEnv();
37
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
38
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
39
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
40
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
41
+
42
+ beforeEach(() => {
43
+ process.env.RESEND_API_KEY = 're_test_key';
44
+ mockRemove.mockClear();
45
+ });
46
+
47
+ afterEach(() => {
48
+ restoreEnv();
49
+ spies?.restore();
50
+ errorSpy?.mockRestore();
51
+ stderrSpy?.mockRestore();
52
+ exitSpy?.mockRestore();
53
+ spies = undefined;
54
+ errorSpy = undefined;
55
+ stderrSpy = undefined;
56
+ exitSpy = undefined;
57
+ });
58
+
59
+ test('deletes broadcast with --yes flag', async () => {
60
+ spies = setupOutputSpies();
61
+
62
+ const { deleteBroadcastCommand } = await import(
63
+ '../../../src/commands/broadcasts/delete'
64
+ );
65
+ await deleteBroadcastCommand.parseAsync(
66
+ ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--yes'],
67
+ {
68
+ from: 'user',
69
+ },
70
+ );
71
+
72
+ expect(mockRemove).toHaveBeenCalledTimes(1);
73
+ expect(mockRemove.mock.calls[0][0]).toBe(
74
+ 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
75
+ );
76
+ });
77
+
78
+ test('outputs JSON result when non-interactive', async () => {
79
+ spies = setupOutputSpies();
80
+
81
+ const { deleteBroadcastCommand } = await import(
82
+ '../../../src/commands/broadcasts/delete'
83
+ );
84
+ await deleteBroadcastCommand.parseAsync(
85
+ ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--yes'],
86
+ {
87
+ from: 'user',
88
+ },
89
+ );
90
+
91
+ const output = spies.logSpy.mock.calls[0][0] as string;
92
+ const parsed = JSON.parse(output);
93
+ expect(parsed.deleted).toBe(true);
94
+ expect(parsed.id).toBe('d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6');
95
+ expect(parsed.object).toBe('broadcast');
96
+ });
97
+
98
+ test('errors with confirmation_required when --yes absent in non-interactive mode', async () => {
99
+ setNonInteractive();
100
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
101
+ exitSpy = mockExitThrow();
102
+
103
+ const { deleteBroadcastCommand } = await import(
104
+ '../../../src/commands/broadcasts/delete'
105
+ );
106
+ await expectExit1(() =>
107
+ deleteBroadcastCommand.parseAsync(
108
+ ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
109
+ { from: 'user' },
110
+ ),
111
+ );
112
+
113
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
114
+ expect(output).toContain('confirmation_required');
115
+ });
116
+
117
+ test('does not call SDK when confirmation_required error is raised', async () => {
118
+ setNonInteractive();
119
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
120
+ exitSpy = mockExitThrow();
121
+
122
+ const { deleteBroadcastCommand } = await import(
123
+ '../../../src/commands/broadcasts/delete'
124
+ );
125
+ await expectExit1(() =>
126
+ deleteBroadcastCommand.parseAsync(
127
+ ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
128
+ { from: 'user' },
129
+ ),
130
+ );
131
+
132
+ expect(mockRemove).not.toHaveBeenCalled();
133
+ });
134
+
135
+ test('errors with auth_error when no API key', async () => {
136
+ setNonInteractive();
137
+ delete process.env.RESEND_API_KEY;
138
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
139
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
140
+ exitSpy = mockExitThrow();
141
+
142
+ const { deleteBroadcastCommand } = await import(
143
+ '../../../src/commands/broadcasts/delete'
144
+ );
145
+ await expectExit1(() =>
146
+ deleteBroadcastCommand.parseAsync(
147
+ ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--yes'],
148
+ {
149
+ from: 'user',
150
+ },
151
+ ),
152
+ );
153
+
154
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
155
+ expect(output).toContain('auth_error');
156
+ });
157
+
158
+ test('errors with delete_error when SDK returns an error', async () => {
159
+ setNonInteractive();
160
+ mockRemove.mockResolvedValueOnce(
161
+ mockSdkError('Cannot delete sent broadcast', 'validation_error'),
162
+ );
163
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
164
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
165
+ exitSpy = mockExitThrow();
166
+
167
+ const { deleteBroadcastCommand } = await import(
168
+ '../../../src/commands/broadcasts/delete'
169
+ );
170
+ await expectExit1(() =>
171
+ deleteBroadcastCommand.parseAsync(
172
+ ['s1e2n3t4-5a6b-7c8d-9e0f-a1b2c3d4e5f6', '--yes'],
173
+ {
174
+ from: 'user',
175
+ },
176
+ ),
177
+ );
178
+
179
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
180
+ expect(output).toContain('delete_error');
181
+ });
182
+ });