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,192 @@
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: 'contact' as const,
22
+ deleted: true,
23
+ contact: 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
24
+ },
25
+ error: null,
26
+ }));
27
+
28
+ mock.module('resend', () => ({
29
+ Resend: class MockResend {
30
+ constructor(public key: string) {}
31
+ contacts = { remove: mockRemove };
32
+ },
33
+ }));
34
+
35
+ describe('contacts 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 contact by ID with --yes', async () => {
60
+ spies = setupOutputSpies();
61
+
62
+ const { deleteContactCommand } = await import(
63
+ '../../../src/commands/contacts/delete'
64
+ );
65
+ await deleteContactCommand.parseAsync(
66
+ ['a1b2c3d4-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
+ 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
75
+ );
76
+ });
77
+
78
+ test('deletes contact by email with --yes', async () => {
79
+ spies = setupOutputSpies();
80
+
81
+ const { deleteContactCommand } = await import(
82
+ '../../../src/commands/contacts/delete'
83
+ );
84
+ await deleteContactCommand.parseAsync(['jane@example.com', '--yes'], {
85
+ from: 'user',
86
+ });
87
+
88
+ expect(mockRemove.mock.calls[0][0]).toBe('jane@example.com');
89
+ });
90
+
91
+ test('outputs synthesized JSON result when non-interactive', async () => {
92
+ spies = setupOutputSpies();
93
+
94
+ const { deleteContactCommand } = await import(
95
+ '../../../src/commands/contacts/delete'
96
+ );
97
+ await deleteContactCommand.parseAsync(
98
+ ['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--yes'],
99
+ {
100
+ from: 'user',
101
+ },
102
+ );
103
+
104
+ const output = spies.logSpy.mock.calls[0][0] as string;
105
+ const parsed = JSON.parse(output);
106
+ expect(parsed.object).toBe('contact');
107
+ expect(parsed.id).toBe('a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6');
108
+ expect(parsed.deleted).toBe(true);
109
+ });
110
+
111
+ test('errors with confirmation_required when --yes absent in non-interactive mode', async () => {
112
+ setNonInteractive();
113
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
114
+ exitSpy = mockExitThrow();
115
+
116
+ const { deleteContactCommand } = await import(
117
+ '../../../src/commands/contacts/delete'
118
+ );
119
+ await expectExit1(() =>
120
+ deleteContactCommand.parseAsync(
121
+ ['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
122
+ { from: 'user' },
123
+ ),
124
+ );
125
+
126
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
127
+ expect(output).toContain('confirmation_required');
128
+ });
129
+
130
+ test('does not call SDK when confirmation_required error is raised', async () => {
131
+ setNonInteractive();
132
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
133
+ exitSpy = mockExitThrow();
134
+
135
+ const { deleteContactCommand } = await import(
136
+ '../../../src/commands/contacts/delete'
137
+ );
138
+ await expectExit1(() =>
139
+ deleteContactCommand.parseAsync(
140
+ ['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
141
+ { from: 'user' },
142
+ ),
143
+ );
144
+
145
+ expect(mockRemove).not.toHaveBeenCalled();
146
+ });
147
+
148
+ test('errors with auth_error when no API key', async () => {
149
+ setNonInteractive();
150
+ delete process.env.RESEND_API_KEY;
151
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
152
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
153
+ exitSpy = mockExitThrow();
154
+
155
+ const { deleteContactCommand } = await import(
156
+ '../../../src/commands/contacts/delete'
157
+ );
158
+ await expectExit1(() =>
159
+ deleteContactCommand.parseAsync(
160
+ ['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--yes'],
161
+ {
162
+ from: 'user',
163
+ },
164
+ ),
165
+ );
166
+
167
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
168
+ expect(output).toContain('auth_error');
169
+ });
170
+
171
+ test('errors with delete_error when SDK returns an error', async () => {
172
+ setNonInteractive();
173
+ mockRemove.mockResolvedValueOnce(
174
+ mockSdkError('Contact not found', 'not_found'),
175
+ );
176
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
177
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
178
+ exitSpy = mockExitThrow();
179
+
180
+ const { deleteContactCommand } = await import(
181
+ '../../../src/commands/contacts/delete'
182
+ );
183
+ await expectExit1(() =>
184
+ deleteContactCommand.parseAsync(['nonexistent_id', '--yes'], {
185
+ from: 'user',
186
+ }),
187
+ );
188
+
189
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
190
+ expect(output).toContain('delete_error');
191
+ });
192
+ });
@@ -0,0 +1,148 @@
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 mockGet = mock(async () => ({
20
+ data: {
21
+ object: 'contact' as const,
22
+ id: 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
23
+ email: 'jane@example.com',
24
+ first_name: 'Jane',
25
+ last_name: 'Smith',
26
+ created_at: '2026-01-01T00:00:00.000Z',
27
+ unsubscribed: false,
28
+ properties: {},
29
+ },
30
+ error: null,
31
+ }));
32
+
33
+ mock.module('resend', () => ({
34
+ Resend: class MockResend {
35
+ constructor(public key: string) {}
36
+ contacts = { get: mockGet };
37
+ },
38
+ }));
39
+
40
+ describe('contacts get command', () => {
41
+ const restoreEnv = captureTestEnv();
42
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
43
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
44
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
45
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
46
+
47
+ beforeEach(() => {
48
+ process.env.RESEND_API_KEY = 're_test_key';
49
+ mockGet.mockClear();
50
+ });
51
+
52
+ afterEach(() => {
53
+ restoreEnv();
54
+ spies?.restore();
55
+ errorSpy?.mockRestore();
56
+ stderrSpy?.mockRestore();
57
+ exitSpy?.mockRestore();
58
+ spies = undefined;
59
+ errorSpy = undefined;
60
+ stderrSpy = undefined;
61
+ exitSpy = undefined;
62
+ });
63
+
64
+ test('calls SDK with contact ID', async () => {
65
+ spies = setupOutputSpies();
66
+
67
+ const { getContactCommand } = await import(
68
+ '../../../src/commands/contacts/get'
69
+ );
70
+ await getContactCommand.parseAsync(
71
+ ['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
72
+ { from: 'user' },
73
+ );
74
+
75
+ expect(mockGet).toHaveBeenCalledTimes(1);
76
+ expect(mockGet.mock.calls[0][0]).toBe(
77
+ 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
78
+ );
79
+ });
80
+
81
+ test('calls SDK with email address', async () => {
82
+ spies = setupOutputSpies();
83
+
84
+ const { getContactCommand } = await import(
85
+ '../../../src/commands/contacts/get'
86
+ );
87
+ await getContactCommand.parseAsync(['jane@example.com'], { from: 'user' });
88
+
89
+ expect(mockGet.mock.calls[0][0]).toBe('jane@example.com');
90
+ });
91
+
92
+ test('outputs JSON when non-interactive', async () => {
93
+ spies = setupOutputSpies();
94
+
95
+ const { getContactCommand } = await import(
96
+ '../../../src/commands/contacts/get'
97
+ );
98
+ await getContactCommand.parseAsync(
99
+ ['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
100
+ { from: 'user' },
101
+ );
102
+
103
+ const output = spies.logSpy.mock.calls[0][0] as string;
104
+ const parsed = JSON.parse(output);
105
+ expect(parsed.id).toBe('a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6');
106
+ expect(parsed.email).toBe('jane@example.com');
107
+ });
108
+
109
+ test('errors with auth_error when no API key', async () => {
110
+ setNonInteractive();
111
+ delete process.env.RESEND_API_KEY;
112
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
113
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
114
+ exitSpy = mockExitThrow();
115
+
116
+ const { getContactCommand } = await import(
117
+ '../../../src/commands/contacts/get'
118
+ );
119
+ await expectExit1(() =>
120
+ getContactCommand.parseAsync(['a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'], {
121
+ from: 'user',
122
+ }),
123
+ );
124
+
125
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
126
+ expect(output).toContain('auth_error');
127
+ });
128
+
129
+ test('errors with fetch_error when SDK returns an error', async () => {
130
+ setNonInteractive();
131
+ mockGet.mockResolvedValueOnce(
132
+ mockSdkError('Contact not found', 'not_found'),
133
+ );
134
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
135
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
136
+ exitSpy = mockExitThrow();
137
+
138
+ const { getContactCommand } = await import(
139
+ '../../../src/commands/contacts/get'
140
+ );
141
+ await expectExit1(() =>
142
+ getContactCommand.parseAsync(['nonexistent_id'], { from: 'user' }),
143
+ );
144
+
145
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
146
+ expect(output).toContain('fetch_error');
147
+ });
148
+ });
@@ -0,0 +1,175 @@
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 mockList = mock(async () => ({
20
+ data: {
21
+ object: 'list' as const,
22
+ data: [
23
+ {
24
+ id: 'c1',
25
+ email: 'jane@example.com',
26
+ first_name: 'Jane',
27
+ last_name: 'Smith',
28
+ unsubscribed: false,
29
+ created_at: '2026-01-01T00:00:00.000Z',
30
+ },
31
+ ],
32
+ has_more: false,
33
+ },
34
+ error: null,
35
+ }));
36
+
37
+ mock.module('resend', () => ({
38
+ Resend: class MockResend {
39
+ constructor(public key: string) {}
40
+ contacts = { list: mockList };
41
+ },
42
+ }));
43
+
44
+ describe('contacts list command', () => {
45
+ const restoreEnv = captureTestEnv();
46
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
47
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
48
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
49
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
50
+
51
+ beforeEach(() => {
52
+ process.env.RESEND_API_KEY = 're_test_key';
53
+ mockList.mockClear();
54
+ });
55
+
56
+ afterEach(() => {
57
+ restoreEnv();
58
+ spies?.restore();
59
+ errorSpy?.mockRestore();
60
+ stderrSpy?.mockRestore();
61
+ exitSpy?.mockRestore();
62
+ spies = undefined;
63
+ errorSpy = undefined;
64
+ stderrSpy = undefined;
65
+ exitSpy = undefined;
66
+ });
67
+
68
+ test('calls SDK with default limit of 10', async () => {
69
+ spies = setupOutputSpies();
70
+
71
+ const { listContactsCommand } = await import(
72
+ '../../../src/commands/contacts/list'
73
+ );
74
+ await listContactsCommand.parseAsync([], { from: 'user' });
75
+
76
+ expect(mockList).toHaveBeenCalledTimes(1);
77
+ const args = mockList.mock.calls[0][0] as Record<string, unknown>;
78
+ expect(args.limit).toBe(10);
79
+ });
80
+
81
+ test('calls SDK with custom --limit', async () => {
82
+ spies = setupOutputSpies();
83
+
84
+ const { listContactsCommand } = await import(
85
+ '../../../src/commands/contacts/list'
86
+ );
87
+ await listContactsCommand.parseAsync(['--limit', '25'], { from: 'user' });
88
+
89
+ const args = mockList.mock.calls[0][0] as Record<string, unknown>;
90
+ expect(args.limit).toBe(25);
91
+ });
92
+
93
+ test('calls SDK with --after cursor', async () => {
94
+ spies = setupOutputSpies();
95
+
96
+ const { listContactsCommand } = await import(
97
+ '../../../src/commands/contacts/list'
98
+ );
99
+ await listContactsCommand.parseAsync(['--after', 'cursor_xyz'], {
100
+ from: 'user',
101
+ });
102
+
103
+ const args = mockList.mock.calls[0][0] as Record<string, unknown>;
104
+ expect(args.after).toBe('cursor_xyz');
105
+ });
106
+
107
+ test('outputs JSON list when non-interactive', async () => {
108
+ spies = setupOutputSpies();
109
+
110
+ const { listContactsCommand } = await import(
111
+ '../../../src/commands/contacts/list'
112
+ );
113
+ await listContactsCommand.parseAsync([], { from: 'user' });
114
+
115
+ const output = spies.logSpy.mock.calls[0][0] as string;
116
+ const parsed = JSON.parse(output);
117
+ expect(parsed.object).toBe('list');
118
+ expect(Array.isArray(parsed.data)).toBe(true);
119
+ expect(parsed.data[0].email).toBe('jane@example.com');
120
+ });
121
+
122
+ test('errors with invalid_limit when --limit is out of range', async () => {
123
+ setNonInteractive();
124
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
125
+ exitSpy = mockExitThrow();
126
+
127
+ const { listContactsCommand } = await import(
128
+ '../../../src/commands/contacts/list'
129
+ );
130
+ await expectExit1(() =>
131
+ listContactsCommand.parseAsync(['--limit', '0'], { from: 'user' }),
132
+ );
133
+
134
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
135
+ expect(output).toContain('invalid_limit');
136
+ });
137
+
138
+ test('errors with auth_error when no API key', async () => {
139
+ setNonInteractive();
140
+ delete process.env.RESEND_API_KEY;
141
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
142
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
143
+ exitSpy = mockExitThrow();
144
+
145
+ const { listContactsCommand } = await import(
146
+ '../../../src/commands/contacts/list'
147
+ );
148
+ await expectExit1(() =>
149
+ listContactsCommand.parseAsync([], { from: 'user' }),
150
+ );
151
+
152
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
153
+ expect(output).toContain('auth_error');
154
+ });
155
+
156
+ test('errors with list_error when SDK returns an error', async () => {
157
+ setNonInteractive();
158
+ mockList.mockResolvedValueOnce(
159
+ mockSdkError('Server error', 'server_error'),
160
+ );
161
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
162
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
163
+ exitSpy = mockExitThrow();
164
+
165
+ const { listContactsCommand } = await import(
166
+ '../../../src/commands/contacts/list'
167
+ );
168
+ await expectExit1(() =>
169
+ listContactsCommand.parseAsync([], { from: 'user' }),
170
+ );
171
+
172
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
173
+ expect(output).toContain('list_error');
174
+ });
175
+ });
@@ -0,0 +1,166 @@
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 mockRemoveSegment = mock(async () => ({
20
+ data: { id: '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d', deleted: true },
21
+ error: null,
22
+ }));
23
+
24
+ mock.module('resend', () => ({
25
+ Resend: class MockResend {
26
+ constructor(public key: string) {}
27
+ contacts = {
28
+ segments: { remove: mockRemoveSegment },
29
+ };
30
+ },
31
+ }));
32
+
33
+ describe('contacts remove-segment command', () => {
34
+ const restoreEnv = captureTestEnv();
35
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
36
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
37
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
38
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
39
+
40
+ beforeEach(() => {
41
+ process.env.RESEND_API_KEY = 're_test_key';
42
+ mockRemoveSegment.mockClear();
43
+ });
44
+
45
+ afterEach(() => {
46
+ restoreEnv();
47
+ spies?.restore();
48
+ errorSpy?.mockRestore();
49
+ stderrSpy?.mockRestore();
50
+ exitSpy?.mockRestore();
51
+ spies = undefined;
52
+ errorSpy = undefined;
53
+ stderrSpy = undefined;
54
+ exitSpy = undefined;
55
+ });
56
+
57
+ test('removes contact from segment by contact ID', async () => {
58
+ spies = setupOutputSpies();
59
+
60
+ const { removeContactSegmentCommand } = await import(
61
+ '../../../src/commands/contacts/remove-segment'
62
+ );
63
+ await removeContactSegmentCommand.parseAsync(
64
+ [
65
+ 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
66
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
67
+ ],
68
+ { from: 'user' },
69
+ );
70
+
71
+ expect(mockRemoveSegment).toHaveBeenCalledTimes(1);
72
+ const args = mockRemoveSegment.mock.calls[0][0] as Record<string, unknown>;
73
+ expect(args.contactId).toBe('a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6');
74
+ expect(args.segmentId).toBe('7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d');
75
+ });
76
+
77
+ test('removes contact from segment by email', async () => {
78
+ spies = setupOutputSpies();
79
+
80
+ const { removeContactSegmentCommand } = await import(
81
+ '../../../src/commands/contacts/remove-segment'
82
+ );
83
+ await removeContactSegmentCommand.parseAsync(
84
+ ['jane@example.com', '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d'],
85
+ { from: 'user' },
86
+ );
87
+
88
+ const args = mockRemoveSegment.mock.calls[0][0] as Record<string, unknown>;
89
+ expect(args.email).toBe('jane@example.com');
90
+ expect(args.segmentId).toBe('7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d');
91
+ });
92
+
93
+ test('outputs JSON result when non-interactive', async () => {
94
+ spies = setupOutputSpies();
95
+
96
+ const { removeContactSegmentCommand } = await import(
97
+ '../../../src/commands/contacts/remove-segment'
98
+ );
99
+ await removeContactSegmentCommand.parseAsync(
100
+ [
101
+ 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
102
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
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('7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d');
110
+ expect(parsed.deleted).toBe(true);
111
+ });
112
+
113
+ test('errors with auth_error when no API key', async () => {
114
+ setNonInteractive();
115
+ delete process.env.RESEND_API_KEY;
116
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
117
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
118
+ exitSpy = mockExitThrow();
119
+
120
+ const { removeContactSegmentCommand } = await import(
121
+ '../../../src/commands/contacts/remove-segment'
122
+ );
123
+ await expectExit1(() =>
124
+ removeContactSegmentCommand.parseAsync(
125
+ [
126
+ 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
127
+ '7b1e0a3d-4c5f-4e8a-9b2d-1a3c5e7f9b2d',
128
+ ],
129
+ {
130
+ from: 'user',
131
+ },
132
+ ),
133
+ );
134
+
135
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
136
+ expect(output).toContain('auth_error');
137
+ });
138
+
139
+ test('errors with remove_segment_error when SDK returns an error', async () => {
140
+ setNonInteractive();
141
+ mockRemoveSegment.mockResolvedValueOnce(
142
+ mockSdkError('Not found', 'not_found'),
143
+ );
144
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
145
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
146
+ exitSpy = mockExitThrow();
147
+
148
+ const { removeContactSegmentCommand } = await import(
149
+ '../../../src/commands/contacts/remove-segment'
150
+ );
151
+ await expectExit1(() =>
152
+ removeContactSegmentCommand.parseAsync(
153
+ [
154
+ 'a1b2c3d4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
155
+ '00000000-0000-0000-0000-00000bad0seg',
156
+ ],
157
+ {
158
+ from: 'user',
159
+ },
160
+ ),
161
+ );
162
+
163
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
164
+ expect(output).toContain('remove_segment_error');
165
+ });
166
+ });