@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,323 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- plugin integration mocks intentionally expose dynamic method bags */
2
+ import { getClient as getBrainClient, getApiTokenClient } from '@learncard/network-brain-client';
3
+ import { vi } from 'vitest';
4
+
5
+ import { ACT_AS_HEADER, getLearnCardNetworkPlugin } from '../plugin';
6
+
7
+ vi.mock('@learncard/network-brain-client', () => ({
8
+ getClient: vi.fn(),
9
+ getApiTokenClient: vi.fn(),
10
+ }));
11
+
12
+ const PROFILE = {
13
+ profileId: 'usera',
14
+ displayName: 'User A',
15
+ shortBio: '',
16
+ bio: '',
17
+ did: 'did:web:network.example:users:usera',
18
+ isPrivate: false,
19
+ profileVisibility: 'public',
20
+ showEmail: false,
21
+ allowConnectionRequests: 'anyone',
22
+ isServiceProfile: false,
23
+ };
24
+
25
+ const URL = 'https://network.example/trpc';
26
+
27
+ const getMockClient = () => ({
28
+ profile: { getProfile: { query: vi.fn().mockResolvedValue(PROFILE) } },
29
+ });
30
+
31
+ const getMockLearnCard = () => {
32
+ const learnCard: any = {
33
+ plugins: [],
34
+ id: { did: () => PROFILE.did },
35
+ invoke: { getDidAuthVp: vi.fn().mockResolvedValue('mock-jwt') },
36
+ debug: vi.fn(),
37
+ };
38
+ learnCard.addPlugin = vi.fn(async (plugin: any) => ({
39
+ ...learnCard,
40
+ plugins: [...learnCard.plugins, plugin],
41
+ }));
42
+
43
+ return learnCard;
44
+ };
45
+
46
+ describe('actAs', () => {
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ });
50
+
51
+ describe('extraHeaders wiring', () => {
52
+ it('omits the act-as header for the API-token client when actAs is not set', async () => {
53
+ vi.mocked(getApiTokenClient).mockResolvedValue(getMockClient() as never);
54
+ const learnCard = getMockLearnCard();
55
+
56
+ await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
57
+
58
+ expect(getApiTokenClient).toHaveBeenCalledWith(
59
+ URL,
60
+ 'api-token-value',
61
+ undefined,
62
+ undefined
63
+ );
64
+ });
65
+
66
+ it('adds the act-as header for the API-token client when actAs is set', async () => {
67
+ vi.mocked(getApiTokenClient).mockResolvedValue(getMockClient() as never);
68
+ const learnCard = getMockLearnCard();
69
+
70
+ await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value', {
71
+ actAs: 'managed-child',
72
+ });
73
+
74
+ expect(getApiTokenClient).toHaveBeenCalledWith(URL, 'api-token-value', undefined, {
75
+ [ACT_AS_HEADER]: 'managed-child',
76
+ });
77
+ });
78
+
79
+ it('merges the act-as header alongside other extraHeaders', async () => {
80
+ vi.mocked(getApiTokenClient).mockResolvedValue(getMockClient() as never);
81
+ const learnCard = getMockLearnCard();
82
+
83
+ await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value', {
84
+ actAs: 'managed-child',
85
+ extraHeaders: { 'X-Custom': '1' },
86
+ });
87
+
88
+ expect(getApiTokenClient).toHaveBeenCalledWith(URL, 'api-token-value', undefined, {
89
+ 'X-Custom': '1',
90
+ [ACT_AS_HEADER]: 'managed-child',
91
+ });
92
+ });
93
+
94
+ it('omits the act-as header for the seed/DID-auth client when actAs is not set', async () => {
95
+ vi.mocked(getBrainClient).mockResolvedValue(getMockClient() as never);
96
+ const learnCard = getMockLearnCard();
97
+
98
+ await getLearnCardNetworkPlugin(learnCard, URL);
99
+
100
+ expect(getBrainClient).toHaveBeenCalledWith(
101
+ URL,
102
+ expect.any(Function),
103
+ undefined,
104
+ undefined
105
+ );
106
+ });
107
+
108
+ it('adds the act-as header for the seed/DID-auth client when actAs is set', async () => {
109
+ vi.mocked(getBrainClient).mockResolvedValue(getMockClient() as never);
110
+ const learnCard = getMockLearnCard();
111
+
112
+ await getLearnCardNetworkPlugin(learnCard, URL, { actAs: 'managed-child' });
113
+
114
+ expect(getBrainClient).toHaveBeenCalledWith(URL, expect.any(Function), undefined, {
115
+ [ACT_AS_HEADER]: 'managed-child',
116
+ });
117
+ });
118
+ });
119
+
120
+ describe('.actAs(profileId) method', () => {
121
+ it('api-token mode: returns a new LearnCard carrying the header, without mutating the original', async () => {
122
+ const originalClient = getMockClient();
123
+ const actingClient = getMockClient();
124
+ vi.mocked(getApiTokenClient)
125
+ .mockResolvedValueOnce(originalClient as never)
126
+ .mockResolvedValueOnce(actingClient as never);
127
+
128
+ const learnCard = getMockLearnCard();
129
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
130
+
131
+ expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
132
+
133
+ const newLearnCard = await plugin.methods.actAs(learnCard, 'managed-child');
134
+
135
+ expect(getApiTokenClient).toHaveBeenNthCalledWith(
136
+ 2,
137
+ URL,
138
+ 'api-token-value',
139
+ undefined,
140
+ {
141
+ [ACT_AS_HEADER]: 'managed-child',
142
+ }
143
+ );
144
+ expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
145
+
146
+ const [actingPlugin] = vi.mocked(learnCard.addPlugin).mock.calls[0] as [any];
147
+ expect(actingPlugin.name).toBe('LearnCard Network');
148
+ expect(actingPlugin.methods.getLCNClient(learnCard)).toBe(actingClient);
149
+
150
+ // The original instance/plugin must be untouched by the .actAs call.
151
+ expect(newLearnCard).not.toBe(learnCard);
152
+ expect(learnCard.plugins).toEqual([]);
153
+ expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
154
+ });
155
+
156
+ it('seed/DID-auth mode: returns a new LearnCard carrying the header, without mutating the original', async () => {
157
+ const originalClient = getMockClient();
158
+ const actingClient = getMockClient();
159
+ vi.mocked(getBrainClient)
160
+ .mockResolvedValueOnce(originalClient as never)
161
+ .mockResolvedValueOnce(actingClient as never);
162
+
163
+ const learnCard = getMockLearnCard();
164
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL);
165
+
166
+ expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
167
+
168
+ await plugin.methods.actAs(learnCard, 'managed-child');
169
+
170
+ expect(getBrainClient).toHaveBeenNthCalledWith(
171
+ 2,
172
+ URL,
173
+ expect.any(Function),
174
+ undefined,
175
+ {
176
+ [ACT_AS_HEADER]: 'managed-child',
177
+ }
178
+ );
179
+
180
+ const [actingPlugin] = vi.mocked(learnCard.addPlugin).mock.calls[0] as [any];
181
+ expect(actingPlugin.methods.getLCNClient(learnCard)).toBe(actingClient);
182
+
183
+ // Original plugin's client reference is unaffected.
184
+ expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
185
+ });
186
+
187
+ it('preserves pre-existing extraHeaders and federation options when composing the acting plugin', async () => {
188
+ vi.mocked(getApiTokenClient)
189
+ .mockResolvedValueOnce(getMockClient() as never)
190
+ .mockResolvedValueOnce(getMockClient() as never);
191
+
192
+ const learnCard = getMockLearnCard();
193
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value', {
194
+ extraHeaders: { 'X-Custom': '1' },
195
+ trustedFederationHosts: ['trusted.example'],
196
+ });
197
+
198
+ await plugin.methods.actAs(learnCard, 'managed-child');
199
+
200
+ expect(getApiTokenClient).toHaveBeenNthCalledWith(
201
+ 2,
202
+ URL,
203
+ 'api-token-value',
204
+ undefined,
205
+ {
206
+ 'X-Custom': '1',
207
+ [ACT_AS_HEADER]: 'managed-child',
208
+ }
209
+ );
210
+ });
211
+
212
+ it.each(['FORBIDDEN', 'BAD_REQUEST'])(
213
+ 'rethrows a %s act-as denial, wrapping the original error as `cause`',
214
+ async code => {
215
+ const denial = Object.assign(new Error(`act-as ${code}`), { data: { code } });
216
+ const actingClient = {
217
+ profile: { getProfile: { query: vi.fn().mockRejectedValue(denial) } },
218
+ };
219
+ vi.mocked(getApiTokenClient)
220
+ .mockResolvedValueOnce(getMockClient() as never)
221
+ .mockResolvedValueOnce(actingClient as never);
222
+
223
+ const learnCard = getMockLearnCard();
224
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
225
+
226
+ const rejection: any = await plugin.methods
227
+ .actAs(learnCard, 'not-mine')
228
+ .catch(e => e);
229
+ expect(rejection).toBeInstanceOf(Error);
230
+ expect(rejection.cause).toBe(denial);
231
+ expect(learnCard.addPlugin).not.toHaveBeenCalled();
232
+ }
233
+ );
234
+
235
+ it('rethrows a NOT_FOUND act-as denial when the message matches resolveActAs', async () => {
236
+ const denial = Object.assign(new Error('Act-as target profile not found'), {
237
+ data: { code: 'NOT_FOUND' },
238
+ });
239
+ const actingClient = {
240
+ profile: { getProfile: { query: vi.fn().mockRejectedValue(denial) } },
241
+ };
242
+ vi.mocked(getApiTokenClient)
243
+ .mockResolvedValueOnce(getMockClient() as never)
244
+ .mockResolvedValueOnce(actingClient as never);
245
+
246
+ const learnCard = getMockLearnCard();
247
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
248
+
249
+ const rejection: any = await plugin.methods.actAs(learnCard, 'not-mine').catch(e => e);
250
+ expect(rejection).toBeInstanceOf(Error);
251
+ expect(rejection.cause).toBe(denial);
252
+ expect(learnCard.addPlugin).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it('does not treat a NOT_FOUND with an unrelated message as an act-as denial', async () => {
256
+ const unrelated = Object.assign(
257
+ new Error('Profile not found. Please make a profile!'),
258
+ { data: { code: 'NOT_FOUND' } }
259
+ );
260
+ const actingClient = {
261
+ profile: { getProfile: { query: vi.fn().mockRejectedValue(unrelated) } },
262
+ };
263
+ vi.mocked(getApiTokenClient)
264
+ .mockResolvedValueOnce(getMockClient() as never)
265
+ .mockResolvedValueOnce(actingClient as never);
266
+
267
+ const learnCard = getMockLearnCard();
268
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
269
+
270
+ await expect(plugin.methods.actAs(learnCard, 'managed-child')).resolves.toBeDefined();
271
+ expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
272
+ expect(learnCard.debug).toHaveBeenCalledWith(
273
+ 'LCN actAs: getProfile failed (non-fatal)',
274
+ unrelated
275
+ );
276
+ });
277
+
278
+ it('does not treat an INTERNAL_SERVER_ERROR as an act-as denial', async () => {
279
+ const serverError = Object.assign(new Error('boom'), {
280
+ data: { code: 'INTERNAL_SERVER_ERROR' },
281
+ });
282
+ const actingClient = {
283
+ profile: { getProfile: { query: vi.fn().mockRejectedValue(serverError) } },
284
+ };
285
+ vi.mocked(getApiTokenClient)
286
+ .mockResolvedValueOnce(getMockClient() as never)
287
+ .mockResolvedValueOnce(actingClient as never);
288
+
289
+ const learnCard = getMockLearnCard();
290
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
291
+
292
+ await expect(plugin.methods.actAs(learnCard, 'managed-child')).resolves.toBeDefined();
293
+ expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
294
+ expect(learnCard.debug).toHaveBeenCalledWith(
295
+ 'LCN actAs: getProfile failed (non-fatal)',
296
+ serverError
297
+ );
298
+ });
299
+
300
+ it('still returns an instance when the profile fetch fails for an unrelated reason', async () => {
301
+ const actingClient = {
302
+ profile: {
303
+ getProfile: {
304
+ query: vi.fn().mockRejectedValue(
305
+ Object.assign(new Error('no scope'), {
306
+ data: { code: 'UNAUTHORIZED' },
307
+ })
308
+ ),
309
+ },
310
+ },
311
+ };
312
+ vi.mocked(getApiTokenClient)
313
+ .mockResolvedValueOnce(getMockClient() as never)
314
+ .mockResolvedValueOnce(actingClient as never);
315
+
316
+ const learnCard = getMockLearnCard();
317
+ const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
318
+
319
+ await expect(plugin.methods.actAs(learnCard, 'managed-child')).resolves.toBeDefined();
320
+ expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
321
+ });
322
+ });
323
+ });
@@ -17,17 +17,13 @@ vi.mock('@learncard/core', () => ({ generateLearnCard: vi.fn() }));
17
17
  vi.mock('@learncard/didkit-plugin', () => ({ getDidKitPlugin: vi.fn() }));
