@learncard/cli 3.5.0 → 3.6.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +230 -2
  3. package/dist/index.js +4337 -986
  4. package/examples/branded.network.yaml +20 -0
  5. package/examples/delegated-service-account.network.yaml +23 -0
  6. package/examples/minimal.network.yaml +7 -0
  7. package/examples/self-hosted-signing.network.yaml +10 -0
  8. package/examples/service-account.network.yaml +13 -0
  9. package/examples/state-districts.network.yaml +36 -0
  10. package/package.json +21 -17
  11. package/src/auth-grant.test.ts +54 -0
  12. package/src/auth-grant.ts +34 -0
  13. package/src/clr/validate.test.ts +65 -0
  14. package/src/clr/validate.ts +242 -0
  15. package/src/clr.ts +119 -0
  16. package/src/demo-inbox-refresh.test.ts +737 -0
  17. package/src/demo-inbox-refresh.ts +804 -0
  18. package/src/demo-refresh-command.test.ts +57 -0
  19. package/src/demo-refresh-command.ts +22 -0
  20. package/src/demo-refresh-ui.test.ts +66 -0
  21. package/src/demo-refresh-ui.ts +65 -0
  22. package/src/demo-refresh.test.ts +140 -0
  23. package/src/demo-refresh.ts +309 -0
  24. package/src/doctor/checks.test.ts +448 -0
  25. package/src/doctor/checks.ts +497 -0
  26. package/src/doctor.test.ts +67 -0
  27. package/src/doctor.ts +118 -0
  28. package/src/inbox.test.ts +257 -0
  29. package/src/inbox.ts +221 -0
  30. package/src/index.tsx +70 -8
  31. package/src/init.ts +1 -1
  32. package/src/open.ts +1 -1
  33. package/src/org/apply.test.ts +1108 -0
  34. package/src/org/apply.ts +924 -0
  35. package/src/org/branding.test.ts +60 -0
  36. package/src/org/diff.ts +14 -0
  37. package/src/org/load.ts +50 -0
  38. package/src/org/schema.test.ts +256 -0
  39. package/src/org/schema.ts +216 -0
  40. package/src/org.ts +124 -0
  41. package/src/project.test.ts +26 -1
  42. package/src/project.ts +105 -10
  43. package/src/promote.test.ts +142 -0
  44. package/src/promote.ts +202 -0
  45. package/src/refresh.test.ts +86 -0
  46. package/src/refresh.ts +93 -0
  47. package/src/send.test.ts +278 -2
  48. package/src/send.ts +152 -24
  49. package/src/setup-signing.ts +1 -1
  50. package/src/status.ts +2 -4
  51. package/src/whoami.test.ts +67 -0
  52. package/src/whoami.ts +129 -0
  53. package/tsconfig.json +1 -1
