@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.
@@ -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
- const SUPPORTED_STEP_TYPES = ['send_email', 'email', 'slack_message', 'slack'];
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
- function validateSteps(steps) {
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
- error('--steps must be a JSON array of step objects.');
43
+ return '--steps must be a JSON array of step objects.';
35
44
  if (steps.length === 0)
36
- error('--steps cannot be an empty array.');
37
- steps.forEach((s, i) => {
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
- error(`${where}: must be a JSON object.`);
50
+ return `${where}: must be a JSON object.`;
41
51
  if (!s.type)
42
- error(`${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
52
+ return `${where}: missing required field "type". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`;
43
53
  if (!SUPPORTED_STEP_TYPES.includes(s.type)) {
44
- error(`${where}: unknown type "${s.type}". Supported: ${SUPPORTED_STEP_TYPES.join(', ')}`);
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
- error(`${where} (${s.type}): missing required field "${f}".`);
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
- error(`${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`);
65
+ return `${where} (${s.type}): must include exactly one of "body", "html", or "template_id".`;
56
66
  }
57
67
  if (bodyForms.length > 1) {
58
- error(`${where} (${s.type}): can only use one of "body", "html", "template_id" — got: ${bodyForms.join(', ')}.`);
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
- error(`${where} (${s.type}): "template_vars" only makes sense with "template_id".`);
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
- error(`${where} (${s.type}): "template_vars" must be a JSON object.`);
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
- error(`${where} (${s.type}): missing required field "webhook_url".`);
79
+ return `${where} (${s.type}): missing required field "webhook_url".`;
70
80
  }
71
81
  if (!SLACK_HOOK_RE.test(s.webhook_url)) {
72
- error(`${where} (${s.type}): "webhook_url" must look like https://hooks.slack.com/services/T.../B.../xxx — got "${s.webhook_url}".`);
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
- error(`${where} (${s.type}): missing required field "text".`);
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 {
@@ -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 {};