@learncard/network-plugin 2.14.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,646 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- plugin integration mocks intentionally expose dynamic method bags */
2
+ /**
3
+ * LC-2198 Task 3 — SDK unified send (`refresh: true`) runtime behavior.
4
+ *
5
+ * Covers the dedicated managed branch of the network plugin's `send` method:
6
+ * recipient-category rejection before any work, local signing (allocate → inject →
7
+ * sign once) followed by the validated server unified-send handoff, delegation for
8
+ * clients without local signing, untouched caller-supplied signed credentials, and
9
+ * the guarantee that failures never fall back to a non-refresh send.
10
+ *
11
+ * `sendBoost` return-shape compatibility lives in `refresh-send.test-d.ts` (types)
12
+ * and `index.test.ts` (runtime).
13
+ */
14
+ import { generateLearnCard } from '@learncard/core';
15
+ import { getDidKitPlugin } from '@learncard/didkit-plugin';
16
+ import { getDidKeyPlugin } from '@learncard/didkey-plugin';
17
+ import { getVCPlugin } from '@learncard/vc-plugin';
18
+ import { getClient as getBrainClient } from '@learncard/network-brain-client';
19
+ import type { UnsignedVC } from '@learncard/types';
20
+ import { vi } from 'vitest';
21
+
22
+ import { getLearnCardNetworkPlugin } from '../';
23
+
24
+ vi.mock('@learncard/network-brain-client', () => ({
25
+ getClient: vi.fn(),
26
+ getApiTokenClient: vi.fn(),
27
+ }));
28
+ vi.mock('@learncard/core', () => ({ generateLearnCard: vi.fn() }));
29
+ vi.mock('@learncard/didkit-plugin', () => ({ getDidKitPlugin: vi.fn() }));
30
+ vi.mock('@learncard/didkey-plugin', () => ({ getDidKeyPlugin: vi.fn() }));
31
+ vi.mock('@learncard/vc-plugin', () => ({ getVCPlugin: vi.fn() }));
32
+ vi.mock('@learncard/helpers', async importOriginal => ({
33
+ ...(await importOriginal<typeof import('@learncard/helpers')>()),
34
+ getCredentialStatusArray: () => [],
35
+ resolveStorageReadResult: (value: any) => value,
36
+ }));
37
+ vi.mock('@learncard/types', async importOriginal => ({
38
+ ...(await importOriginal<object>()),
39
+ VCValidator: {
40
+ parse: (value: any) => {
41
+ if (!value?.type) throw new Error('Invalid credential');
42
+ return value;
43
+ },
44
+ },
45
+ UnsignedVCValidator: {
46
+ spa: async (value: any) => ({
47
+ success: true,
48
+ data: typeof value === 'string' ? JSON.parse(value) : value,
49
+ }),
50
+ },
51
+ }));
52
+
53
+ const PROFILE = {
54
+ profileId: 'usera',
55
+ displayName: 'User A',
56
+ shortBio: '',
57
+ bio: '',
58
+ did: 'did:web:network.example:users:usera',
59
+ isPrivate: false,
60
+ profileVisibility: 'public',
61
+ showEmail: false,
62
+ allowConnectionRequests: 'anyone',
63
+ isServiceProfile: false,
64
+ };
65
+
66
+ const TARGET_PROFILE = { profileId: 'userb', did: 'did:key:ztarget-holder-did' };
67
+ const TARGET_DID = 'did:web:network.example:users:userb';
68
+
69
+ const REFRESH_ID = 'cnVsbGFiZWxseXVuZ3Vlc3NhYmxlcmVmcmVzaGlkMTIzNDU2Nzg';
70
+ const ALLOCATION = {
71
+ refreshId: REFRESH_ID,
72
+ refreshService: {
73
+ id: `https://network.example/refresh/${REFRESH_ID}`,
74
+ type: 'LearnCardCredentialRefresh2026',
75
+ authorization: { type: 'LearnCardDIDAuth' },
76
+ },
77
+ };
78
+
79
+ /**
80
+ * The single server preparation result the SDK now signs against. `prepareRefreshableSend`
81
+ * runs every managed-send guard, creates/reuses the boost and allocates the refresh, so the
82
+ * SDK never allocates client-side and cannot know the holder DID without it.
83
+ */
84
+ const defaultPrepared = {
85
+ boostUri: 'lc:network:localhost%3A3000/trpc:boost:prepared',
86
+ credentialId: 'urn:uuid:prepared-credential',
87
+ refreshId: 'refresh-prepared',
88
+ refreshService: {
89
+ id: 'http://localhost:3000/refresh/refresh-prepared',
90
+ type: 'LearnCardCredentialRefresh2026',
91
+ authorization: { type: 'LearnCardDIDAuth' },
92
+ },
93
+ holderDid: 'did:web:localhost%3A3000:users:userb',
94
+ };
95
+
96
+ const SERVER_MANAGED_RESPONSE = {
97
+ type: 'boost' as const,
98
+ uri: 'did:web:network.example:boost:1',
99
+ credentialUri: 'did:web:network.example:credentials:managed-1',
100
+ activityId: 'activity-123',
101
+ refresh: {
102
+ refreshId: REFRESH_ID,
103
+ refreshService: ALLOCATION.refreshService,
104
+ credentialId: 'urn:uuid:signed-credential-id',
105
+ issuerDid: PROFILE.did,
106
+ holderDid: TARGET_PROFILE.did,
107
+ credentialStatus: {
108
+ id: 'https://network.example/status/3#94567',
109
+ type: 'BitstringStatusListEntry',
110
+ statusPurpose: 'revocation',
111
+ statusListIndex: '94567',
112
+ statusListCredential: 'https://network.example/status/3',
113
+ },
114
+ },
115
+ };
116
+
117
+ const getUnsignedBoostTemplate = (overrides: Record<string, unknown> = {}) => ({
118
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
119
+ type: ['VerifiableCredential', 'BoostCredential'],
120
+ issuer: 'did:example:template-issuer',
121
+ validFrom: '2020-01-01T00:00:00.000Z',
122
+ credentialSubject: { id: 'did:example:template-subject' },
123
+ ...overrides,
124
+ });
125
+
126
+ const getMockIssuingLearnCard = (overrides: Record<string, any> = {}) =>
127
+ ({
128
+ id: { did: () => PROFILE.did },
129
+ invoke: {
130
+ getDidAuthVp: vi.fn(),
131
+ getProfile: vi.fn().mockResolvedValue(TARGET_PROFILE),
132
+ issueCredential: vi.fn(async (credential: any) => ({
133
+ ...credential,
134
+ proof: { type: 'DataIntegrityProof' },
135
+ })),
136
+ createDagJwe: vi.fn(),
137
+ ...overrides,
138
+ },
139
+ debug: vi.fn(),
140
+ }) as any;
141
+
142
+ const getMockClient = (options: Record<string, any> = {}) => ({
143
+ profile: {
144
+ getProfile: { query: vi.fn().mockResolvedValue(PROFILE) },
145
+ },
146
+ boost: {
147
+ prepareRefreshableSend: {
148
+ mutate: vi.fn().mockResolvedValue(defaultPrepared),
149
+ },
150
+ getBoost: { query: vi.fn().mockResolvedValue({ boost: getUnsignedBoostTemplate() }) },
151
+ send: {
152
+ mutate: vi
153
+ .fn()
154
+ .mockImplementation((input: any) =>
155
+ Promise.resolve(
156
+ input.refresh === true ? SERVER_MANAGED_RESPONSE : 'plain-send-response'
157
+ )
158
+ ),
159
+ },
160
+ allocateCredentialStatus: { mutate: vi.fn().mockResolvedValue([]) },
161
+ },
162
+ utilities: { getDid: { query: vi.fn().mockResolvedValue('did:web:network.example') } },
163
+ credentialRefresh: {
164
+ allocateCredentialRefresh: { mutate: vi.fn().mockResolvedValue(ALLOCATION) },
165
+ sendRefreshableCredential: { mutate: vi.fn().mockResolvedValue('managed-credential-uri') },
166
+ },
167
+ ...options,
168
+ });
169
+
170
+ const getPlugin = async (learnCard: any, client: any) => {
171
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
172
+
173
+ return getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
174
+ };
175
+
176
+ describe('unified send with refresh: true (managed branch)', () => {
177
+ beforeEach(() => {
178
+ vi.clearAllMocks();
179
+ });
180
+
181
+ it.each(['holder@example.com', '+15551234567'])(
182
+ 'delegates refresh inbox issuance for %s without local signing',
183
+ async recipient => {
184
+ const client = getMockClient();
185
+ const learnCard = getMockIssuingLearnCard();
186
+ const plugin = await getPlugin(learnCard, client);
187
+ const input = {
188
+ type: 'boost' as const,
189
+ recipient,
190
+ templateUri: 'did:web:network.example:boost:1',
191
+ refresh: true,
192
+ };
193
+ await plugin.methods?.send(learnCard, input);
194
+ expect(client.boost.send.mutate).toHaveBeenCalledWith(input);
195
+ expect(
196
+ client.credentialRefresh.allocateCredentialRefresh.mutate
197
+ ).not.toHaveBeenCalled();
198
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
199
+ }
200
+ );
201
+
202
+ it('signs locally from a template URI after a single server prepare and hands the signed credential to the server unified send', async () => {
203
+ const client = getMockClient();
204
+ const learnCard = getMockIssuingLearnCard();
205
+ const plugin = await getPlugin(learnCard, client);
206
+
207
+ const result = await plugin.methods?.send(learnCard, {
208
+ type: 'boost',
209
+ recipient: 'userb',
210
+ templateUri: 'did:web:network.example:boost:1',
211
+ templateData: { name: 'Refreshable Badge' },
212
+ refresh: true,
213
+ });
214
+
215
+ // Canonical server response (including the receipt and a real activityId)
216
+ // is forwarded unchanged.
217
+ expect(result).toEqual(SERVER_MANAGED_RESPONSE);
218
+
219
+ // The server runs every guard, creates/reuses the boost and allocates the
220
+ // refresh in one call; the SDK never allocates client-side.
221
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledExactlyOnceWith({
222
+ recipient: 'userb',
223
+ templateUri: 'did:web:network.example:boost:1',
224
+ templateData: { name: 'Refreshable Badge' },
225
+ });
226
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
227
+
228
+ const prepareOrder =
229
+ client.boost.prepareRefreshableSend.mutate.mock.invocationCallOrder[0]!;
230
+ const signOrder = learnCard.invoke.issueCredential.mock.invocationCallOrder[0]!;
231
+ expect(prepareOrder).toBeLessThan(signOrder);
232
+
233
+ // The signed credential embeds the prepared managed refresh service and
234
+ // its inline JSON-LD context.
235
+ const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
236
+ expect(forwarded.signedCredential.refreshService).toEqual(defaultPrepared.refreshService);
237
+ expect(forwarded.signedCredential['@context']).toContainEqual(
238
+ expect.objectContaining({ LearnCardCredentialRefresh2026: expect.any(String) })
239
+ );
240
+ expect(forwarded.signedCredential.credentialSubject.id).toEqual(defaultPrepared.holderDid);
241
+ expect(forwarded.signedCredential.issuer).toEqual(PROFILE.did);
242
+
243
+ // Validated handoff: the signed credential plus refresh flag reach the
244
+ // unified send — nothing was sent through the managed-send shortcut or
245
+ // legacy storage from the client.
246
+ expect(forwarded.refresh).toBe(true);
247
+ expect(forwarded.templateUri).toEqual(defaultPrepared.boostUri);
248
+ expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
249
+ expect(learnCard.invoke.createDagJwe).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('prepares the inline boost before signing and reuses its prepared URI for delivery', async () => {
253
+ const client = getMockClient();
254
+ const learnCard = getMockIssuingLearnCard();
255
+ const plugin = await getPlugin(learnCard, client);
256
+
257
+ const template = {
258
+ name: 'Inline Refreshable Badge',
259
+ credential: getUnsignedBoostTemplate({ id: 'urn:uuid:inline-credential-id' }),
260
+ claimPermissions: { canView: true },
261
+ skills: [{ frameworkId: 'framework-1', id: 'skill-1' }],
262
+ };
263
+
264
+ await plugin.methods?.send(learnCard, {
265
+ type: 'boost',
266
+ recipient: 'userb',
267
+ template,
268
+ contractUri: 'contract:1',
269
+ refresh: true,
270
+ });
271
+
272
+ const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
273
+
274
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledExactlyOnceWith({
275
+ recipient: 'userb',
276
+ template,
277
+ contractUri: 'contract:1',
278
+ credentialId: 'urn:uuid:inline-credential-id',
279
+ });
280
+ expect(forwarded.template).toBeUndefined();
281
+ expect(forwarded.templateUri).toBe(defaultPrepared.boostUri);
282
+ expect(forwarded.contractUri).toBe('contract:1');
283
+ // The server-returned credential ID is the one that gets signed.
284
+ expect(forwarded.signedCredential.id).toEqual(defaultPrepared.credentialId);
285
+ expect(forwarded.signedCredential.boostId).toBe(forwarded.templateUri);
286
+ expect(forwarded.refresh).toBe(true);
287
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledTimes(1);
288
+ expect(learnCard.invoke.issueCredential.mock.calls[0][0].boostId).toBe(
289
+ forwarded.templateUri
290
+ );
291
+ expect(template.credential).not.toHaveProperty('boostId');
292
+ expect(client.boost.prepareRefreshableSend.mutate.mock.invocationCallOrder[0]).toBeLessThan(
293
+ learnCard.invoke.issueCredential.mock.invocationCallOrder[0]
294
+ );
295
+
296
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
297
+ expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it('signs with the prepared credential ID, refresh service and holder DID', async () => {
301
+ const client = getMockClient();
302
+ const learnCard = getMockIssuingLearnCard();
303
+ const plugin = await getPlugin(learnCard, client);
304
+
305
+ await plugin.methods?.send(learnCard, {
306
+ type: 'boost',
307
+ recipient: 'userb',
308
+ template: { credential: getUnsignedBoostTemplate() },
309
+ refresh: true,
310
+ });
311
+
312
+ const signed = learnCard.invoke.issueCredential.mock.calls[0]![0] as UnsignedVC;
313
+
314
+ expect(signed.id).toBe('urn:uuid:prepared-credential');
315
+ expect((signed as any).refreshService.id).toBe(
316
+ 'http://localhost:3000/refresh/refresh-prepared'
317
+ );
318
+ expect((signed.credentialSubject as any).id).toBe('did:web:localhost%3A3000:users:userb');
319
+ expect(client.boost.send.mutate).toHaveBeenCalledWith(
320
+ expect.objectContaining({
321
+ templateUri: 'lc:network:localhost%3A3000/trpc:boost:prepared',
322
+ refresh: true,
323
+ })
324
+ );
325
+ });
326
+
327
+ it('passes idempotencyKey to prepare and to the final send', async () => {
328
+ const client = getMockClient();
329
+ const learnCard = getMockIssuingLearnCard();
330
+ const plugin = await getPlugin(learnCard, client);
331
+
332
+ await plugin.methods?.send(learnCard, {
333
+ type: 'boost',
334
+ recipient: 'userb',
335
+ template: { credential: getUnsignedBoostTemplate() },
336
+ refresh: true,
337
+ idempotencyKey: 'k-1',
338
+ });
339
+
340
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledWith(
341
+ expect.objectContaining({ idempotencyKey: 'k-1' })
342
+ );
343
+ expect(client.boost.send.mutate).toHaveBeenCalledWith(
344
+ expect.objectContaining({ idempotencyKey: 'k-1' })
345
+ );
346
+ });
347
+
348
+ it('returns a completed result from prepare without signing or sending', async () => {
349
+ const client = getMockClient();
350
+ const learnCard = getMockIssuingLearnCard();
351
+ const plugin = await getPlugin(learnCard, client);
352
+
353
+ const completed = {
354
+ type: 'boost',
355
+ uri: 'lc:network:localhost%3A3000/trpc:boost:prepared',
356
+ credentialUri: 'lc:network:localhost%3A3000/trpc:credential:root',
357
+ activityId: 'activity-1',
358
+ };
359
+ client.boost.prepareRefreshableSend.mutate.mockResolvedValueOnce({
360
+ ...defaultPrepared,
361
+ completed,
362
+ });
363
+
364
+ const result = await plugin.methods?.send(learnCard, {
365
+ type: 'boost',
366
+ recipient: 'userb',
367
+ template: { credential: getUnsignedBoostTemplate() },
368
+ refresh: true,
369
+ idempotencyKey: 'k-2',
370
+ });
371
+
372
+ expect(result).toEqual(completed);
373
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
374
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it('stops before allocation, signing or delivery if inline preparation is rejected', async () => {
378
+ const client = getMockClient();
379
+ client.boost.prepareRefreshableSend.mutate.mockRejectedValue(
380
+ new Error('Credential refresh is not available')
381
+ );
382
+ const learnCard = getMockIssuingLearnCard();
383
+ const plugin = await getPlugin(learnCard, client);
384
+ await expect(
385
+ plugin.methods?.send(learnCard, {
386
+ type: 'boost',
387
+ recipient: 'userb',
388
+ template: { credential: getUnsignedBoostTemplate() },
389
+ refresh: true,
390
+ })
391
+ ).rejects.toThrow('Credential refresh is not available');
392
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
393
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
394
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it('prepares with the recipient DID for DID recipients and signs to the prepared holder DID', async () => {
398
+ const client = getMockClient();
399
+ const learnCard = getMockIssuingLearnCard({
400
+ getProfile: vi.fn().mockResolvedValue(undefined),
401
+ });
402
+ const plugin = await getPlugin(learnCard, client);
403
+
404
+ await plugin.methods?.send(learnCard, {
405
+ type: 'boost',
406
+ recipient: TARGET_PROFILE.did,
407
+ templateUri: 'did:web:network.example:boost:1',
408
+ refresh: true,
409
+ });
410
+
411
+ // The server resolves the recipient to a holder DID; the SDK never does a
412
+ // local profile lookup.
413
+ expect(learnCard.invoke.getProfile).not.toHaveBeenCalled();
414
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledWith(
415
+ expect.objectContaining({ recipient: TARGET_PROFILE.did })
416
+ );
417
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
418
+
419
+ const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
420
+ expect(forwarded.signedCredential.credentialSubject.id).toEqual(defaultPrepared.holderDid);
421
+ });
422
+
423
+ it('includes claim data and integration in the keyed preparation fingerprint', async () => {
424
+ const client = getMockClient();
425
+ const learnCard = getMockIssuingLearnCard();
426
+ const plugin = await getPlugin(learnCard, client);
427
+ await plugin.methods?.send(learnCard, {
428
+ type: 'boost',
429
+ recipient: 'userb',
430
+ templateUri: 'did:web:network.example:boost:1',
431
+ refresh: true,
432
+ idempotencyKey: 'award-1',
433
+ templateData: { grade: 'A' },
434
+ integrationId: 'integration-1',
435
+ });
436
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledWith(
437
+ expect.objectContaining({
438
+ templateData: { grade: 'A' },
439
+ integrationId: 'integration-1',
440
+ idempotencyKey: 'award-1',
441
+ })
442
+ );
443
+ });
444
+
445
+ it('delegates unchanged to the server signing-authority path when local signing is unavailable', async () => {
446
+ const client = getMockClient();
447
+ const learnCard = getMockIssuingLearnCard();
448
+ delete learnCard.invoke.issueCredential;
449
+ const plugin = await getPlugin(learnCard, client);
450
+
451
+ const input = {
452
+ type: 'boost' as const,
453
+ recipient: 'userb',
454
+ templateUri: 'did:web:network.example:boost:1',
455
+ refresh: true as const,
456
+ };
457
+
458
+ const result = await plugin.methods?.send(learnCard, input);
459
+
460
+ expect(result).toEqual(SERVER_MANAGED_RESPONSE);
461
+ expect(client.boost.send.mutate).toHaveBeenCalledWith(input);
462
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
463
+ expect(learnCard.invoke.issueCredential).toBeUndefined();
464
+ });
465
+
466
+ it.each([
467
+ {},
468
+ { templateUri: 'did:web:network.example:boost:1' },
469
+ { template: { credential: getUnsignedBoostTemplate() } },
470
+ ])('forwards caller-supplied signed credentials unchanged with source %j', async source => {
471
+ const client = getMockClient();
472
+ const learnCard = getMockIssuingLearnCard();
473
+ const plugin = await getPlugin(learnCard, client);
474
+
475
+ const signedCredential = {
476
+ ...getUnsignedBoostTemplate({ id: 'urn:uuid:caller-signed' }),
477
+ issuer: PROFILE.did,
478
+ credentialSubject: { id: TARGET_PROFILE.did },
479
+ refreshService: ALLOCATION.refreshService,
480
+ proof: { type: 'DataIntegrityProof', proofPurpose: 'assertionMethod' },
481
+ };
482
+ const signedSnapshot = JSON.parse(JSON.stringify(signedCredential));
483
+
484
+ await plugin.methods?.send(learnCard, {
485
+ type: 'boost',
486
+ recipient: 'userb',
487
+ signedCredential: signedCredential as any,
488
+ ...source,
489
+ refresh: true,
490
+ });
491
+
492
+ const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
493
+ expect(forwarded.signedCredential).toEqual(signedSnapshot);
494
+ expect(forwarded.refresh).toBe(true);
495
+
496
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
497
+ // No local re-signing or second allocation for pre-signed credentials.
498
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
499
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
500
+ });
501
+
502
+ it('routes unresolvable profile recipients through the server prepare step instead of falling back locally', async () => {
503
+ const client = getMockClient();
504
+ const learnCard = getMockIssuingLearnCard({
505
+ getProfile: vi.fn().mockResolvedValue({ profileId: 'ghost' }),
506
+ });
507
+ const plugin = await getPlugin(learnCard, client);
508
+
509
+ const input = {
510
+ type: 'boost' as const,
511
+ recipient: 'ghost',
512
+ templateUri: 'did:web:network.example:boost:1',
513
+ refresh: true as const,
514
+ };
515
+
516
+ // The plugin forwards the refresh request to the server's prepare step; the
517
+ // authoritative guard decides the outcome. No local profile lookup, no
518
+ // non-refresh fallback.
519
+ await plugin.methods?.send(learnCard, input);
520
+
521
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledWith(
522
+ expect.objectContaining({ recipient: 'ghost', templateUri: input.templateUri })
523
+ );
524
+ expect(learnCard.invoke.getProfile).not.toHaveBeenCalled();
525
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
526
+ });
527
+
528
+ it('surfaces server-side refresh rejections without falling back to a non-refresh send', async () => {
529
+ const client = getMockClient({
530
+ boost: {
531
+ prepareRefreshableSend: { mutate: vi.fn().mockResolvedValue(defaultPrepared) },
532
+ getBoost: {
533
+ query: vi.fn().mockResolvedValue({ boost: getUnsignedBoostTemplate() }),
534
+ },
535
+ send: {
536
+ mutate: vi.fn().mockRejectedValue(
537
+ Object.assign(new Error('Credential refresh is not available'), {
538
+ code: 'NOT_FOUND',
539
+ })
540
+ ),
541
+ },
542
+ allocateCredentialStatus: { mutate: vi.fn().mockResolvedValue([]) },
543
+ },
544
+ });
545
+ const learnCard = getMockIssuingLearnCard();
546
+ const plugin = await getPlugin(learnCard, client);
547
+
548
+ await expect(
549
+ plugin.methods?.send(learnCard, {
550
+ type: 'boost',
551
+ recipient: 'userb',
552
+ templateUri: 'did:web:network.example:boost:1',
553
+ refresh: true,
554
+ })
555
+ ).rejects.toThrow('Credential refresh is not available');
556
+
557
+ // The failure must be terminal: exactly one delivery attempt, no
558
+ // non-refresh retry, no managed-send shortcut.
559
+ expect(client.boost.send.mutate).toHaveBeenCalledTimes(1);
560
+ expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
561
+ });
562
+
563
+ it('keeps absent-refresh sends on their existing local branch with no allocation', async () => {
564
+ const client = getMockClient();
565
+ const learnCard = getMockIssuingLearnCard();
566
+ const plugin = await getPlugin(learnCard, client);
567
+
568
+ await plugin.methods?.send(learnCard, {
569
+ type: 'boost',
570
+ recipient: 'userb',
571
+ templateUri: 'did:web:network.example:boost:1',
572
+ });
573
+
574
+ const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
575
+ expect(forwarded.signedCredential).toBeDefined();
576
+ expect(forwarded.refresh).toBeUndefined();
577
+ expect(forwarded.signedCredential.refreshService).toBeUndefined();
578
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
579
+ expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
580
+ });
581
+
582
+ it('keeps false-refresh sends on their existing local branch with no allocation', async () => {
583
+ const client = getMockClient();
584
+ const learnCard = getMockIssuingLearnCard();
585
+ const plugin = await getPlugin(learnCard, client);
586
+
587
+ await plugin.methods?.send(learnCard, {
588
+ type: 'boost',
589
+ recipient: 'userb',
590
+ templateUri: 'did:web:network.example:boost:1',
591
+ refresh: false,
592
+ });
593
+
594
+ const forwarded = client.boost.send.mutate.mock.calls[0]?.[0] as any;
595
+ expect(forwarded.refresh).toBe(false);
596
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
597
+ });
598
+
599
+ it('keeps non-refresh remote did:web sends on the existing federation branch', async () => {
600
+ const client = getMockClient();
601
+ const learnCard = getMockIssuingLearnCard({
602
+ getBoost: vi.fn().mockResolvedValue({ boost: getUnsignedBoostTemplate() }),
603
+ sendCredential: vi.fn().mockResolvedValue('federated-credential-uri'),
604
+ });
605
+ const plugin = await getPlugin(learnCard, client);
606
+
607
+ const result = await plugin.methods?.send(learnCard, {
608
+ type: 'boost',
609
+ recipient: 'did:web:remote.example:users:carol',
610
+ templateUri: 'did:web:network.example:boost:1',
611
+ });
612
+
613
+ expect(result).toEqual({
614
+ type: 'boost',
615
+ credentialUri: 'federated-credential-uri',
616
+ uri: 'did:web:network.example:boost:1',
617
+ activityId: '',
618
+ });
619
+ expect(learnCard.invoke.sendCredential).toHaveBeenCalled();
620
+ expect(client.credentialRefresh.allocateCredentialRefresh.mutate).not.toHaveBeenCalled();
621
+ });
622
+ });
623
+
624
+ describe('network plugin integrity', () => {
625
+ beforeEach(() => {
626
+ vi.clearAllMocks();
627
+ });
628
+
629
+ it('runs ensureUser before unified send', async () => {
630
+ const client = getMockClient();
631
+ client.profile.getProfile.query = vi.fn().mockResolvedValue(undefined);
632
+ const learnCard = getMockIssuingLearnCard();
633
+ const plugin = await getPlugin(learnCard, client);
634
+
635
+ await expect(
636
+ plugin.methods?.send(learnCard, {
637
+ type: 'boost',
638
+ recipient: 'userb',
639
+ templateUri: 'did:web:network.example:boost:1',
640
+ refresh: true,
641
+ })
642
+ ).rejects.toThrow('Please make an account first!');
643
+
644
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
645
+ });
646
+ });