@cakemail-org/cakemail-cli 1.7.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.
- package/.claude/settings.local.json +12 -0
- package/.env.example +40 -0
- package/.env.test.example +45 -0
- package/CHANGELOG.md +1031 -0
- package/README.md +41 -37
- package/audit-formats.js +128 -0
- package/cakemail.rb +20 -0
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/commands/account.js +1 -1
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/attributes.js +1 -1
- package/dist/commands/attributes.js.map +1 -1
- package/dist/commands/campaigns.js +1 -1
- package/dist/commands/campaigns.js.map +1 -1
- package/dist/commands/contacts.js +1 -1
- package/dist/commands/contacts.js.map +1 -1
- package/dist/commands/emails.js +1 -1
- package/dist/commands/emails.js.map +1 -1
- package/dist/commands/interests.js +1 -1
- package/dist/commands/interests.js.map +1 -1
- package/dist/commands/lists.js +1 -1
- package/dist/commands/lists.js.map +1 -1
- package/dist/commands/logs.js +1 -1
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/reports.js +1 -1
- package/dist/commands/reports.js.map +1 -1
- package/dist/commands/segments.js +1 -1
- package/dist/commands/segments.js.map +1 -1
- package/dist/commands/senders.js +1 -1
- package/dist/commands/senders.js.map +1 -1
- package/dist/commands/suppressed.js +1 -1
- package/dist/commands/suppressed.js.map +1 -1
- package/dist/commands/tags.js +1 -1
- package/dist/commands/tags.js.map +1 -1
- package/dist/commands/templates.js +1 -1
- package/dist/commands/templates.js.map +1 -1
- package/dist/commands/transactional-templates.js +1 -1
- package/dist/commands/transactional-templates.js.map +1 -1
- package/dist/commands/webhooks.js +1 -1
- package/dist/commands/webhooks.js.map +1 -1
- package/dist/utils/config.js +2 -2
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/errors.js +1 -1
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/progress.d.ts.map +1 -1
- package/dist/utils/progress.js +32 -4
- package/dist/utils/progress.js.map +1 -1
- package/dist/utils/spinner.d.ts +17 -0
- package/dist/utils/spinner.d.ts.map +1 -0
- package/dist/utils/spinner.js +43 -0
- package/dist/utils/spinner.js.map +1 -0
- package/docs/DOCUMENTATION-STANDARD.md +1068 -0
- package/docs/README.md +161 -0
- package/docs/developer/ARCHITECTURE.md +516 -0
- package/docs/developer/AUTH.md +204 -0
- package/docs/developer/CONTRIBUTING.md +227 -0
- package/docs/developer/DOCUMENTATION_SUMMARY.md +346 -0
- package/docs/developer/PROJECT_INDEX.md +365 -0
- package/docs/planning/API_COVERAGE.md +1045 -0
- package/docs/planning/BACKLOG.md +1159 -0
- package/docs/planning/PROFILE_SYSTEM_TASKS.md +287 -0
- package/docs/planning/UX_IMPLEMENTATION_PLAN.md +691 -0
- package/docs/planning/archive/RELEASE_CHECKLIST_v1.3.0.md +332 -0
- package/docs/planning/archive/RELEASE_v1.3.0.md +428 -0
- package/docs/planning/archive/cakemail-cli-ux-improvements.md +438 -0
- package/docs/planning/cakemail-profile-system-plan.md +1121 -0
- package/docs/testing/AI_USER_SIMULATION_DESIGN.md +1342 -0
- package/docs/testing/KENOGAMI_BIDIRECTIONAL_FLOW.md +1517 -0
- package/docs/testing/KENOGAMI_TRUTH_RECONCILIATION_SYSTEM.md +1369 -0
- package/docs/user-manual/.obsidian/app.json +1 -0
- package/docs/user-manual/.obsidian/appearance.json +1 -0
- package/docs/user-manual/.obsidian/core-plugins.json +33 -0
- package/docs/user-manual/.obsidian/workspace.json +167 -0
- package/docs/user-manual/01-getting-started/01-installation.md +214 -0
- package/docs/user-manual/01-getting-started/02-quick-start.md +432 -0
- package/docs/user-manual/01-getting-started/03-authentication.md +448 -0
- package/docs/user-manual/01-getting-started/04-configuration.md +430 -0
- package/docs/user-manual/01-getting-started/05-output-formats.md +447 -0
- package/docs/user-manual/02-core-concepts/01-accounts.md +514 -0
- package/docs/user-manual/02-core-concepts/02-profile-system.md +771 -0
- package/docs/user-manual/02-core-concepts/03-smart-defaults.md +485 -0
- package/docs/user-manual/02-core-concepts/04-authentication-methods.md +435 -0
- package/docs/user-manual/02-core-concepts/05-pagination-filtering.md +600 -0
- package/docs/user-manual/02-core-concepts/06-error-handling.md +718 -0
- package/docs/user-manual/02-core-concepts/07-api-coverage.md +483 -0
- package/docs/user-manual/03-email-operations/01-senders.md +490 -0
- package/docs/user-manual/03-email-operations/02-templates.md +444 -0
- package/docs/user-manual/03-email-operations/03-transactional-emails.md +706 -0
- package/docs/user-manual/03-email-operations/04-email-tracking.md +407 -0
- package/docs/user-manual/04-campaign-management/01-campaigns-basics.md +394 -0
- package/docs/user-manual/04-campaign-management/02-campaign-scheduling.md +630 -0
- package/docs/user-manual/04-campaign-management/03-campaign-testing.md +997 -0
- package/docs/user-manual/04-campaign-management/04-campaign-lifecycle.md +709 -0
- package/docs/user-manual/04-campaign-management/05-campaign-links.md +934 -0
- package/docs/user-manual/05-contact-management/01-lists.md +836 -0
- package/docs/user-manual/05-contact-management/02-contacts.md +1035 -0
- package/docs/user-manual/05-contact-management/03-custom-attributes.md +788 -0
- package/docs/user-manual/05-contact-management/04-segments.md +1028 -0
- package/docs/user-manual/05-contact-management/05-contact-import-export.md +1031 -0
- package/docs/user-manual/06-analytics-reporting/01-campaign-analytics.md +867 -0
- package/docs/user-manual/06-analytics-reporting/02-account-reports.md +227 -0
- package/docs/user-manual/07-integrations/01-webhooks-integration.md +259 -0
- package/docs/user-manual/07-integrations/02-automation.md +326 -0
- package/docs/user-manual/08-advanced-usage/01-scripting-patterns.md +672 -0
- package/docs/user-manual/08-advanced-usage/02-bulk-operations.md +932 -0
- package/docs/user-manual/08-advanced-usage/03-ci-cd-integration.md +892 -0
- package/docs/user-manual/08-advanced-usage/04-performance-optimization.md +766 -0
- package/docs/user-manual/09-command-reference/01-config.md +776 -0
- package/docs/user-manual/09-command-reference/02-account.md +652 -0
- package/docs/user-manual/09-command-reference/03-lists.md +958 -0
- package/docs/user-manual/09-command-reference/04-contacts.md +1408 -0
- package/docs/user-manual/09-command-reference/05-attributes.md +617 -0
- package/docs/user-manual/09-command-reference/06-segments.md +894 -0
- package/docs/user-manual/09-command-reference/07-senders.md +803 -0
- package/docs/user-manual/09-command-reference/08-templates.md +818 -0
- package/docs/user-manual/09-command-reference/09-campaigns.md +1250 -0
- package/docs/user-manual/09-command-reference/10-emails.md +807 -0
- package/docs/user-manual/09-command-reference/11-reports.md +1135 -0
- package/docs/user-manual/09-command-reference/12-webhooks.md +773 -0
- package/docs/user-manual/09-command-reference/13-suppressed.md +797 -0
- package/docs/user-manual/09-command-reference/14-interests.md +630 -0
- package/docs/user-manual/09-command-reference/15-tags.md +584 -0
- package/docs/user-manual/09-command-reference/16-logs.md +656 -0
- package/docs/user-manual/09-command-reference/17-transactional-templates.md +850 -0
- package/docs/user-manual/10-troubleshooting/01-common-errors.md +457 -0
- package/docs/user-manual/10-troubleshooting/02-authentication-issues.md +558 -0
- package/docs/user-manual/10-troubleshooting/03-connection-problems.md +634 -0
- package/docs/user-manual/10-troubleshooting/04-debugging.md +725 -0
- package/docs/user-manual/11-appendix/04-faq.md +484 -0
- package/docs/user-manual/11-appendix/05-glossary.md +250 -0
- package/docs/user-manual/README.md +0 -0
- package/package.json +13 -61
- package/src/cli.ts +125 -0
- package/src/client.ts +16 -0
- package/src/commands/account.ts +267 -0
- package/src/commands/accounts.ts +78 -0
- package/src/commands/actions.ts +249 -0
- package/src/commands/attributes.ts +139 -0
- package/src/commands/campaign-blueprints.ts +106 -0
- package/src/commands/campaigns.ts +469 -0
- package/src/commands/config.ts +77 -0
- package/src/commands/contacts.ts +612 -0
- package/src/commands/custom-attributes.ts +127 -0
- package/src/commands/dkims.ts +117 -0
- package/src/commands/domains.ts +82 -0
- package/src/commands/email-apis.ts +569 -0
- package/src/commands/emails.ts +197 -0
- package/src/commands/forms.ts +283 -0
- package/src/commands/interests.ts +155 -0
- package/src/commands/links.ts +38 -0
- package/src/commands/lists.ts +406 -0
- package/src/commands/logos.ts +71 -0
- package/src/commands/logs.ts +386 -0
- package/src/commands/reports.ts +306 -0
- package/src/commands/segments.ts +158 -0
- package/src/commands/senders.ts +204 -0
- package/src/commands/sub-accounts.ts +271 -0
- package/src/commands/suppressed-emails.ts +234 -0
- package/src/commands/suppressed.ts +198 -0
- package/src/commands/system-emails.ts +85 -0
- package/src/commands/tags.ts +146 -0
- package/src/commands/tasks.ts +116 -0
- package/src/commands/templates.ts +189 -0
- package/src/commands/tokens.ts +83 -0
- package/src/commands/transactional-emails.ts +374 -0
- package/src/commands/transactional-templates.ts +385 -0
- package/src/commands/users.ts +506 -0
- package/src/commands/webhooks.ts +172 -0
- package/src/commands/workflow-blueprints.ts +123 -0
- package/src/commands/workflows.ts +265 -0
- package/src/types/profile.ts +93 -0
- package/src/utils/auth.ts +272 -0
- package/src/utils/config-file.ts +96 -0
- package/src/utils/config.ts +134 -0
- package/src/utils/confirm.ts +32 -0
- package/src/utils/defaults.ts +99 -0
- package/src/utils/errors.ts +116 -0
- package/src/utils/interactive.ts +91 -0
- package/src/utils/list-defaults.ts +74 -0
- package/src/utils/output.ts +190 -0
- package/src/utils/progress.ts +320 -0
- package/src/utils/spinner.ts +22 -0
- package/tests/IMPLEMENTATION_STATUS.md +258 -0
- package/tests/PTY_SETUP.md +118 -0
- package/tests/PTY_TESTING_GUIDE.md +507 -0
- package/tests/README.md +244 -0
- package/tests/fixtures/api-responses/campaigns.json +34 -0
- package/tests/fixtures/test-config.json +13 -0
- package/tests/helpers/cli-runner.ts +128 -0
- package/tests/helpers/mock-server.ts +301 -0
- package/tests/helpers/pty-runner.ts +181 -0
- package/tests/integration/campaigns-real-api.test.ts +196 -0
- package/tests/integration/setup-integration.ts +50 -0
- package/tests/pty/campaigns.test.ts +241 -0
- package/tests/setup.ts +34 -0
- package/tsconfig.json +15 -0
- 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
|
+
});
|