@topolo/sdk 0.7.0 → 0.8.1

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.
@@ -1,540 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { TopoloClient } from '../client.js';
3
- import { P2PModule } from './p2p.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: { app_id: 'service_p2p' } }), {
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({
19
- data: {
20
- services: [
21
- {
22
- app_id: 'app_p2p',
23
- app_slug: 'topolo-p2p',
24
- name: 'Topolo P2P',
25
- api_base_url: 'https://p2p.topolo.app',
26
- launch_url: 'https://p2p.topolo.app',
27
- surface_kind: 'api',
28
- status: 'active',
29
- granted_permissions: ['app_p2p.directory:read'],
30
- },
31
- ],
32
- },
33
- }), {
34
- status: 200,
35
- headers: { 'Content-Type': 'application/json' },
36
- });
37
- }
38
- const next = responses.shift() ?? { status: 200, body: { success: true, data: {} } };
39
- return new Response(JSON.stringify(next.body ?? { success: true, data: {} }), {
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((call) =>
49
- !call.url.includes('/api/services/by-slug/') &&
50
- !call.url.includes('/api/services/catalog')
51
- );
52
- }
53
-
54
- function clientWith(impl: typeof fetch) {
55
- return new TopoloClient({
56
- credential: { kind: 'access_token', accessToken: 'tok_test' },
57
- agent: AGENT,
58
- fetch: impl,
59
- });
60
- }
61
-
62
- describe('P2PModule.listDirectory', () => {
63
- beforeEach(() => {
64
- TopoloClient.clearAppIdCache();
65
- });
66
-
67
- it('GETs /api/directory/capabilities and unwraps the envelope', async () => {
68
- const { impl, calls } = stubFetch([
69
- {
70
- body: {
71
- success: true,
72
- data: {
73
- capabilities: [
74
- {
75
- capability_id: 'cap_1',
76
- publisher_org_id: 'org_a',
77
- app_id: 'app_x',
78
- name: 'Demo',
79
- description: 'A capability',
80
- category: 'other',
81
- pricing_model: 'free',
82
- pricing_amount_minor: null,
83
- currency: null,
84
- requires_human_approval: false,
85
- supported_contexts: ['agent'],
86
- trust_level: 'standard',
87
- source_revision: 'rev_1',
88
- synced_at: 1,
89
- },
90
- ],
91
- },
92
- },
93
- },
94
- ]);
95
- const p2p = new P2PModule(clientWith(impl));
96
-
97
- const capabilities = await p2p.listDirectory({ limit: 10 });
98
- expect(capabilities).toHaveLength(1);
99
- expect(capabilities[0]!.capability_id).toBe('cap_1');
100
- const call = requestCalls(calls)[0]!;
101
- const url = new URL(call.url);
102
- expect(url.pathname).toBe('/api/directory/capabilities');
103
- expect(url.searchParams.get('limit')).toBe('10');
104
- expect((call.init.method ?? 'GET').toUpperCase()).toBe('GET');
105
- });
106
- });
107
-
108
- describe('P2PModule.publishCapability', () => {
109
- it('POSTs to the internal sync route with the publisher_org_id and acting_for_org_id default', async () => {
110
- const { impl, calls } = stubFetch([
111
- {
112
- body: {
113
- success: true,
114
- data: {
115
- capability: {
116
- capability_id: 'cap_z',
117
- publisher_org_id: 'org_p',
118
- app_id: 'app_x',
119
- name: 'Demo',
120
- description: 'A capability',
121
- category: 'other',
122
- pricing_model: 'fixed',
123
- pricing_amount_minor: 500,
124
- currency: 'USD',
125
- requires_human_approval: false,
126
- supported_contexts: ['agent'],
127
- trust_level: 'standard',
128
- source_revision: 'rev_2',
129
- synced_at: 1,
130
- },
131
- },
132
- },
133
- },
134
- ]);
135
- const p2p = new P2PModule(clientWith(impl));
136
-
137
- const capability = await p2p.publishCapability({
138
- capability_id: 'cap_z',
139
- publisher_org_id: 'org_p',
140
- app_id: 'app_x',
141
- name: 'Demo',
142
- description: 'A capability',
143
- category: 'other',
144
- pricing_model: 'fixed',
145
- pricing_amount_minor: 500,
146
- currency: 'USD',
147
- supported_contexts: ['agent'],
148
- public_directory_visible: true,
149
- source_revision: 'rev_2',
150
- });
151
- expect(capability.capability_id).toBe('cap_z');
152
-
153
- const call = requestCalls(calls)[0]!;
154
- const sent = JSON.parse(String(call.init.body));
155
- expect(sent.publisher_org_id).toBe('org_p');
156
- expect(sent.acting_for_org_id).toBe('org_p');
157
- expect(sent.public_directory_visible).toBe(true);
158
- expect(sent.requires_human_approval).toBe(false);
159
- expect(sent.trust_level).toBe('standard');
160
- const url = new URL(call.url);
161
- expect(url.pathname).toBe('/api/p2p/internal/capabilities/sync');
162
- expect((call.init.method ?? '').toUpperCase()).toBe('POST');
163
- });
164
-
165
- it('honours an explicit acting_for_org_id when the platform key is publishing for another org', async () => {
166
- const { impl, calls } = stubFetch([
167
- { body: { success: true, data: { capability: null } } },
168
- ]);
169
- const p2p = new P2PModule(clientWith(impl));
170
- await p2p.publishCapability({
171
- capability_id: 'cap_other',
172
- publisher_org_id: 'org_target',
173
- app_id: 'app_x',
174
- name: 'Demo',
175
- description: '...',
176
- category: 'other',
177
- pricing_model: 'free',
178
- supported_contexts: ['agent'],
179
- public_directory_visible: true,
180
- source_revision: 'rev_1',
181
- acting_for_org_id: 'org_target',
182
- });
183
- const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
184
- expect(sent.acting_for_org_id).toBe('org_target');
185
- });
186
- });
187
-
188
- describe('P2PModule.requestAction', () => {
189
- it('rejects without an idempotency_key', async () => {
190
- const { impl } = stubFetch([]);
191
- const p2p = new P2PModule(clientWith(impl));
192
- await expect(
193
- p2p.requestAction({ capability_id: 'cap_1', idempotency_key: '', inputs: {} }),
194
- ).rejects.toThrow(/idempotency_key/);
195
- });
196
-
197
- it('returns the worker decision payload', async () => {
198
- const { impl } = stubFetch([
199
- {
200
- body: {
201
- success: true,
202
- data: {
203
- decision: 'auto_accepted',
204
- idempotent: false,
205
- request: { request_id: 'req_1', state: 'auto_accepted' },
206
- run: null,
207
- },
208
- },
209
- },
210
- ]);
211
- const p2p = new P2PModule(clientWith(impl));
212
- const result = await p2p.requestAction({
213
- capability_id: 'cap_1',
214
- idempotency_key: 'key_1',
215
- inputs: { foo: 'bar' },
216
- });
217
- expect(result.decision).toBe('auto_accepted');
218
- expect(result.idempotent).toBe(false);
219
- expect(result.request.request_id).toBe('req_1');
220
- });
221
- });
222
-
223
- describe('P2PModule.getCapability', () => {
224
- it('returns null on a 404 from the worker', async () => {
225
- const { impl } = stubFetch([{ status: 404, body: { success: false, error: 'capability_not_found' } }]);
226
- const p2p = new P2PModule(clientWith(impl));
227
- const capability = await p2p.getCapability('cap_missing');
228
- expect(capability).toBeNull();
229
- });
230
- });
231
-
232
- describe('P2PModule.releaseHold', () => {
233
- it('POSTs the resolution + reason and returns hold + cancel entries', async () => {
234
- const { impl, calls } = stubFetch([
235
- {
236
- body: {
237
- success: true,
238
- data: {
239
- hold: { hold_id: 'hold_1', status: 'released' },
240
- resolution: 'seller',
241
- cancel_entries: [{ entry_id: 'led_1', amount_minor: 500 }],
242
- },
243
- },
244
- },
245
- ]);
246
- const p2p = new P2PModule(clientWith(impl));
247
- const result = await p2p.releaseHold('hold_1', 'seller', { reason: 'arbitration' });
248
- expect(result.resolution).toBe('seller');
249
- expect(result.cancel_entries[0]!.entry_id).toBe('led_1');
250
- const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
251
- expect(sent.resolution).toBe('seller');
252
- expect(sent.reason).toBe('arbitration');
253
- });
254
-
255
- it('rejects an empty holdId', async () => {
256
- const { impl } = stubFetch([]);
257
- const p2p = new P2PModule(clientWith(impl));
258
- await expect(p2p.releaseHold('', 'buyer')).rejects.toThrow('holdId is required');
259
- });
260
-
261
- it('defaults reason to null when omitted', async () => {
262
- const { impl, calls } = stubFetch([
263
- { body: { success: true, data: { hold: { hold_id: 'h2' }, resolution: 'buyer', cancel_entries: [] } } },
264
- ]);
265
- const p2p = new P2PModule(clientWith(impl));
266
- await p2p.releaseHold('h2', 'buyer');
267
- const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
268
- expect(sent.reason).toBeNull();
269
- });
270
- });
271
-
272
- describe('P2PModule.getCapability (additional)', () => {
273
- it('rejects an empty capabilityId', async () => {
274
- const { impl } = stubFetch([]);
275
- const p2p = new P2PModule(clientWith(impl));
276
- await expect(p2p.getCapability('')).rejects.toThrow('capabilityId is required');
277
- });
278
-
279
- it('returns the unwrapped capability on success', async () => {
280
- const { impl, calls } = stubFetch([
281
- { body: { success: true, data: { capability: { capability_id: 'cap_ok' } } } },
282
- ]);
283
- const p2p = new P2PModule(clientWith(impl));
284
- const cap = await p2p.getCapability('cap ok/1');
285
- expect(cap!.capability_id).toBe('cap_ok');
286
- expect(requestCalls(calls)[0]!.url).toContain('/api/directory/capabilities/cap%20ok%2F1');
287
- });
288
-
289
- it('rethrows non-404 errors', async () => {
290
- const { impl } = stubFetch([{ status: 500, body: { success: false, error: 'boom' } }]);
291
- const p2p = new P2PModule(clientWith(impl));
292
- await expect(p2p.getCapability('cap_x')).rejects.toThrow();
293
- });
294
- });
295
-
296
- describe('P2PModule.listActions', () => {
297
- it('GETs /api/actions forwarding state + limit', async () => {
298
- const { impl, calls } = stubFetch([
299
- { body: { success: true, data: { actions: [{ request_id: 'req_1' }] } } },
300
- ]);
301
- const p2p = new P2PModule(clientWith(impl));
302
- const actions = await p2p.listActions({ state: 'pending', limit: 5 });
303
- expect(actions).toHaveLength(1);
304
- const url = new URL(requestCalls(calls)[0]!.url);
305
- expect(url.pathname).toBe('/api/actions');
306
- expect(url.searchParams.get('state')).toBe('pending');
307
- expect(url.searchParams.get('limit')).toBe('5');
308
- });
309
- });
310
-
311
- describe('P2PModule.getAction', () => {
312
- it('rejects an empty requestId', async () => {
313
- const { impl } = stubFetch([]);
314
- const p2p = new P2PModule(clientWith(impl));
315
- await expect(p2p.getAction('')).rejects.toThrow('requestId is required');
316
- });
317
-
318
- it('returns the action detail on success', async () => {
319
- const { impl } = stubFetch([
320
- { body: { success: true, data: { request: { request_id: 'req_2' } } } },
321
- ]);
322
- const p2p = new P2PModule(clientWith(impl));
323
- const detail = await p2p.getAction('req_2');
324
- expect(detail).toMatchObject({ request: { request_id: 'req_2' } });
325
- });
326
-
327
- it('returns null on 404', async () => {
328
- const { impl } = stubFetch([{ status: 404, body: { success: false, error: 'not_found' } }]);
329
- const p2p = new P2PModule(clientWith(impl));
330
- expect(await p2p.getAction('req_missing')).toBeNull();
331
- });
332
- });
333
-
334
- describe('P2PModule.requestAction (validation + happy path)', () => {
335
- it('rejects a missing capability_id', async () => {
336
- const { impl } = stubFetch([]);
337
- const p2p = new P2PModule(clientWith(impl));
338
- await expect(
339
- p2p.requestAction({ capability_id: '', idempotency_key: 'k', inputs: {} }),
340
- ).rejects.toThrow('capability_id is required');
341
- });
342
-
343
- it('POSTs the request and defaults inputs to {}', async () => {
344
- const { impl, calls } = stubFetch([
345
- { body: { success: true, data: { request_id: 'req_new', status: 'requested' } } },
346
- ]);
347
- const p2p = new P2PModule(clientWith(impl));
348
- const result = await p2p.requestAction({ capability_id: 'cap_1', idempotency_key: 'idem_1' });
349
- expect(result).toMatchObject({ request_id: 'req_new' });
350
- const call = requestCalls(calls)[0]!;
351
- expect(new URL(call.url).pathname).toBe('/api/actions/request');
352
- const sent = JSON.parse(String(call.init.body));
353
- expect(sent.idempotency_key).toBe('idem_1');
354
- expect(sent.inputs).toEqual({});
355
- });
356
- });
357
-
358
- describe('P2PModule.respondAction / completeAction / disputeAction', () => {
359
- it('respondAction POSTs response + extras and unwraps request', async () => {
360
- const { impl, calls } = stubFetch([
361
- { body: { success: true, data: { request: { request_id: 'req_3', state: 'accepted' } } } },
362
- ]);
363
- const p2p = new P2PModule(clientWith(impl));
364
- const row = await p2p.respondAction('req_3', 'accepted', { quote_minor: 100 });
365
- expect(row).toMatchObject({ request_id: 'req_3', state: 'accepted' });
366
- const call = requestCalls(calls)[0]!;
367
- expect(new URL(call.url).pathname).toBe('/api/actions/req_3/respond');
368
- const sent = JSON.parse(String(call.init.body));
369
- expect(sent.response).toBe('accepted');
370
- expect(sent.quote_minor).toBe(100);
371
- });
372
-
373
- it('respondAction rejects an empty requestId', async () => {
374
- const { impl } = stubFetch([]);
375
- const p2p = new P2PModule(clientWith(impl));
376
- await expect(p2p.respondAction('', 'rejected')).rejects.toThrow('requestId is required');
377
- });
378
-
379
- it('completeAction POSTs the result payload', async () => {
380
- const { impl, calls } = stubFetch([
381
- { body: { success: true, data: { request: { request_id: 'req_4' } } } },
382
- ]);
383
- const p2p = new P2PModule(clientWith(impl));
384
- await p2p.completeAction('req_4', { output: 'done' });
385
- const call = requestCalls(calls)[0]!;
386
- expect(new URL(call.url).pathname).toBe('/api/actions/req_4/complete');
387
- expect(JSON.parse(String(call.init.body)).result).toEqual({ output: 'done' });
388
- });
389
-
390
- it('completeAction rejects an empty requestId', async () => {
391
- const { impl } = stubFetch([]);
392
- const p2p = new P2PModule(clientWith(impl));
393
- await expect(p2p.completeAction('')).rejects.toThrow('requestId is required');
394
- });
395
-
396
- it('disputeAction POSTs the reason', async () => {
397
- const { impl, calls } = stubFetch([
398
- { body: { success: true, data: { request: { request_id: 'req_5' } } } },
399
- ]);
400
- const p2p = new P2PModule(clientWith(impl));
401
- await p2p.disputeAction('req_5', 'wrong output');
402
- const call = requestCalls(calls)[0]!;
403
- expect(new URL(call.url).pathname).toBe('/api/actions/req_5/dispute');
404
- expect(JSON.parse(String(call.init.body)).reason).toBe('wrong output');
405
- });
406
-
407
- it('disputeAction rejects an empty requestId', async () => {
408
- const { impl } = stubFetch([]);
409
- const p2p = new P2PModule(clientWith(impl));
410
- await expect(p2p.disputeAction('', 'reason')).rejects.toThrow('requestId is required');
411
- });
412
- });
413
-
414
- describe('P2PModule.getPolicy / updatePolicy', () => {
415
- it('getPolicy GETs /api/policies and unwraps the policy', async () => {
416
- const { impl, calls } = stubFetch([
417
- { body: { success: true, data: { policy: { organization_id: 'org_a', auto_accept: false } } } },
418
- ]);
419
- const p2p = new P2PModule(clientWith(impl));
420
- const policy = await p2p.getPolicy();
421
- expect(policy).toMatchObject({ organization_id: 'org_a' });
422
- expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/policies');
423
- });
424
-
425
- it('updatePolicy PUTs the partial policy', async () => {
426
- const { impl, calls } = stubFetch([
427
- { body: { success: true, data: { policy: { organization_id: 'org_a', auto_accept: true } } } },
428
- ]);
429
- const p2p = new P2PModule(clientWith(impl));
430
- const policy = await p2p.updatePolicy({ auto_accept: true } as never);
431
- expect(policy).toMatchObject({ auto_accept: true });
432
- const call = requestCalls(calls)[0]!;
433
- expect((call.init.method ?? '').toUpperCase()).toBe('PUT');
434
- expect(JSON.parse(String(call.init.body)).auto_accept).toBe(true);
435
- });
436
- });
437
-
438
- describe('P2PModule connections', () => {
439
- it('listConnections GETs /api/connections', async () => {
440
- const { impl, calls } = stubFetch([
441
- { body: { success: true, data: { connections: [{ connection_id: 'conn_1' }] } } },
442
- ]);
443
- const p2p = new P2PModule(clientWith(impl));
444
- const conns = await p2p.listConnections();
445
- expect(conns).toHaveLength(1);
446
- expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/connections');
447
- });
448
-
449
- it('createConnection POSTs counterparty + default active status', async () => {
450
- const { impl, calls } = stubFetch([
451
- { body: { success: true, data: { connection: { connection_id: 'conn_2', status: 'active' } } } },
452
- ]);
453
- const p2p = new P2PModule(clientWith(impl));
454
- const conn = await p2p.createConnection('org_other');
455
- expect(conn.status).toBe('active');
456
- const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
457
- expect(sent.counterparty_org_id).toBe('org_other');
458
- expect(sent.status).toBe('active');
459
- });
460
-
461
- it('createConnection honors an explicit status', async () => {
462
- const { impl, calls } = stubFetch([
463
- { body: { success: true, data: { connection: { connection_id: 'conn_3', status: 'pending' } } } },
464
- ]);
465
- const p2p = new P2PModule(clientWith(impl));
466
- await p2p.createConnection('org_other', { status: 'pending' });
467
- expect(JSON.parse(String(requestCalls(calls)[0]!.init.body)).status).toBe('pending');
468
- });
469
-
470
- it('createConnection rejects an empty counterpartyOrgId', async () => {
471
- const { impl } = stubFetch([]);
472
- const p2p = new P2PModule(clientWith(impl));
473
- await expect(p2p.createConnection('')).rejects.toThrow('counterpartyOrgId is required');
474
- });
475
-
476
- it('revokeConnection DELETEs the encoded connection id', async () => {
477
- const { impl, calls } = stubFetch([{ body: { success: true, data: {} } }]);
478
- const p2p = new P2PModule(clientWith(impl));
479
- await p2p.revokeConnection('conn/9');
480
- const call = requestCalls(calls)[0]!;
481
- expect((call.init.method ?? '').toUpperCase()).toBe('DELETE');
482
- expect(call.url).toContain('/api/connections/conn%2F9');
483
- });
484
-
485
- it('revokeConnection rejects an empty connectionId', async () => {
486
- const { impl } = stubFetch([]);
487
- const p2p = new P2PModule(clientWith(impl));
488
- await expect(p2p.revokeConnection('')).rejects.toThrow('connectionId is required');
489
- });
490
- });
491
-
492
- describe('P2PModule ledger + settlements', () => {
493
- it('listLedger GETs /api/ledger and unwraps entries', async () => {
494
- const { impl, calls } = stubFetch([
495
- { body: { success: true, data: { ledger_entries: [{ entry_id: 'led_1' }] } } },
496
- ]);
497
- const p2p = new P2PModule(clientWith(impl));
498
- const entries = await p2p.listLedger();
499
- expect(entries).toHaveLength(1);
500
- expect(new URL(requestCalls(calls)[0]!.url).pathname).toBe('/api/ledger');
501
- });
502
-
503
- it('previewSettlements POSTs filters and unwraps groups', async () => {
504
- const { impl, calls } = stubFetch([
505
- { body: { success: true, data: { groups: [{ buyer_org_id: 'org_b', seller_org_id: 'org_s' }] } } },
506
- ]);
507
- const p2p = new P2PModule(clientWith(impl));
508
- const groups = await p2p.previewSettlements({ currency: 'USD' });
509
- expect(groups).toHaveLength(1);
510
- const call = requestCalls(calls)[0]!;
511
- expect(new URL(call.url).pathname).toBe('/api/settlements/preview');
512
- expect(JSON.parse(String(call.init.body)).currency).toBe('USD');
513
- });
514
-
515
- it('createSettlement defaults trigger to manual and unwraps batch + group', async () => {
516
- const { impl, calls } = stubFetch([
517
- {
518
- body: {
519
- success: true,
520
- data: { batch: { batch_id: 'bat_1' }, group: { buyer_org_id: 'org_b' } },
521
- },
522
- },
523
- ]);
524
- const p2p = new P2PModule(clientWith(impl));
525
- const result = await p2p.createSettlement({ buyer_org_id: 'org_b' });
526
- expect(result.batch.batch_id).toBe('bat_1');
527
- const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
528
- expect(sent.trigger).toBe('manual');
529
- expect(sent.buyer_org_id).toBe('org_b');
530
- });
531
-
532
- it('createSettlement honors an explicit trigger override', async () => {
533
- const { impl, calls } = stubFetch([
534
- { body: { success: true, data: { batch: { batch_id: 'bat_2' }, group: {} } } },
535
- ]);
536
- const p2p = new P2PModule(clientWith(impl));
537
- await p2p.createSettlement({ trigger: 'schedule' });
538
- expect(JSON.parse(String(requestCalls(calls)[0]!.init.body)).trigger).toBe('schedule');
539
- });
540
- });