@myapihq/cli 1.2.5 → 1.2.6

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.
@@ -33,7 +33,7 @@ describe('fn.createFunction', () => {
33
33
  it('POSTs to /function/orgs/{org_id}/functions with the bearer auth + JSON body', async () => {
34
34
  fetchMock.mockResolvedValueOnce(ok({
35
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',
36
+ scoped_api_key: 'hq_live_scoped_xyz',
37
37
  scoped_api_key_id: 'key_xyz',
38
38
  }));
39
39
  const result = await fn.createFunction(API_KEY, ORG_ID, { name: 'my-app-api', trigger_type: 'http' });
@@ -45,7 +45,7 @@ describe('fn.createFunction', () => {
45
45
  expect(init.headers['Content-Type']).toBe('application/json');
46
46
  expect(JSON.parse(init.body)).toEqual({ name: 'my-app-api', trigger_type: 'http' });
47
47
  expect(result.function.id).toBe(FN_ID);
48
- expect(result.scoped_api_key).toBe('myapi_live_scoped_xyz');
48
+ expect(result.scoped_api_key).toBe('hq_live_scoped_xyz');
49
49
  expect(result.scoped_api_key_id).toBe('key_xyz');
50
50
  // Invocation URL is empty in Story 1 — populated by Story 2.
51
51
  expect(result.function.invocation_url).toBe('');
@@ -53,7 +53,7 @@ describe('fn.createFunction', () => {
53
53
  it('passes cron_schedule through when trigger_type=cron', async () => {
54
54
  fetchMock.mockResolvedValueOnce(ok({
55
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',
56
+ scoped_api_key: 'hq_live_scoped_xyz',
57
57
  scoped_api_key_id: 'key_xyz',
58
58
  }));
59
59
  await fn.createFunction(API_KEY, ORG_ID, { name: 'daily-report', trigger_type: 'cron', cron_schedule: '0 8 * * *' });
@@ -150,19 +150,86 @@ describe('fn.deleteFunction', () => {
150
150
  expect(init.method).toBe('DELETE');
151
151
  });
152
152
  });
153
+ describe('fn.uploadBundle', () => {
154
+ it('POSTs multipart to /bundle with the `bundle` field + bearer auth', async () => {
155
+ fetchMock.mockResolvedValueOnce(ok({
156
+ function: { id: FN_ID, org_id: ORG_ID, name: 'my-app-api', trigger_type: 'http', invocation_url: 'https://x.fn.myapihq.com', created_at: 't', updated_at: 't' },
157
+ invocation_url: 'https://x.fn.myapihq.com',
158
+ scoped_api_key: 'hq_live_rotated_abc',
159
+ }));
160
+ const result = await fn.uploadBundle(API_KEY, ORG_ID, FN_ID, 'export default {}', 'app.js');
161
+ const [url, init] = fetchMock.mock.calls[0];
162
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}/bundle`);
163
+ expect(init.method).toBe('POST');
164
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
165
+ // multipart — body is FormData, no JSON Content-Type set by the SDK.
166
+ expect(init.body).toBeInstanceOf(FormData);
167
+ expect(init.body.has('bundle')).toBe(true);
168
+ expect(result.invocation_url).toBe('https://x.fn.myapihq.com');
169
+ expect(result.scoped_api_key).toBe('hq_live_rotated_abc');
170
+ });
171
+ it('surfaces BUNDLE_TOO_LARGE (413) as a typed MyApiError', async () => {
172
+ fetchMock.mockResolvedValueOnce(fail('BUNDLE_TOO_LARGE', 'bundle exceeds 4MB', 413));
173
+ await expect(fn.uploadBundle(API_KEY, ORG_ID, FN_ID, 'x'))
174
+ .rejects.toMatchObject({ code: 'BUNDLE_TOO_LARGE', status: 413 });
175
+ });
176
+ it('surfaces RUNTIME_UNAVAILABLE (503)', async () => {
177
+ fetchMock.mockResolvedValueOnce(fail('RUNTIME_UNAVAILABLE', 'cf not configured', 503));
178
+ await expect(fn.uploadBundle(API_KEY, ORG_ID, FN_ID, 'x'))
179
+ .rejects.toMatchObject({ code: 'RUNTIME_UNAVAILABLE', status: 503 });
180
+ });
181
+ });
182
+ describe('fn.setFunctionEnv', () => {
183
+ it('POSTs {name,value} JSON and resolves on 204', async () => {
184
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
185
+ await fn.setFunctionEnv(API_KEY, ORG_ID, FN_ID, 'STRIPE_KEY', 'sk_live_x');
186
+ const [url, init] = fetchMock.mock.calls[0];
187
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}/env`);
188
+ expect(init.method).toBe('POST');
189
+ expect(JSON.parse(init.body)).toEqual({ name: 'STRIPE_KEY', value: 'sk_live_x' });
190
+ });
191
+ it('surfaces NOT_DEPLOYED (409) when the function has no bundle yet', async () => {
192
+ fetchMock.mockResolvedValueOnce(fail('NOT_DEPLOYED', 'deploy a bundle first', 409));
193
+ await expect(fn.setFunctionEnv(API_KEY, ORG_ID, FN_ID, 'K', 'v'))
194
+ .rejects.toMatchObject({ code: 'NOT_DEPLOYED', status: 409 });
195
+ });
196
+ });
197
+ describe('fn.listFunctionRuns', () => {
198
+ it('GETs /runs and returns the run records', async () => {
199
+ fetchMock.mockResolvedValueOnce(ok([
200
+ { id: 'run_1', function_id: FN_ID, invoked_at: 't', duration_ms: 12, status: 'ok' },
201
+ { id: 'run_2', function_id: FN_ID, invoked_at: 't', status: 'error', error_message: 'boom' },
202
+ ]));
203
+ const runs = await fn.listFunctionRuns(API_KEY, ORG_ID, FN_ID);
204
+ expect(runs).toHaveLength(2);
205
+ expect(runs[1].error_message).toBe('boom');
206
+ const [url, init] = fetchMock.mock.calls[0];
207
+ expect(url).toContain(`/function/orgs/${ORG_ID}/functions/${FN_ID}/runs`);
208
+ expect(init.method).toBe('GET');
209
+ });
210
+ it('returns an empty array when there are no runs', async () => {
211
+ fetchMock.mockResolvedValueOnce(ok([]));
212
+ expect(await fn.listFunctionRuns(API_KEY, ORG_ID, FN_ID)).toEqual([]);
213
+ });
214
+ });
153
215
  describe('fn.EXPOSES', () => {
154
- it('matches the Story 1 contract — exactly 4 endpoints', () => {
216
+ it('matches the Story 1 + Story 2/4/5 contract — exactly 7 endpoints', () => {
155
217
  expect(fn.EXPOSES).toEqual([
156
218
  'POST /function/orgs/{org_id}/functions',
157
219
  'GET /function/orgs/{org_id}/functions',
158
220
  'GET /function/orgs/{org_id}/functions/{id}',
159
221
  'DELETE /function/orgs/{org_id}/functions/{id}',
222
+ 'POST /function/orgs/{org_id}/functions/{id}/bundle',
223
+ 'POST /function/orgs/{org_id}/functions/{id}/env',
224
+ 'GET /function/orgs/{org_id}/functions/{id}/runs',
160
225
  ]);
161
226
  });
162
- it('does NOT yet expose /logs or /env (Story 4/5 pending)', () => {
227
+ it('exposes deploy/env/runs but not /logs (still pending)', () => {
163
228
  const flat = fn.EXPOSES.join(' ');
229
+ expect(flat).toContain('/bundle');
230
+ expect(flat).toContain('/env');
231
+ expect(flat).toContain('/runs');
164
232
  expect(flat).not.toContain('/logs');
165
- expect(flat).not.toContain('/env');
166
233
  });
167
234
  });
168
235
  // MyApiError is the universal error shape callers can `instanceof`-check or
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ // SDK-level unit tests for funnel.publishFiles — the my-funnel-api v2
2
+ // directory publish. Verifies the multipart shape (all parts named `files`,
3
+ // each part's filename = the site path), the form fields, and response
4
+ // parsing against myapi-hq/internal/routes/funnel/files.go.
5
+ //
6
+ // Mocks global fetch — does NOT hit the network.
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import { funnel } from '@myapihq/sdk';
9
+ const API_KEY = 'myapi_test_abc';
10
+ const ORG_ID = '11111111-1111-4111-8111-111111111111';
11
+ const FUNNEL_ID = '44444444-4444-4444-8444-444444444444';
12
+ let fetchMock;
13
+ function ok(data, status = 201) {
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('funnel.publishFiles', () => {
33
+ it('POSTs multipart to /files with one `files` part per file + bearer auth', async () => {
34
+ fetchMock.mockResolvedValueOnce(ok({
35
+ manifest_id: 'm1', channel: 'prod', file_count: 2, size_bytes: 42,
36
+ spa_mode: true, published_url: 'https://site.makeautonomous.com',
37
+ }));
38
+ const res = await funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [
39
+ { path: 'index.html', content: '<h1>hi</h1>' },
40
+ { path: 'assets/app.js', content: 'console.log(1)' },
41
+ ]);
42
+ const [url, init] = fetchMock.mock.calls[0];
43
+ expect(url).toContain(`/funnel/orgs/${ORG_ID}/funnels/${FUNNEL_ID}/files`);
44
+ expect(init.method).toBe('POST');
45
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
46
+ expect(init.body).toBeInstanceOf(FormData);
47
+ // Both files share the part name `files`.
48
+ expect(init.body.getAll('files')).toHaveLength(2);
49
+ expect(res.file_count).toBe(2);
50
+ expect(res.published_url).toBe('https://site.makeautonomous.com');
51
+ });
52
+ it('includes env / spa_mode / api_function_id form fields when given', async () => {
53
+ fetchMock.mockResolvedValueOnce(ok({
54
+ manifest_id: 'm1', channel: 'dev', file_count: 1, size_bytes: 10,
55
+ spa_mode: false, published_url: 'https://site-dev.makeautonomous.com',
56
+ }));
57
+ await funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [{ path: 'index.html', content: 'x' }], { env: 'dev', spaMode: false, apiFunctionId: 'fn_1' });
58
+ const body = fetchMock.mock.calls[0][1].body;
59
+ expect(body.get('env')).toBe('dev');
60
+ expect(body.get('spa_mode')).toBe('false');
61
+ expect(body.get('api_function_id')).toBe('fn_1');
62
+ });
63
+ it('omits optional form fields when not given', async () => {
64
+ fetchMock.mockResolvedValueOnce(ok({
65
+ manifest_id: 'm1', channel: 'prod', file_count: 1, size_bytes: 10,
66
+ spa_mode: true, published_url: 'https://site.makeautonomous.com',
67
+ }));
68
+ await funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [{ path: 'index.html', content: 'x' }]);
69
+ const body = fetchMock.mock.calls[0][1].body;
70
+ expect(body.has('env')).toBe(false);
71
+ expect(body.has('spa_mode')).toBe(false);
72
+ expect(body.has('api_function_id')).toBe(false);
73
+ });
74
+ it('surfaces NO_FILES (422) as a typed MyApiError', async () => {
75
+ fetchMock.mockResolvedValueOnce(fail('NO_FILES', 'files field required', 422));
76
+ await expect(funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, []))
77
+ .rejects.toMatchObject({ code: 'NO_FILES', status: 422 });
78
+ });
79
+ it('surfaces publish_too_large (413)', async () => {
80
+ fetchMock.mockResolvedValueOnce(fail('publish_too_large', 'exceeds 25MB', 413));
81
+ await expect(funnel.publishFiles(API_KEY, ORG_ID, FUNNEL_ID, [{ path: 'big', content: 'x' }]))
82
+ .rejects.toMatchObject({ code: 'publish_too_large', status: 413 });
83
+ });
84
+ });
85
+ describe('funnel.EXPOSES includes the v2 publish endpoint', () => {
86
+ it('lists POST .../files', () => {
87
+ expect(funnel.EXPOSES).toContain('POST /funnel/orgs/{org_id}/funnels/{funnel_id}/files');
88
+ });
89
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ // SDK unit tests for the capability-IAM + spend-cap surface shipped
2
+ // 2026-05-15 (design-iam-capability-keys-2026-05-15.md). Verifies request
3
+ // URL/body shape, response parsing, and error envelopes. Mocks global
4
+ // fetch — no network.
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
+ import { hq, MyApiError } from '@myapihq/sdk';
7
+ const KEY = 'hq_live_caller';
8
+ const ORG = '11111111-1111-4111-8111-111111111111';
9
+ let fetchMock;
10
+ function ok(data, status = 200) {
11
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
12
+ status, headers: { 'content-type': 'application/json' },
13
+ });
14
+ }
15
+ function fail(code, message, status) {
16
+ return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
17
+ status, headers: { 'content-type': 'application/json' },
18
+ });
19
+ }
20
+ function bodyOf(call) {
21
+ return JSON.parse(fetchMock.mock.calls[call][1].body);
22
+ }
23
+ beforeEach(() => {
24
+ fetchMock = vi.fn();
25
+ globalThis.fetch = fetchMock;
26
+ });
27
+ afterEach(() => vi.restoreAllMocks());
28
+ // A representative keyView the backend returns.
29
+ const KEY_VIEW = {
30
+ id: 'key_1', name: 'ci', prefix: 'hq_live_ab', kind: 'manual',
31
+ org_id: null, grants: { '*': 'write' },
32
+ spend_cap_cents: null, spend_cap_period: 'month',
33
+ };
34
+ describe('hq.createApiKey', () => {
35
+ it('POSTs to /hq/account/create/key with just {name} when no opts given', async () => {
36
+ fetchMock.mockResolvedValueOnce(ok({ ...KEY_VIEW, api_key: 'hq_live_secret' }));
37
+ const key = await hq.createApiKey(KEY, 'ci');
38
+ const [url, init] = fetchMock.mock.calls[0];
39
+ expect(url).toContain('/hq/account/create/key');
40
+ expect(init.method).toBe('POST');
41
+ expect(bodyOf(0)).toEqual({ name: 'ci' });
42
+ expect(key.api_key).toBe('hq_live_secret');
43
+ expect(key.kind).toBe('manual');
44
+ });
45
+ it('includes grants / org_id / spend_cap_cents when opts are passed', async () => {
46
+ fetchMock.mockResolvedValueOnce(ok({
47
+ ...KEY_VIEW, org_id: ORG, grants: { email: 'write', crm: 'read' },
48
+ spend_cap_cents: 5000, api_key: 'hq_live_secret',
49
+ }));
50
+ await hq.createApiKey(KEY, 'billing-fn', {
51
+ grants: { email: 'write', crm: 'read' },
52
+ orgId: ORG,
53
+ spendCapCents: 5000,
54
+ });
55
+ expect(bodyOf(0)).toEqual({
56
+ name: 'billing-fn',
57
+ grants: { email: 'write', crm: 'read' },
58
+ org_id: ORG,
59
+ spend_cap_cents: 5000,
60
+ });
61
+ });
62
+ it('sends spend_cap_cents: 0 (a real cap of $0) but omits when undefined', async () => {
63
+ fetchMock.mockResolvedValueOnce(ok({ ...KEY_VIEW, api_key: 'k' }));
64
+ await hq.createApiKey(KEY, 'zero', { spendCapCents: 0 });
65
+ expect(bodyOf(0)).toEqual({ name: 'zero', spend_cap_cents: 0 });
66
+ });
67
+ it('surfaces INVALID_GRANTS (422)', async () => {
68
+ fetchMock.mockResolvedValueOnce(fail('INVALID_GRANTS', 'unknown slot', 422));
69
+ await expect(hq.createApiKey(KEY, 'x', { grants: { bogus: 'write' } }))
70
+ .rejects.toMatchObject({ code: 'INVALID_GRANTS', status: 422 });
71
+ });
72
+ it('surfaces SCOPE_FORBIDDEN (403) when the request would escalate', async () => {
73
+ fetchMock.mockResolvedValueOnce(fail('SCOPE_FORBIDDEN', 'requested grants exceed authority', 403));
74
+ await expect(hq.createApiKey(KEY, 'x', { grants: { '*': 'write' } }))
75
+ .rejects.toMatchObject({ code: 'SCOPE_FORBIDDEN', status: 403 });
76
+ });
77
+ });
78
+ describe('hq.listApiKeys', () => {
79
+ it('GETs /hq/account/keys and returns the ApiKey[] shape', async () => {
80
+ fetchMock.mockResolvedValueOnce(ok([
81
+ { ...KEY_VIEW, id: 'k1' },
82
+ { ...KEY_VIEW, id: 'k2', kind: 'function', org_id: ORG,
83
+ grants: { email: 'write' }, spend_cap_cents: 2500,
84
+ current_period_spend_cents: 800 },
85
+ ]));
86
+ const keys = await hq.listApiKeys(KEY);
87
+ expect(fetchMock.mock.calls[0][1].method).toBe('GET');
88
+ expect(keys).toHaveLength(2);
89
+ expect(keys[1].kind).toBe('function');
90
+ expect(keys[1].current_period_spend_cents).toBe(800);
91
+ expect(keys[1].spend_cap_cents).toBe(2500);
92
+ });
93
+ it('returns [] when the account has no keys', async () => {
94
+ fetchMock.mockResolvedValueOnce(ok([]));
95
+ expect(await hq.listApiKeys(KEY)).toEqual([]);
96
+ });
97
+ });
98
+ describe('hq.revokeAllKeys', () => {
99
+ it('POSTs an empty body for "all kinds"', async () => {
100
+ fetchMock.mockResolvedValueOnce(ok({ revoked: 7 }));
101
+ const res = await hq.revokeAllKeys(KEY);
102
+ const [url, init] = fetchMock.mock.calls[0];
103
+ expect(url).toContain('/hq/account/keys/revoke-all');
104
+ expect(init.method).toBe('POST');
105
+ expect(bodyOf(0)).toEqual({});
106
+ expect(res.revoked).toBe(7);
107
+ });
108
+ it('includes {kind} when narrowing', async () => {
109
+ fetchMock.mockResolvedValueOnce(ok({ revoked: 3 }));
110
+ await hq.revokeAllKeys(KEY, 'function');
111
+ expect(bodyOf(0)).toEqual({ kind: 'function' });
112
+ });
113
+ it('surfaces INVALID_KIND (422)', async () => {
114
+ fetchMock.mockResolvedValueOnce(fail('INVALID_KIND', 'bad kind', 422));
115
+ await expect(hq.revokeAllKeys(KEY, 'bogus'))
116
+ .rejects.toMatchObject({ code: 'INVALID_KIND', status: 422 });
117
+ });
118
+ });
119
+ describe('hq.setAccountSpendCap', () => {
120
+ it('PATCHes a cents value', async () => {
121
+ fetchMock.mockResolvedValueOnce(ok({ spend_cap_cents: 5000, spend_cap_period: 'month' }));
122
+ const res = await hq.setAccountSpendCap(KEY, 5000);
123
+ const [url, init] = fetchMock.mock.calls[0];
124
+ expect(url).toContain('/hq/account/spend-cap');
125
+ expect(init.method).toBe('PATCH');
126
+ expect(bodyOf(0)).toEqual({ spend_cap_cents: 5000 });
127
+ expect(res.spend_cap_period).toBe('month');
128
+ });
129
+ it('PATCHes null to clear the cap', async () => {
130
+ fetchMock.mockResolvedValueOnce(ok({ spend_cap_cents: null, spend_cap_period: 'month' }));
131
+ await hq.setAccountSpendCap(KEY, null);
132
+ expect(bodyOf(0)).toEqual({ spend_cap_cents: null });
133
+ });
134
+ it('includes period when given', async () => {
135
+ fetchMock.mockResolvedValueOnce(ok({ spend_cap_cents: 500, spend_cap_period: 'day' }));
136
+ await hq.setAccountSpendCap(KEY, 500, 'day');
137
+ expect(bodyOf(0)).toEqual({ spend_cap_cents: 500, period: 'day' });
138
+ });
139
+ it('surfaces INVALID_PERIOD (422)', async () => {
140
+ fetchMock.mockResolvedValueOnce(fail('INVALID_PERIOD', 'bad period', 422));
141
+ await expect(hq.setAccountSpendCap(KEY, 500, 'week'))
142
+ .rejects.toMatchObject({ code: 'INVALID_PERIOD', status: 422 });
143
+ });
144
+ });
145
+ describe('hq.getAccount — spend-cap fields', () => {
146
+ it('parses spend_cap_cents + current_period_spend_cents', async () => {
147
+ fetchMock.mockResolvedValueOnce(ok({
148
+ account_id: 'acc_1', email: 'x@y.com',
149
+ spend_cap_cents: 5000, current_period_spend_cents: 4200,
150
+ }));
151
+ const acct = await hq.getAccount(KEY);
152
+ expect(acct.spend_cap_cents).toBe(5000);
153
+ expect(acct.current_period_spend_cents).toBe(4200);
154
+ });
155
+ it('tolerates an account with no cap set (fields absent)', async () => {
156
+ fetchMock.mockResolvedValueOnce(ok({ account_id: 'acc_1', email: 'x@y.com' }));
157
+ const acct = await hq.getAccount(KEY);
158
+ expect(acct.spend_cap_cents).toBeUndefined();
159
+ });
160
+ });
161
+ describe('hq IAM contract surface', () => {
162
+ it('EXPOSES includes the 2026-05-15 endpoints', () => {
163
+ expect(hq.EXPOSES).toContain('POST /hq/account/keys/revoke-all');
164
+ expect(hq.EXPOSES).toContain('PATCH /hq/account/spend-cap');
165
+ });
166
+ it('GRANTABLE_SLOTS matches the backend iam.GrantableSlots vocabulary', () => {
167
+ expect([...hq.GRANTABLE_SLOTS].sort()).toEqual(['audience', 'company', 'crm', 'database', 'domain', 'email', 'function',
168
+ 'funnel', 'image', 'llm', 'people', 'storage', 'url', 'webhook', 'workflow']);
169
+ });
170
+ it('GRANTABLE_SLOTS excludes management/non-grantable surfaces', () => {
171
+ for (const s of ['hq', 'admin', 'internal', 'schema', 'ops', 'pixel']) {
172
+ expect(hq.GRANTABLE_SLOTS).not.toContain(s);
173
+ }
174
+ });
175
+ });
176
+ // MyApiError is the universal error shape — confirm IAM errors carry the body.
177
+ describe('MyApiError on IAM rejections', () => {
178
+ it('preserves code + status', async () => {
179
+ fetchMock.mockResolvedValueOnce(fail('SCOPE_FORBIDDEN', 'an org-scoped key can only mint same-org keys', 403));
180
+ try {
181
+ await hq.createApiKey(KEY, 'x', { orgId: ORG });
182
+ expect.fail('should have thrown');
183
+ }
184
+ catch (e) {
185
+ expect(e).toBeInstanceOf(MyApiError);
186
+ expect(e.code).toBe('SCOPE_FORBIDDEN');
187
+ expect(e.status).toBe(403);
188
+ }
189
+ });
190
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ // SDK-level unit tests for the payments module. Verifies request URL/body
2
+ // shape, response parsing, and error envelopes against the T0 contract
3
+ // shipped by myapi-hq/internal/routes/payments/.
4
+ //
5
+ // Mocks global fetch — does NOT hit the network.
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
7
+ import { payments } from '@myapihq/sdk';
8
+ const API_KEY = 'myapi_test_abc';
9
+ const ORG_ID = '11111111-1111-4111-8111-111111111111';
10
+ const CHARGE_ID = '33333333-3333-4333-8333-333333333333';
11
+ let fetchMock;
12
+ function ok(data, status = 200) {
13
+ return new Response(JSON.stringify({ success: true, data, meta: {} }), {
14
+ status,
15
+ headers: { 'content-type': 'application/json' },
16
+ });
17
+ }
18
+ function fail(code, message, status = 422) {
19
+ return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
20
+ status,
21
+ headers: { 'content-type': 'application/json' },
22
+ });
23
+ }
24
+ beforeEach(() => {
25
+ fetchMock = vi.fn();
26
+ globalThis.fetch = fetchMock;
27
+ });
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+ describe('payments.connect', () => {
32
+ it('POSTs {tier:"t0",stripe_secret_key} to /connect', async () => {
33
+ fetchMock.mockResolvedValueOnce(ok({ tier: 't0', stripe_account_id: 'acct_1', onboarding_status: 'complete' }, 201));
34
+ const res = await payments.connect(API_KEY, ORG_ID, 'sk_live_xyz');
35
+ const [url, init] = fetchMock.mock.calls[0];
36
+ expect(url).toContain(`/payments/orgs/${ORG_ID}/connect`);
37
+ expect(init.method).toBe('POST');
38
+ expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
39
+ expect(JSON.parse(init.body)).toEqual({ tier: 't0', stripe_secret_key: 'sk_live_xyz' });
40
+ expect(res.stripe_account_id).toBe('acct_1');
41
+ });
42
+ it('passes tier through so a t1 caller can see the deferral', async () => {
43
+ fetchMock.mockResolvedValueOnce(fail('T1_DEFERRED', 'Connect Express is not yet available', 501));
44
+ await expect(payments.connect(API_KEY, ORG_ID, 'sk_x', 't1'))
45
+ .rejects.toMatchObject({ code: 'T1_DEFERRED', status: 501 });
46
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).tier).toBe('t1');
47
+ });
48
+ it('surfaces KEY_REJECTED (422)', async () => {
49
+ fetchMock.mockResolvedValueOnce(fail('KEY_REJECTED', 'Stripe rejected the key', 422));
50
+ await expect(payments.connect(API_KEY, ORG_ID, 'sk_bad'))
51
+ .rejects.toMatchObject({ code: 'KEY_REJECTED', status: 422 });
52
+ });
53
+ });
54
+ describe('payments.getConnect', () => {
55
+ it('GETs /connect and returns the status', async () => {
56
+ fetchMock.mockResolvedValueOnce(ok({ tier: 't0', stripe_account_id: 'acct_1', onboarding_status: 'complete', application_fee_bps: 0 }));
57
+ const res = await payments.getConnect(API_KEY, ORG_ID);
58
+ expect(res.application_fee_bps).toBe(0);
59
+ const [url, init] = fetchMock.mock.calls[0];
60
+ expect(url).toContain(`/payments/orgs/${ORG_ID}/connect`);
61
+ expect(init.method).toBe('GET');
62
+ });
63
+ it('surfaces NOT_CONNECTED (404)', async () => {
64
+ fetchMock.mockResolvedValueOnce(fail('NOT_CONNECTED', 'no Stripe connection', 404));
65
+ await expect(payments.getConnect(API_KEY, ORG_ID))
66
+ .rejects.toMatchObject({ code: 'NOT_CONNECTED', status: 404 });
67
+ });
68
+ });
69
+ describe('payments.createCharge', () => {
70
+ it('POSTs the charge payload and returns the checkout URL', async () => {
71
+ fetchMock.mockResolvedValueOnce(ok({ payment_id: CHARGE_ID, checkout_url: 'https://checkout.stripe.com/x', status: 'pending' }, 201));
72
+ const res = await payments.createCharge(API_KEY, ORG_ID, { amount_cents: 1900, description: 'Pro plan' });
73
+ const [url, init] = fetchMock.mock.calls[0];
74
+ expect(url).toContain(`/payments/orgs/${ORG_ID}/charges`);
75
+ expect(init.method).toBe('POST');
76
+ expect(JSON.parse(init.body)).toEqual({ amount_cents: 1900, description: 'Pro plan' });
77
+ expect(res.checkout_url).toBe('https://checkout.stripe.com/x');
78
+ expect(res.payment_id).toBe(CHARGE_ID);
79
+ });
80
+ it('passes `every` through for subscriptions', async () => {
81
+ fetchMock.mockResolvedValueOnce(ok({ payment_id: CHARGE_ID, checkout_url: 'u', status: 'pending' }, 201));
82
+ await payments.createCharge(API_KEY, ORG_ID, { amount_cents: 999, every: 'month' });
83
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).every).toBe('month');
84
+ });
85
+ it('surfaces NOT_CONNECTED (409) when Stripe is not linked', async () => {
86
+ fetchMock.mockResolvedValueOnce(fail('NOT_CONNECTED', 'connect Stripe first', 409));
87
+ await expect(payments.createCharge(API_KEY, ORG_ID, { amount_cents: 100 }))
88
+ .rejects.toMatchObject({ code: 'NOT_CONNECTED', status: 409 });
89
+ });
90
+ });
91
+ describe('payments.listCharges / getCharge', () => {
92
+ it('GETs the charge list', async () => {
93
+ fetchMock.mockResolvedValueOnce(ok([
94
+ { id: CHARGE_ID, amount_cents: 1900, currency: 'usd', status: 'succeeded', created_at: 't' },
95
+ ]));
96
+ const list = await payments.listCharges(API_KEY, ORG_ID);
97
+ expect(list).toHaveLength(1);
98
+ expect(list[0].amount_cents).toBe(1900);
99
+ });
100
+ it('GETs a single charge', async () => {
101
+ fetchMock.mockResolvedValueOnce(ok({ id: CHARGE_ID, amount_cents: 1900, currency: 'usd', status: 'succeeded', created_at: 't' }));
102
+ const c = await payments.getCharge(API_KEY, ORG_ID, CHARGE_ID);
103
+ expect(c.id).toBe(CHARGE_ID);
104
+ expect(fetchMock.mock.calls[0][0]).toContain(`/payments/orgs/${ORG_ID}/charges/${CHARGE_ID}`);
105
+ });
106
+ it('surfaces charge_not_found (404)', async () => {
107
+ fetchMock.mockResolvedValueOnce(fail('charge_not_found', 'not found', 404));
108
+ await expect(payments.getCharge(API_KEY, ORG_ID, CHARGE_ID))
109
+ .rejects.toMatchObject({ code: 'charge_not_found', status: 404 });
110
+ });
111
+ });
112
+ describe('payments.refundCharge', () => {
113
+ it('POSTs to /refund and returns the refunded status', async () => {
114
+ fetchMock.mockResolvedValueOnce(ok({ id: CHARGE_ID, status: 'refunded' }));
115
+ const res = await payments.refundCharge(API_KEY, ORG_ID, CHARGE_ID);
116
+ expect(res.status).toBe('refunded');
117
+ const [url, init] = fetchMock.mock.calls[0];
118
+ expect(url).toContain(`/payments/orgs/${ORG_ID}/charges/${CHARGE_ID}/refund`);
119
+ expect(init.method).toBe('POST');
120
+ });
121
+ it('surfaces "already refunded" (409)', async () => {
122
+ fetchMock.mockResolvedValueOnce(fail('charge already refunded', 'charge already refunded', 409));
123
+ await expect(payments.refundCharge(API_KEY, ORG_ID, CHARGE_ID))
124
+ .rejects.toMatchObject({ status: 409 });
125
+ });
126
+ });
127
+ describe('payments.EXPOSES', () => {
128
+ it('covers connect, charges CRUD, refund, and the webhook', () => {
129
+ expect(payments.EXPOSES).toEqual([
130
+ 'POST /payments/orgs/{org_id}/connect',
131
+ 'GET /payments/orgs/{org_id}/connect',
132
+ 'POST /payments/orgs/{org_id}/charges',
133
+ 'GET /payments/orgs/{org_id}/charges',
134
+ 'GET /payments/orgs/{org_id}/charges/{id}',
135
+ 'POST /payments/orgs/{org_id}/charges/{id}/refund',
136
+ 'POST /payments/webhook/{org_id}',
137
+ ]);
138
+ });
139
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "1.2.5",
4
+ "version": "1.2.6",
5
5
  "description": "MyAPI command-line interface",
6
6
  "type": "module",
7
7
  "files": [
@@ -18,6 +18,8 @@
18
18
  "test": "vitest run src test/scripts",
19
19
  "test:smoke": "npm run build && vitest run src test/smoke test/scripts",
20
20
  "test:online": "npm run build && MYAPI_RUN_RESET=1 vitest run test/online",
21
+ "test:realistic": "npm run build && MYAPI_RUN_REALISTIC=1 vitest run test/realistic/autonomous-company.test.ts",
22
+ "test:realistic:cleanup": "npm run build && vitest run test/realistic/cleanup.test.ts",
21
23
  "test:all": "npm run build && MYAPI_RUN_RESET=1 vitest run src test/smoke test/scripts test/online",
22
24
  "check-coverage": "npm run build && node scripts/check-coverage.js",
23
25
  "check-coverage:live": "npm run build && node scripts/check-coverage.js --live",
@@ -27,7 +29,7 @@
27
29
  "lint:changelog": "node ../../scripts/lint-changelog.js"
28
30
  },
29
31
  "dependencies": {
30
- "@myapihq/sdk": "^1.2.5",
32
+ "@myapihq/sdk": "^1.2.6",
31
33
  "omelette": "^0.4.17"
32
34
  },
33
35
  "devDependencies": {