@cakemail-org/cakemail-cli 1.5.0 → 2.0.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 (234) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/.env.example +40 -0
  3. package/.env.test.example +45 -0
  4. package/CHANGELOG.md +1031 -0
  5. package/README.md +319 -15
  6. package/audit-formats.js +128 -0
  7. package/cakemail.rb +20 -0
  8. package/dist/cli.js +27 -10
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client.d.ts +2 -0
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +16 -6
  13. package/dist/client.js.map +1 -1
  14. package/dist/commands/account.js +1 -1
  15. package/dist/commands/account.js.map +1 -1
  16. package/dist/commands/attributes.js +1 -1
  17. package/dist/commands/attributes.js.map +1 -1
  18. package/dist/commands/campaigns.d.ts.map +1 -1
  19. package/dist/commands/campaigns.js +103 -8
  20. package/dist/commands/campaigns.js.map +1 -1
  21. package/dist/commands/config.d.ts.map +1 -1
  22. package/dist/commands/config.js +63 -4
  23. package/dist/commands/config.js.map +1 -1
  24. package/dist/commands/contacts.d.ts.map +1 -1
  25. package/dist/commands/contacts.js +91 -12
  26. package/dist/commands/contacts.js.map +1 -1
  27. package/dist/commands/emails.js +1 -1
  28. package/dist/commands/emails.js.map +1 -1
  29. package/dist/commands/interests.d.ts +5 -0
  30. package/dist/commands/interests.d.ts.map +1 -0
  31. package/dist/commands/interests.js +172 -0
  32. package/dist/commands/interests.js.map +1 -0
  33. package/dist/commands/lists.d.ts.map +1 -1
  34. package/dist/commands/lists.js +6 -8
  35. package/dist/commands/lists.js.map +1 -1
  36. package/dist/commands/logs.d.ts +5 -0
  37. package/dist/commands/logs.d.ts.map +1 -0
  38. package/dist/commands/logs.js +237 -0
  39. package/dist/commands/logs.js.map +1 -0
  40. package/dist/commands/reports.js +1 -1
  41. package/dist/commands/reports.js.map +1 -1
  42. package/dist/commands/segments.js +1 -1
  43. package/dist/commands/segments.js.map +1 -1
  44. package/dist/commands/senders.d.ts.map +1 -1
  45. package/dist/commands/senders.js +11 -8
  46. package/dist/commands/senders.js.map +1 -1
  47. package/dist/commands/suppressed.js +1 -1
  48. package/dist/commands/suppressed.js.map +1 -1
  49. package/dist/commands/tags.d.ts +5 -0
  50. package/dist/commands/tags.d.ts.map +1 -0
  51. package/dist/commands/tags.js +124 -0
  52. package/dist/commands/tags.js.map +1 -0
  53. package/dist/commands/templates.js +1 -1
  54. package/dist/commands/templates.js.map +1 -1
  55. package/dist/commands/transactional-templates.d.ts +5 -0
  56. package/dist/commands/transactional-templates.d.ts.map +1 -0
  57. package/dist/commands/transactional-templates.js +354 -0
  58. package/dist/commands/transactional-templates.js.map +1 -0
  59. package/dist/commands/webhooks.js +1 -1
  60. package/dist/commands/webhooks.js.map +1 -1
  61. package/dist/utils/auth.d.ts +8 -1
  62. package/dist/utils/auth.d.ts.map +1 -1
  63. package/dist/utils/auth.js +39 -11
  64. package/dist/utils/auth.js.map +1 -1
  65. package/dist/utils/config-file.d.ts +7 -0
  66. package/dist/utils/config-file.d.ts.map +1 -1
  67. package/dist/utils/config-file.js +15 -0
  68. package/dist/utils/config-file.js.map +1 -1
  69. package/dist/utils/config.d.ts +2 -0
  70. package/dist/utils/config.d.ts.map +1 -1
  71. package/dist/utils/config.js +12 -4
  72. package/dist/utils/config.js.map +1 -1
  73. package/dist/utils/errors.js +1 -1
  74. package/dist/utils/errors.js.map +1 -1
  75. package/dist/utils/list-defaults.d.ts +33 -0
  76. package/dist/utils/list-defaults.d.ts.map +1 -0
  77. package/dist/utils/list-defaults.js +52 -0
  78. package/dist/utils/list-defaults.js.map +1 -0
  79. package/dist/utils/output.d.ts.map +1 -1
  80. package/dist/utils/output.js +36 -13
  81. package/dist/utils/output.js.map +1 -1
  82. package/dist/utils/progress.d.ts.map +1 -1
  83. package/dist/utils/progress.js +32 -4
  84. package/dist/utils/progress.js.map +1 -1
  85. package/dist/utils/spinner.d.ts +17 -0
  86. package/dist/utils/spinner.d.ts.map +1 -0
  87. package/dist/utils/spinner.js +43 -0
  88. package/dist/utils/spinner.js.map +1 -0
  89. package/docs/DOCUMENTATION-STANDARD.md +1068 -0
  90. package/docs/README.md +161 -0
  91. package/docs/developer/ARCHITECTURE.md +516 -0
  92. package/docs/developer/AUTH.md +204 -0
  93. package/docs/developer/CONTRIBUTING.md +227 -0
  94. package/docs/developer/DOCUMENTATION_SUMMARY.md +346 -0
  95. package/docs/developer/PROJECT_INDEX.md +365 -0
  96. package/docs/planning/API_COVERAGE.md +1045 -0
  97. package/docs/planning/BACKLOG.md +1159 -0
  98. package/docs/planning/PROFILE_SYSTEM_TASKS.md +287 -0
  99. package/docs/planning/UX_IMPLEMENTATION_PLAN.md +691 -0
  100. package/docs/planning/archive/RELEASE_CHECKLIST_v1.3.0.md +332 -0
  101. package/docs/planning/archive/RELEASE_v1.3.0.md +428 -0
  102. package/docs/planning/archive/cakemail-cli-ux-improvements.md +438 -0
  103. package/docs/planning/cakemail-profile-system-plan.md +1121 -0
  104. package/docs/testing/AI_USER_SIMULATION_DESIGN.md +1342 -0
  105. package/docs/testing/KENOGAMI_BIDIRECTIONAL_FLOW.md +1517 -0
  106. package/docs/testing/KENOGAMI_TRUTH_RECONCILIATION_SYSTEM.md +1369 -0
  107. package/docs/user-manual/.obsidian/app.json +1 -0
  108. package/docs/user-manual/.obsidian/appearance.json +1 -0
  109. package/docs/user-manual/.obsidian/core-plugins.json +33 -0
  110. package/docs/user-manual/.obsidian/workspace.json +167 -0
  111. package/docs/user-manual/01-getting-started/01-installation.md +214 -0
  112. package/docs/user-manual/01-getting-started/02-quick-start.md +432 -0
  113. package/docs/user-manual/01-getting-started/03-authentication.md +448 -0
  114. package/docs/user-manual/01-getting-started/04-configuration.md +430 -0
  115. package/docs/user-manual/01-getting-started/05-output-formats.md +447 -0
  116. package/docs/user-manual/02-core-concepts/01-accounts.md +514 -0
  117. package/docs/user-manual/02-core-concepts/02-profile-system.md +771 -0
  118. package/docs/user-manual/02-core-concepts/03-smart-defaults.md +485 -0
  119. package/docs/user-manual/02-core-concepts/04-authentication-methods.md +435 -0
  120. package/docs/user-manual/02-core-concepts/05-pagination-filtering.md +600 -0
  121. package/docs/user-manual/02-core-concepts/06-error-handling.md +718 -0
  122. package/docs/user-manual/02-core-concepts/07-api-coverage.md +483 -0
  123. package/docs/user-manual/03-email-operations/01-senders.md +490 -0
  124. package/docs/user-manual/03-email-operations/02-templates.md +444 -0
  125. package/docs/user-manual/03-email-operations/03-transactional-emails.md +706 -0
  126. package/docs/user-manual/03-email-operations/04-email-tracking.md +407 -0
  127. package/docs/user-manual/04-campaign-management/01-campaigns-basics.md +394 -0
  128. package/docs/user-manual/04-campaign-management/02-campaign-scheduling.md +630 -0
  129. package/docs/user-manual/04-campaign-management/03-campaign-testing.md +997 -0
  130. package/docs/user-manual/04-campaign-management/04-campaign-lifecycle.md +709 -0
  131. package/docs/user-manual/04-campaign-management/05-campaign-links.md +934 -0
  132. package/docs/user-manual/05-contact-management/01-lists.md +836 -0
  133. package/docs/user-manual/05-contact-management/02-contacts.md +1035 -0
  134. package/docs/user-manual/05-contact-management/03-custom-attributes.md +788 -0
  135. package/docs/user-manual/05-contact-management/04-segments.md +1028 -0
  136. package/docs/user-manual/05-contact-management/05-contact-import-export.md +1031 -0
  137. package/docs/user-manual/06-analytics-reporting/01-campaign-analytics.md +867 -0
  138. package/docs/user-manual/06-analytics-reporting/02-account-reports.md +227 -0
  139. package/docs/user-manual/07-integrations/01-webhooks-integration.md +259 -0
  140. package/docs/user-manual/07-integrations/02-automation.md +326 -0
  141. package/docs/user-manual/08-advanced-usage/01-scripting-patterns.md +672 -0
  142. package/docs/user-manual/08-advanced-usage/02-bulk-operations.md +932 -0
  143. package/docs/user-manual/08-advanced-usage/03-ci-cd-integration.md +892 -0
  144. package/docs/user-manual/08-advanced-usage/04-performance-optimization.md +766 -0
  145. package/docs/user-manual/09-command-reference/01-config.md +776 -0
  146. package/docs/user-manual/09-command-reference/02-account.md +652 -0
  147. package/docs/user-manual/09-command-reference/03-lists.md +958 -0
  148. package/docs/user-manual/09-command-reference/04-contacts.md +1408 -0
  149. package/docs/user-manual/09-command-reference/05-attributes.md +617 -0
  150. package/docs/user-manual/09-command-reference/06-segments.md +894 -0
  151. package/docs/user-manual/09-command-reference/07-senders.md +803 -0
  152. package/docs/user-manual/09-command-reference/08-templates.md +818 -0
  153. package/docs/user-manual/09-command-reference/09-campaigns.md +1250 -0
  154. package/docs/user-manual/09-command-reference/10-emails.md +807 -0
  155. package/docs/user-manual/09-command-reference/11-reports.md +1135 -0
  156. package/docs/user-manual/09-command-reference/12-webhooks.md +773 -0
  157. package/docs/user-manual/09-command-reference/13-suppressed.md +797 -0
  158. package/docs/user-manual/09-command-reference/14-interests.md +630 -0
  159. package/docs/user-manual/09-command-reference/15-tags.md +584 -0
  160. package/docs/user-manual/09-command-reference/16-logs.md +656 -0
  161. package/docs/user-manual/09-command-reference/17-transactional-templates.md +850 -0
  162. package/docs/user-manual/10-troubleshooting/01-common-errors.md +457 -0
  163. package/docs/user-manual/10-troubleshooting/02-authentication-issues.md +558 -0
  164. package/docs/user-manual/10-troubleshooting/03-connection-problems.md +634 -0
  165. package/docs/user-manual/10-troubleshooting/04-debugging.md +725 -0
  166. package/docs/user-manual/11-appendix/04-faq.md +484 -0
  167. package/docs/user-manual/11-appendix/05-glossary.md +250 -0
  168. package/docs/user-manual/README.md +0 -0
  169. package/package.json +13 -47
  170. package/src/cli.ts +125 -0
  171. package/src/client.ts +16 -0
  172. package/src/commands/account.ts +267 -0
  173. package/src/commands/accounts.ts +78 -0
  174. package/src/commands/actions.ts +249 -0
  175. package/src/commands/attributes.ts +139 -0
  176. package/src/commands/campaign-blueprints.ts +106 -0
  177. package/src/commands/campaigns.ts +469 -0
  178. package/src/commands/config.ts +77 -0
  179. package/src/commands/contacts.ts +612 -0
  180. package/src/commands/custom-attributes.ts +127 -0
  181. package/src/commands/dkims.ts +117 -0
  182. package/src/commands/domains.ts +82 -0
  183. package/src/commands/email-apis.ts +569 -0
  184. package/src/commands/emails.ts +197 -0
  185. package/src/commands/forms.ts +283 -0
  186. package/src/commands/interests.ts +155 -0
  187. package/src/commands/links.ts +38 -0
  188. package/src/commands/lists.ts +406 -0
  189. package/src/commands/logos.ts +71 -0
  190. package/src/commands/logs.ts +386 -0
  191. package/src/commands/reports.ts +306 -0
  192. package/src/commands/segments.ts +158 -0
  193. package/src/commands/senders.ts +204 -0
  194. package/src/commands/sub-accounts.ts +271 -0
  195. package/src/commands/suppressed-emails.ts +234 -0
  196. package/src/commands/suppressed.ts +198 -0
  197. package/src/commands/system-emails.ts +85 -0
  198. package/src/commands/tags.ts +146 -0
  199. package/src/commands/tasks.ts +116 -0
  200. package/src/commands/templates.ts +189 -0
  201. package/src/commands/tokens.ts +83 -0
  202. package/src/commands/transactional-emails.ts +374 -0
  203. package/src/commands/transactional-templates.ts +385 -0
  204. package/src/commands/users.ts +506 -0
  205. package/src/commands/webhooks.ts +172 -0
  206. package/src/commands/workflow-blueprints.ts +123 -0
  207. package/src/commands/workflows.ts +265 -0
  208. package/src/types/profile.ts +93 -0
  209. package/src/utils/auth.ts +272 -0
  210. package/src/utils/config-file.ts +96 -0
  211. package/src/utils/config.ts +134 -0
  212. package/src/utils/confirm.ts +32 -0
  213. package/src/utils/defaults.ts +99 -0
  214. package/src/utils/errors.ts +116 -0
  215. package/src/utils/interactive.ts +91 -0
  216. package/src/utils/list-defaults.ts +74 -0
  217. package/src/utils/output.ts +190 -0
  218. package/src/utils/progress.ts +320 -0
  219. package/src/utils/spinner.ts +22 -0
  220. package/tests/IMPLEMENTATION_STATUS.md +258 -0
  221. package/tests/PTY_SETUP.md +118 -0
  222. package/tests/PTY_TESTING_GUIDE.md +507 -0
  223. package/tests/README.md +244 -0
  224. package/tests/fixtures/api-responses/campaigns.json +34 -0
  225. package/tests/fixtures/test-config.json +13 -0
  226. package/tests/helpers/cli-runner.ts +128 -0
  227. package/tests/helpers/mock-server.ts +301 -0
  228. package/tests/helpers/pty-runner.ts +181 -0
  229. package/tests/integration/campaigns-real-api.test.ts +196 -0
  230. package/tests/integration/setup-integration.ts +50 -0
  231. package/tests/pty/campaigns.test.ts +241 -0
  232. package/tests/setup.ts +34 -0
  233. package/tsconfig.json +15 -0
  234. package/vitest.config.ts +28 -0
