@myapihq/cli 1.2.4 → 1.2.5
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/dist/commands/domain.js +26 -7
- package/dist/commands/fn-validation.test.d.ts +1 -0
- package/dist/commands/fn-validation.test.js +60 -0
- package/dist/commands/fn.d.ts +13 -0
- package/dist/commands/fn.js +180 -0
- package/dist/commands/funnel.js +18 -5
- package/dist/commands/webhook-validation.test.d.ts +1 -0
- package/dist/commands/webhook-validation.test.js +35 -0
- package/dist/commands/webhook.d.ts +2 -0
- package/dist/commands/webhook.js +72 -2
- package/dist/commands/workflow-validation.test.d.ts +1 -0
- package/dist/commands/workflow-validation.test.js +137 -0
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.js +58 -17
- package/dist/exposes.test.js +1 -0
- package/dist/index.js +7 -0
- package/dist/sdk-domain-assign.test.d.ts +1 -0
- package/dist/sdk-domain-assign.test.js +53 -0
- package/dist/sdk-function.test.d.ts +1 -0
- package/dist/sdk-function.test.js +190 -0
- package/dist/sdk-funnel-name.test.d.ts +1 -0
- package/dist/sdk-funnel-name.test.js +48 -0
- package/dist/sdk-webhook.test.d.ts +1 -0
- package/dist/sdk-webhook.test.js +86 -0
- package/package.json +2 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Unit tests for workflow.ts CLI's pure step validator. The 2026-05-15
|
|
2
|
+
// additive contract change adds `http_request` (alias `http`) to the
|
|
3
|
+
// supported step types; this test file pins the new validation rules and
|
|
4
|
+
// regression-tests the prior shapes.
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { _validateSteps } from './workflow.js';
|
|
7
|
+
const VALID_EMAIL = { type: 'send_email', from: 'a@x.com', to: 'b@x.com', subject: 'hi', html: '<p>x</p>' };
|
|
8
|
+
const VALID_SLACK = { type: 'slack_message', webhook_url: 'https://hooks.slack.com/services/T1/B1/abc', text: 'hi' };
|
|
9
|
+
const VALID_HTTP = { type: 'http_request', url: 'https://example.com/hook' };
|
|
10
|
+
describe('_validateSteps — input shape', () => {
|
|
11
|
+
it('rejects non-array', () => {
|
|
12
|
+
expect(_validateSteps('not an array')).toMatch(/must be a JSON array/);
|
|
13
|
+
expect(_validateSteps({})).toMatch(/must be a JSON array/);
|
|
14
|
+
expect(_validateSteps(null)).toMatch(/must be a JSON array/);
|
|
15
|
+
});
|
|
16
|
+
it('rejects empty array', () => {
|
|
17
|
+
expect(_validateSteps([])).toMatch(/cannot be an empty array/);
|
|
18
|
+
});
|
|
19
|
+
it('rejects steps that are not objects', () => {
|
|
20
|
+
expect(_validateSteps(['string'])).toMatch(/must be a JSON object/);
|
|
21
|
+
expect(_validateSteps([null])).toMatch(/must be a JSON object/);
|
|
22
|
+
});
|
|
23
|
+
it('rejects unknown step types', () => {
|
|
24
|
+
expect(_validateSteps([{ type: 'pigeon' }])).toMatch(/unknown type "pigeon"/);
|
|
25
|
+
});
|
|
26
|
+
it('rejects steps missing the type field', () => {
|
|
27
|
+
expect(_validateSteps([{ from: 'x' }])).toMatch(/missing required field "type"/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('_validateSteps — send_email / email', () => {
|
|
31
|
+
it('accepts valid send_email with html body', () => {
|
|
32
|
+
expect(_validateSteps([VALID_EMAIL])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('accepts the `email` alias', () => {
|
|
35
|
+
expect(_validateSteps([{ ...VALID_EMAIL, type: 'email' }])).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('rejects send_email missing required fields', () => {
|
|
38
|
+
expect(_validateSteps([{ type: 'send_email', from: 'a@x.com' }]))
|
|
39
|
+
.toMatch(/missing required field "to"/);
|
|
40
|
+
});
|
|
41
|
+
it('rejects send_email with no body form', () => {
|
|
42
|
+
expect(_validateSteps([{ type: 'send_email', from: 'a@x.com', to: 'b@x.com', subject: 's' }]))
|
|
43
|
+
.toMatch(/must include exactly one of "body", "html", or "template_id"/);
|
|
44
|
+
});
|
|
45
|
+
it('rejects send_email with multiple body forms', () => {
|
|
46
|
+
expect(_validateSteps([{ ...VALID_EMAIL, body: 'plain', html: '<p>x</p>' }]))
|
|
47
|
+
.toMatch(/can only use one of/);
|
|
48
|
+
});
|
|
49
|
+
it('rejects template_vars without template_id', () => {
|
|
50
|
+
expect(_validateSteps([{ ...VALID_EMAIL, template_vars: { name: 'x' } }]))
|
|
51
|
+
.toMatch(/"template_vars" only makes sense with "template_id"/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('_validateSteps — slack_message / slack', () => {
|
|
55
|
+
it('accepts valid slack_message', () => {
|
|
56
|
+
expect(_validateSteps([VALID_SLACK])).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it('accepts the `slack` alias', () => {
|
|
59
|
+
expect(_validateSteps([{ ...VALID_SLACK, type: 'slack' }])).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('rejects slack with non-Slack webhook URL', () => {
|
|
62
|
+
expect(_validateSteps([{ ...VALID_SLACK, webhook_url: 'https://example.com/hook' }]))
|
|
63
|
+
.toMatch(/must look like https:\/\/hooks\.slack\.com/);
|
|
64
|
+
});
|
|
65
|
+
it('rejects slack missing text', () => {
|
|
66
|
+
expect(_validateSteps([{ type: 'slack_message', webhook_url: VALID_SLACK.webhook_url }]))
|
|
67
|
+
.toMatch(/missing required field "text"/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('_validateSteps — http_request / http (2026-05-15)', () => {
|
|
71
|
+
it('accepts the canonical type name', () => {
|
|
72
|
+
expect(_validateSteps([VALID_HTTP])).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
it('accepts the `http` alias', () => {
|
|
75
|
+
expect(_validateSteps([{ ...VALID_HTTP, type: 'http' }])).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it('accepts all supported HTTP methods', () => {
|
|
78
|
+
for (const m of ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']) {
|
|
79
|
+
expect(_validateSteps([{ ...VALID_HTTP, method: m }])).toBeNull();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it('accepts a body string with templating', () => {
|
|
83
|
+
expect(_validateSteps([{ ...VALID_HTTP, body: '{"email":"{{ payload.email }}"}' }])).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
it('accepts a headers map of strings', () => {
|
|
86
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: { 'X-Token': 'abc', 'X-Other': 'def' } }])).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it('rejects when url is missing', () => {
|
|
89
|
+
expect(_validateSteps([{ type: 'http_request' }]))
|
|
90
|
+
.toMatch(/missing required field "url"/);
|
|
91
|
+
});
|
|
92
|
+
it('rejects when url is not a string', () => {
|
|
93
|
+
expect(_validateSteps([{ type: 'http_request', url: 12345 }]))
|
|
94
|
+
.toMatch(/missing required field "url"/);
|
|
95
|
+
});
|
|
96
|
+
it('rejects non-http(s) URLs', () => {
|
|
97
|
+
expect(_validateSteps([{ ...VALID_HTTP, url: 'ftp://example.com' }]))
|
|
98
|
+
.toMatch(/must start with http:\/\/ or https:\/\//);
|
|
99
|
+
expect(_validateSteps([{ ...VALID_HTTP, url: 'javascript:alert(1)' }]))
|
|
100
|
+
.toMatch(/must start with http:\/\/ or https:\/\//);
|
|
101
|
+
});
|
|
102
|
+
it('rejects unknown HTTP methods', () => {
|
|
103
|
+
expect(_validateSteps([{ ...VALID_HTTP, method: 'TRACE' }]))
|
|
104
|
+
.toMatch(/"method" must be one of/);
|
|
105
|
+
});
|
|
106
|
+
it('rejects body as a non-string (object)', () => {
|
|
107
|
+
expect(_validateSteps([{ ...VALID_HTTP, body: { not: 'a string' } }]))
|
|
108
|
+
.toMatch(/"body" must be a string/);
|
|
109
|
+
});
|
|
110
|
+
it('rejects headers as a non-object', () => {
|
|
111
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: 'X-Token: abc' }]))
|
|
112
|
+
.toMatch(/"headers" must be a JSON object/);
|
|
113
|
+
});
|
|
114
|
+
it('rejects headers as an array', () => {
|
|
115
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: ['X-Token: abc'] }]))
|
|
116
|
+
.toMatch(/"headers" must be a JSON object/);
|
|
117
|
+
});
|
|
118
|
+
it('rejects header value that is not a string', () => {
|
|
119
|
+
expect(_validateSteps([{ ...VALID_HTTP, headers: { 'X-Token': 42 } }]))
|
|
120
|
+
.toMatch(/"headers" value for "X-Token" must be a string/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('_validateSteps — mixed-type pipelines', () => {
|
|
124
|
+
it('accepts an http_request after a send_email (pipeline shape)', () => {
|
|
125
|
+
expect(_validateSteps([
|
|
126
|
+
VALID_EMAIL,
|
|
127
|
+
{ ...VALID_HTTP, body: '{"sent_to":"{{ payload.email }}"}' },
|
|
128
|
+
])).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
it('reports the failing step index in the error message', () => {
|
|
131
|
+
expect(_validateSteps([
|
|
132
|
+
VALID_EMAIL,
|
|
133
|
+
VALID_SLACK,
|
|
134
|
+
{ type: 'http_request' }, // missing url at index 2
|
|
135
|
+
])).toMatch(/^step 2 \(http_request\):/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -3,6 +3,7 @@ import { type Flags } from '../helpers.js';
|
|
|
3
3
|
import type { Exposes } from '../exposes.js';
|
|
4
4
|
export declare const EXPOSES: Exposes;
|
|
5
5
|
export declare const SCHEMA: FlagSchema;
|
|
6
|
+
export declare function _validateSteps(steps: unknown): string | null;
|
|
6
7
|
export declare function list(flags: Flags): Promise<void>;
|
|
7
8
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
8
9
|
export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
|
|
@@ -23,59 +23,100 @@ export const SCHEMA = {
|
|
|
23
23
|
// Mirrors the backend's SupportedStepTypes list. Both alias and underscore
|
|
24
24
|
// forms are accepted by the workflow runner. Keep this in sync if the
|
|
25
25
|
// backend grows new step types.
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// 2026-05-15: backend added `http_request` (alias `http`). See
|
|
28
|
+
// myapi-hq/internal/routes/workflow/execute.go.
|
|
29
|
+
const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack', 'http_request', 'http'];
|
|
30
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']);
|
|
27
31
|
const SLACK_HOOK_RE = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/;
|
|
28
32
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
33
|
// Per-step-type required fields. Validated client-side so typos and
|
|
30
34
|
// hallucinated shapes fail fast, before the network call. Backend
|
|
31
35
|
// performs the same validation as defense in depth.
|
|
32
|
-
|
|
36
|
+
//
|
|
37
|
+
// `_validateSteps` is the pure form — returns the first error message
|
|
38
|
+
// (string) or null on success. Suitable for unit tests. The internal
|
|
39
|
+
// `validateSteps` wrapper calls `error()` on a non-null result, which
|
|
40
|
+
// exits the process — used by command handlers.
|
|
41
|
+
export function _validateSteps(steps) {
|
|
33
42
|
if (!Array.isArray(steps))
|
|
34
|
-
|
|
43
|
+
return '--steps must be a JSON array of step objects.';
|
|
35
44
|
if (steps.length === 0)
|
|
36
|
-
|
|
37
|
-
steps.
|
|
45
|
+
return '--steps cannot be an empty array.';
|
|
46
|
+
for (let i = 0; i < steps.length; i++) {
|
|
47
|
+
const s = steps[i];
|
|
38
48
|
const where = `step ${i}`;
|
|
39
49
|
if (!s || typeof s !== 'object')
|
|
40
|
-
|
|
50
|
+
return `${where}: must be a JSON object.`;
|
|
41
51
|
if (!s.type)
|
|
42
|
-
|
|
52
|
+
return `${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`;
|
|
43
53
|
if (!SUPPORTED_STEP_TYPES.includes(s.type)) {
|
|
44
|
-
|
|
54
|
+
return `${where}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`;
|
|
45
55
|
}
|
|
46
56
|
if (s.type === 'send_email' || s.type === 'email') {
|
|
47
57
|
const required = ['from', 'to', 'subject'];
|
|
48
58
|
for (const f of required) {
|
|
49
59
|
if (!s[f] || typeof s[f] !== 'string') {
|
|
50
|
-
|
|
60
|
+
return `${where} (${s.type}): missing required field "${f}".`;
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
const bodyForms = ['body', 'html', 'template_id'].filter(f => s[f] !== undefined && s[f] !== '');
|
|
54
64
|
if (bodyForms.length === 0) {
|
|
55
|
-
|
|
65
|
+
return `${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`;
|
|
56
66
|
}
|
|
57
67
|
if (bodyForms.length > 1) {
|
|
58
|
-
|
|
68
|
+
return `${where} (${s.type}): can only use one of "body", "html", "template_id" — got: ${bodyForms.join(', ')}.`;
|
|
59
69
|
}
|
|
60
70
|
if (s.template_vars !== undefined && !s.template_id) {
|
|
61
|
-
|
|
71
|
+
return `${where} (${s.type}): "template_vars" only makes sense with "template_id".`;
|
|
62
72
|
}
|
|
63
73
|
if (s.template_vars !== undefined && (typeof s.template_vars !== 'object' || Array.isArray(s.template_vars))) {
|
|
64
|
-
|
|
74
|
+
return `${where} (${s.type}): "template_vars" must be a JSON object.`;
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
if (s.type === 'slack_message' || s.type === 'slack') {
|
|
68
78
|
if (!s.webhook_url || typeof s.webhook_url !== 'string') {
|
|
69
|
-
|
|
79
|
+
return `${where} (${s.type}): missing required field "webhook_url".`;
|
|
70
80
|
}
|
|
71
81
|
if (!SLACK_HOOK_RE.test(s.webhook_url)) {
|
|
72
|
-
|
|
82
|
+
return `${where} (${s.type}): "webhook_url" must look like https://hooks.slack.com/services/T.../B.../xxx — got "${s.webhook_url}".`;
|
|
73
83
|
}
|
|
74
84
|
if (!s.text || typeof s.text !== 'string') {
|
|
75
|
-
|
|
85
|
+
return `${where} (${s.type}): missing required field "text".`;
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
|
-
|
|
88
|
+
if (s.type === 'http_request' || s.type === 'http') {
|
|
89
|
+
if (!s.url || typeof s.url !== 'string') {
|
|
90
|
+
return `${where} (${s.type}): missing required field "url".`;
|
|
91
|
+
}
|
|
92
|
+
if (!/^https?:\/\//.test(s.url)) {
|
|
93
|
+
return `${where} (${s.type}): "url" must start with http:// or https:// — got "${s.url}".`;
|
|
94
|
+
}
|
|
95
|
+
if (s.method !== undefined) {
|
|
96
|
+
if (typeof s.method !== 'string' || !HTTP_METHODS.has(s.method.toUpperCase())) {
|
|
97
|
+
return `${where} (${s.type}): "method" must be one of ${[...HTTP_METHODS].join(', ')}.`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (s.body !== undefined && typeof s.body !== 'string') {
|
|
101
|
+
return `${where} (${s.type}): "body" must be a string (template substitutions supported).`;
|
|
102
|
+
}
|
|
103
|
+
if (s.headers !== undefined) {
|
|
104
|
+
if (typeof s.headers !== 'object' || Array.isArray(s.headers)) {
|
|
105
|
+
return `${where} (${s.type}): "headers" must be a JSON object of {string: string}.`;
|
|
106
|
+
}
|
|
107
|
+
for (const [k, v] of Object.entries(s.headers)) {
|
|
108
|
+
if (typeof v !== 'string')
|
|
109
|
+
return `${where} (${s.type}): "headers" value for "${k}" must be a string.`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
function validateSteps(steps) {
|
|
117
|
+
const err = _validateSteps(steps);
|
|
118
|
+
if (err)
|
|
119
|
+
error(err);
|
|
79
120
|
}
|
|
80
121
|
function summarizeWorkflow(w) {
|
|
81
122
|
return {
|
package/dist/exposes.test.js
CHANGED
|
@@ -37,6 +37,7 @@ const COMMAND_MODULES = [
|
|
|
37
37
|
'./commands/url.js',
|
|
38
38
|
'./commands/webhook.js',
|
|
39
39
|
'./commands/workflow.js',
|
|
40
|
+
'./commands/fn.js',
|
|
40
41
|
];
|
|
41
42
|
const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
|
|
42
43
|
describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as audienceCmd from './commands/audience.js';
|
|
|
29
29
|
import * as llmCmd from './commands/llm.js';
|
|
30
30
|
import * as databaseCmd from './commands/database.js';
|
|
31
31
|
import * as crmCmd from './commands/crm/index.js';
|
|
32
|
+
import * as fnCmd from './commands/fn.js';
|
|
32
33
|
import { initCompletion, installCompletion, uninstallCompletion } from './completion.js';
|
|
33
34
|
// Each command file declares the value flags it understands. We union them
|
|
34
35
|
// into a single schema for the upfront parse, so adding a new value flag in
|
|
@@ -55,6 +56,7 @@ const COMBINED_SCHEMA = {
|
|
|
55
56
|
...urlCmd.SCHEMA,
|
|
56
57
|
...webhookCmd.SCHEMA,
|
|
57
58
|
...workflowCmd.SCHEMA,
|
|
59
|
+
...fnCmd.SCHEMA,
|
|
58
60
|
// Top-level flags
|
|
59
61
|
version: 'boolean',
|
|
60
62
|
V: 'boolean',
|
|
@@ -197,6 +199,9 @@ async function main() {
|
|
|
197
199
|
case 'url':
|
|
198
200
|
await urlCmd.run(subcommand, restArgs, flags);
|
|
199
201
|
break;
|
|
202
|
+
case 'fn':
|
|
203
|
+
await fnCmd.run(subcommand, restArgs, flags);
|
|
204
|
+
break;
|
|
200
205
|
// Convenience aliases
|
|
201
206
|
case 'setup':
|
|
202
207
|
await setupCmd.setup(flags);
|
|
@@ -317,6 +322,7 @@ const HELP_TARGETS = {
|
|
|
317
322
|
llm: f => llmCmd.run(undefined, [], f),
|
|
318
323
|
database: f => databaseCmd.run(undefined, [], f),
|
|
319
324
|
crm: f => crmCmd.run(undefined, [], f),
|
|
325
|
+
fn: f => fnCmd.run(undefined, [], f),
|
|
320
326
|
org: f => orgCmd.run(undefined, [], f),
|
|
321
327
|
billing: f => billingCmd.run(undefined, [], f),
|
|
322
328
|
keys: f => keysCmd.run(undefined, [], f),
|
|
@@ -358,6 +364,7 @@ Commands:
|
|
|
358
364
|
update Update CLI and skills to the latest version
|
|
359
365
|
domain Manage domain configurations
|
|
360
366
|
funnel Manage websites (publish pages, custom domains, funnels)
|
|
367
|
+
fn Create functions on the edge runtime (Story 1 — metadata + scoped key only; bundle upload pending Story 2)
|
|
361
368
|
webhook Manage inbound webhook endpoints and inspect deliveries
|
|
362
369
|
email Manage mailboxes, send/read email, templates, and campaigns
|
|
363
370
|
workflow Run actions (send email, post to Slack) when a webhook fires
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Verifies the 2026-05-15 additive contract change: POST
|
|
2
|
+
// /domain/.../{domain}/assign now accepts an optional `funnel_id` in the body.
|
|
3
|
+
// SDK assignDomain takes `{ funnelId?: string }` and forwards it.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { domain } from '@myapihq/sdk';
|
|
6
|
+
const API_KEY = 'myapi_test_abc';
|
|
7
|
+
const ORG_ID = '11111111-1111-4111-8111-111111111111';
|
|
8
|
+
const DOMAIN = 'example.com';
|
|
9
|
+
const FUNNEL_ID = '33333333-3333-4333-8333-333333333333';
|
|
10
|
+
let fetchMock;
|
|
11
|
+
function ok(data) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'content-type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
fetchMock = vi.fn().mockResolvedValue(ok({
|
|
19
|
+
domain: DOMAIN, org_id: ORG_ID, include_www: true, routes_bound: [DOMAIN, `www.${DOMAIN}`],
|
|
20
|
+
}));
|
|
21
|
+
globalThis.fetch = fetchMock;
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
describe('domain.assignDomain — funnel_id (additive 2026-05-15)', () => {
|
|
27
|
+
it('omits funnel_id from body by default (back-compat)', async () => {
|
|
28
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN);
|
|
29
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
30
|
+
expect(body).toEqual({ org_id: ORG_ID });
|
|
31
|
+
expect(body).not.toHaveProperty('funnel_id');
|
|
32
|
+
});
|
|
33
|
+
it('includes funnel_id when passed in opts', async () => {
|
|
34
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN, { funnelId: FUNNEL_ID });
|
|
35
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
36
|
+
expect(body).toEqual({ org_id: ORG_ID, funnel_id: FUNNEL_ID });
|
|
37
|
+
});
|
|
38
|
+
it('combines funnel_id with include_www=false', async () => {
|
|
39
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN, { funnelId: FUNNEL_ID, includeWww: false });
|
|
40
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
41
|
+
expect(body).toEqual({ org_id: ORG_ID, include_www: false, funnel_id: FUNNEL_ID });
|
|
42
|
+
});
|
|
43
|
+
it('omits funnel_id when it is empty string (treat as not-set)', async () => {
|
|
44
|
+
await domain.assignDomain(API_KEY, ORG_ID, DOMAIN, { funnelId: '' });
|
|
45
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
46
|
+
expect(body).not.toHaveProperty('funnel_id');
|
|
47
|
+
});
|
|
48
|
+
it('still works for unassignDomain (no funnel_id involved)', async () => {
|
|
49
|
+
await domain.unassignDomain(API_KEY, ORG_ID, DOMAIN);
|
|
50
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
51
|
+
expect(body).toEqual({ org_id: null });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SDK-level unit tests for the function module. Verifies request URL/body
|
|
2
|
+
// shape, response parsing, and error envelopes against the Story 1 contract
|
|
3
|
+
// shipped by myapi-hq/internal/routes/function/crud.go.
|
|
4
|
+
//
|
|
5
|
+
// Mocks global fetch — does NOT hit the network.
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import { fn, MyApiError } from '@myapihq/sdk';
|
|
8
|
+
const API_KEY = 'myapi_test_abc';
|
|
9
|
+
const ORG_ID = '11111111-1111-4111-8111-111111111111';
|
|
10
|
+
const FN_ID = '22222222-2222-4222-8222-222222222222';
|
|
11
|
+
let fetchMock;
|
|
12
|
+
function ok(data, status = 200) {
|
|
13
|
+
// Backend wraps everything in `{ success, data, meta }`. The SDK unwraps.
|
|
14
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function fail(code, message, status = 422) {
|
|
20
|
+
return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
|
|
21
|
+
status,
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
fetchMock = vi.fn();
|
|
27
|
+
globalThis.fetch = fetchMock;
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
describe('fn.createFunction', () => {
|
|
33
|
+
it('POSTs to /function/orgs/{org_id}/functions with the bearer auth + JSON body', async () => {
|
|
34
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
35
|
+
function: { id: FN_ID, org_id: ORG_ID, name: 'my-app-api', trigger_type: 'http', invocation_url: '', created_at: 't1', updated_at: 't1' },
|
|
36
|
+
scoped_api_key: 'myapi_live_scoped_xyz',
|
|
37
|
+
scoped_api_key_id: 'key_xyz',
|
|
38
|
+
}));
|
|
39
|
+
const result = await fn.createFunction(API_KEY, ORG_ID, { name: 'my-app-api', trigger_type: 'http' });
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
41
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
42
|
+
expect(url).toContain(`/function/orgs/${ORG_ID}/functions`);
|
|
43
|
+
expect(init.method).toBe('POST');
|
|
44
|
+
expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
|
|
45
|
+
expect(init.headers['Content-Type']).toBe('application/json');
|
|
46
|
+
expect(JSON.parse(init.body)).toEqual({ name: 'my-app-api', trigger_type: 'http' });
|
|
47
|
+
expect(result.function.id).toBe(FN_ID);
|
|
48
|
+
expect(result.scoped_api_key).toBe('myapi_live_scoped_xyz');
|
|
49
|
+
expect(result.scoped_api_key_id).toBe('key_xyz');
|
|
50
|
+
// Invocation URL is empty in Story 1 — populated by Story 2.
|
|
51
|
+
expect(result.function.invocation_url).toBe('');
|
|
52
|
+
});
|
|
53
|
+
it('passes cron_schedule through when trigger_type=cron', async () => {
|
|
54
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
55
|
+
function: { id: FN_ID, org_id: ORG_ID, name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *', invocation_url: '', created_at: 't1', updated_at: 't1' },
|
|
56
|
+
scoped_api_key: 'myapi_live_scoped_xyz',
|
|
57
|
+
scoped_api_key_id: 'key_xyz',
|
|
58
|
+
}));
|
|
59
|
+
await fn.createFunction(API_KEY, ORG_ID, { name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *' });
|
|
60
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
61
|
+
expect(body).toEqual({ name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *' });
|
|
62
|
+
});
|
|
63
|
+
it('encodes path params (defensive against : ? / # in org id)', async () => {
|
|
64
|
+
// UUIDs don't contain reserved chars in practice — but the SDK still
|
|
65
|
+
// encodes, so an alternate id with unsafe chars wouldn't break path
|
|
66
|
+
// routing.
|
|
67
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
68
|
+
function: { id: FN_ID, org_id: 'org/with?weird#chars', name: 'x', trigger_type: 'http', invocation_url: '', created_at: 't', updated_at: 't' },
|
|
69
|
+
scoped_api_key: 'k', scoped_api_key_id: 'i',
|
|
70
|
+
}));
|
|
71
|
+
await fn.createFunction(API_KEY, 'org/with?weird#chars', { name: 'x' });
|
|
72
|
+
const [url] = fetchMock.mock.calls[0];
|
|
73
|
+
expect(url).toContain('/function/orgs/org%2Fwith%3Fweird%23chars/functions');
|
|
74
|
+
});
|
|
75
|
+
it('surfaces backend INVALID_NAME (422) as a typed MyApiError', async () => {
|
|
76
|
+
fetchMock.mockResolvedValueOnce(fail('INVALID_NAME', 'name must match ^[a-z0-9][a-z0-9-]{0,49}$', 422));
|
|
77
|
+
await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'BAD NAME' }))
|
|
78
|
+
.rejects.toMatchObject({
|
|
79
|
+
name: 'MyApiError',
|
|
80
|
+
code: 'INVALID_NAME',
|
|
81
|
+
status: 422,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
it('surfaces NAME_TAKEN (409) cleanly', async () => {
|
|
85
|
+
fetchMock.mockResolvedValueOnce(fail('NAME_TAKEN', 'function name "x" is already used in this org', 409));
|
|
86
|
+
await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
|
|
87
|
+
.rejects.toMatchObject({ code: 'NAME_TAKEN', status: 409 });
|
|
88
|
+
});
|
|
89
|
+
it('surfaces 401 (invalid api key)', async () => {
|
|
90
|
+
fetchMock.mockResolvedValueOnce(fail('unauthorized', 'invalid api key', 401));
|
|
91
|
+
await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
|
|
92
|
+
.rejects.toMatchObject({ status: 401 });
|
|
93
|
+
});
|
|
94
|
+
it('surfaces 403 SCOPE_FORBIDDEN when called with a slot_call-only key', async () => {
|
|
95
|
+
// 2026-05-15 enforcement: scoped api_keys with scopes=["slot_call"]
|
|
96
|
+
// can only call slot endpoints, not /hq/*, /admin/*, /internal/*.
|
|
97
|
+
// Note: /function/* IS a slot path, so SCOPE_FORBIDDEN shouldn't trigger
|
|
98
|
+
// here in practice — but the SDK must still surface it cleanly if it
|
|
99
|
+
// ever appears (e.g. for /hq/* mistakes).
|
|
100
|
+
fetchMock.mockResolvedValueOnce(fail('SCOPE_FORBIDDEN', 'this key cannot call /hq/* endpoints', 403));
|
|
101
|
+
await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
|
|
102
|
+
.rejects.toMatchObject({ code: 'SCOPE_FORBIDDEN', status: 403 });
|
|
103
|
+
});
|
|
104
|
+
it('wraps non-JSON 5xx responses (HTML error pages) helpfully', async () => {
|
|
105
|
+
fetchMock.mockResolvedValueOnce(new Response('<html>502 Bad Gateway</html>', {
|
|
106
|
+
status: 502, headers: { 'content-type': 'text/html' },
|
|
107
|
+
}));
|
|
108
|
+
await expect(fn.createFunction(API_KEY, ORG_ID, { name: 'x' }))
|
|
109
|
+
.rejects.toMatchObject({ code: 'invalid_json_response', status: 502 });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('fn.listFunctions', () => {
|
|
113
|
+
it('GETs /function/orgs/{org_id}/functions with bearer auth + no body', async () => {
|
|
114
|
+
fetchMock.mockResolvedValueOnce(ok([
|
|
115
|
+
{ id: FN_ID, org_id: ORG_ID, name: 'a', trigger_type: 'http', invocation_url: '', created_at: 't', updated_at: 't' },
|
|
116
|
+
]));
|
|
117
|
+
const fns = await fn.listFunctions(API_KEY, ORG_ID);
|
|
118
|
+
expect(fns).toHaveLength(1);
|
|
119
|
+
expect(fns[0].name).toBe('a');
|
|
120
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
121
|
+
expect(url).toContain(`/function/orgs/${ORG_ID}/functions`);
|
|
122
|
+
expect(init.method).toBe('GET');
|
|
123
|
+
expect(init.body).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
it('returns an empty array when org has no functions', async () => {
|
|
126
|
+
fetchMock.mockResolvedValueOnce(ok([]));
|
|
127
|
+
expect(await fn.listFunctions(API_KEY, ORG_ID)).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('fn.getFunction', () => {
|
|
131
|
+
it('GETs /function/orgs/{org_id}/functions/{id}', async () => {
|
|
132
|
+
fetchMock.mockResolvedValueOnce(ok({ id: FN_ID, org_id: ORG_ID, name: 'a', trigger_type: 'http', invocation_url: '', created_at: 't', updated_at: 't' }));
|
|
133
|
+
const got = await fn.getFunction(API_KEY, ORG_ID, FN_ID);
|
|
134
|
+
expect(got.id).toBe(FN_ID);
|
|
135
|
+
const [url] = fetchMock.mock.calls[0];
|
|
136
|
+
expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}`);
|
|
137
|
+
});
|
|
138
|
+
it('throws function_not_found on 404', async () => {
|
|
139
|
+
fetchMock.mockResolvedValueOnce(fail('function_not_found', 'not found', 404));
|
|
140
|
+
await expect(fn.getFunction(API_KEY, ORG_ID, FN_ID))
|
|
141
|
+
.rejects.toMatchObject({ code: 'function_not_found', status: 404 });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('fn.deleteFunction', () => {
|
|
145
|
+
it('DELETEs /function/orgs/{org_id}/functions/{id}', async () => {
|
|
146
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
147
|
+
await fn.deleteFunction(API_KEY, ORG_ID, FN_ID);
|
|
148
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
149
|
+
expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}`);
|
|
150
|
+
expect(init.method).toBe('DELETE');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('fn.EXPOSES', () => {
|
|
154
|
+
it('matches the Story 1 contract — exactly 4 endpoints', () => {
|
|
155
|
+
expect(fn.EXPOSES).toEqual([
|
|
156
|
+
'POST /function/orgs/{org_id}/functions',
|
|
157
|
+
'GET /function/orgs/{org_id}/functions',
|
|
158
|
+
'GET /function/orgs/{org_id}/functions/{id}',
|
|
159
|
+
'DELETE /function/orgs/{org_id}/functions/{id}',
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
it('does NOT yet expose /logs or /env (Story 4/5 pending)', () => {
|
|
163
|
+
const flat = fn.EXPOSES.join(' ');
|
|
164
|
+
expect(flat).not.toContain('/logs');
|
|
165
|
+
expect(flat).not.toContain('/env');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
// MyApiError is the universal error shape callers can `instanceof`-check or
|
|
169
|
+
// switch on `.code`. Validate it surfaces the right fields from typed
|
|
170
|
+
// (object) errors and raw-string errors alike.
|
|
171
|
+
describe('MyApiError surfacing', () => {
|
|
172
|
+
it('preserves the body object on object-shaped errors (for CF_API_ERROR etc.)', async () => {
|
|
173
|
+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
174
|
+
success: false,
|
|
175
|
+
error: { code: 'CF_API_ERROR', message: 'cf failed', cf_message: 'Forbidden zone', cf_status: 403 },
|
|
176
|
+
meta: {},
|
|
177
|
+
}), { status: 500, headers: { 'content-type': 'application/json' } }));
|
|
178
|
+
try {
|
|
179
|
+
await fn.createFunction(API_KEY, ORG_ID, { name: 'x' });
|
|
180
|
+
expect.fail('Should have thrown');
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
expect(e).toBeInstanceOf(MyApiError);
|
|
184
|
+
const err = e;
|
|
185
|
+
expect(err.code).toBe('CF_API_ERROR');
|
|
186
|
+
expect(err.body?.cf_message).toBe('Forbidden zone');
|
|
187
|
+
expect(err.body?.cf_status).toBe(403);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// 2026-05-15: POST /funnel/orgs/{org_id}/funnels now accepts optional
|
|
2
|
+
// `name` (defaults server-side to the org's preview_subdomain). SDK
|
|
3
|
+
// createFunnel was extended to forward { name } through.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { funnel } from '@myapihq/sdk';
|
|
6
|
+
const API_KEY = 'myapi_test';
|
|
7
|
+
const ORG_ID = '11111111-1111-4111-8111-111111111111';
|
|
8
|
+
let fetchMock;
|
|
9
|
+
function ok(data) {
|
|
10
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fetchMock = vi.fn().mockResolvedValue(ok({
|
|
17
|
+
funnel: { id: 'f1', org_id: ORG_ID, name: 'my-app', created_at: 't', updated_at: 't' },
|
|
18
|
+
subdomain_url: 'https://my-app.makeautonomous.com',
|
|
19
|
+
}));
|
|
20
|
+
globalThis.fetch = fetchMock;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
23
|
+
describe('funnel.createFunnel — name (additive 2026-05-15)', () => {
|
|
24
|
+
it('omits name from body when not provided (back-compat — server picks preview_subdomain)', async () => {
|
|
25
|
+
await funnel.createFunnel(API_KEY, ORG_ID);
|
|
26
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
it('omits name when opts is provided but has no name', async () => {
|
|
29
|
+
await funnel.createFunnel(API_KEY, ORG_ID, {});
|
|
30
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
it('includes name when provided', async () => {
|
|
33
|
+
await funnel.createFunnel(API_KEY, ORG_ID, { name: 'my-app' });
|
|
34
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'my-app' });
|
|
35
|
+
});
|
|
36
|
+
it('hits the right path and method', async () => {
|
|
37
|
+
await funnel.createFunnel(API_KEY, ORG_ID, { name: 'x' });
|
|
38
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
39
|
+
expect(url).toContain(`/funnel/orgs/${ORG_ID}/funnels`);
|
|
40
|
+
expect(init.method).toBe('POST');
|
|
41
|
+
expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
|
|
42
|
+
});
|
|
43
|
+
it('returns the funnel with name populated', async () => {
|
|
44
|
+
const result = await funnel.createFunnel(API_KEY, ORG_ID, { name: 'my-app' });
|
|
45
|
+
expect(result.funnel.name).toBe('my-app');
|
|
46
|
+
expect(result.subdomain_url).toBe('https://my-app.makeautonomous.com');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|