@topolo/sdk 0.4.7 → 0.4.8
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/index.cjs +145 -135
- package/dist/index.d.cts +84 -92
- package/dist/index.d.ts +84 -92
- package/dist/index.js +145 -135
- package/package.json +1 -1
- package/src/application-requirements.ts +9 -5
- package/src/applications.generated.ts +52 -108
- package/src/client.test.ts +74 -0
- package/src/client.ts +101 -0
- package/src/index.test.ts +69 -0
- package/src/index.ts +1 -0
- package/src/modules/crm.test.ts +208 -0
- package/src/modules/identity.test.ts +165 -0
- package/src/modules/p2p.test.ts +286 -0
- package/src/oauth.test.ts +103 -0
- package/src/services.generated.ts +13 -14
- package/src/services.test.ts +18 -7
- package/src/widgets.test.ts +216 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TopoloClient } from '../client.js';
|
|
3
|
+
import { CrmModule } from './crm.js';
|
|
4
|
+
|
|
5
|
+
const AGENT = { clientName: 'topolo-sdk-test', clientVersion: '0.0.0' };
|
|
6
|
+
|
|
7
|
+
function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
8
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
9
|
+
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_crm' } }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (String(url).includes('/api/services/catalog')) {
|
|
18
|
+
return new Response(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
data: {
|
|
21
|
+
services: [
|
|
22
|
+
{
|
|
23
|
+
service_id: 'srv_crm',
|
|
24
|
+
app_slug: 'topolo-crm',
|
|
25
|
+
name: 'Topolo CRM',
|
|
26
|
+
api_base_url: 'https://crm.topolo.app',
|
|
27
|
+
launch_url: 'https://crm.topolo.app',
|
|
28
|
+
surface_kind: 'api',
|
|
29
|
+
status: 'active',
|
|
30
|
+
granted_permissions: ['srv_crm.contacts:read'],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const next = responses.shift() ?? { status: 200, body: { data: {} } };
|
|
39
|
+
return new Response(JSON.stringify(next.body ?? {}), {
|
|
40
|
+
status: next.status ?? 200,
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
48
|
+
return calls.filter(
|
|
49
|
+
(call) =>
|
|
50
|
+
!call.url.includes('/api/services/by-slug/') &&
|
|
51
|
+
!call.url.includes('/api/services/catalog'),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clientWith(impl: typeof fetch) {
|
|
56
|
+
return new TopoloClient({
|
|
57
|
+
credential: { kind: 'access_token', accessToken: 'tok_test' },
|
|
58
|
+
agent: AGENT,
|
|
59
|
+
fetch: impl,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
TopoloClient.clearServiceIdCache();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('CrmModule.listContacts', () => {
|
|
68
|
+
it('maps snake_case rows into camelCase summaries and forwards query params', async () => {
|
|
69
|
+
const { impl, calls } = stubFetch([
|
|
70
|
+
{
|
|
71
|
+
body: {
|
|
72
|
+
contacts: [
|
|
73
|
+
{
|
|
74
|
+
id: 'c_1',
|
|
75
|
+
first: 'Ada',
|
|
76
|
+
last: 'Lovelace',
|
|
77
|
+
email: 'ada@example.com',
|
|
78
|
+
phone: '+1',
|
|
79
|
+
company: 'Analytical',
|
|
80
|
+
lead_status: 'new',
|
|
81
|
+
lifecycle_stage: 'lead',
|
|
82
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
total: 42,
|
|
86
|
+
page: 2,
|
|
87
|
+
pageSize: 25,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
const crm = new CrmModule(clientWith(impl));
|
|
92
|
+
|
|
93
|
+
const result = await crm.listContacts({ q: 'ada', page: 2, pageSize: 25 });
|
|
94
|
+
|
|
95
|
+
expect(result.contacts).toEqual([
|
|
96
|
+
{
|
|
97
|
+
id: 'c_1',
|
|
98
|
+
firstName: 'Ada',
|
|
99
|
+
lastName: 'Lovelace',
|
|
100
|
+
email: 'ada@example.com',
|
|
101
|
+
phone: '+1',
|
|
102
|
+
company: 'Analytical',
|
|
103
|
+
leadStatus: 'new',
|
|
104
|
+
lifecycleStage: 'lead',
|
|
105
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
expect(result.total).toBe(42);
|
|
109
|
+
expect(result.page).toBe(2);
|
|
110
|
+
expect(result.pageSize).toBe(25);
|
|
111
|
+
|
|
112
|
+
const url = new URL(requestCalls(calls)[0]!.url);
|
|
113
|
+
expect(url.pathname).toBe('/api/contacts');
|
|
114
|
+
expect(url.searchParams.get('q')).toBe('ada');
|
|
115
|
+
expect(url.searchParams.get('page')).toBe('2');
|
|
116
|
+
expect(url.searchParams.get('pageSize')).toBe('25');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reads rows from the data[] envelope and pagination block', async () => {
|
|
120
|
+
const { impl } = stubFetch([
|
|
121
|
+
{
|
|
122
|
+
body: {
|
|
123
|
+
data: [{ id: 'c_2' }],
|
|
124
|
+
pagination: { total: 7, page: 3, pageSize: 10 },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
const crm = new CrmModule(clientWith(impl));
|
|
129
|
+
|
|
130
|
+
const result = await crm.listContacts();
|
|
131
|
+
|
|
132
|
+
expect(result.contacts).toEqual([
|
|
133
|
+
{
|
|
134
|
+
id: 'c_2',
|
|
135
|
+
firstName: null,
|
|
136
|
+
lastName: null,
|
|
137
|
+
email: null,
|
|
138
|
+
phone: null,
|
|
139
|
+
company: null,
|
|
140
|
+
leadStatus: null,
|
|
141
|
+
lifecycleStage: null,
|
|
142
|
+
updatedAt: null,
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
expect(result.total).toBe(7);
|
|
146
|
+
expect(result.page).toBe(3);
|
|
147
|
+
expect(result.pageSize).toBe(10);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('falls back to row count + option defaults when no metadata is returned', async () => {
|
|
151
|
+
const { impl } = stubFetch([{ body: { contacts: [{ id: 'c_3' }, { id: 'c_4' }] } }]);
|
|
152
|
+
const crm = new CrmModule(clientWith(impl));
|
|
153
|
+
|
|
154
|
+
const result = await crm.listContacts();
|
|
155
|
+
|
|
156
|
+
expect(result.total).toBe(2);
|
|
157
|
+
expect(result.page).toBe(1);
|
|
158
|
+
expect(result.pageSize).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns an empty list when the response has neither contacts nor data arrays', async () => {
|
|
162
|
+
const { impl } = stubFetch([{ body: { unexpected: true } }]);
|
|
163
|
+
const crm = new CrmModule(clientWith(impl));
|
|
164
|
+
|
|
165
|
+
const result = await crm.listContacts();
|
|
166
|
+
|
|
167
|
+
expect(result.contacts).toEqual([]);
|
|
168
|
+
expect(result.total).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('CrmModule.getContact', () => {
|
|
173
|
+
it('rejects an empty contactId', async () => {
|
|
174
|
+
const { impl } = stubFetch([]);
|
|
175
|
+
const crm = new CrmModule(clientWith(impl));
|
|
176
|
+
await expect(crm.getContact('')).rejects.toThrow('contactId is required');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('unwraps a data-enveloped contact', async () => {
|
|
180
|
+
const { impl, calls } = stubFetch([
|
|
181
|
+
{ body: { data: { id: 'c_9', first: 'Grace', last: 'Hopper' } } },
|
|
182
|
+
]);
|
|
183
|
+
const crm = new CrmModule(clientWith(impl));
|
|
184
|
+
|
|
185
|
+
const contact = await crm.getContact('c_9');
|
|
186
|
+
|
|
187
|
+
expect(contact).toMatchObject({ id: 'c_9', firstName: 'Grace', lastName: 'Hopper' });
|
|
188
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/contacts/c_9');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('accepts a bare contact object (no data envelope)', async () => {
|
|
192
|
+
const { impl } = stubFetch([{ body: { id: 'c_10', email: 'g@example.com' } }]);
|
|
193
|
+
const crm = new CrmModule(clientWith(impl));
|
|
194
|
+
|
|
195
|
+
const contact = await crm.getContact('c_10');
|
|
196
|
+
|
|
197
|
+
expect(contact).toMatchObject({ id: 'c_10', email: 'g@example.com' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('url-encodes contact ids with special characters', async () => {
|
|
201
|
+
const { impl, calls } = stubFetch([{ body: { data: { id: 'a/b c' } } }]);
|
|
202
|
+
const crm = new CrmModule(clientWith(impl));
|
|
203
|
+
|
|
204
|
+
await crm.getContact('a/b c');
|
|
205
|
+
|
|
206
|
+
expect(requestCalls(calls)[0]!.url).toContain('/api/contacts/a%2Fb%20c');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TopoloClient } from '../client.js';
|
|
3
|
+
import { IdentityModule } from './identity.js';
|
|
4
|
+
|
|
5
|
+
const AGENT = { clientName: 'topolo-sdk-test', clientVersion: '0.0.0' };
|
|
6
|
+
|
|
7
|
+
function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
8
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
9
|
+
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_auth' } }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (String(url).includes('/api/services/catalog')) {
|
|
18
|
+
return new Response(JSON.stringify({ data: { services: [] } }), {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const next = responses.shift() ?? { status: 200, body: {} };
|
|
24
|
+
return new Response(JSON.stringify(next.body ?? {}), {
|
|
25
|
+
status: next.status ?? 200,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
33
|
+
return calls.filter(
|
|
34
|
+
(call) =>
|
|
35
|
+
!call.url.includes('/api/services/by-slug/') &&
|
|
36
|
+
!call.url.includes('/api/services/catalog'),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clientWith(
|
|
41
|
+
impl: typeof fetch,
|
|
42
|
+
credential: { kind: 'access_token'; accessToken: string } | { kind: 'api_key'; apiKey: string } = {
|
|
43
|
+
kind: 'access_token',
|
|
44
|
+
accessToken: 'tok_test',
|
|
45
|
+
},
|
|
46
|
+
) {
|
|
47
|
+
return new TopoloClient({
|
|
48
|
+
credential,
|
|
49
|
+
agent: AGENT,
|
|
50
|
+
fetch: impl,
|
|
51
|
+
serviceIds: { auth: 'service_auth' },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
TopoloClient.clearServiceIdCache();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('IdentityModule.whoami', () => {
|
|
60
|
+
it('resolves a data-enveloped access-token identity with an organization', async () => {
|
|
61
|
+
const { impl, calls } = stubFetch([
|
|
62
|
+
{
|
|
63
|
+
body: {
|
|
64
|
+
data: {
|
|
65
|
+
credentialType: 'access_token',
|
|
66
|
+
user: { id: 'u_1', email: 'ada@example.com', name: 'Ada', role: 'owner' },
|
|
67
|
+
organization: { id: 'org_1', slug: 'acme', name: 'Acme Inc' },
|
|
68
|
+
permissions: ['crm.contacts:read'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
74
|
+
|
|
75
|
+
const me = await identity.whoami();
|
|
76
|
+
|
|
77
|
+
expect(me).toEqual({
|
|
78
|
+
kind: 'access_token',
|
|
79
|
+
user: {
|
|
80
|
+
id: 'u_1',
|
|
81
|
+
email: 'ada@example.com',
|
|
82
|
+
name: 'Ada',
|
|
83
|
+
role: 'owner',
|
|
84
|
+
permissions: ['crm.contacts:read'],
|
|
85
|
+
},
|
|
86
|
+
organization: { id: 'org_1', slug: 'acme', name: 'Acme Inc' },
|
|
87
|
+
});
|
|
88
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/auth/me');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('defaults api_key role to "service" and reads a bare (non-enveloped) payload', async () => {
|
|
92
|
+
const { impl } = stubFetch([
|
|
93
|
+
{
|
|
94
|
+
body: {
|
|
95
|
+
credentialType: 'api_key',
|
|
96
|
+
user: { id: 'svc_1', email: 'svc@example.com' },
|
|
97
|
+
organization: { id: 'org_2', slug: 'beta' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const identity = new IdentityModule(
|
|
102
|
+
clientWith(impl, { kind: 'api_key', apiKey: 'dak_xyz' }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const me = await identity.whoami();
|
|
106
|
+
|
|
107
|
+
expect(me.kind).toBe('api_key');
|
|
108
|
+
expect(me.user.role).toBe('service');
|
|
109
|
+
expect(me.user.name).toBeNull();
|
|
110
|
+
// organization name falls back to slug when name is absent
|
|
111
|
+
expect(me.organization).toEqual({ id: 'org_2', slug: 'beta', name: 'beta' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('defaults access_token role to "member" and reads permissions from user when top-level absent', async () => {
|
|
115
|
+
const { impl } = stubFetch([
|
|
116
|
+
{
|
|
117
|
+
body: {
|
|
118
|
+
data: {
|
|
119
|
+
user: {
|
|
120
|
+
id: 'u_3',
|
|
121
|
+
email: 'm@example.com',
|
|
122
|
+
permissions: ['mail.messages:read'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
129
|
+
|
|
130
|
+
const me = await identity.whoami();
|
|
131
|
+
|
|
132
|
+
expect(me.kind).toBe('access_token');
|
|
133
|
+
expect(me.user.role).toBe('member');
|
|
134
|
+
expect(me.user.permissions).toEqual(['mail.messages:read']);
|
|
135
|
+
expect(me.organization).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('synthesizes an organization from user.orgId/orgSlug when no organization object is present', async () => {
|
|
139
|
+
const { impl } = stubFetch([
|
|
140
|
+
{
|
|
141
|
+
body: {
|
|
142
|
+
data: {
|
|
143
|
+
user: { id: 'u_4', email: 'o@example.com', orgId: 'org_9', orgSlug: 'gamma' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
149
|
+
|
|
150
|
+
const me = await identity.whoami();
|
|
151
|
+
|
|
152
|
+
expect(me.organization).toEqual({ id: 'org_9', slug: 'gamma', name: 'gamma' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('defaults missing permissions to an empty array', async () => {
|
|
156
|
+
const { impl } = stubFetch([
|
|
157
|
+
{ body: { data: { user: { id: 'u_5', email: 'n@example.com' } } } },
|
|
158
|
+
]);
|
|
159
|
+
const identity = new IdentityModule(clientWith(impl));
|
|
160
|
+
|
|
161
|
+
const me = await identity.whoami();
|
|
162
|
+
|
|
163
|
+
expect(me.user.permissions).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/modules/p2p.test.ts
CHANGED
|
@@ -255,4 +255,290 @@ describe('P2PModule.releaseHold', () => {
|
|
|
255
255
|
expect(sent.resolution).toBe('seller');
|
|
256
256
|
expect(sent.reason).toBe('arbitration');
|
|
257
257
|
});
|
|
258
|
+
|
|
259
|
+
it('rejects an empty holdId', async () => {
|
|
260
|
+
const { impl } = stubFetch([]);
|
|
261
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
262
|
+
await expect(p2p.releaseHold('', 'buyer')).rejects.toThrow('holdId is required');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('defaults reason to null when omitted', async () => {
|
|
266
|
+
const { impl, calls } = stubFetch([
|
|
267
|
+
{ body: { success: true, data: { hold: { hold_id: 'h2' }, resolution: 'buyer', cancel_entries: [] } } },
|
|
268
|
+
]);
|
|
269
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
270
|
+
await p2p.releaseHold('h2', 'buyer');
|
|
271
|
+
const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
|
|
272
|
+
expect(sent.reason).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('P2PModule.getCapability (additional)', () => {
|
|
277
|
+
it('rejects an empty capabilityId', async () => {
|
|
278
|
+
const { impl } = stubFetch([]);
|
|
279
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
280
|
+
await expect(p2p.getCapability('')).rejects.toThrow('capabilityId is required');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('returns the unwrapped capability on success', async () => {
|
|
284
|
+
const { impl, calls } = stubFetch([
|
|
285
|
+
{ body: { success: true, data: { capability: { capability_id: 'cap_ok' } } } },
|
|
286
|
+
]);
|
|
287
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
288
|
+
const cap = await p2p.getCapability('cap ok/1');
|
|
289
|
+
expect(cap!.capability_id).toBe('cap_ok');
|
|
290
|
+
expect(requestCalls(calls)[0]!.url).toContain('/api/directory/capabilities/cap%20ok%2F1');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('rethrows non-404 errors', async () => {
|
|
294
|
+
const { impl } = stubFetch([{ status: 500, body: { success: false, error: 'boom' } }]);
|
|
295
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
296
|
+
await expect(p2p.getCapability('cap_x')).rejects.toThrow();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('P2PModule.listActions', () => {
|
|
301
|
+
it('GETs /api/actions forwarding state + limit', async () => {
|
|
302
|
+
const { impl, calls } = stubFetch([
|
|
303
|
+
{ body: { success: true, data: { actions: [{ request_id: 'req_1' }] } } },
|
|
304
|
+
]);
|
|
305
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
306
|
+
const actions = await p2p.listActions({ state: 'pending', limit: 5 });
|
|
307
|
+
expect(actions).toHaveLength(1);
|
|
308
|
+
const url = new URL(requestCalls(calls)[0]!.url);
|
|
309
|
+
expect(url.pathname).toBe('/api/actions');
|
|
310
|
+
expect(url.searchParams.get('state')).toBe('pending');
|
|
311
|
+
expect(url.searchParams.get('limit')).toBe('5');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('P2PModule.getAction', () => {
|
|
316
|
+
it('rejects an empty requestId', async () => {
|
|
317
|
+
const { impl } = stubFetch([]);
|
|
318
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
319
|
+
await expect(p2p.getAction('')).rejects.toThrow('requestId is required');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('returns the action detail on success', async () => {
|
|
323
|
+
const { impl } = stubFetch([
|
|
324
|
+
{ body: { success: true, data: { request: { request_id: 'req_2' } } } },
|
|
325
|
+
]);
|
|
326
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
327
|
+
const detail = await p2p.getAction('req_2');
|
|
328
|
+
expect(detail).toMatchObject({ request: { request_id: 'req_2' } });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('returns null on 404', async () => {
|
|
332
|
+
const { impl } = stubFetch([{ status: 404, body: { success: false, error: 'not_found' } }]);
|
|
333
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
334
|
+
expect(await p2p.getAction('req_missing')).toBeNull();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('P2PModule.requestAction (validation + happy path)', () => {
|
|
339
|
+
it('rejects a missing capability_id', async () => {
|
|
340
|
+
const { impl } = stubFetch([]);
|
|
341
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
342
|
+
await expect(
|
|
343
|
+
p2p.requestAction({ capability_id: '', idempotency_key: 'k', inputs: {} }),
|
|
344
|
+
).rejects.toThrow('capability_id is required');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('POSTs the request and defaults inputs to {}', async () => {
|
|
348
|
+
const { impl, calls } = stubFetch([
|
|
349
|
+
{ body: { success: true, data: { request_id: 'req_new', status: 'requested' } } },
|
|
350
|
+
]);
|
|
351
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
352
|
+
const result = await p2p.requestAction({ capability_id: 'cap_1', idempotency_key: 'idem_1' });
|
|
353
|
+
expect(result).toMatchObject({ request_id: 'req_new' });
|
|
354
|
+
const call = requestCalls(calls)[0]!;
|
|
355
|
+
expect(new URL(call.url).pathname).toBe('/api/actions/request');
|
|
356
|
+
const sent = JSON.parse(String(call.init.body));
|
|
357
|
+
expect(sent.idempotency_key).toBe('idem_1');
|
|
358
|
+
expect(sent.inputs).toEqual({});
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('P2PModule.respondAction / completeAction / disputeAction', () => {
|
|
363
|
+
it('respondAction POSTs response + extras and unwraps request', async () => {
|
|
364
|
+
const { impl, calls } = stubFetch([
|
|
365
|
+
{ body: { success: true, data: { request: { request_id: 'req_3', state: 'accepted' } } } },
|
|
366
|
+
]);
|
|
367
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
368
|
+
const row = await p2p.respondAction('req_3', 'accepted', { quote_minor: 100 });
|
|
369
|
+
expect(row).toMatchObject({ request_id: 'req_3', state: 'accepted' });
|
|
370
|
+
const call = requestCalls(calls)[0]!;
|
|
371
|
+
expect(new URL(call.url).pathname).toBe('/api/actions/req_3/respond');
|
|
372
|
+
const sent = JSON.parse(String(call.init.body));
|
|
373
|
+
expect(sent.response).toBe('accepted');
|
|
374
|
+
expect(sent.quote_minor).toBe(100);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('respondAction rejects an empty requestId', async () => {
|
|
378
|
+
const { impl } = stubFetch([]);
|
|
379
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
380
|
+
await expect(p2p.respondAction('', 'rejected')).rejects.toThrow('requestId is required');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('completeAction POSTs the result payload', async () => {
|
|
384
|
+
const { impl, calls } = stubFetch([
|
|
385
|
+
{ body: { success: true, data: { request: { request_id: 'req_4' } } } },
|
|
386
|
+
]);
|
|
387
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
388
|
+
await p2p.completeAction('req_4', { output: 'done' });
|
|
389
|
+
const call = requestCalls(calls)[0]!;
|
|
390
|
+
expect(new URL(call.url).pathname).toBe('/api/actions/req_4/complete');
|
|
391
|
+
expect(JSON.parse(String(call.init.body)).result).toEqual({ output: 'done' });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('completeAction rejects an empty requestId', async () => {
|
|
395
|
+
const { impl } = stubFetch([]);
|
|
396
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
397
|
+
await expect(p2p.completeAction('')).rejects.toThrow('requestId is required');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('disputeAction POSTs the reason', async () => {
|
|
401
|
+
const { impl, calls } = stubFetch([
|
|
402
|
+
{ body: { success: true, data: { request: { request_id: 'req_5' } } } },
|
|
403
|
+
]);
|
|
404
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
405
|
+
await p2p.disputeAction('req_5', 'wrong output');
|
|
406
|
+
const call = requestCalls(calls)[0]!;
|
|
407
|
+
expect(new URL(call.url).pathname).toBe('/api/actions/req_5/dispute');
|
|
408
|
+
expect(JSON.parse(String(call.init.body)).reason).toBe('wrong output');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('disputeAction rejects an empty requestId', async () => {
|
|
412
|
+
const { impl } = stubFetch([]);
|
|
413
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
414
|
+
await expect(p2p.disputeAction('', 'reason')).rejects.toThrow('requestId is required');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe('P2PModule.getPolicy / updatePolicy', () => {
|
|
419
|
+
it('getPolicy GETs /api/policies and unwraps the policy', async () => {
|
|
420
|
+
const { impl, calls } = stubFetch([
|
|
421
|
+
{ body: { success: true, data: { policy: { organization_id: 'org_a', auto_accept: false } } } },
|
|
422
|
+
]);
|
|
423
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
424
|
+
const policy = await p2p.getPolicy();
|
|
425
|
+
expect(policy).toMatchObject({ organization_id: 'org_a' });
|
|
426
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/policies');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('updatePolicy PUTs the partial policy', async () => {
|
|
430
|
+
const { impl, calls } = stubFetch([
|
|
431
|
+
{ body: { success: true, data: { policy: { organization_id: 'org_a', auto_accept: true } } } },
|
|
432
|
+
]);
|
|
433
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
434
|
+
const policy = await p2p.updatePolicy({ auto_accept: true } as never);
|
|
435
|
+
expect(policy).toMatchObject({ auto_accept: true });
|
|
436
|
+
const call = requestCalls(calls)[0]!;
|
|
437
|
+
expect((call.init.method ?? '').toUpperCase()).toBe('PUT');
|
|
438
|
+
expect(JSON.parse(String(call.init.body)).auto_accept).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('P2PModule connections', () => {
|
|
443
|
+
it('listConnections GETs /api/connections', async () => {
|
|
444
|
+
const { impl, calls } = stubFetch([
|
|
445
|
+
{ body: { success: true, data: { connections: [{ connection_id: 'conn_1' }] } } },
|
|
446
|
+
]);
|
|
447
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
448
|
+
const conns = await p2p.listConnections();
|
|
449
|
+
expect(conns).toHaveLength(1);
|
|
450
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/connections');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('createConnection POSTs counterparty + default active status', async () => {
|
|
454
|
+
const { impl, calls } = stubFetch([
|
|
455
|
+
{ body: { success: true, data: { connection: { connection_id: 'conn_2', status: 'active' } } } },
|
|
456
|
+
]);
|
|
457
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
458
|
+
const conn = await p2p.createConnection('org_other');
|
|
459
|
+
expect(conn.status).toBe('active');
|
|
460
|
+
const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
|
|
461
|
+
expect(sent.counterparty_org_id).toBe('org_other');
|
|
462
|
+
expect(sent.status).toBe('active');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('createConnection honors an explicit status', async () => {
|
|
466
|
+
const { impl, calls } = stubFetch([
|
|
467
|
+
{ body: { success: true, data: { connection: { connection_id: 'conn_3', status: 'pending' } } } },
|
|
468
|
+
]);
|
|
469
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
470
|
+
await p2p.createConnection('org_other', { status: 'pending' });
|
|
471
|
+
expect(JSON.parse(String(requestCalls(calls)[0]!.init.body)).status).toBe('pending');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('createConnection rejects an empty counterpartyOrgId', async () => {
|
|
475
|
+
const { impl } = stubFetch([]);
|
|
476
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
477
|
+
await expect(p2p.createConnection('')).rejects.toThrow('counterpartyOrgId is required');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('revokeConnection DELETEs the encoded connection id', async () => {
|
|
481
|
+
const { impl, calls } = stubFetch([{ body: { success: true, data: {} } }]);
|
|
482
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
483
|
+
await p2p.revokeConnection('conn/9');
|
|
484
|
+
const call = requestCalls(calls)[0]!;
|
|
485
|
+
expect((call.init.method ?? '').toUpperCase()).toBe('DELETE');
|
|
486
|
+
expect(call.url).toContain('/api/connections/conn%2F9');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('revokeConnection rejects an empty connectionId', async () => {
|
|
490
|
+
const { impl } = stubFetch([]);
|
|
491
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
492
|
+
await expect(p2p.revokeConnection('')).rejects.toThrow('connectionId is required');
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('P2PModule ledger + settlements', () => {
|
|
497
|
+
it('listLedger GETs /api/ledger and unwraps entries', async () => {
|
|
498
|
+
const { impl, calls } = stubFetch([
|
|
499
|
+
{ body: { success: true, data: { ledger_entries: [{ entry_id: 'led_1' }] } } },
|
|
500
|
+
]);
|
|
501
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
502
|
+
const entries = await p2p.listLedger();
|
|
503
|
+
expect(entries).toHaveLength(1);
|
|
504
|
+
expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/ledger');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('previewSettlements POSTs filters and unwraps groups', async () => {
|
|
508
|
+
const { impl, calls } = stubFetch([
|
|
509
|
+
{ body: { success: true, data: { groups: [{ buyer_org_id: 'org_b', seller_org_id: 'org_s' }] } } },
|
|
510
|
+
]);
|
|
511
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
512
|
+
const groups = await p2p.previewSettlements({ currency: 'USD' });
|
|
513
|
+
expect(groups).toHaveLength(1);
|
|
514
|
+
const call = requestCalls(calls)[0]!;
|
|
515
|
+
expect(new URL(call.url).pathname).toBe('/api/settlements/preview');
|
|
516
|
+
expect(JSON.parse(String(call.init.body)).currency).toBe('USD');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('createSettlement defaults trigger to manual and unwraps batch + group', async () => {
|
|
520
|
+
const { impl, calls } = stubFetch([
|
|
521
|
+
{
|
|
522
|
+
body: {
|
|
523
|
+
success: true,
|
|
524
|
+
data: { batch: { batch_id: 'bat_1' }, group: { buyer_org_id: 'org_b' } },
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
]);
|
|
528
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
529
|
+
const result = await p2p.createSettlement({ buyer_org_id: 'org_b' });
|
|
530
|
+
expect(result.batch.batch_id).toBe('bat_1');
|
|
531
|
+
const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
|
|
532
|
+
expect(sent.trigger).toBe('manual');
|
|
533
|
+
expect(sent.buyer_org_id).toBe('org_b');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('createSettlement honors an explicit trigger override', async () => {
|
|
537
|
+
const { impl, calls } = stubFetch([
|
|
538
|
+
{ body: { success: true, data: { batch: { batch_id: 'bat_2' }, group: {} } } },
|
|
539
|
+
]);
|
|
540
|
+
const p2p = new P2PModule(clientWith(impl));
|
|
541
|
+
await p2p.createSettlement({ trigger: 'schedule' });
|
|
542
|
+
expect(JSON.parse(String(requestCalls(calls)[0]!.init.body)).trigger).toBe('schedule');
|
|
543
|
+
});
|
|
258
544
|
});
|