@@ -0,0 +1,181 @@
1
+ import { spawn, IPty } from 'node-pty';
2
+ import { join } from 'path';
3
+ import stripAnsi from 'strip-ansi';
4
+
5
+ export interface PTYOptions {
6
+ mockServerPort: number;
7
+ email?: string;
8
+ password?: string;
9
+ timeout?: number;
10
+ enableColors?: boolean;
11
+ onData?: (data: string) => void;
12
+ interactive?: boolean;
13
+ }
14
+
15
+ export interface PTYResult {
16
+ output: string;
17
+ cleanOutput: string; // Output with ANSI codes stripped
18
+ exitCode: number;
19
+ pty: IPty;
20
+ }
21
+
22
+ /**
23
+ * Run CLI command with PTY (real terminal simulation)
24
+ *
25
+ * This simulates a REAL user typing commands in a terminal.
26
+ * Perfect for testing:
27
+ * - Interactive prompts
28
+ * - Colors and formatting
29
+ * - Spinners and progress bars
30
+ * - Terminal-specific behavior
31
+ */
32
+ export async function runCLIWithPTY(
33
+ args: string[],
34
+ options: PTYOptions
35
+ ): Promise<PTYResult> {
36
+ const cliPath = join(process.cwd(), 'dist', 'cli.js');
37
+
38
+ const env: Record<string, string> = {
39
+ // Inherit other env vars first
40
+ ...process.env,
41
+
42
+ // Point to mock server (override any existing CAKEMAIL_API_BASE)
43
+ CAKEMAIL_API_BASE: `http://localhost:${options.mockServerPort}`,
44
+
45
+ // Credentials (override any existing credentials)
46
+ CAKEMAIL_EMAIL: options.email || 'test@example.com',
47
+ CAKEMAIL_PASSWORD: options.password || 'test-password',
48
+
49
+ // Use mock access token to bypass OAuth flow
50
+ CAKEMAIL_ACCESS_TOKEN: 'mock-access-token',
51
+
52
+ // Batch mode unless interactive
53
+ // Note: We explicitly set CAKEMAIL_BATCH_MODE to override NODE_ENV=test
54
+ CAKEMAIL_BATCH_MODE: options.interactive ? 'false' : 'true',
55
+
56
+ // Terminal settings
57
+ TERM: options.enableColors ? 'xterm-256color' : 'dumb',
58
+ FORCE_COLOR: options.enableColors ? '1' : '0',
59
+ };
60
+
61
+ const pty = spawn('node', [cliPath, ...args], {
62
+ name: 'xterm-color',
63
+ cols: 80,
64
+ rows: 30,
65
+ cwd: process.cwd(),
66
+ env
67
+ });
68
+
69
+ let output = '';
70
+
71
+ // Collect output
72
+ pty.onData((data) => {
73
+ output += data;
74
+ options.onData?.(data);
75
+ });
76
+
77
+ // Wait for exit or timeout
78
+ const exitCode = await new Promise<number>((resolve, reject) => {
79
+ pty.onExit(({ exitCode }) => {
80
+ resolve(exitCode);
81
+ });
82
+
83
+ // Timeout
84
+ const timeout = options.timeout || 10000;
85
+ setTimeout(() => {
86
+ pty.kill();
87
+ reject(new Error(`PTY timeout after ${timeout}ms`));
88
+ }, timeout);
89
+ });
90
+
91
+ return {
92
+ output,
93
+ cleanOutput: stripAnsi(output),
94
+ exitCode,
95
+ pty
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Run CLI and expect success (exit code 0)
101
+ */
102
+ export async function runPTYSuccess(
103
+ args: string[],
104
+ options: PTYOptions
105
+ ): Promise<PTYResult> {
106
+ const result = await runCLIWithPTY(args, options);
107
+
108
+ if (result.exitCode !== 0) {
109
+ throw new Error(
110
+ `CLI command failed with exit code ${result.exitCode}\n` +
111
+ `Command: cakemail ${args.join(' ')}\n` +
112
+ `Output:\n${result.cleanOutput}`
113
+ );
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Run CLI and expect failure (exit code !== 0)
121
+ */
122
+ export async function runPTYFailure(
123
+ args: string[],
124
+ options: PTYOptions
125
+ ): Promise<PTYResult> {
126
+ const result = await runCLIWithPTY(args, options);
127
+
128
+ if (result.exitCode === 0) {
129
+ throw new Error(
130
+ `CLI command succeeded when failure was expected\n` +
131
+ `Command: cakemail ${args.join(' ')}\n` +
132
+ `Output:\n${result.cleanOutput}`
133
+ );
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Simulate interactive user input
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * const interaction = createInteraction([
145
+ * { prompt: 'List name:', response: 'My List\r' },
146
+ * { prompt: 'Language:', response: 'en\r' },
147
+ * { prompt: 'Confirm?', response: 'y\r' }
148
+ * ]);
149
+ *
150
+ * const result = await runCLIWithPTY(['lists', 'create'], {
151
+ * mockServerPort,
152
+ * interactive: true,
153
+ * onData: interaction.handler
154
+ * });
155
+ * ```
156
+ */
157
+ export function createInteraction(steps: Array<{ prompt: string; response: string }>) {
158
+ let currentStep = 0;
159
+ let pty: IPty | null = null;
160
+
161
+ return {
162
+ handler: (data: string) => {
163
+ if (currentStep >= steps.length) return;
164
+
165
+ const step = steps[currentStep];
166
+ if (data.includes(step.prompt)) {
167
+ // Wait a bit to simulate real user thinking
168
+ setTimeout(() => {
169
+ pty?.write(step.response);
170
+ currentStep++;
171
+ }, 100);
172
+ }
173
+ },
174
+
175
+ setPty: (p: IPty) => {
176
+ pty = p;
177
+ },
178
+
179
+ isComplete: () => currentStep >= steps.length
180
+ };
181
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { runCLI, runCLISuccess, parseJSONOutput } from '../helpers/cli-runner';
3
+ import './setup-integration';
4
+
5
+ /**
6
+ * REAL API INTEGRATION TESTS
7
+ *
8
+ * These tests make actual HTTP requests to the Cakemail API.
9
+ * They test the full end-to-end flow including:
10
+ * - Authentication
11
+ * - Real HTTP requests
12
+ * - Actual data creation/modification
13
+ * - Real API responses
14
+ *
15
+ * Requirements:
16
+ * - CAKEMAIL_TEST_EMAIL environment variable
17
+ * - CAKEMAIL_TEST_PASSWORD environment variable
18
+ * - Valid Cakemail test account
19
+ *
20
+ * Note: These tests are slower than mocked tests but provide
21
+ * real confidence that the CLI works with the actual API.
22
+ */
23
+
24
+ const hasCredentials = () => {
25
+ // Check for test credentials first, then fall back to regular credentials
26
+ const hasTestCreds = !!(process.env.CAKEMAIL_TEST_EMAIL && process.env.CAKEMAIL_TEST_PASSWORD);
27
+ const hasRegularCreds = !!(process.env.CAKEMAIL_EMAIL && process.env.CAKEMAIL_PASSWORD);
28
+ return hasTestCreds || hasRegularCreds;
29
+ };
30
+
31
+ describe('campaigns (REAL API)', () => {
32
+ let testCampaignId: number | null = null;
33
+ let testListId: number | null = null;
34
+ let testSenderId: number | null = null;
35
+
36
+ beforeAll(async () => {
37
+ if (!hasCredentials()) {
38
+ console.warn('⚠️ Skipping integration tests - no credentials provided');
39
+ return;
40
+ }
41
+
42
+ // Get or create a test list (force JSON output for parsing)
43
+ const listsResult = await runCLI(['-f', 'json', 'lists', 'list', '--limit', '1']);
44
+ if (listsResult.exitCode === 0) {
45
+ const lists = parseJSONOutput(listsResult.stdout);
46
+ if (lists.data && lists.data.length > 0) {
47
+ testListId = lists.data[0].id;
48
+ }
49
+ }
50
+
51
+ // Get or create a test sender (force JSON output for parsing)
52
+ const sendersResult = await runCLI(['-f', 'json', 'senders', 'list', '--limit', '1']);
53
+ if (sendersResult.exitCode === 0) {
54
+ const senders = parseJSONOutput(sendersResult.stdout);
55
+ if (senders.data && senders.data.length > 0) {
56
+ testSenderId = senders.data[0].id;
57
+ }
58
+ }
59
+ });
60
+
61
+ afterAll(async () => {
62
+ // Clean up: delete test campaign if it was created
63
+ if (testCampaignId) {
64
+ await runCLI(['campaigns', 'delete', testCampaignId.toString(), '--force']);
65
+ }
66
+ });
67
+
68
+ it.skipIf(!hasCredentials())('should list campaigns from real API', async () => {
69
+ const result = await runCLISuccess(['-f', 'json', 'campaigns', 'list', '--limit', '5']);
70
+
71
+ expect(result.exitCode).toBe(0);
72
+ const output = parseJSONOutput(result.stdout);
73
+ expect(output).toHaveProperty('data');
74
+ expect(Array.isArray(output.data)).toBe(true);
75
+ });
76
+
77
+ it.skipIf(!hasCredentials())('should list campaigns in table format', async () => {
78
+ const result = await runCLISuccess(['-f', 'table', 'campaigns', 'list', '--limit', '5']);
79
+
80
+ expect(result.exitCode).toBe(0);
81
+ // Table output should contain borders
82
+ expect(result.stdout).toContain('│');
83
+ });
84
+
85
+ it.skipIf(!hasCredentials())('should support pagination', async () => {
86
+ const result = await runCLISuccess([
87
+ '-f', 'json',
88
+ 'campaigns', 'list',
89
+ '--page', '1',
90
+ '--limit', '2'
91
+ ]);
92
+
93
+ expect(result.exitCode).toBe(0);
94
+ const output = parseJSONOutput(result.stdout);
95
+ expect(output.data.length).toBeLessThanOrEqual(2);
96
+ });
97
+
98
+ it.skipIf(!hasCredentials() || !testListId || !testSenderId)(
99
+ 'should create, update, and delete a campaign',
100
+ async () => {
101
+ const campaignName = `Test Campaign ${Date.now()}`;
102
+
103
+ // 1. Create campaign
104
+ const createResult = await runCLISuccess([
105
+ '-f', 'json',
106
+ 'campaigns', 'create',
107
+ '--name', campaignName,
108
+ '--list-id', testListId!.toString(),
109
+ '--sender-id', testSenderId!.toString(),
110
+ '--batch'
111
+ ]);
112
+
113
+ expect(createResult.exitCode).toBe(0);
114
+ expect(createResult.stdout.toLowerCase()).toContain('created');
115
+
116
+ // Extract campaign ID from output
117
+ const lines = createResult.stdout.split('\n');
118
+ const jsonLine = lines.find(line => line.trim().startsWith('{'));
119
+ expect(jsonLine).toBeDefined();
120
+
121
+ const created = JSON.parse(jsonLine!);
122
+ testCampaignId = created.id;
123
+ expect(testCampaignId).toBeDefined();
124
+ expect(created.name).toBe(campaignName);
125
+
126
+ // 2. Get campaign details
127
+ const getResult = await runCLISuccess(['-f', 'json', 'campaigns', 'get', testCampaignId!.toString()]);
128
+ const fetched = parseJSONOutput(getResult.stdout);
129
+ expect(fetched.id).toBe(testCampaignId);
130
+ expect(fetched.name).toBe(campaignName);
131
+
132
+ // 3. Update campaign
133
+ const updatedName = `Updated Campaign ${Date.now()}`;
134
+ const updateResult = await runCLISuccess([
135
+ '-f', 'json',
136
+ 'campaigns', 'update', testCampaignId!.toString(),
137
+ '--name', updatedName
138
+ ]);
139
+
140
+ expect(updateResult.exitCode).toBe(0);
141
+ expect(updateResult.stdout.toLowerCase()).toContain('updated');
142
+
143
+ // 4. Verify update
144
+ const getUpdatedResult = await runCLISuccess(['-f', 'json', 'campaigns', 'get', testCampaignId!.toString()]);
145
+ const updated = parseJSONOutput(getUpdatedResult.stdout);
146
+ expect(updated.name).toBe(updatedName);
147
+
148
+ // 5. Delete campaign
149
+ const deleteResult = await runCLISuccess([
150
+ '-f', 'json',
151
+ 'campaigns', 'delete', testCampaignId!.toString(),
152
+ '--force'
153
+ ]);
154
+
155
+ expect(deleteResult.exitCode).toBe(0);
156
+ expect(deleteResult.stdout.toLowerCase()).toContain('deleted');
157
+
158
+ testCampaignId = null; // Mark as cleaned up
159
+ },
160
+ 60000 // 60 second timeout for full lifecycle
161
+ );
162
+
163
+ it.skipIf(!hasCredentials())('should handle non-existent campaign gracefully', async () => {
164
+ const result = await runCLI(['campaigns', 'get', '99999999']);
165
+
166
+ expect(result.exitCode).toBe(1);
167
+ // The API returns a 400 error for non-existent campaigns
168
+ expect(result.stderr.toLowerCase()).toMatch(/error|400/);
169
+ });
170
+
171
+ it.skipIf(!hasCredentials())('should work with different output profiles', async () => {
172
+ // Test developer profile
173
+ const devResult = await runCLISuccess([
174
+ '--profile', 'developer',
175
+ 'campaigns', 'list',
176
+ '--limit', '1'
177
+ ]);
178
+ expect(devResult.exitCode).toBe(0);
179
+
180
+ // Test marketer profile
181
+ const marketerResult = await runCLISuccess([
182
+ '--profile', 'marketer',
183
+ 'campaigns', 'list',
184
+ '--limit', '1'
185
+ ]);
186
+ expect(marketerResult.exitCode).toBe(0);
187
+
188
+ // Test balanced profile
189
+ const balancedResult = await runCLISuccess([
190
+ '--profile', 'balanced',
191
+ 'campaigns', 'list',
192
+ '--limit', '1'
193
+ ]);
194
+ expect(balancedResult.exitCode).toBe(0);
195
+ });
196
+ });
@@ -0,0 +1,50 @@
1
+ import { beforeAll } from 'vitest';
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+
5
+ // Load .env file from project root IMMEDIATELY (before test file loads)
6
+ // This ensures credentials are available for it.skipIf() checks
7
+ config({ path: resolve(process.cwd(), '.env') });
8
+
9
+ // IMPORTANT: Capture real credentials early, before tests/setup.ts overwrites them
10
+ // Integration tests need REAL credentials, not the test defaults
11
+ const envCreds = {
12
+ email: process.env.CAKEMAIL_EMAIL,
13
+ password: process.env.CAKEMAIL_PASSWORD
14
+ };
15
+
16
+ /**
17
+ * Setup for REAL API integration tests
18
+ * These tests make actual HTTP calls to the Cakemail API
19
+ */
20
+ beforeAll(() => {
21
+ // Restore real credentials (tests/setup.ts sets fake defaults)
22
+ if (envCreds.email && envCreds.password) {
23
+ process.env.CAKEMAIL_EMAIL = envCreds.email;
24
+ process.env.CAKEMAIL_PASSWORD = envCreds.password;
25
+ }
26
+
27
+ // Verify we have real test credentials
28
+ const hasTestCreds = !!(process.env.CAKEMAIL_TEST_EMAIL && process.env.CAKEMAIL_TEST_PASSWORD);
29
+ const hasRegularCreds = !!(process.env.CAKEMAIL_EMAIL && process.env.CAKEMAIL_PASSWORD);
30
+
31
+ if (!hasTestCreds && !hasRegularCreds) {
32
+ console.warn('⚠️ Integration tests require credentials:');
33
+ console.warn(' CAKEMAIL_TEST_EMAIL + CAKEMAIL_TEST_PASSWORD');
34
+ console.warn(' OR');
35
+ console.warn(' CAKEMAIL_EMAIL + CAKEMAIL_PASSWORD');
36
+ console.warn(' These tests will be skipped.');
37
+ }
38
+
39
+ // Use TEST credentials if available, otherwise use regular credentials
40
+ if (process.env.CAKEMAIL_TEST_EMAIL && process.env.CAKEMAIL_TEST_PASSWORD) {
41
+ process.env.CAKEMAIL_EMAIL = process.env.CAKEMAIL_TEST_EMAIL;
42
+ process.env.CAKEMAIL_PASSWORD = process.env.CAKEMAIL_TEST_PASSWORD;
43
+ }
44
+ // If no TEST credentials, regular CAKEMAIL_EMAIL/PASSWORD will be used as-is
45
+
46
+ // Ensure batch mode for non-interactive testing
47
+ process.env.CAKEMAIL_BATCH_MODE = 'true';
48
+
49
+ // Don't override NODE_ENV to 'test' - the SDK may need it for proper initialization
50
+ });
@@ -0,0 +1,241 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { Server } from 'http';
3
+ import { startMockServer, stopMockServer } from '../helpers/mock-server';
4
+ import { runPTYSuccess, runPTYFailure } from '../helpers/pty-runner';
5
+
6
+ /**
7
+ * PTY Tests for Campaigns Command
8
+ *
9
+ * These tests simulate a REAL user in a terminal:
10
+ * - Real terminal (PTY)
11
+ * - Real HTTP requests to mock server
12
+ * - Can see colors, spinners, interactive prompts
13
+ *
14
+ * Run with: npm install node-pty @types/node-pty
15
+ */
16
+ describe('campaigns commands (PTY)', () => {
17
+ let mockServer: Server;
18
+ let mockPort: number;
19
+
20
+ beforeAll(async () => {
21
+ const server = await startMockServer();
22
+ mockServer = server.server;
23
+ mockPort = server.port;
24
+ console.log(`Mock server started on port ${mockPort}`);
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await stopMockServer(mockServer);
29
+ });
30
+
31
+ describe('campaigns list', () => {
32
+ it('should list campaigns like a real user', async () => {
33
+ const result = await runPTYSuccess(
34
+ ['campaigns', 'list'],
35
+ {
36
+ mockServerPort: mockPort,
37
+ interactive: true // Enable spinners
38
+ }
39
+ );
40
+
41
+ // Check output (can see spinners, colors, etc!)
42
+ expect(result.exitCode).toBe(0);
43
+ expect(result.cleanOutput).toContain('Test Campaign 1');
44
+ expect(result.cleanOutput).toContain('Test Campaign 2');
45
+
46
+ // Output should have shown spinner
47
+ expect(result.output).toMatch(/Fetching campaigns/);
48
+ });
49
+
50
+ it('should list campaigns in JSON format', async () => {
51
+ const result = await runPTYSuccess(
52
+ ['-f', 'json', 'campaigns', 'list'],
53
+ { mockServerPort: mockPort }
54
+ );
55
+
56
+ expect(result.exitCode).toBe(0);
57
+
58
+ // Parse JSON output
59
+ const json = JSON.parse(result.cleanOutput);
60
+ expect(json.data).toHaveLength(2);
61
+ expect(json.data[0].name).toBe('Test Campaign 1');
62
+ });
63
+
64
+ it('should list campaigns in table format', async () => {
65
+ const result = await runPTYSuccess(
66
+ ['-f', 'table', 'campaigns', 'list'],
67
+ { mockServerPort: mockPort }
68
+ );
69
+
70
+ expect(result.exitCode).toBe(0);
71
+ expect(result.cleanOutput).toContain('│'); // Table borders
72
+ expect(result.cleanOutput).toContain('Test Campaign 1');
73
+ });
74
+
75
+ it('should support pagination', async () => {
76
+ const result = await runPTYSuccess(
77
+ ['campaigns', 'list', '--page', '1', '--limit', '10'],
78
+ { mockServerPort: mockPort }
79
+ );
80
+
81
+ expect(result.exitCode).toBe(0);
82
+ });
83
+ });
84
+
85
+ describe('campaigns get', () => {
86
+ it('should get campaign by ID', async () => {
87
+ const result = await runPTYSuccess(
88
+ ['-f', 'json', 'campaigns', 'get', '123'],
89
+ { mockServerPort: mockPort }
90
+ );
91
+
92
+ expect(result.exitCode).toBe(0);
93
+
94
+ const json = JSON.parse(result.cleanOutput);
95
+ expect(json.id).toBe(123);
96
+ expect(json.name).toBe('Campaign 123');
97
+ });
98
+
99
+ it('should handle campaign not found', async () => {
100
+ const result = await runPTYFailure(
101
+ ['campaigns', 'get', '999'],
102
+ { mockServerPort: mockPort }
103
+ );
104
+
105
+ expect(result.exitCode).toBe(1);
106
+ expect(result.cleanOutput.toLowerCase()).toMatch(/not found|404|error/);
107
+ });
108
+ });
109
+
110
+ describe('campaigns create', () => {
111
+ it('should create campaign', async () => {
112
+ const result = await runPTYSuccess(
113
+ [
114
+ '-f', 'json',
115
+ 'campaigns', 'create',
116
+ '--name', 'New Campaign',
117
+ '--list-id', '1',
118
+ '--sender-id', '1',
119
+ '--batch'
120
+ ],
121
+ { mockServerPort: mockPort }
122
+ );
123
+
124
+ expect(result.exitCode).toBe(0);
125
+
126
+ // In batch mode, spinner messages are suppressed, so just parse the JSON output
127
+ const created = JSON.parse(result.cleanOutput);
128
+ expect(created.id).toBe(123);
129
+ expect(created.name).toBe('New Campaign');
130
+ });
131
+
132
+ it('should fail without required fields', async () => {
133
+ const result = await runPTYFailure(
134
+ ['campaigns', 'create', '--batch'],
135
+ { mockServerPort: mockPort }
136
+ );
137
+
138
+ expect(result.exitCode).toBe(1);
139
+ expect(result.cleanOutput.toLowerCase()).toMatch(/required|missing/);
140
+ });
141
+ });
142
+
143
+ describe('campaigns update', () => {
144
+ it('should update campaign name', async () => {
145
+ const result = await runPTYSuccess(
146
+ [
147
+ '-f', 'json',
148
+ 'campaigns', 'update', '123',
149
+ '--name', 'Updated Campaign'
150
+ ],
151
+ { mockServerPort: mockPort }
152
+ );
153
+
154
+ expect(result.exitCode).toBe(0);
155
+ expect(result.cleanOutput.toLowerCase()).toContain('updated');
156
+ });
157
+ });
158
+
159
+ describe('campaigns delete', () => {
160
+ it('should delete campaign with force flag', async () => {
161
+ const result = await runPTYSuccess(
162
+ ['campaigns', 'delete', '123', '--force'],
163
+ { mockServerPort: mockPort }
164
+ );
165
+
166
+ expect(result.exitCode).toBe(0);
167
+ expect(result.cleanOutput.toLowerCase()).toContain('deleted');
168
+ });
169
+ });
170
+
171
+ describe('output profiles', () => {
172
+ it('should work with developer profile', async () => {
173
+ const result = await runPTYSuccess(
174
+ ['-f', 'json', '--profile', 'developer', 'campaigns', 'list'],
175
+ { mockServerPort: mockPort }
176
+ );
177
+
178
+ expect(result.exitCode).toBe(0);
179
+ // Developer profile defaults to JSON (but we force it here because batch mode changes defaults)
180
+ expect(() => JSON.parse(result.cleanOutput)).not.toThrow();
181
+ });
182
+
183
+ it('should work with marketer profile', async () => {
184
+ const result = await runPTYSuccess(
185
+ ['--profile', 'marketer', 'campaigns', 'list'],
186
+ { mockServerPort: mockPort }
187
+ );
188
+
189
+ expect(result.exitCode).toBe(0);
190
+ });
191
+
192
+ it('should work with balanced profile', async () => {
193
+ const result = await runPTYSuccess(
194
+ ['--profile', 'balanced', 'campaigns', 'list'],
195
+ { mockServerPort: mockPort }
196
+ );
197
+
198
+ expect(result.exitCode).toBe(0);
199
+ });
200
+ });
201
+
202
+ describe('terminal features', () => {
203
+ it('should show colors when enabled', async () => {
204
+ const result = await runPTYSuccess(
205
+ ['campaigns', 'list'],
206
+ {
207
+ mockServerPort: mockPort,
208
+ enableColors: true
209
+ }
210
+ );
211
+
212
+ // Should contain ANSI escape codes
213
+ expect(result.output).toMatch(/\u001b\[\d+m/);
214
+ });
215
+
216
+ it('should show spinners during loading', async () => {
217
+ const result = await runPTYSuccess(
218
+ ['campaigns', 'list'],
219
+ {
220
+ mockServerPort: mockPort,
221
+ interactive: true // Enable spinners
222
+ }
223
+ );
224
+
225
+ // Should show "Fetching campaigns..." message
226
+ expect(result.output).toMatch(/Fetching campaigns/);
227
+ });
228
+
229
+ it('should handle batch mode (no colors/spinners)', async () => {
230
+ const result = await runPTYSuccess(
231
+ ['campaigns', 'list'],
232
+ {
233
+ mockServerPort: mockPort,
234
+ enableColors: false
235
+ }
236
+ );
237
+
238
+ expect(result.exitCode).toBe(0);
239
+ });
240
+ });
241
+ });
package/tests/setup.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { beforeAll, afterAll } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ /**
6
+ * Global test setup
7
+ *
8
+ * This runs for all test types:
9
+ * - PTY tests (use mock HTTP server)
10
+ * - Integration tests (use real API, override these settings)
11
+ */
12
+ beforeAll(() => {
13
+ // Create temporary test config directory
14
+ const testConfigDir = join(process.cwd(), '.test-config');
15
+ if (!existsSync(testConfigDir)) {
16
+ mkdirSync(testConfigDir, { recursive: true });
17
+ }
18
+
19
+ // Set default test environment variables
20
+ // Note: Integration tests will override these with real credentials
21
+ process.env.CAKEMAIL_EMAIL = 'test@example.com';
22
+ process.env.CAKEMAIL_PASSWORD = 'test-password';
23
+ process.env.CAKEMAIL_BATCH_MODE = 'true'; // Disable interactive prompts
24
+ process.env.NODE_ENV = 'test';
25
+ });
26
+
27
+ // Clean up after all tests
28
+ afterAll(() => {
29
+ // Remove test config directory
30
+ const testConfigDir = join(process.cwd(), '.test-config');
31
+ if (existsSync(testConfigDir)) {
32
+ rmSync(testConfigDir, { recursive: true, force: true });
33
+ }
34
+ });