@@ -0,0 +1,86 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { formatRefreshVersion, mapRefreshHistoryError, runRefreshHistory } from './refresh';
3
+ import { connect, loadProject } from './project';
4
+
5
+ const { getCredentialRefreshHistory } = vi.hoisted(() => ({
6
+ getCredentialRefreshHistory: vi.fn(),
7
+ }));
8
+ vi.mock('./project', () => ({
9
+ loadProject: vi.fn().mockResolvedValue({ env: {} }),
10
+ resolveServices: vi.fn().mockReturnValue({ network: 'https://network.learncard.com/trpc' }),
11
+ connect: vi.fn().mockResolvedValue({ invoke: { getCredentialRefreshHistory } }),
12
+ }));
13
+ vi.mock('./out', () => ({ out: { log: vi.fn(), set: vi.fn() } }));
14
+
15
+ describe('refresh history limit', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ getCredentialRefreshHistory.mockResolvedValue({ records: [], hasMore: false });
19
+ });
20
+
21
+ it.each(['abc', '0', '-1', '1.5', '', 'NaN', 'Infinity'])(
22
+ 'rejects %j before connecting',
23
+ async limit => {
24
+ await expect(runRefreshHistory('refresh-1', { limit })).rejects.toThrow(
25
+ 'Invalid --limit'
26
+ );
27
+ expect(loadProject).not.toHaveBeenCalled();
28
+ expect(connect).not.toHaveBeenCalled();
29
+ expect(getCredentialRefreshHistory).not.toHaveBeenCalled();
30
+ }
31
+ );
32
+
33
+ it.each(['1', '10'])('passes a validated limit of %s', async limit => {
34
+ await runRefreshHistory('refresh-1', { limit });
35
+ expect(getCredentialRefreshHistory).toHaveBeenCalledWith({
36
+ refreshId: 'refresh-1',
37
+ limit: Number(limit),
38
+ });
39
+ });
40
+
41
+ it('leaves the server default unchanged when omitted', async () => {
42
+ await runRefreshHistory('refresh-1', {});
43
+ expect(getCredentialRefreshHistory).toHaveBeenCalledWith({ refreshId: 'refresh-1' });
44
+ });
45
+ });
46
+
47
+ describe('formatRefreshVersion', () => {
48
+ it('formats version, publishedAt, and summary', () => {
49
+ const line = formatRefreshVersion({
50
+ version: 2,
51
+ publishedAt: '2026-01-01T00:00:00Z',
52
+ updateSummary: 'Finalized grades',
53
+ });
54
+ expect(line).toContain('2');
55
+ expect(line).toContain('2026-01-01T00:00:00Z');
56
+ expect(line).toContain('Finalized grades');
57
+ });
58
+
59
+ it('omits the summary column when absent', () => {
60
+ const line = formatRefreshVersion({ version: 1, publishedAt: '2026-01-01T00:00:00Z' });
61
+ expect(line.trim().endsWith('2026-01-01T00:00:00Z')).toBe(true);
62
+ });
63
+ });
64
+
65
+ describe('mapRefreshHistoryError', () => {
66
+ it('rewrites the "not available" failure into an actionable message', () => {
67
+ const mapped = mapRefreshHistoryError(
68
+ new Error('Credential refresh is not available'),
69
+ 'https://network.learncard.com/trpc'
70
+ );
71
+ expect(mapped.message).toContain("isn't enabled on this network");
72
+ expect(mapped.message).toContain('https://network.learncard.com/trpc');
73
+ expect(mapped.message).toContain('--network staging');
74
+ });
75
+
76
+ it('passes other errors through unchanged', () => {
77
+ const original = new Error('Refresh not found');
78
+ expect(mapRefreshHistoryError(original, 'x')).toBe(original);
79
+ });
80
+
81
+ it('wraps non-Error throwables', () => {
82
+ const mapped = mapRefreshHistoryError('boom', 'x');
83
+ expect(mapped).toBeInstanceOf(Error);
84
+ expect(mapped.message).toBe('boom');
85
+ });
86
+ });
package/src/refresh.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Command } from 'commander';
2
+ import type { CredentialRefreshVersionMetadata } from '@learncard/types';
3
+
4
+ import { connect, loadProject, resolveServices, type ProjectOptions } from './project';
5
+ import { out } from './out';
6
+ import { parseLimit } from './inbox';
7
+
8
+ export type RefreshHistoryOptions = ProjectOptions & { limit?: string };
9
+
10
+ /** One human-readable line per published version: `version publishedAt summary`. */
11
+ export const formatRefreshVersion = (record: CredentialRefreshVersionMetadata): string =>
12
+ `${String(record.version).padEnd(7)} ${record.publishedAt} ${record.updateSummary ?? ''}`.trimEnd();
13
+
14
+ const NOT_AVAILABLE_PATTERN = /credential refresh is not available/i;
15
+
16
+ /** Rewrites the network's generic "not available" failure into an actionable message. */
17
+ export const mapRefreshHistoryError = (error: unknown, network: string): Error => {
18
+ const message = error instanceof Error ? error.message : String(error);
19
+ if (NOT_AVAILABLE_PATTERN.test(message)) {
20
+ return new Error(
21
+ `Credential refresh isn't enabled on this network (${network}). Use --network staging or ask LearnCard to enable it.`
22
+ );
23
+ }
24
+ return error instanceof Error ? error : new Error(message);
25
+ };
26
+
27
+ export const runRefreshHistory = async (
28
+ refreshId: string,
29
+ options: RefreshHistoryOptions
30
+ ): Promise<void> => {
31
+ const limit = options.limit === undefined ? undefined : parseLimit(options.limit);
32
+ const project = await loadProject(process.cwd());
33
+ const services = resolveServices(project.env, options.network);
34
+ const learnCard = await connect(project, options);
35
+
36
+ let records: CredentialRefreshVersionMetadata[];
37
+ let hasMore: boolean;
38
+ try {
39
+ const result = await learnCard.invoke.getCredentialRefreshHistory({
40
+ refreshId,
41
+ ...(limit !== undefined ? { limit } : {}),
42
+ });
43
+ records = result.records;
44
+ hasMore = result.hasMore;
45
+ } catch (error) {
46
+ throw mapRefreshHistoryError(error, services.network);
47
+ }
48
+
49
+ if (!records.length) {
50
+ out.log(`No published versions found for ${refreshId}.`);
51
+ out.set({ refreshId, versions: [], hasMore });
52
+ return;
53
+ }
54
+
55
+ out.log('version publishedAt summary');
56
+ for (const record of records) out.log(formatRefreshVersion(record));
57
+ if (hasMore) out.log('…more. Raise --limit to see additional versions.');
58
+
59
+ out.set({
60
+ refreshId,
61
+ versions: records.map(record => ({
62
+ version: record.version,
63
+ publishedAt: record.publishedAt,
64
+ updateSummary: record.updateSummary,
65
+ })),
66
+ hasMore,
67
+ });
68
+ };
69
+
70
+ export const registerRefreshCommand = (
71
+ program: Command,
72
+ run: (
73
+ command: string,
74
+ options: { json?: boolean },
75
+ action: (didkit: Promise<Buffer>) => Promise<void>,
76
+ wrap?: boolean
77
+ ) => Promise<void>
78
+ ): void => {
79
+ const refresh = program
80
+ .command('refresh')
81
+ .description('Managed credential refresh: audit published versions of a transcript.');
82
+ refresh
83
+ .command('history <refreshId>')
84
+ .description('List the published version history for a managed refresh.')
85
+ .option('--limit <n>', 'how many versions to list')
86
+ .option('--network <url>', 'network tRPC URL or staging (default: production)')
87
+ .option('--json', 'print a single JSON result on stdout')
88
+ .action((refreshId, options) =>
89
+ run('refresh history', options, async didkit => {
90
+ await runRefreshHistory(refreshId, { ...options, didkit });
91
+ })
92
+ );
93
+ };
package/src/send.test.ts CHANGED
@@ -1,9 +1,39 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import fs from 'fs/promises';
4
+ import * as project from './project';
5
+ import { out } from './out';
3
6
 
