@learncard/credential-library 2.0.4 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundles/index.d.ts +5 -0
- package/dist/bundles/index.d.ts.map +1 -0
- package/dist/bundles/student.d.ts +3 -0
- package/dist/bundles/student.d.ts.map +1 -0
- package/dist/bundles/types.d.ts +24 -0
- package/dist/bundles/types.d.ts.map +1 -0
- package/dist/credential-library.cjs.development.js +10817 -682
- package/dist/credential-library.cjs.development.js.map +4 -4
- package/dist/credential-library.cjs.production.min.js +11 -1
- package/dist/credential-library.cjs.production.min.js.map +4 -4
- package/dist/credential-library.esm.js +10813 -676
- package/dist/credential-library.esm.js.map +4 -4
- package/dist/fixtures/clr/achievement-id-associations.d.ts +3 -0
- package/dist/fixtures/clr/achievement-id-associations.d.ts.map +1 -0
- package/dist/fixtures/clr/employment-record.d.ts +8 -0
- package/dist/fixtures/clr/employment-record.d.ts.map +1 -0
- package/dist/fixtures/clr/licensing-regulatory-record.d.ts +8 -0
- package/dist/fixtures/clr/licensing-regulatory-record.d.ts.map +1 -0
- package/dist/fixtures/clr/military-training-record.d.ts +8 -0
- package/dist/fixtures/clr/military-training-record.d.ts.map +1 -0
- package/dist/fixtures/clr/mixed-career-record.d.ts +8 -0
- package/dist/fixtures/clr/mixed-career-record.d.ts.map +1 -0
- package/dist/fixtures/clr/professional-organization-record.d.ts +8 -0
- package/dist/fixtures/clr/professional-organization-record.d.ts.map +1 -0
- package/dist/fixtures/clr/student-official-academic-transcript.d.ts +3 -0
- package/dist/fixtures/clr/student-official-academic-transcript.d.ts.map +1 -0
- package/dist/fixtures/clr/training-provider-record.d.ts +8 -0
- package/dist/fixtures/clr/training-provider-record.d.ts.map +1 -0
- package/dist/fixtures/index.d.ts +11 -1
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/obv3/student-additional-credentials.d.ts +3 -0
- package/dist/fixtures/obv3/student-additional-credentials.d.ts.map +1 -0
- package/dist/fixtures/obv3/student-sample-badges.d.ts +586 -0
- package/dist/fixtures/obv3/student-sample-badges.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/prepare.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/bundles.test.ts +234 -0
- package/src/__tests__/clr-non-academic.test.ts +220 -0
- package/src/__tests__/registry.test.ts +31 -0
- package/src/bundles/index.ts +19 -0
- package/src/bundles/student.ts +251 -0
- package/src/bundles/types.ts +25 -0
- package/src/fixtures/clr/README.md +142 -0
- package/src/fixtures/clr/achievement-id-associations.ts +342 -0
- package/src/fixtures/clr/employment-record.ts +487 -0
- package/src/fixtures/clr/licensing-regulatory-record.ts +388 -0
- package/src/fixtures/clr/military-training-record.ts +468 -0
- package/src/fixtures/clr/mixed-career-record.ts +814 -0
- package/src/fixtures/clr/professional-organization-record.ts +403 -0
- package/src/fixtures/clr/student-official-academic-transcript.ts +3202 -0
- package/src/fixtures/clr/training-provider-record.ts +510 -0
- package/src/fixtures/index.ts +41 -0
- package/src/fixtures/obv3/student-additional-credentials.ts +3912 -0
- package/src/fixtures/obv3/student-sample-badges.ts +424 -0
- package/src/index.ts +2 -0
- package/src/prepare.ts +55 -11
- package/dist/__tests__/docs-json.test.d.ts +0 -2
- package/dist/__tests__/docs-json.test.d.ts.map +0 -1
- package/dist/__tests__/issuance.test.d.ts +0 -2
- package/dist/__tests__/issuance.test.d.ts.map +0 -1
- package/dist/__tests__/registry.test.d.ts +0 -2
- package/dist/__tests__/registry.test.d.ts.map +0 -1
- package/dist/__tests__/sd-jwt-vc.test.d.ts +0 -2
- package/dist/__tests__/sd-jwt-vc.test.d.ts.map +0 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { KnownAchievementTypeValidator } from '@learncard/types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { getBundle, getFixture, isCredentialFixture, prepareFixture } from '../index';
|
|
5
|
+
|
|
6
|
+
const STANDARD_CONTEXTS = new Set([
|
|
7
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
8
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
9
|
+
'https://purl.imsglobal.org/spec/clr/v2p0/context.json',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const PROPRIETARY_FIELDS = new Set([
|
|
13
|
+
'address',
|
|
14
|
+
'attachments',
|
|
15
|
+
'boostId',
|
|
16
|
+
'boostID',
|
|
17
|
+
'display',
|
|
18
|
+
'groupID',
|
|
19
|
+
'skills',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const collectContexts = (value: unknown): string[] => {
|
|
23
|
+
if (Array.isArray(value)) return value.flatMap(collectContexts);
|
|
24
|
+
if (!value || typeof value !== 'object') return [];
|
|
25
|
+
|
|
26
|
+
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
|
27
|
+
key === '@context' && Array.isArray(nested)
|
|
28
|
+
? nested.filter((context): context is string => typeof context === 'string')
|
|
29
|
+
: collectContexts(nested)
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const collectPropertyValues = (value: unknown, property: string): unknown[] => {
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
return value.flatMap(nested => collectPropertyValues(nested, property));
|
|
36
|
+
}
|
|
37
|
+
if (!value || typeof value !== 'object') return [];
|
|
38
|
+
|
|
39
|
+
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) => [
|
|
40
|
+
...(key === property ? [nested] : []),
|
|
41
|
+
...collectPropertyValues(nested, property),
|
|
42
|
+
]);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const collectProprietaryFields = (value: unknown): string[] => {
|
|
46
|
+
if (Array.isArray(value)) return value.flatMap(collectProprietaryFields);
|
|
47
|
+
if (!value || typeof value !== 'object') return [];
|
|
48
|
+
|
|
49
|
+
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) => [
|
|
50
|
+
...(PROPRIETARY_FIELDS.has(key) ? [key] : []),
|
|
51
|
+
...collectProprietaryFields(nested),
|
|
52
|
+
]);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const collectSubjectIds = (credential: unknown): unknown[] =>
|
|
56
|
+
collectPropertyValues(credential, 'credentialSubject').flatMap(subject => {
|
|
57
|
+
const subjects = Array.isArray(subject) ? subject : [subject];
|
|
58
|
+
|
|
59
|
+
return subjects.map(currentSubject =>
|
|
60
|
+
currentSubject && typeof currentSubject === 'object'
|
|
61
|
+
? (currentSubject as Record<string, unknown>).id
|
|
62
|
+
: undefined
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('student credential bundle', () => {
|
|
67
|
+
it('prepares every manifest entry as a standards-pure credential', () => {
|
|
68
|
+
const bundle = getBundle('student');
|
|
69
|
+
const fixtureIds = bundle.entries.map(entry => entry.fixtureId);
|
|
70
|
+
let backgroundImageHintCount = 0;
|
|
71
|
+
|
|
72
|
+
expect(bundle.entries).toHaveLength(33);
|
|
73
|
+
expect(new Set(fixtureIds).size).toBe(fixtureIds.length);
|
|
74
|
+
expect(bundle.entries.some(entry => entry.fixtureId.startsWith('clr/'))).toBe(true);
|
|
75
|
+
expect(new Set(bundle.entries.map(entry => entry.issuer.profileId)).size).toBeGreaterThan(
|
|
76
|
+
1
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
for (const entry of bundle.entries) {
|
|
80
|
+
expect(entry.issuer.profileId).toMatch(/^sample-/);
|
|
81
|
+
|
|
82
|
+
const fixture = getFixture(entry.fixtureId);
|
|
83
|
+
expect(isCredentialFixture(fixture)).toBe(true);
|
|
84
|
+
if (!isCredentialFixture(fixture)) {
|
|
85
|
+
throw new Error(`${entry.fixtureId} is not a credential fixture`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sourceCredential = fixture.credential as Record<string, unknown>;
|
|
89
|
+
expect(sourceCredential).not.toHaveProperty('id');
|
|
90
|
+
expect(sourceCredential).not.toHaveProperty('proof');
|
|
91
|
+
expect(sourceCredential).not.toHaveProperty('credentialStatus');
|
|
92
|
+
expect(sourceCredential).not.toHaveProperty('image');
|
|
93
|
+
expect(collectProprietaryFields(sourceCredential)).toEqual([]);
|
|
94
|
+
|
|
95
|
+
const serializedCredential = JSON.stringify(sourceCredential);
|
|
96
|
+
expect(serializedCredential).not.toContain('ctx.learncard.com');
|
|
97
|
+
expect(serializedCredential).not.toContain('BoostCredential');
|
|
98
|
+
expect(serializedCredential).not.toContain('"BoostID"');
|
|
99
|
+
|
|
100
|
+
const contexts = collectContexts(sourceCredential);
|
|
101
|
+
expect(contexts.length).toBeGreaterThan(0);
|
|
102
|
+
expect(contexts.every(context => STANDARD_CONTEXTS.has(context))).toBe(true);
|
|
103
|
+
|
|
104
|
+
const achievementTypes = collectPropertyValues(sourceCredential, 'achievementType');
|
|
105
|
+
expect(achievementTypes.length).toBeGreaterThan(0);
|
|
106
|
+
achievementTypes.forEach(achievementType => {
|
|
107
|
+
expect(KnownAchievementTypeValidator.safeParse(achievementType).success).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
backgroundImageHintCount += collectPropertyValues(sourceCredential, 'tag').filter(
|
|
111
|
+
tags =>
|
|
112
|
+
Array.isArray(tags) &&
|
|
113
|
+
tags.some(
|
|
114
|
+
tag =>
|
|
115
|
+
typeof tag === 'string' &&
|
|
116
|
+
tag.startsWith('lc:bgImage:https://cdn.filestackcontent.com/')
|
|
117
|
+
)
|
|
118
|
+
).length;
|
|
119
|
+
|
|
120
|
+
const issuerDid = `did:web:demo.example:users:${entry.issuer.profileId}`;
|
|
121
|
+
const credential = prepareFixture(fixture, {
|
|
122
|
+
issuerDid,
|
|
123
|
+
subjectDid: 'did:example:prepared-student',
|
|
124
|
+
freshIds: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(fixture.validity).toBe('valid');
|
|
128
|
+
expect(fixture.validator?.safeParse(credential).success ?? true).toBe(true);
|
|
129
|
+
expect(credential.name).toBe(entry.name ?? sourceCredential.name);
|
|
130
|
+
expect(credential.issuer).toMatchObject({ id: issuerDid });
|
|
131
|
+
expect(new Set(collectSubjectIds(credential))).toEqual(
|
|
132
|
+
new Set(['did:example:prepared-student'])
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
expect(backgroundImageHintCount).toBe(26);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('preserves the supplied Capstone Project badge presentation as OBv3 tags', () => {
|
|
140
|
+
const fixture = getFixture('obv3/student-capstone-project');
|
|
141
|
+
if (!isCredentialFixture(fixture)) throw new Error('Capstone Project fixture is missing');
|
|
142
|
+
|
|
143
|
+
const subject = fixture.credential.credentialSubject as Record<string, unknown>;
|
|
144
|
+
const achievement = subject.achievement as Record<string, unknown>;
|
|
145
|
+
|
|
146
|
+
expect(achievement.achievementType).toBe('Assignment');
|
|
147
|
+
expect(achievement.image).toBe('https://cdn.filestackcontent.com/W6nqpwFySGO5EwFi4dAk');
|
|
148
|
+
expect(achievement.tag).toEqual(
|
|
149
|
+
expect.arrayContaining([
|
|
150
|
+
'lc:category:Accomplishment',
|
|
151
|
+
'lc:subtype:Project',
|
|
152
|
+
'lc:displayType:badge',
|
|
153
|
+
'lc:bgImage:https://cdn.filestackcontent.com/OQWyjlSauKQAD3Z2qwEw',
|
|
154
|
+
])
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('preserves the supplied ID artwork and issuer marks as OBv3 tags', () => {
|
|
159
|
+
const expectations = [
|
|
160
|
+
{
|
|
161
|
+
fixtureId: 'obv3/student-world-scouting-troop-id',
|
|
162
|
+
idBackgroundImage: 'https://cdn.filestackcontent.com/SyKchHHLRucuS087I4rE',
|
|
163
|
+
idIssuerThumbnail: 'https://cdn.filestackcontent.com/KsarfGXWS2uR4xmqEeDD',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
fixtureId: 'obv3/student-hill-valley-high-school-student-id',
|
|
167
|
+
idBackgroundImage: 'https://cdn.filestackcontent.com/cKTNAyZ9RGF4oUGTRh2r',
|
|
168
|
+
idIssuerThumbnail: 'https://cdn.filestackcontent.com/Otu9MBWYSemRxEKJQxAB',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
fixtureId: 'obv3/student-motlow-college-id',
|
|
172
|
+
idBackgroundImage: 'https://cdn.filestackcontent.com/CnqU0q7xQoOxkwQMliz4',
|
|
173
|
+
idIssuerThumbnail: 'https://cdn.filestackcontent.com/mSjAUhi3Rw2BH2pp4Nah',
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
for (const expectation of expectations) {
|
|
178
|
+
const fixture = getFixture(expectation.fixtureId);
|
|
179
|
+
if (!isCredentialFixture(fixture)) {
|
|
180
|
+
throw new Error(`${expectation.fixtureId} fixture is missing`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const subject = fixture.credential.credentialSubject as Record<string, unknown>;
|
|
184
|
+
const achievement = subject.achievement as Record<string, unknown>;
|
|
185
|
+
|
|
186
|
+
expect(achievement.tag).toEqual(
|
|
187
|
+
expect.arrayContaining([
|
|
188
|
+
`lc:idBackgroundImage:${expectation.idBackgroundImage}`,
|
|
189
|
+
`lc:idIssuerThumbnail:${expectation.idIssuerThumbnail}`,
|
|
190
|
+
'lc:idDimBackgroundImage:true',
|
|
191
|
+
])
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('preserves all authoritative CLR records and the official identifier context', () => {
|
|
197
|
+
const fixture = getFixture('clr/student-official-academic-transcript');
|
|
198
|
+
if (!isCredentialFixture(fixture)) throw new Error('Student CLR fixture is missing');
|
|
199
|
+
|
|
200
|
+
const transcriptEntry = getBundle('student').entries.find(
|
|
201
|
+
entry => entry.fixtureId === 'clr/student-official-academic-transcript'
|
|
202
|
+
);
|
|
203
|
+
expect(transcriptEntry?.issuer.profileId).toBe('sample-redwood-valley-university');
|
|
204
|
+
expect(fixture.credential.issuer).toMatchObject({
|
|
205
|
+
id: 'did:web:network.learncard.com:users:redwoodvalley2',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const subject = fixture.credential.credentialSubject as Record<string, unknown>;
|
|
209
|
+
const context = subject['@context'];
|
|
210
|
+
const embeddedCredentials = subject.verifiableCredential as Record<string, unknown>[];
|
|
211
|
+
const typographyCourse = embeddedCredentials.find(credential => {
|
|
212
|
+
const embeddedSubject = credential.credentialSubject as Record<string, unknown>;
|
|
213
|
+
const achievement = embeddedSubject.achievement as Record<string, unknown>;
|
|
214
|
+
|
|
215
|
+
return achievement.humanCode === 'IMD 120';
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(context).toEqual({
|
|
219
|
+
identifier: {
|
|
220
|
+
'@id': 'https://purl.imsglobal.org/spec/vc/clr/vocab.html#identifier-1',
|
|
221
|
+
'@type': 'https://purl.imsglobal.org/spec/vc/ob/vocab.html#Identifier',
|
|
222
|
+
'@container': '@set',
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(embeddedCredentials).toHaveLength(35);
|
|
226
|
+
expect(typographyCourse).toBeDefined();
|
|
227
|
+
expect(
|
|
228
|
+
(
|
|
229
|
+
(typographyCourse?.credentialSubject as Record<string, unknown>)
|
|
230
|
+
.achievement as Record<string, unknown>
|
|
231
|
+
).name
|
|
232
|
+
).toBe('Typography and Layout');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
UnsignedAchievementCredentialValidator,
|
|
4
|
+
UnsignedClrCredentialValidator,
|
|
5
|
+
UnsignedVCValidator,
|
|
6
|
+
} from '@learncard/types';
|
|
7
|
+
|
|
8
|
+
import { ALL_FIXTURES } from '../fixtures';
|
|
9
|
+
import { clrEmploymentRecord } from '../fixtures/clr/employment-record';
|
|
10
|
+
import { clrTrainingProviderRecord } from '../fixtures/clr/training-provider-record';
|
|
11
|
+
import { clrMilitaryTrainingRecord } from '../fixtures/clr/military-training-record';
|
|
12
|
+
import { clrProfessionalOrganizationRecord } from '../fixtures/clr/professional-organization-record';
|
|
13
|
+
import { clrLicensingRegulatoryRecord } from '../fixtures/clr/licensing-regulatory-record';
|
|
14
|
+
import { clrMixedCareerRecord } from '../fixtures/clr/mixed-career-record';
|
|
15
|
+
import { prepareFixture } from '../prepare';
|
|
16
|
+
import type { CredentialFixture } from '../types';
|
|
17
|
+
|
|
18
|
+
type JsonRecord = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
const asRecord = (value: unknown): JsonRecord => {
|
|
21
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
22
|
+
throw new Error('Expected a JSON object in a CLR fixture');
|
|
23
|
+
}
|
|
24
|
+
return value as JsonRecord;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const asRecords = (value: unknown): JsonRecord[] => {
|
|
28
|
+
if (!Array.isArray(value)) throw new Error('Expected a JSON array in a CLR fixture');
|
|
29
|
+
return value.map(asRecord);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const subjectOf = (credential: JsonRecord): JsonRecord => asRecord(credential.credentialSubject);
|
|
33
|
+
const achievementOf = (credential: JsonRecord): JsonRecord =>
|
|
34
|
+
asRecord(subjectOf(credential).achievement);
|
|
35
|
+
const childrenOf = (credential: JsonRecord): JsonRecord[] =>
|
|
36
|
+
asRecords(subjectOf(credential).verifiableCredential);
|
|
37
|
+
|
|
38
|
+
const fixtures: CredentialFixture[] = [
|
|
39
|
+
clrEmploymentRecord,
|
|
40
|
+
clrTrainingProviderRecord,
|
|
41
|
+
clrMilitaryTrainingRecord,
|
|
42
|
+
clrProfessionalOrganizationRecord,
|
|
43
|
+
clrLicensingRegulatoryRecord,
|
|
44
|
+
clrMixedCareerRecord,
|
|
45
|
+
];
|
|
46
|
+
const cases = fixtures.map(fixture => [fixture.id, fixture] as const);
|
|
47
|
+
|
|
48
|
+
/** Check achievement, result-description and rubric references without network lookups. */
|
|
49
|
+
const expectResolvableReferences = (credential: JsonRecord): void => {
|
|
50
|
+
const children = childrenOf(credential);
|
|
51
|
+
const achievementIds = new Set(children.map(child => achievementOf(child).id));
|
|
52
|
+
const childIds = children.map(child => child.id);
|
|
53
|
+
expect(new Set(childIds).size).toBe(childIds.length);
|
|
54
|
+
|
|
55
|
+
for (const association of asRecords(subjectOf(credential).association)) {
|
|
56
|
+
// CLR associations target achievement definitions, not credential IDs.
|
|
57
|
+
expect(achievementIds.has(association.sourceId)).toBe(true);
|
|
58
|
+
expect(achievementIds.has(association.targetId)).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const child of children) {
|
|
62
|
+
const descriptions = asRecords(achievementOf(child).resultDescription);
|
|
63
|
+
const byId = new Map(descriptions.map(description => [description.id, description]));
|
|
64
|
+
expect(byId.size).toBe(descriptions.length);
|
|
65
|
+
|
|
66
|
+
for (const result of asRecords(subjectOf(child).result)) {
|
|
67
|
+
const description = byId.get(result.resultDescription);
|
|
68
|
+
expect(description).toBeDefined();
|
|
69
|
+
if (!description) throw new Error('Missing referenced result description');
|
|
70
|
+
if (description.resultType === 'Status') expect(result.status).toBeDefined();
|
|
71
|
+
if (result.value !== undefined) expect(typeof result.value).toBe('string');
|
|
72
|
+
|
|
73
|
+
const levels = description.rubricCriterionLevel
|
|
74
|
+
? asRecords(description.rubricCriterionLevel)
|
|
75
|
+
: [];
|
|
76
|
+
if (result.achievedLevel) {
|
|
77
|
+
expect(levels.some(level => level.id === result.achievedLevel)).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
if (description.requiredLevel) {
|
|
80
|
+
expect(levels.some(level => level.id === description.requiredLevel)).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe('LC-2184 non-academic CLR fixtures', () => {
|
|
87
|
+
it.each(cases)('%s is registered exactly once and explicitly unsigned', (_id, fixture) => {
|
|
88
|
+
expect(ALL_FIXTURES.filter(entry => entry.id === fixture.id)).toEqual([fixture]);
|
|
89
|
+
expect(fixture.spec).toBe('clr-v2');
|
|
90
|
+
expect(fixture.signed).toBe(false);
|
|
91
|
+
expect(fixture.validity).toBe('valid'); // Valid unsigned template, not a verified VC.
|
|
92
|
+
expect(fixture.source).toBe('synthetic');
|
|
93
|
+
expect(fixture.credential.proof).toBeUndefined();
|
|
94
|
+
for (const child of childrenOf(fixture.credential)) expect(child.proof).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each(cases)(
|
|
98
|
+
'%s passes the shared unsigned CLR and achievement validators',
|
|
99
|
+
(_id, fixture) => {
|
|
100
|
+
expect(UnsignedVCValidator.safeParse(fixture.credential).success).toBe(true);
|
|
101
|
+
expect(UnsignedClrCredentialValidator.safeParse(fixture.credential).success).toBe(true);
|
|
102
|
+
for (const child of childrenOf(fixture.credential)) {
|
|
103
|
+
expect(UnsignedAchievementCredentialValidator.safeParse(child).success).toBe(true);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
it.each(cases)(
|
|
109
|
+
'%s binds every embedded record to the same synthetic learner',
|
|
110
|
+
(_id, fixture) => {
|
|
111
|
+
const parentSubject = subjectOf(fixture.credential);
|
|
112
|
+
expect(parentSubject.id).toBe('did:example:lc2184-alex-morgan');
|
|
113
|
+
for (const child of childrenOf(fixture.credential)) {
|
|
114
|
+
expect(subjectOf(child).id).toBe(parentSubject.id);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it.each(cases)('%s has resolvable achievement and result references', (_id, fixture) => {
|
|
120
|
+
expectResolvableReferences(fixture.credential);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it.each(cases)('%s keeps references intact when UUIDs are regenerated', (_id, fixture) => {
|
|
124
|
+
const before = JSON.stringify(fixture.credential);
|
|
125
|
+
const prepared = prepareFixture(fixture, {
|
|
126
|
+
issuerDid: 'did:example:lc2184-test-publisher',
|
|
127
|
+
// Deliberately no subjectDid: prepareFixture is not a recursive child rebinder.
|
|
128
|
+
freshIds: true,
|
|
129
|
+
validFrom: '2026-09-01T12:00:00Z',
|
|
130
|
+
});
|
|
131
|
+
expect(prepared.id).not.toBe(fixture.credential.id);
|
|
132
|
+
expectResolvableReferences(prepared);
|
|
133
|
+
expect(JSON.stringify(fixture.credential)).toBe(before);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('keeps non-academic training free of inferred GPA, terms, and academic credits', () => {
|
|
137
|
+
const children = childrenOf(clrTrainingProviderRecord.credential);
|
|
138
|
+
for (const child of children) {
|
|
139
|
+
expect(subjectOf(child).term).toBeUndefined();
|
|
140
|
+
expect(subjectOf(child).creditsEarned).toBeUndefined();
|
|
141
|
+
expect(achievementOf(child).creditsAvailable).toBeUndefined();
|
|
142
|
+
expect(
|
|
143
|
+
asRecords(achievementOf(child).resultDescription).some(
|
|
144
|
+
description => description.resultType === 'GradePointAverage'
|
|
145
|
+
)
|
|
146
|
+
).toBe(false);
|
|
147
|
+
}
|
|
148
|
+
const ongoing = children.find(child =>
|
|
149
|
+
asRecords(subjectOf(child).result).some(result => result.status === 'InProgress')
|
|
150
|
+
);
|
|
151
|
+
expect(ongoing).toBeDefined();
|
|
152
|
+
if (!ongoing) throw new Error('Missing ongoing training record');
|
|
153
|
+
expect(subjectOf(ongoing).activityStartDate).toBeDefined();
|
|
154
|
+
expect(subjectOf(ongoing).activityEndDate).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('includes achieved-level-only results without manufacturing value or completion status', () => {
|
|
158
|
+
for (const fixture of [clrEmploymentRecord, clrMilitaryTrainingRecord]) {
|
|
159
|
+
const results = childrenOf(fixture.credential).flatMap(child =>
|
|
160
|
+
asRecords(subjectOf(child).result)
|
|
161
|
+
);
|
|
162
|
+
expect(
|
|
163
|
+
results.some(
|
|
164
|
+
result =>
|
|
165
|
+
typeof result.achievedLevel === 'string' &&
|
|
166
|
+
result.value === undefined &&
|
|
167
|
+
result.status === undefined
|
|
168
|
+
)
|
|
169
|
+
).toBe(true);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('retains an expired child without calling the entire licensing collection expired', () => {
|
|
174
|
+
const credential = clrLicensingRegulatoryRecord.credential;
|
|
175
|
+
const snapshot = Date.parse(String(credential.validFrom));
|
|
176
|
+
const children = childrenOf(credential);
|
|
177
|
+
expect(children.some(child => Date.parse(String(child.validUntil)) < snapshot)).toBe(true);
|
|
178
|
+
expect(children.some(child => Date.parse(String(child.validUntil)) > snapshot)).toBe(true);
|
|
179
|
+
expect(Date.parse(String(credential.validUntil))).toBeGreaterThan(snapshot);
|
|
180
|
+
expect(children.some(child => subjectOf(child).licenseNumber === 'SYN-LIC-2184-042')).toBe(
|
|
181
|
+
true
|
|
182
|
+
);
|
|
183
|
+
// No fake status list or cryptographic proof is used to claim real-world standing.
|
|
184
|
+
for (const child of children) expect(child.credentialStatus).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('keeps course-first mixed records and their multiple issuers', () => {
|
|
188
|
+
const credential = clrMixedCareerRecord.credential;
|
|
189
|
+
const children = childrenOf(credential);
|
|
190
|
+
expect(achievementOf(children[0]!).achievementType).toBe('Course');
|
|
191
|
+
expect(children.map(child => achievementOf(child).achievementType)).toEqual([
|
|
192
|
+
'Course',
|
|
193
|
+
'Achievement',
|
|
194
|
+
'Membership',
|
|
195
|
+
'ApprenticeshipCertificate',
|
|
196
|
+
'Competency',
|
|
197
|
+
'License',
|
|
198
|
+
]);
|
|
199
|
+
const publisherId = asRecord(credential.issuer).id;
|
|
200
|
+
const childIssuerIds = new Set(children.map(child => asRecord(child.issuer).id));
|
|
201
|
+
expect(childIssuerIds.size).toBe(4);
|
|
202
|
+
expect(childIssuerIds.has(publisherId)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('separates activity end, award, and credential validity dates', () => {
|
|
206
|
+
const work = childrenOf(clrEmploymentRecord.credential)[0]!;
|
|
207
|
+
expect(subjectOf(work).activityEndDate).toBe('2026-06-30T17:00:00Z');
|
|
208
|
+
expect(work.awardedDate).toBe('2026-07-10T12:00:00Z');
|
|
209
|
+
expect(work.validFrom).toBe('2026-08-15T12:00:00Z');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('keeps duplicated source credentials identical across independent fixture objects', () => {
|
|
213
|
+
const source = childrenOf(clrProfessionalOrganizationRecord.credential)[0]!;
|
|
214
|
+
const copy = childrenOf(clrMixedCareerRecord.credential).find(
|
|
215
|
+
child => child.id === source.id
|
|
216
|
+
);
|
|
217
|
+
expect(copy).toEqual(source);
|
|
218
|
+
expect(copy).not.toBe(source);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -582,6 +582,37 @@ describe('prepareFixture', () => {
|
|
|
582
582
|
expect(subject.id).toBe(subjectDid);
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
it('preserves signed credentials nested in a CLR', () => {
|
|
586
|
+
const fixture = getFixture('vc-v2/basic');
|
|
587
|
+
const signedEmbeddedCredential = {
|
|
588
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
589
|
+
id: fixture.credential.id,
|
|
590
|
+
type: ['VerifiableCredential'],
|
|
591
|
+
issuer: 'did:example:nested-issuer',
|
|
592
|
+
validFrom: '2025-01-01T00:00:00Z',
|
|
593
|
+
credentialSubject: { id: 'did:example:signed-subject' },
|
|
594
|
+
proof: { type: 'DataIntegrityProof', proofValue: 'signed-value' },
|
|
595
|
+
};
|
|
596
|
+
const prepared = prepareFixture(
|
|
597
|
+
{
|
|
598
|
+
...fixture,
|
|
599
|
+
credential: {
|
|
600
|
+
...fixture.credential,
|
|
601
|
+
credentialSubject: {
|
|
602
|
+
id: 'did:example:old-root',
|
|
603
|
+
verifiableCredential: [signedEmbeddedCredential],
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
{ issuerDid, subjectDid }
|
|
608
|
+
);
|
|
609
|
+
const subject = prepared.credentialSubject as UnknownRecord;
|
|
610
|
+
const embedded = subject.verifiableCredential as UnknownRecord[];
|
|
611
|
+
|
|
612
|
+
expect(subject.id).toBe(subjectDid);
|
|
613
|
+
expect(embedded[0]).toEqual(signedEmbeddedCredential);
|
|
614
|
+
});
|
|
615
|
+
|
|
585
616
|
it('generates fresh UUIDs for id fields by default', () => {
|
|
586
617
|
const fixture = getFixture('obv3/minimal-badge');
|
|
587
618
|
const prepared = prepareFixture(fixture, { issuerDid });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CredentialBundle } from './types';
|
|
2
|
+
import { studentBundle } from './student';
|
|
3
|
+
|
|
4
|
+
const BUNDLES: Readonly<Record<string, CredentialBundle>> = {
|
|
5
|
+
[studentBundle.id]: studentBundle,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const getBundle = (id: string): CredentialBundle => {
|
|
9
|
+
const bundle = BUNDLES[id];
|
|
10
|
+
|
|
11
|
+
if (!bundle) {
|
|
12
|
+
throw new Error(`Unknown credential bundle: ${id}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return bundle;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { studentBundle } from './student';
|
|
19
|
+
export type { CredentialBundle, CredentialBundleEntry, CredentialBundleIssuer } from './types';
|