18
18
  vi.mock('@learncard/didkey-plugin', () => ({ getDidKeyPlugin: vi.fn() }));
19
19
  vi.mock('@learncard/vc-plugin', () => ({ getVCPlugin: vi.fn() }));
20
- vi.mock('@learncard/helpers', () => ({
21
- isVC2Format: (credential: any) => {
22
- const contexts = credential?.['@context'];
23
- const list = Array.isArray(contexts) ? contexts : [contexts];
24
-
25
- return list.includes('https://www.w3.org/ns/credentials/v2');
26
- },
20
+ vi.mock('@learncard/helpers', async importOriginal => ({
21
+ ...(await importOriginal<typeof import('@learncard/helpers')>()),
27
22
  getCredentialStatusArray: () => [],
28
23
  resolveStorageReadResult: (value: any) => value,
29
24
  }));
30
- vi.mock('@learncard/types', () => ({
25
+ vi.mock('@learncard/types', async importOriginal => ({
26
+ ...(await importOriginal<object>()),
31
27
  VCValidator: {
32
28
  parse: (value: any) => {
33
29
  if (!value?.type) throw new Error('Invalid credential');
@@ -184,6 +180,124 @@ const getLearnCard = async (seed = 'a'.repeat(64)) => {
184
180
  };
185
181
  };
186
182
 
183
+ describe('inbox batch method', () => {
184
+ beforeEach(() => vi.clearAllMocks());
185
+
186
+ it('forwards configuration, keys and items to issueBatch and returns an acceptance receipt', async () => {
187
+ const response = {
188
+ batchId: 'batch-1',
189
+ status: 'QUEUED',
190
+ createdAt: '2026-09-17T00:00:00.000Z',
191
+ };
192
+ const mutate = vi.fn().mockResolvedValue(response);
193
+ const client = { ...getMockClient(), inbox: { issueBatch: { mutate } } };
194
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
195
+ const learnCard = getMockLearnCard();
196
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
197
+ const batch = {
198
+ configuration: { delivery: { suppress: true } },
199
+ items: [
200
+ {
201
+ recipient: { type: 'email' as const, value: 'student@example.test' },
202
+ templateUri: 'test-template',
203
+ idempotencyKey: 'test-key',
204
+ },
205
+ ],
206
+ };
207
+ await expect(plugin.methods?.sendCredentialsViaInbox(learnCard, batch)).resolves.toEqual(
208
+ response
209
+ );
210
+ expect(mutate).toHaveBeenCalledExactlyOnceWith(batch);
211
+ await expect(
212
+ plugin.methods?.sendCredentialBatchViaInbox(learnCard, batch)
213
+ ).resolves.toEqual(response);
214
+ expect(mutate).toHaveBeenLastCalledWith(batch);
215
+ expect(client.profile.getProfile.query).toHaveBeenCalled();
216
+ });
217
+
218
+ it('submits once, persists the receipt, and waits for completion through the SDK', async () => {
219
+ const receipt = { batchId: 'batch-1', status: 'QUEUED', createdAt: '2026-09-17' };
220
+ const response = { ...receipt, done: true, status: 'COMPLETED', items: [] };
221
+ const mutate = vi.fn().mockResolvedValue(receipt);
222
+ let saved = false;
223
+ const query = vi.fn(async () => {
224
+ expect(saved).toBe(true);
225
+ return response;
226
+ });
227
+ const client = {
228
+ ...getMockClient(),
229
+ inbox: { issueBatch: { mutate }, getBatch: { query } },
230
+ };
231
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
232
+ const learnCard = getMockLearnCard();
233
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
234
+ const batch = {
235
+ items: [
236
+ {
237
+ recipient: { type: 'email' as const, value: 'a@example.test' },
238
+ templateUri: 'template',
239
+ },
240
+ ],
241
+ };
242
+ await expect(
243
+ plugin.methods?.sendCredentialsViaInboxAndWait(learnCard, batch, {
244
+ onSubmitted: async received => {
245
+ expect(received).toEqual(receipt);
246
+ await Promise.resolve();
247
+ saved = true;
248
+ },
249
+ })
250
+ ).resolves.toEqual(response);
251
+ expect(mutate).toHaveBeenCalledTimes(1);
252
+ expect(query).toHaveBeenCalledWith(
253
+ { batchId: receipt.batchId },
254
+ { signal: expect.any(AbortSignal) }
255
+ );
256
+ await expect(
257
+ plugin.methods?.waitForInboxCredentialBatch(learnCard, receipt.batchId)
258
+ ).resolves.toEqual(response);
259
+ expect(mutate).toHaveBeenCalledTimes(1);
260
+
261
+ const controller = new AbortController();
262
+ controller.abort();
263
+ await expect(
264
+ plugin.methods?.sendCredentialsViaInboxAndWait(learnCard, batch, {
265
+ signal: controller.signal,
266
+ })
267
+ ).rejects.toMatchObject({ name: 'AbortError' });
268
+ expect(mutate).toHaveBeenCalledTimes(1);
269
+ });
270
+
271
+ it('polls the batch endpoint and preserves progress and item results', async () => {
272
+ const response = { batchId: 'batch-1', status: 'PROCESSING', items: [] };
273
+ const query = vi.fn().mockResolvedValue(response);
274
+ const client = { ...getMockClient(), inbox: { getBatch: { query } } };
275
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
276
+ const learnCard = getMockLearnCard();
277
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
278
+ await expect(
279
+ plugin.methods?.getInboxCredentialBatch(learnCard, 'batch-1')
280
+ ).resolves.toEqual(response);
281
+ expect(query).toHaveBeenCalledExactlyOnceWith({ batchId: 'batch-1' });
282
+ query.mockRejectedValueOnce(new Error('Not found'));
283
+ await expect(plugin.methods?.getInboxCredentialBatch(learnCard, 'missing')).rejects.toThrow(
284
+ 'Not found'
285
+ );
286
+ });
287
+
288
+ it('does not submit a batch when the issuer profile is missing', async () => {
289
+ const mutate = vi.fn();
290
+ const client = { ...getMockClient(null), inbox: { issueBatch: { mutate } } };
291
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
292
+ const learnCard = getMockLearnCard();
293
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
294
+ await expect(
295
+ plugin.methods?.sendCredentialsViaInbox(learnCard, { items: [] })
296
+ ).rejects.toThrow();
297
+ expect(mutate).not.toHaveBeenCalled();
298
+ });
299
+ });
300
+
187
301
  describe('connection prompt methods', () => {
188
302
  beforeEach(() => {
189
303
  vi.clearAllMocks();
@@ -434,12 +548,25 @@ describe('credential refresh methods', () => {
434
548
  const { learnCard, issuedCredentials } = getMockIssuingLearnCard();
435
549
  const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
436
550
 
437
- const uri = await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
551
+ const result = await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
438
552
  enableRefresh: true,
439
553
  skipNotification: true,
440
554
  });
441
555
 
442
- expect(uri).toEqual('managed-credential-uri');
556
+ // Refresh-enabled callers receive the issuance receipt alongside the
557
+ // credential URI (LC-2198 decision 4).
558
+ expect(result).toEqual({
559
+ credentialUri: 'managed-credential-uri',
560
+ refresh: {
561
+ refreshId: REFRESH_ID,
562
+ refreshService: ALLOCATION.refreshService,
563
+ credentialId: expect.stringMatching(
564
+ /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
565
+ ),
566
+ issuerDid: PROFILE.did,
567
+ holderDid: TARGET_DID,
568
+ },
569
+ });
443
570
 
444
571
  // A stable UUID credential ID is generated when the template has none
445
572
  expect(client.credentialRefresh.allocateCredentialRefresh.mutate).toHaveBeenCalledTimes(1);
@@ -449,6 +576,9 @@ describe('credential refresh methods', () => {
449
576
  expect(allocateInput.credentialId).toMatch(
450
577
  /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
451
578
  );
579
+ expect(
580
+ (result as unknown as { refresh: { credentialId: string } }).refresh.credentialId
581
+ ).toEqual(allocateInput.credentialId);
452
582
 
453
583
  // Allocation happens before signing so the service lands in the signed payload
454
584
  const allocateOrder =
@@ -483,6 +613,27 @@ describe('credential refresh methods', () => {
483
613
  expect(client.utilities.getDid.query).not.toHaveBeenCalled();
484
614
  });
485
615
 
616
+ it('sendBoost receipt preserves the exact credentialStatus descriptor from the signed VC', async () => {
617
+ const client = getMockClient();
618
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
619
+ const { learnCard } = getMockIssuingLearnCard();
620
+ const credentialStatus = {
621
+ id: 'https://network.example/status/3#94567',
622
+ type: 'BitstringStatusListEntry',
623
+ statusPurpose: 'revocation',
624
+ statusListIndex: '94567',
625
+ statusListCredential: 'https://network.example/status/3',
626
+ };
627
+ client.boost.allocateCredentialStatus.mutate.mockResolvedValue([credentialStatus]);
628
+ const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
629
+
630
+ const result = (await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
631
+ enableRefresh: true,
632
+ })) as unknown as { refresh: Record<string, unknown> };
633
+
634
+ expect(result.refresh.credentialStatus).toEqual(credentialStatus);
635
+ });
636
+
486
637
  it('sendBoost with enableRefresh reuses an existing credential ID', async () => {
487
638
  const client = getMockClient();
488
639
  vi.mocked(getBrainClient).mockResolvedValue(client as never);
@@ -0,0 +1,152 @@
1
+ /**
2
+ * LC-2198 Task 3 — compile-time verification of the `sendBoost` return-shape opt-in
3
+ * and the unified `send` refresh contract, exercised through the REAL plugin type
4
+ * system (`LearnCard<...>['invoke']` composition).
5
+ *
6
+ * The package Vitest config enables typechecking, so `bun run test` checks
7
+ * these assertions with the TypeScript compiler alongside the runtime tests.
8
+ */
9
+ import { describe, expectTypeOf, it } from 'vitest';
10
+ import type { LearnCard, Plugin } from '@learncard/core';
11
+ import type { ManagedCredentialRefreshReceipt } from '@learncard/types';
12
+
13
+ import type {
14
+ LearnCardNetworkPlugin,
15
+ SendBoostNetworkOptions,
16
+ SendBoostRefreshResult,
17
+ } from '../types';
18
+
19
+ /** Minimal host plugin so the network plugin is composed through the real machinery. */
20
+ declare const basePlugin: Plugin<'Minimal'>;
21
+
22
+ /**
23
+ * The composed wallet type — identical to how `initLearnCard` / `addPlugin` derive
24
+ * `learnCard.invoke` (`GetPluginMethods` → `UnionToIntersection` over the plugins'
25
+ * declared methods).
26
+ */
27
+ type NetworkLearnCard = LearnCard<[typeof basePlugin, LearnCardNetworkPlugin]>;
28
+ type NetworkInvoke = NetworkLearnCard['invoke'];
29
+
30
+ describe('learnCard.invoke.sendBoost return-shape opt-in', () => {
31
+ const sendBoost = {} as NetworkInvoke['sendBoost'];
32
+
33
+ it('keeps the legacy string result for omitted options', () => {
34
+ expectTypeOf(sendBoost('userb', 'did:web:example:boost:1')).toEqualTypeOf<
35
+ Promise<string>
36
+ >();
37
+ });
38
+
39
+ it('keeps the legacy string result for legacy boolean options', () => {
40
+ expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', true)).toEqualTypeOf<
41
+ Promise<string>
42
+ >();
43
+ expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', false)).toEqualTypeOf<
44
+ Promise<string>
45
+ >();
46
+ });
47
+
48
+ it('keeps the legacy string result when enableRefresh is absent or false', () => {
49
+ expectTypeOf(
50
+ sendBoost('userb', 'did:web:example:boost:1', {
51
+ encrypt: false,
52
+ skipNotification: true,
53
+ })
54
+ ).toEqualTypeOf<Promise<string>>();
55
+ expectTypeOf(
56
+ sendBoost('userb', 'did:web:example:boost:1', { enableRefresh: false })
57
+ ).toEqualTypeOf<Promise<string>>();
58
+ });
59
+
60
+ it('returns the refreshable issuance result for literal enableRefresh: true', () => {
61
+ expectTypeOf(
62
+ sendBoost('userb', 'did:web:example:boost:1', { enableRefresh: true })
63
+ ).toEqualTypeOf<Promise<SendBoostRefreshResult>>();
64
+ expectTypeOf(
65
+ sendBoost('userb', 'did:web:example:boost:1', {
66
+ enableRefresh: true,
67
+ skipNotification: true,
68
+ })
69
+ ).toEqualTypeOf<Promise<SendBoostRefreshResult>>();
70
+ });
71
+
72
+ it('preserves the literal opt-in with status purposes and nested template data', () => {
73
+ expectTypeOf(
74
+ sendBoost('userb', 'did:web:example:boost:1', {
75
+ enableRefresh: true,
76
+ statusPurposes: ['revocation'],
77
+ templateData: { grade: 'A' },
78
+ })
79
+ ).toEqualTypeOf<Promise<SendBoostRefreshResult>>();
80
+ });
81
+
82
+ it('degrades to the union for a dynamically typed enableRefresh boolean', () => {
83
+ const dynamic: boolean = Math.random() > 0.5;
84
+
85
+ expectTypeOf(
86
+ sendBoost('userb', 'did:web:example:boost:1', { enableRefresh: dynamic })
87
+ ).toEqualTypeOf<Promise<string | SendBoostRefreshResult>>();
88
+ });
89
+
90
+ it('degrades to the union when options are typed with an optional enableRefresh', () => {
91
+ const options: SendBoostNetworkOptions = { enableRefresh: true };
92
+
93
+ expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', options)).toEqualTypeOf<
94
+ Promise<string | SendBoostRefreshResult>
95
+ >();
96
+ });
97
+
98
+ it('degrades to the union for boolean-or-object option variables', () => {
99
+ const options = {} as boolean | SendBoostNetworkOptions;
100
+
101
+ expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', options)).toEqualTypeOf<
102
+ Promise<string | SendBoostRefreshResult>
103
+ >();
104
+ });
105
+
106
+ it('keeps the string result for object options that omit enableRefresh', () => {
107
+ expectTypeOf(
108
+ sendBoost('userb', 'did:web:example:boost:1', { encrypt: true, skipNotification: true })
109
+ ).toEqualTypeOf<Promise<string>>();
110
+ });
111
+
112
+ it('returns a metadata-only receipt carrying the publication inputs', () => {
113
+ expectTypeOf<
114
+ SendBoostRefreshResult['refresh']
115
+ >().toEqualTypeOf<ManagedCredentialRefreshReceipt>();
116
+
117
+ // Required publication inputs (identity fields are mandatory; the exact
118
+ // credentialStatus descriptor is optional because not every VC carries one).
119
+ expectTypeOf<
120
+ Pick<
121
+ SendBoostRefreshResult['refresh'],
122
+ 'refreshId' | 'refreshService' | 'credentialId' | 'issuerDid' | 'holderDid'
123
+ >
124
+ >().toBeObject();
125
+ });
126
+ });
127
+
128
+ describe('learnCard.invoke.send unified refresh contract', () => {
129
+ const send = {} as NetworkInvoke['send'];
130
+
131
+ it('accepts refresh: true on boost sends and may resolve with the receipt', async () => {
132
+ const result = await send({
133
+ type: 'boost',
134
+ recipient: 'userb',
135
+ templateUri: 'did:web:example:boost:1',
136
+ refresh: true,
137
+ });
138
+
139
+ expectTypeOf(result).toMatchTypeOf<{
140
+ uri: string;
141
+ credentialUri: string;
142
+ activityId: string;
143
+ }>();
144
+ expectTypeOf(result.refresh).toEqualTypeOf<ManagedCredentialRefreshReceipt | undefined>();
145
+ });
146
+
147
+ it('keeps the refresh flag optional on the unified send input', () => {
148
+ expectTypeOf<Parameters<NetworkInvoke['send']>[0]['refresh']>().toEqualTypeOf<
149
+ boolean | undefined
150
+ >();
151
+ });
152
+ });