4
- import { describe, expect, it } from 'vitest';
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
8
 
6
- import { SEND_MJS, parseEnv, upsertEnv, toProfileId } from './send';
9
+ import {
10
+ SEND_MJS,
11
+ parseEnv,
12
+ upsertEnv,
13
+ toProfileId,
14
+ isPlaceholderRecipient,
15
+ invalidRecipientReason,
16
+ resolveRecipient,
17
+ RECIPIENT_PROMPT,
18
+ personalizeSendMjs,
19
+ withManagedIssuer,
20
+ runSend,
21
+ } from './send';
22
+
23
+ const fakePrompts = (answers: string[], interactive = true) => {
24
+ const asked: string[] = [];
25
+ return {
26
+ asked,
27
+ prompts: {
28
+ interactive,
29
+ ask: async (question: string, fallback: string) => {
30
+ asked.push(question);
31
+ return answers.shift() ?? fallback;
32
+ },
33
+ close: () => {},
34
+ },
35
+ };
36
+ };
7
37
 
8
38
  describe('send command', () => {
9
39
  it('ships the exact send.mjs the Quickstart docs show', () => {
@@ -30,7 +60,141 @@ describe('send command', () => {
30
60
  });
31
61
  });
32
62
 
63
+ describe('recipient validation', () => {
64
+ it('flags RFC 2606 / 6761 placeholder domains but not real ones', () => {
65
+ expect(isPlaceholderRecipient('you@example.com')).toBe(true);
66
+ expect(isPlaceholderRecipient('YOU@EXAMPLE.ORG')).toBe(true);
67
+ expect(isPlaceholderRecipient('a@sub.example.net')).toBe(true);
68
+ expect(isPlaceholderRecipient('a@foo.test')).toBe(true);
69
+ expect(isPlaceholderRecipient('a@host.invalid')).toBe(true);
70
+ expect(isPlaceholderRecipient('a@localhost')).toBe(true);
71
+ expect(isPlaceholderRecipient('a@test')).toBe(true);
72
+ expect(isPlaceholderRecipient('a@contest.com')).toBe(false);
73
+ expect(isPlaceholderRecipient('a@latest.io')).toBe(false);
74
+ expect(isPlaceholderRecipient('a@test.com')).toBe(false);
75
+ expect(isPlaceholderRecipient('a@myexample.com')).toBe(false);
76
+ expect(isPlaceholderRecipient('a@example.co.uk')).toBe(false);
77
+ expect(isPlaceholderRecipient('+15555550100')).toBe(false);
78
+ });
79
+
80
+ it('explains why a recipient is unusable', () => {
81
+ expect(invalidRecipientReason('you@example.com')).toMatch(/placeholder/);
82
+ expect(invalidRecipientReason('Not A Recipient!')).toMatch(/not an email/);
83
+ expect(invalidRecipientReason('')).toBe(
84
+ 'Enter an email, phone number, profile ID, or DID.'
85
+ );
86
+ expect(invalidRecipientReason('me@acme.org')).toBeUndefined();
87
+ expect(invalidRecipientReason('+15555550100')).toBeUndefined();
88
+ expect(invalidRecipientReason('cs-exampleville')).toBeUndefined();
89
+ expect(invalidRecipientReason('did:web:network.learncard.com:users:exde')).toBeUndefined();
90
+ });
91
+
92
+ it('accepts a valid recipient without prompting', async () => {
93
+ const { prompts, asked } = fakePrompts([]);
94
+ expect(await resolveRecipient(' me@acme.org ', prompts)).toBe('me@acme.org');
95
+ expect(asked).toEqual([]);
96
+ });
97
+
98
+ it('prompts when the recipient is omitted', async () => {
99
+ const { prompts, asked } = fakePrompts(['me@acme.org']);
100
+ expect(await resolveRecipient(undefined, prompts)).toBe('me@acme.org');
101
+ expect(asked).toEqual([RECIPIENT_PROMPT]);
102
+ });
103
+
104
+ it('re-prompts interactively until a placeholder is replaced', async () => {
105
+ const { prompts, asked } = fakePrompts(['not a recipient', 'me@acme.org']);
106
+ expect(await resolveRecipient('you@example.com', prompts)).toBe('me@acme.org');
107
+ expect(asked).toEqual([RECIPIENT_PROMPT, RECIPIENT_PROMPT]);
108
+ });
109
+
110
+ it('re-prompts with a hint when Enter is pressed on an empty line', async () => {
111
+ const { prompts, asked } = fakePrompts(['', ' ', 'me@acme.org']);
112
+ expect(await resolveRecipient(undefined, prompts)).toBe('me@acme.org');
113
+ expect(asked).toHaveLength(3);
114
+ });
115
+
116
+ it('fails fast on a missing recipient when non-interactive', async () => {
117
+ const { prompts, asked } = fakePrompts(['me@acme.org'], false);
118
+ await expect(resolveRecipient(undefined, prompts)).rejects.toThrow(/recipient is required/);
119
+ expect(asked).toEqual([]);
120
+ });
121
+
122
+ it('fails fast on a placeholder when non-interactive', async () => {
123
+ const { prompts, asked } = fakePrompts(['me@acme.org'], false);
124
+ await expect(resolveRecipient('you@example.com', prompts)).rejects.toThrow(
125
+ /placeholder address/
126
+ );
127
+ expect(asked).toEqual([]);
128
+ });
129
+ });
130
+
33
131
  describe('personalizeSendMjs', () => {
132
+ const managedDid = 'did:web:network.learncard.com:users:managed';
133
+ const script = withManagedIssuer(
134
+ personalizeSendMjs('Parent', {
135
+ name: 'Managed badge',
136
+ description: 'Sent as managed.',
137
+ }),
138
+ managedDid
139
+ );
140
+ const execute = (env: Record<string, string>, initLearnCard: ReturnType<typeof vi.fn>) => {
141
+ const body = script
142
+ .split('\n')
143
+ .filter(line => !line.startsWith('import '))
144
+ .join('\n');
145
+ return new Function(
146
+ 'initLearnCard',
147
+ 'process',
148
+ 'randomUUID',
149
+ 'console',
150
+ `return (async () => {${body}})();`
151
+ )(initLearnCard, { env, argv: ['node', 'send.mjs', 'me@acme.org'] }, () => 'uuid', {
152
+ log: vi.fn(),
153
+ });
154
+ };
155
+
156
+ it('re-runs the managed script with didWeb and issues as that profile', async () => {
157
+ const card = {
158
+ id: { did: () => managedDid },
159
+ invoke: {
160
+ getProfile: vi.fn().mockResolvedValue({ profileId: 'managed' }),
161
+ issueCredential: vi.fn().mockResolvedValue({ proof: {} }),
162
+ send: vi.fn().mockResolvedValue({ uri: 'boost-uri' }),
163
+ },
164
+ };
165
+ const initLearnCard = vi.fn().mockResolvedValue(card);
166
+ await execute({ SECURE_SEED: 'parent-seed', MANAGED_DID: managedDid }, initLearnCard);
167
+ expect(initLearnCard).toHaveBeenCalledWith({
168
+ seed: 'parent-seed',
169
+ network: true,
170
+ didWeb: managedDid,
171
+ });
172
+ expect(card.invoke.issueCredential).toHaveBeenCalledWith(
173
+ expect.objectContaining({ issuer: managedDid })
174
+ );
175
+ expect(script).not.toContain('createProfile');
176
+ });
177
+
178
+ it.each([undefined, 'did:web:other:users:another'])(
179
+ 'fails closed for MANAGED_DID=%s',
180
+ async did => {
181
+ const initLearnCard = vi.fn();
182
+ await expect(execute(did ? { MANAGED_DID: did } : {}, initLearnCard)).rejects.toThrow(
183
+ 'MANAGED_DID is missing or changed'
184
+ );
185
+ expect(initLearnCard).not.toHaveBeenCalled();
186
+ }
187
+ );
188
+
189
+ it('does not create a parent profile when the managed profile is missing', async () => {
190
+ const initLearnCard = vi
191
+ .fn()
192
+ .mockResolvedValue({ invoke: { getProfile: vi.fn().mockResolvedValue(null) } });
193
+ await expect(execute({ MANAGED_DID: managedDid }, initLearnCard)).rejects.toThrow(
194
+ 'managed issuer profile could not be found'
195
+ );
196
+ });
197
+
34
198
  it('substitutes the display name, badge name, and description', async () => {
35
199
  const { personalizeSendMjs } = await import('./send');
36
200
  const out = personalizeSendMjs('Acme Learning', {
@@ -43,3 +207,115 @@ describe('personalizeSendMjs', () => {
43
207
  expect(out).toContain('description: "You joined."');
44
208
  });
45
209
  });
210
+
211
+ describe('classifyRecipient', () => {
212
+ it('detects each recipient kind the network routes on', async () => {
213
+ const { classifyRecipient } = await import('./send');
214
+ expect(classifyRecipient('you@example.com')).toBe('email');
215
+ expect(classifyRecipient('+15555550123')).toBe('phone');
216
+ expect(classifyRecipient('did:web:network.learncard.com:users:exde')).toBe('did');
217
+ expect(classifyRecipient('cs-exampleville')).toBe('profileId');
218
+ });
219
+
220
+ it('rejects anything else with an example', async () => {
221
+ const { classifyRecipient } = await import('./send');
222
+ expect(() => classifyRecipient('Not A Recipient!')).toThrow(/profile ID, or DID/);
223
+ });
224
+ });
225
+
226
+ describe('send --as script generation', () => {
227
+ afterEach(() => vi.restoreAllMocks());
228
+
229
+ it.each([false, true])(
230
+ 'preserves the managed issuer with existing script=%s',
231
+ async existing => {
232
+ const managedDid = 'did:web:staging.network.learncard.com:users:managed';
233
+ const loaded = {
234
+ env: { SECURE_SEED: 'seed', PROFILE_ID: 'parent' },
235
+ envPath: '/unused/.env',
236
+ existing: '',
237
+ };
238
+ vi.spyOn(project, 'loadProject').mockResolvedValue(loaded);
239
+ vi.spyOn(project, 'createPrompts').mockReturnValue(fakePrompts([], false).prompts);
240
+ vi.spyOn(project, 'ensureIdentity').mockResolvedValue({
241
+ seed: 'seed',
242
+ profileId: 'parent',
243
+ displayName: 'Parent',
244
+ });
245
+ const card = {
246
+ id: { did: () => managedDid },
247
+ invoke: {
248
+ getProfile: vi
249
+ .fn()
250
+ .mockResolvedValue({ profileId: 'managed', displayName: 'Managed' }),
251
+ issueCredential: vi.fn().mockResolvedValue({}),
252
+ send: vi.fn().mockResolvedValue({ uri: 'boost-uri', activityId: 'activity-1' }),
253
+ },
254
+ };
255
+ vi.spyOn(project, 'connectAsManaged').mockResolvedValue(
256
+ card as unknown as Awaited<ReturnType<typeof project.connectAsManaged>>
257
+ );
258
+ const save = vi.spyOn(project, 'saveProject').mockResolvedValue();
259
+ const stat = vi.spyOn(fs, 'stat');
260
+ if (existing) stat.mockResolvedValue({} as Awaited<ReturnType<typeof fs.stat>>);
261
+ else stat.mockRejectedValue(new Error('ENOENT'));
262
+ const write = vi.spyOn(fs, 'writeFile').mockResolvedValue();
263
+ const log = vi.spyOn(out, 'log').mockImplementation(() => {});
264
+ const set = vi.spyOn(out, 'set').mockImplementation(patch => patch);
265
+
266
+ await runSend('me@acme.org', { as: 'managed', yes: true, network: 'staging' });
267
+
268
+ if (existing) {
269
+ expect(save).not.toHaveBeenCalled();
270
+ expect(write).not.toHaveBeenCalled();
271
+ expect(log).toHaveBeenCalledWith(
272
+ expect.stringContaining('may use a different issuer')
273
+ );
274
+ expect(set).toHaveBeenCalledWith(expect.objectContaining({ files: [] }));
275
+ } else {
276
+ expect(save).toHaveBeenCalledWith(loaded, { MANAGED_DID: managedDid });
277
+ expect(write).toHaveBeenCalledWith(
278
+ expect.stringContaining('send.mjs'),
279
+ expect.stringContaining('didWeb: process.env.MANAGED_DID')
280
+ );
281
+ expect(save.mock.invocationCallOrder[0]).toBeLessThan(
282
+ write.mock.invocationCallOrder[0]!
283
+ );
284
+ expect(set).toHaveBeenCalledWith(
285
+ expect.objectContaining({ files: ['./send.mjs'] })
286
+ );
287
+ const generated = String(write.mock.calls[0]?.[1]);
288
+ const body = generated
289
+ .split('\n')
290
+ .filter(line => !line.startsWith('import '))
291
+ .join('\n');
292
+ const initLearnCard = vi.fn().mockResolvedValue(card);
293
+ card.invoke.issueCredential.mockClear();
294
+ await new Function(
295
+ 'initLearnCard',
296
+ 'process',
297
+ 'randomUUID',
298
+ 'console',
299
+ `return (async () => {${body}})();`
300
+ )(
301
+ initLearnCard,
302
+ {
303
+ env: { SECURE_SEED: 'seed', MANAGED_DID: managedDid },
304
+ argv: ['node', 'send.mjs', 'me@acme.org'],
305
+ },
306
+ () => 'uuid',
307
+ { log: vi.fn() }
308
+ );
309
+ expect(initLearnCard).toHaveBeenCalledWith({
310
+ seed: 'seed',
311
+ network: project.STAGING_NETWORK,
312
+ cloud: { url: 'https://staging.cloud.learncard.com/trpc' },
313
+ didWeb: managedDid,
314
+ });
315
+ expect(card.invoke.issueCredential).toHaveBeenCalledWith(
316
+ expect.objectContaining({ issuer: managedDid })
317
+ );
318
+ }
319
+ }
320
+ );
321
+ });