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.
- package/.claude/settings.local.json +14 -0
- package/.github/scripts/pr-title-check.js +34 -0
- package/.github/workflows/ci.yml +32 -0
- package/.github/workflows/pr-title-check.yml +13 -0
- package/.github/workflows/release.yml +93 -0
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -21
- package/README.md +416 -19
- package/biome.json +36 -0
- package/bun.lock +76 -0
- package/bunfig.toml +2 -0
- package/install.ps1 +140 -0
- package/install.sh +294 -0
- package/package.json +43 -22
- package/src/cli.ts +65 -0
- package/src/commands/api-keys/create.ts +114 -0
- package/src/commands/api-keys/delete.ts +47 -0
- package/src/commands/api-keys/index.ts +26 -0
- package/src/commands/api-keys/list.ts +35 -0
- package/src/commands/api-keys/utils.ts +8 -0
- package/src/commands/auth/index.ts +20 -0
- package/src/commands/auth/login.ts +211 -0
- package/src/commands/auth/logout.ts +105 -0
- package/src/commands/broadcasts/create.ts +196 -0
- package/src/commands/broadcasts/delete.ts +46 -0
- package/src/commands/broadcasts/get.ts +59 -0
- package/src/commands/broadcasts/index.ts +43 -0
- package/src/commands/broadcasts/list.ts +60 -0
- package/src/commands/broadcasts/send.ts +56 -0
- package/src/commands/broadcasts/update.ts +95 -0
- package/src/commands/broadcasts/utils.ts +35 -0
- package/src/commands/contact-properties/create.ts +118 -0
- package/src/commands/contact-properties/delete.ts +48 -0
- package/src/commands/contact-properties/get.ts +46 -0
- package/src/commands/contact-properties/index.ts +48 -0
- package/src/commands/contact-properties/list.ts +68 -0
- package/src/commands/contact-properties/update.ts +88 -0
- package/src/commands/contact-properties/utils.ts +17 -0
- package/src/commands/contacts/add-segment.ts +78 -0
- package/src/commands/contacts/create.ts +122 -0
- package/src/commands/contacts/delete.ts +49 -0
- package/src/commands/contacts/get.ts +53 -0
- package/src/commands/contacts/index.ts +58 -0
- package/src/commands/contacts/list.ts +57 -0
- package/src/commands/contacts/remove-segment.ts +48 -0
- package/src/commands/contacts/segments.ts +39 -0
- package/src/commands/contacts/topics.ts +45 -0
- package/src/commands/contacts/update-topics.ts +90 -0
- package/src/commands/contacts/update.ts +77 -0
- package/src/commands/contacts/utils.ts +119 -0
- package/src/commands/doctor.ts +298 -0
- package/src/commands/domains/create.ts +83 -0
- package/src/commands/domains/delete.ts +42 -0
- package/src/commands/domains/get.ts +47 -0
- package/src/commands/domains/index.ts +35 -0
- package/src/commands/domains/list.ts +53 -0
- package/src/commands/domains/update.ts +75 -0
- package/src/commands/domains/utils.ts +44 -0
- package/src/commands/domains/verify.ts +38 -0
- package/src/commands/emails/batch.ts +140 -0
- package/src/commands/emails/index.ts +24 -0
- package/src/commands/emails/receiving/attachment.ts +55 -0
- package/src/commands/emails/receiving/attachments.ts +68 -0
- package/src/commands/emails/receiving/get.ts +58 -0
- package/src/commands/emails/receiving/index.ts +28 -0
- package/src/commands/emails/receiving/list.ts +59 -0
- package/src/commands/emails/receiving/utils.ts +38 -0
- package/src/commands/emails/send.ts +189 -0
- package/src/commands/segments/create.ts +50 -0
- package/src/commands/segments/delete.ts +47 -0
- package/src/commands/segments/get.ts +38 -0
- package/src/commands/segments/index.ts +36 -0
- package/src/commands/segments/list.ts +58 -0
- package/src/commands/segments/utils.ts +7 -0
- package/src/commands/teams/index.ts +10 -0
- package/src/commands/teams/list.ts +35 -0
- package/src/commands/teams/remove.ts +83 -0
- package/src/commands/teams/switch.ts +73 -0
- package/src/commands/topics/create.ts +73 -0
- package/src/commands/topics/delete.ts +47 -0
- package/src/commands/topics/get.ts +42 -0
- package/src/commands/topics/index.ts +42 -0
- package/src/commands/topics/list.ts +34 -0
- package/src/commands/topics/update.ts +59 -0
- package/src/commands/topics/utils.ts +16 -0
- package/src/commands/webhooks/create.ts +128 -0
- package/src/commands/webhooks/delete.ts +49 -0
- package/src/commands/webhooks/get.ts +42 -0
- package/src/commands/webhooks/index.ts +44 -0
- package/src/commands/webhooks/list.ts +55 -0
- package/src/commands/webhooks/update.ts +83 -0
- package/src/commands/webhooks/utils.ts +36 -0
- package/src/commands/whoami.ts +71 -0
- package/src/lib/actions.ts +157 -0
- package/src/lib/client.ts +29 -0
- package/src/lib/config.ts +211 -0
- package/src/lib/files.ts +15 -0
- package/src/lib/help-text.ts +36 -0
- package/src/lib/output.ts +54 -0
- package/src/lib/pagination.ts +36 -0
- package/src/lib/prompts.ts +149 -0
- package/src/lib/spinner.ts +89 -0
- package/src/lib/table.ts +57 -0
- package/src/lib/tty.ts +28 -0
- package/src/lib/version.ts +4 -0
- package/tests/commands/api-keys/create.test.ts +195 -0
- package/tests/commands/api-keys/delete.test.ts +156 -0
- package/tests/commands/api-keys/list.test.ts +133 -0
- package/tests/commands/auth/login.test.ts +119 -0
- package/tests/commands/auth/logout.test.ts +146 -0
- package/tests/commands/broadcasts/create.test.ts +447 -0
- package/tests/commands/broadcasts/delete.test.ts +182 -0
- package/tests/commands/broadcasts/get.test.ts +146 -0
- package/tests/commands/broadcasts/list.test.ts +196 -0
- package/tests/commands/broadcasts/send.test.ts +161 -0
- package/tests/commands/broadcasts/update.test.ts +283 -0
- package/tests/commands/contact-properties/create.test.ts +250 -0
- package/tests/commands/contact-properties/delete.test.ts +183 -0
- package/tests/commands/contact-properties/get.test.ts +144 -0
- package/tests/commands/contact-properties/list.test.ts +180 -0
- package/tests/commands/contact-properties/update.test.ts +216 -0
- package/tests/commands/contacts/add-segment.test.ts +188 -0
- package/tests/commands/contacts/create.test.ts +270 -0
- package/tests/commands/contacts/delete.test.ts +192 -0
- package/tests/commands/contacts/get.test.ts +148 -0
- package/tests/commands/contacts/list.test.ts +175 -0
- package/tests/commands/contacts/remove-segment.test.ts +166 -0
- package/tests/commands/contacts/segments.test.ts +167 -0
- package/tests/commands/contacts/topics.test.ts +163 -0
- package/tests/commands/contacts/update-topics.test.ts +247 -0
- package/tests/commands/contacts/update.test.ts +205 -0
- package/tests/commands/doctor.test.ts +165 -0
- package/tests/commands/domains/create.test.ts +192 -0
- package/tests/commands/domains/delete.test.ts +156 -0
- package/tests/commands/domains/get.test.ts +137 -0
- package/tests/commands/domains/list.test.ts +164 -0
- package/tests/commands/domains/update.test.ts +223 -0
- package/tests/commands/domains/verify.test.ts +117 -0
- package/tests/commands/emails/batch.test.ts +313 -0
- package/tests/commands/emails/receiving/attachment.test.ts +140 -0
- package/tests/commands/emails/receiving/attachments.test.ts +168 -0
- package/tests/commands/emails/receiving/get.test.ts +140 -0
- package/tests/commands/emails/receiving/list.test.ts +181 -0
- package/tests/commands/emails/send.test.ts +309 -0
- package/tests/commands/segments/create.test.ts +163 -0
- package/tests/commands/segments/delete.test.ts +182 -0
- package/tests/commands/segments/get.test.ts +137 -0
- package/tests/commands/segments/list.test.ts +173 -0
- package/tests/commands/teams/list.test.ts +63 -0
- package/tests/commands/teams/remove.test.ts +103 -0
- package/tests/commands/teams/switch.test.ts +96 -0
- package/tests/commands/topics/create.test.ts +191 -0
- package/tests/commands/topics/delete.test.ts +156 -0
- package/tests/commands/topics/get.test.ts +125 -0
- package/tests/commands/topics/list.test.ts +124 -0
- package/tests/commands/topics/update.test.ts +177 -0
- package/tests/commands/webhooks/create.test.ts +224 -0
- package/tests/commands/webhooks/delete.test.ts +156 -0
- package/tests/commands/webhooks/get.test.ts +125 -0
- package/tests/commands/webhooks/list.test.ts +177 -0
- package/tests/commands/webhooks/update.test.ts +206 -0
- package/tests/commands/whoami.test.ts +99 -0
- package/tests/helpers.ts +93 -0
- package/tests/lib/client.test.ts +71 -0
- package/tests/lib/config.test.ts +414 -0
- package/tests/lib/files.test.ts +65 -0
- package/tests/lib/help-text.test.ts +96 -0
- package/tests/lib/output.test.ts +127 -0
- package/tests/lib/prompts.test.ts +178 -0
- package/tests/lib/spinner.test.ts +146 -0
- package/tests/lib/table.test.ts +63 -0
- package/tests/lib/tty.test.ts +85 -0
- package/tsconfig.json +14 -0
- package/src/index.js +0 -72
- package/src/routes.js +0 -37
- package/src/sections/apikeys.js +0 -99
- package/src/sections/audiences.js +0 -84
- package/src/sections/contacts.js +0 -177
- package/src/sections/domain.js +0 -195
- package/src/sections/email.js +0 -132
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from 'bun:test';
|
|
10
|
+
import { unlinkSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
captureTestEnv,
|
|
14
|
+
expectExit1,
|
|
15
|
+
mockExitThrow,
|
|
16
|
+
setNonInteractive,
|
|
17
|
+
setupOutputSpies,
|
|
18
|
+
} from '../../helpers';
|
|
19
|
+
|
|
20
|
+
const mockSend = mock(async () => ({
|
|
21
|
+
data: { id: 'test-email-id-123' },
|
|
22
|
+
error: null,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockDomainsList = mock(async () => ({
|
|
26
|
+
data: { data: [] },
|
|
27
|
+
error: null,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
mock.module('resend', () => ({
|
|
31
|
+
Resend: class MockResend {
|
|
32
|
+
constructor(public key: string) {}
|
|
33
|
+
emails = { send: mockSend };
|
|
34
|
+
domains = { list: mockDomainsList };
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
describe('send command', () => {
|
|
39
|
+
const restoreEnv = captureTestEnv();
|
|
40
|
+
let spies: ReturnType<typeof setupOutputSpies> | undefined;
|
|
41
|
+
let errorSpy: ReturnType<typeof spyOn> | undefined;
|
|
42
|
+
let stderrSpy: ReturnType<typeof spyOn> | undefined;
|
|
43
|
+
let exitSpy: ReturnType<typeof spyOn> | undefined;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
process.env.RESEND_API_KEY = 're_test_key';
|
|
47
|
+
mockSend.mockClear();
|
|
48
|
+
mockDomainsList.mockClear();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
restoreEnv();
|
|
53
|
+
spies?.restore();
|
|
54
|
+
errorSpy?.mockRestore();
|
|
55
|
+
stderrSpy?.mockRestore();
|
|
56
|
+
exitSpy?.mockRestore();
|
|
57
|
+
spies = undefined;
|
|
58
|
+
errorSpy = undefined;
|
|
59
|
+
stderrSpy = undefined;
|
|
60
|
+
exitSpy = undefined;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('sends email with all flags provided', async () => {
|
|
64
|
+
spies = setupOutputSpies();
|
|
65
|
+
|
|
66
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
67
|
+
await sendCommand.parseAsync(
|
|
68
|
+
[
|
|
69
|
+
'--from',
|
|
70
|
+
'a@test.com',
|
|
71
|
+
'--to',
|
|
72
|
+
'b@test.com',
|
|
73
|
+
'--subject',
|
|
74
|
+
'Test',
|
|
75
|
+
'--text',
|
|
76
|
+
'Hello',
|
|
77
|
+
],
|
|
78
|
+
{ from: 'user' },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
82
|
+
const callArgs = mockSend.mock.calls[0][0] as Record<string, unknown>;
|
|
83
|
+
expect(callArgs.from).toBe('a@test.com');
|
|
84
|
+
expect(callArgs.to).toEqual(['b@test.com']);
|
|
85
|
+
expect(callArgs.subject).toBe('Test');
|
|
86
|
+
expect(callArgs.text).toBe('Hello');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('outputs JSON with email ID on success', async () => {
|
|
90
|
+
spies = setupOutputSpies();
|
|
91
|
+
|
|
92
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
93
|
+
await sendCommand.parseAsync(
|
|
94
|
+
[
|
|
95
|
+
'--from',
|
|
96
|
+
'a@test.com',
|
|
97
|
+
'--to',
|
|
98
|
+
'b@test.com',
|
|
99
|
+
'--subject',
|
|
100
|
+
'Test',
|
|
101
|
+
'--text',
|
|
102
|
+
'Body',
|
|
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('test-email-id-123');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('sends HTML email when --html provided', async () => {
|
|
113
|
+
spies = setupOutputSpies();
|
|
114
|
+
|
|
115
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
116
|
+
await sendCommand.parseAsync(
|
|
117
|
+
[
|
|
118
|
+
'--from',
|
|
119
|
+
'a@test.com',
|
|
120
|
+
'--to',
|
|
121
|
+
'b@test.com',
|
|
122
|
+
'--subject',
|
|
123
|
+
'Test',
|
|
124
|
+
'--html',
|
|
125
|
+
'<h1>Hello</h1>',
|
|
126
|
+
],
|
|
127
|
+
{ from: 'user' },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const callArgs = mockSend.mock.calls[0][0] as Record<string, unknown>;
|
|
131
|
+
expect(callArgs.html).toBe('<h1>Hello</h1>');
|
|
132
|
+
expect(callArgs.text).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('supports multiple --to addresses', async () => {
|
|
136
|
+
spies = setupOutputSpies();
|
|
137
|
+
|
|
138
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
139
|
+
await sendCommand.parseAsync(
|
|
140
|
+
[
|
|
141
|
+
'--from',
|
|
142
|
+
'a@test.com',
|
|
143
|
+
'--to',
|
|
144
|
+
'b@test.com',
|
|
145
|
+
'c@test.com',
|
|
146
|
+
'--subject',
|
|
147
|
+
'Test',
|
|
148
|
+
'--text',
|
|
149
|
+
'Hi',
|
|
150
|
+
],
|
|
151
|
+
{ from: 'user' },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const callArgs = mockSend.mock.calls[0][0] as Record<string, unknown>;
|
|
155
|
+
expect(callArgs.to).toEqual(['b@test.com', 'c@test.com']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('errors when no API key and non-interactive', async () => {
|
|
159
|
+
setNonInteractive();
|
|
160
|
+
delete process.env.RESEND_API_KEY;
|
|
161
|
+
process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
|
|
162
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
163
|
+
exitSpy = mockExitThrow();
|
|
164
|
+
|
|
165
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
166
|
+
await expectExit1(() =>
|
|
167
|
+
sendCommand.parseAsync(
|
|
168
|
+
[
|
|
169
|
+
'--from',
|
|
170
|
+
'a@test.com',
|
|
171
|
+
'--to',
|
|
172
|
+
'b@test.com',
|
|
173
|
+
'--subject',
|
|
174
|
+
'Test',
|
|
175
|
+
'--text',
|
|
176
|
+
'Hi',
|
|
177
|
+
],
|
|
178
|
+
{ from: 'user' },
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('errors listing missing flags in non-interactive mode', async () => {
|
|
184
|
+
setNonInteractive();
|
|
185
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
186
|
+
exitSpy = mockExitThrow();
|
|
187
|
+
|
|
188
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
189
|
+
await expectExit1(() =>
|
|
190
|
+
sendCommand.parseAsync(['--from', 'a@test.com'], { from: 'user' }),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const allErrors = errorSpy.mock.calls.map((c) => c[0]).join(' ');
|
|
194
|
+
expect(allErrors).toContain('--to');
|
|
195
|
+
expect(allErrors).toContain('--subject');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('errors when no body and non-interactive', async () => {
|
|
199
|
+
setNonInteractive();
|
|
200
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
201
|
+
exitSpy = mockExitThrow();
|
|
202
|
+
|
|
203
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
204
|
+
await expectExit1(() =>
|
|
205
|
+
sendCommand.parseAsync(
|
|
206
|
+
['--from', 'a@test.com', '--to', 'b@test.com', '--subject', 'Test'],
|
|
207
|
+
{ from: 'user' },
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('reads HTML body from --html-file', async () => {
|
|
213
|
+
spies = setupOutputSpies();
|
|
214
|
+
|
|
215
|
+
const tmpFile = join(import.meta.dir, '__test_email.html');
|
|
216
|
+
await Bun.write(tmpFile, '<h1>From file</h1>');
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
220
|
+
await sendCommand.parseAsync(
|
|
221
|
+
[
|
|
222
|
+
'--from',
|
|
223
|
+
'a@test.com',
|
|
224
|
+
'--to',
|
|
225
|
+
'b@test.com',
|
|
226
|
+
'--subject',
|
|
227
|
+
'Test',
|
|
228
|
+
'--html-file',
|
|
229
|
+
tmpFile,
|
|
230
|
+
],
|
|
231
|
+
{ from: 'user' },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const callArgs = mockSend.mock.calls[0][0] as Record<string, unknown>;
|
|
235
|
+
expect(callArgs.html).toBe('<h1>From file</h1>');
|
|
236
|
+
} finally {
|
|
237
|
+
unlinkSync(tmpFile);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('passes cc, bcc, reply-to when provided', async () => {
|
|
242
|
+
spies = setupOutputSpies();
|
|
243
|
+
|
|
244
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
245
|
+
await sendCommand.parseAsync(
|
|
246
|
+
[
|
|
247
|
+
'--from',
|
|
248
|
+
'a@test.com',
|
|
249
|
+
'--to',
|
|
250
|
+
'b@test.com',
|
|
251
|
+
'--subject',
|
|
252
|
+
'Test',
|
|
253
|
+
'--text',
|
|
254
|
+
'Body',
|
|
255
|
+
'--cc',
|
|
256
|
+
'cc@test.com',
|
|
257
|
+
'--bcc',
|
|
258
|
+
'bcc@test.com',
|
|
259
|
+
'--reply-to',
|
|
260
|
+
'reply@test.com',
|
|
261
|
+
],
|
|
262
|
+
{ from: 'user' },
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const callArgs = mockSend.mock.calls[0][0] as Record<string, unknown>;
|
|
266
|
+
expect(callArgs.cc).toEqual(['cc@test.com']);
|
|
267
|
+
expect(callArgs.bcc).toEqual(['bcc@test.com']);
|
|
268
|
+
expect(callArgs.replyTo).toBe('reply@test.com');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('does not call domains.list when --from is provided', async () => {
|
|
272
|
+
spies = setupOutputSpies();
|
|
273
|
+
|
|
274
|
+
const { sendCommand } = await import('../../../src/commands/emails/send');
|
|
275
|
+
await sendCommand.parseAsync(
|
|
276
|
+
[
|
|
277
|
+
'--from',
|
|
278
|
+
'a@test.com',
|
|
279
|
+
'--to',
|
|
280
|
+
'b@test.com',
|
|
281
|
+
'--subject',
|
|
282
|
+
'Test',
|
|
283
|
+
'--text',
|
|
284
|
+
'Hello',
|
|
285
|
+
],
|
|
286
|
+
{ from: 'user' },
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(mockDomainsList).not.toHaveBeenCalled();
|
|
290
|
+
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('degrades gracefully when domain fetch fails', async () => {
|
|
294
|
+
const { fetchVerifiedDomains } = await import(
|
|
295
|
+
'../../../src/commands/emails/send'
|
|
296
|
+
);
|
|
297
|
+
const failingResend = {
|
|
298
|
+
domains: {
|
|
299
|
+
list: mock(async () => {
|
|
300
|
+
throw new Error('Network error');
|
|
301
|
+
}),
|
|
302
|
+
},
|
|
303
|
+
} as Record<string, unknown>;
|
|
304
|
+
|
|
305
|
+
// Should return [] without throwing, so the caller falls through to promptForMissing
|
|
306
|
+
const result = await fetchVerifiedDomains(failingResend);
|
|
307
|
+
expect(result).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
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 mockCreate = mock(async () => ({
|
|
20
|
+
data: {
|
|
21
|
+
object: 'segment' as const,
|
|
22
|
+
id: '3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c',
|
|
23
|
+
name: 'Newsletter Subscribers',
|
|
24
|
+
},
|
|
25
|
+
error: null,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module('resend', () => ({
|
|
29
|
+
Resend: class MockResend {
|
|
30
|
+
constructor(public key: string) {}
|
|
31
|
+
segments = { create: mockCreate };
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('segments create 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
|
+
mockCreate.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('creates segment with --name flag', async () => {
|
|
60
|
+
spies = setupOutputSpies();
|
|
61
|
+
|
|
62
|
+
const { createSegmentCommand } = await import(
|
|
63
|
+
'../../../src/commands/segments/create'
|
|
64
|
+
);
|
|
65
|
+
await createSegmentCommand.parseAsync(
|
|
66
|
+
['--name', 'Newsletter Subscribers'],
|
|
67
|
+
{ from: 'user' },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
71
|
+
const args = mockCreate.mock.calls[0][0] as Record<string, unknown>;
|
|
72
|
+
expect(args.name).toBe('Newsletter Subscribers');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('outputs JSON with id and object when non-interactive', async () => {
|
|
76
|
+
spies = setupOutputSpies();
|
|
77
|
+
|
|
78
|
+
const { createSegmentCommand } = await import(
|
|
79
|
+
'../../../src/commands/segments/create'
|
|
80
|
+
);
|
|
81
|
+
await createSegmentCommand.parseAsync(
|
|
82
|
+
['--name', 'Newsletter Subscribers'],
|
|
83
|
+
{ from: 'user' },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const output = spies.logSpy.mock.calls[0][0] as string;
|
|
87
|
+
const parsed = JSON.parse(output);
|
|
88
|
+
expect(parsed.object).toBe('segment');
|
|
89
|
+
expect(parsed.id).toBe('3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c');
|
|
90
|
+
expect(parsed.name).toBe('Newsletter Subscribers');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('errors with missing_name in non-interactive mode when --name absent', async () => {
|
|
94
|
+
setNonInteractive();
|
|
95
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
96
|
+
exitSpy = mockExitThrow();
|
|
97
|
+
|
|
98
|
+
const { createSegmentCommand } = await import(
|
|
99
|
+
'../../../src/commands/segments/create'
|
|
100
|
+
);
|
|
101
|
+
await expectExit1(() =>
|
|
102
|
+
createSegmentCommand.parseAsync([], { from: 'user' }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
|
|
106
|
+
expect(output).toContain('missing_name');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('does not call SDK when missing_name error is raised', async () => {
|
|
110
|
+
setNonInteractive();
|
|
111
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
112
|
+
exitSpy = mockExitThrow();
|
|
113
|
+
|
|
114
|
+
const { createSegmentCommand } = await import(
|
|
115
|
+
'../../../src/commands/segments/create'
|
|
116
|
+
);
|
|
117
|
+
await expectExit1(() =>
|
|
118
|
+
createSegmentCommand.parseAsync([], { from: 'user' }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('errors with auth_error when no API key', async () => {
|
|
125
|
+
setNonInteractive();
|
|
126
|
+
delete process.env.RESEND_API_KEY;
|
|
127
|
+
process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
|
|
128
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
129
|
+
exitSpy = mockExitThrow();
|
|
130
|
+
|
|
131
|
+
const { createSegmentCommand } = await import(
|
|
132
|
+
'../../../src/commands/segments/create'
|
|
133
|
+
);
|
|
134
|
+
await expectExit1(() =>
|
|
135
|
+
createSegmentCommand.parseAsync(['--name', 'Test'], { from: 'user' }),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
|
|
139
|
+
expect(output).toContain('auth_error');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('errors with create_error when SDK returns an error', async () => {
|
|
143
|
+
setNonInteractive();
|
|
144
|
+
mockCreate.mockResolvedValueOnce(
|
|
145
|
+
mockSdkError('Segment already exists', 'validation_error'),
|
|
146
|
+
);
|
|
147
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
148
|
+
stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
149
|
+
exitSpy = mockExitThrow();
|
|
150
|
+
|
|
151
|
+
const { createSegmentCommand } = await import(
|
|
152
|
+
'../../../src/commands/segments/create'
|
|
153
|
+
);
|
|
154
|
+
await expectExit1(() =>
|
|
155
|
+
createSegmentCommand.parseAsync(['--name', 'Newsletter Subscribers'], {
|
|
156
|
+
from: 'user',
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
|
|
161
|
+
expect(output).toContain('create_error');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -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: 'segment' as const,
|
|
22
|
+
id: '3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c',
|
|
23
|
+
deleted: true,
|
|
24
|
+
},
|
|
25
|
+
error: null,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module('resend', () => ({
|
|
29
|
+
Resend: class MockResend {
|
|
30
|
+
constructor(public key: string) {}
|
|
31
|
+
segments = { remove: mockRemove };
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('segments 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 segment with --yes flag', async () => {
|
|
60
|
+
spies = setupOutputSpies();
|
|
61
|
+
|
|
62
|
+
const { deleteSegmentCommand } = await import(
|
|
63
|
+
'../../../src/commands/segments/delete'
|
|
64
|
+
);
|
|
65
|
+
await deleteSegmentCommand.parseAsync(
|
|
66
|
+
['3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c', '--yes'],
|
|
67
|
+
{
|
|
68
|
+
from: 'user',
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(mockRemove).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(mockRemove.mock.calls[0][0]).toBe(
|
|
74
|
+
'3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('outputs synthesized JSON result when non-interactive', async () => {
|
|
79
|
+
spies = setupOutputSpies();
|
|
80
|
+
|
|
81
|
+
const { deleteSegmentCommand } = await import(
|
|
82
|
+
'../../../src/commands/segments/delete'
|
|
83
|
+
);
|
|
84
|
+
await deleteSegmentCommand.parseAsync(
|
|
85
|
+
['3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c', '--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.object).toBe('segment');
|
|
94
|
+
expect(parsed.id).toBe('3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c');
|
|
95
|
+
expect(parsed.deleted).toBe(true);
|
|
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 { deleteSegmentCommand } = await import(
|
|
104
|
+
'../../../src/commands/segments/delete'
|
|
105
|
+
);
|
|
106
|
+
await expectExit1(() =>
|
|
107
|
+
deleteSegmentCommand.parseAsync(
|
|
108
|
+
['3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c'],
|
|
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 { deleteSegmentCommand } = await import(
|
|
123
|
+
'../../../src/commands/segments/delete'
|
|
124
|
+
);
|
|
125
|
+
await expectExit1(() =>
|
|
126
|
+
deleteSegmentCommand.parseAsync(
|
|
127
|
+
['3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c'],
|
|
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 { deleteSegmentCommand } = await import(
|
|
143
|
+
'../../../src/commands/segments/delete'
|
|
144
|
+
);
|
|
145
|
+
await expectExit1(() =>
|
|
146
|
+
deleteSegmentCommand.parseAsync(
|
|
147
|
+
['3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c', '--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('Segment not found', 'not_found'),
|
|
162
|
+
);
|
|
163
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
164
|
+
stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
165
|
+
exitSpy = mockExitThrow();
|
|
166
|
+
|
|
167
|
+
const { deleteSegmentCommand } = await import(
|
|
168
|
+
'../../../src/commands/segments/delete'
|
|
169
|
+
);
|
|
170
|
+
await expectExit1(() =>
|
|
171
|
+
deleteSegmentCommand.parseAsync(
|
|
172
|
+
['00000000-0000-0000-0000-000000000000', '--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
|
+
});
|