@myapihq/cli 1.2.4 → 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.
- package/dist/commands/billing.d.ts +1 -0
- package/dist/commands/billing.js +61 -2
- package/dist/commands/domain.js +31 -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 +16 -0
- package/dist/commands/fn.js +271 -0
- package/dist/commands/funnel.d.ts +1 -0
- package/dist/commands/funnel.js +101 -5
- package/dist/commands/keys-validation.test.d.ts +1 -0
- package/dist/commands/keys-validation.test.js +87 -0
- package/dist/commands/keys.d.ts +6 -2
- package/dist/commands/keys.js +189 -55
- package/dist/commands/payments-validation.test.d.ts +1 -0
- package/dist/commands/payments-validation.test.js +31 -0
- package/dist/commands/payments.d.ts +13 -0
- package/dist/commands/payments.js +219 -0
- 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 +2 -0
- package/dist/index.js +14 -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 +257 -0
- package/dist/sdk-funnel-name.test.d.ts +1 -0
- package/dist/sdk-funnel-name.test.js +48 -0
- package/dist/sdk-funnel-publish.test.d.ts +1 -0
- package/dist/sdk-funnel-publish.test.js +89 -0
- package/dist/sdk-iam.test.d.ts +1 -0
- package/dist/sdk-iam.test.js +190 -0
- package/dist/sdk-payments.test.d.ts +1 -0
- package/dist/sdk-payments.test.js +139 -0
- package/dist/sdk-webhook.test.d.ts +1 -0
- package/dist/sdk-webhook.test.js +86 -0
- package/package.json +4 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// 2026-05-15 additive changes for my-webhook-api:
|
|
2
|
+
// - POST /webhook/orgs/{org_id}/endpoints body now accepts forward_url.
|
|
3
|
+
// - PATCH /webhook/orgs/{org_id}/endpoints/{id} is a new endpoint.
|
|
4
|
+
//
|
|
5
|
+
// Both surfaced through the SDK (createEndpoint+forward_url, updateEndpoint).
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import { webhook } from '@myapihq/sdk';
|
|
8
|
+
const API_KEY = 'myapi_test';
|
|
9
|
+
const ORG_ID = '11111111-1111-4111-8111-111111111111';
|
|
10
|
+
const ENDPOINT_ID = '22222222-2222-4222-8222-222222222222';
|
|
11
|
+
let fetchMock;
|
|
12
|
+
function ok(data) {
|
|
13
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
14
|
+
status: 200,
|
|
15
|
+
headers: { 'content-type': 'application/json' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
fetchMock = vi.fn().mockResolvedValue(ok({
|
|
20
|
+
id: ENDPOINT_ID, org_id: ORG_ID, name: 'x', slug: 'abc', url: 'https://webhook/in/abc',
|
|
21
|
+
}));
|
|
22
|
+
globalThis.fetch = fetchMock;
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
describe('webhook.createEndpoint — forward_url (additive 2026-05-15)', () => {
|
|
28
|
+
it('omits forward_url by default (back-compat)', async () => {
|
|
29
|
+
await webhook.createEndpoint(API_KEY, ORG_ID, 'my-hook');
|
|
30
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
31
|
+
expect(body).toEqual({ name: 'my-hook' });
|
|
32
|
+
expect(body).not.toHaveProperty('forward_url');
|
|
33
|
+
});
|
|
34
|
+
it('includes forward_url when passed', async () => {
|
|
35
|
+
await webhook.createEndpoint(API_KEY, ORG_ID, 'my-hook', {
|
|
36
|
+
forward_url: 'https://example.com/forward',
|
|
37
|
+
});
|
|
38
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
39
|
+
expect(body.forward_url).toBe('https://example.com/forward');
|
|
40
|
+
});
|
|
41
|
+
it('allows explicit empty string for forward_url (clears it)', async () => {
|
|
42
|
+
await webhook.createEndpoint(API_KEY, ORG_ID, 'my-hook', { forward_url: '' });
|
|
43
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
44
|
+
expect(body).toHaveProperty('forward_url', '');
|
|
45
|
+
});
|
|
46
|
+
it('combines forward_url with other options', async () => {
|
|
47
|
+
await webhook.createEndpoint(API_KEY, ORG_ID, 'my-hook', {
|
|
48
|
+
description: 'Stripe events',
|
|
49
|
+
crm_email_path: 'data.object.customer_email',
|
|
50
|
+
forward_url: 'https://example.com/forward',
|
|
51
|
+
});
|
|
52
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
53
|
+
expect(body).toEqual({
|
|
54
|
+
name: 'my-hook',
|
|
55
|
+
description: 'Stripe events',
|
|
56
|
+
crm_email_path: 'data.object.customer_email',
|
|
57
|
+
forward_url: 'https://example.com/forward',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('webhook.updateEndpoint — PATCH (new 2026-05-15)', () => {
|
|
62
|
+
it('PATCHes /webhook/orgs/{org_id}/endpoints/{id}', async () => {
|
|
63
|
+
await webhook.updateEndpoint(API_KEY, ORG_ID, ENDPOINT_ID, { name: 'renamed' });
|
|
64
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
65
|
+
expect(url).toContain(`/webhook/orgs/${ORG_ID}/endpoints/${ENDPOINT_ID}`);
|
|
66
|
+
expect(init.method).toBe('PATCH');
|
|
67
|
+
expect(JSON.parse(init.body)).toEqual({ name: 'renamed' });
|
|
68
|
+
});
|
|
69
|
+
it('sends only the fields that were provided', async () => {
|
|
70
|
+
await webhook.updateEndpoint(API_KEY, ORG_ID, ENDPOINT_ID, {
|
|
71
|
+
forward_url: 'https://other.example.com/fwd',
|
|
72
|
+
});
|
|
73
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
|
74
|
+
forward_url: 'https://other.example.com/fwd',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
it('preserves empty-string sentinel (explicit clear)', async () => {
|
|
78
|
+
await webhook.updateEndpoint(API_KEY, ORG_ID, ENDPOINT_ID, { forward_url: '' });
|
|
79
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ forward_url: '' });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('webhook.EXPOSES — current contract', () => {
|
|
83
|
+
it('includes PATCH for endpoints (2026-05-15)', () => {
|
|
84
|
+
expect(webhook.EXPOSES).toContain('PATCH /webhook/orgs/{org_id}/endpoints/{endpoint_id}');
|
|
85
|
+
});
|
|
86
|
+
});
|
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.
|
|
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.
|
|
32
|
+
"@myapihq/sdk": "^1.2.6",
|
|
31
33
|
"omelette": "^0.4.17"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|