@centrifuge/sdk 1.16.0 → 1.18.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.
@@ -2,7 +2,7 @@ import { expect } from 'chai';
2
2
  import { mockPoolMetadata } from '../tests/mocks/mockPoolMetadata.js';
3
3
  import { mockPoolMetadataV2 } from '../tests/mocks/mockPoolMetadataV2.js';
4
4
  import { isPoolMetadataV2 } from '../types/poolMetadata.js';
5
- import { migratePoolMetadataToV2, parsePoolMetadataV2 } from './poolMetadataMigration.js';
5
+ import { migratePoolMetadataToV2, parsePoolMetadataV2, resolveLinkTarget } from './poolMetadataMigration.js';
6
6
  const clone = (value) => JSON.parse(JSON.stringify(value));
7
7
  /** A legacy fixture with the display fields that drive the factsheet projection. */
8
8
  function legacyFixture() {
@@ -24,8 +24,10 @@ function legacyFixture() {
24
24
  logo: null,
25
25
  shortDescription: 'Acme',
26
26
  categories: [
27
- { type: 'Trustee', value: 'Acme Trust' },
28
- { type: 'Auditor', value: 'Acme Audit' },
27
+ { type: 'Investment Manager', value: 'Anemoy Asset Management Ltd.' }, // absorbed by Issuer (dropped)
28
+ { type: 'Sub-Investment Manager', value: 'Janus Henderson Investors' }, // -> Portfolio Manager (SP)
29
+ { type: 'Fund Administrator', value: 'Trident Trust Company (Cayman) Ltd' }, // -> Service providers
30
+ { type: 'Auditor', value: 'MHA Cayman ' }, // -> Service providers (trailing space, trimmed on migrate)
29
31
  ],
30
32
  },
31
33
  links: { executiveSummary: null },
@@ -64,6 +66,11 @@ function legacyFixture() {
64
66
  loanTemplates: [{ id: 'tpl', createdAt: '2024-01-01' }],
65
67
  addressLabels: { '0xabc': 'Treasury' },
66
68
  workflowPolicies: [],
69
+ withdrawManagers: {
70
+ '0x6756e091ae798a8e51e12e27ee8facdf': [
71
+ { assetAddress: '0xa0b8', chainId: '1', manager: '0x1a3b', label: 'C-AC' },
72
+ ],
73
+ },
67
74
  };
68
75
  }
69
76
  describe('migratePoolMetadataToV2', () => {
@@ -93,64 +100,226 @@ describe('migratePoolMetadataToV2', () => {
93
100
  expect(v2.shareClasses['0x6756e091ae798a8e51e12e27ee8facdf'].apy).to.equal('fixed');
94
101
  expect(v2.shareClasses['0xda64aae939e4d3a981004619f1709d8f'].apy).to.equal('90d365');
95
102
  });
96
- it('preserves engine fields verbatim', () => {
103
+ it('preserves engine fields verbatim (incl. withdrawManagers)', () => {
97
104
  const v2 = migratePoolMetadataToV2(legacyFixture());
98
105
  expect(v2.addressLabels).to.deep.equal({ '0xabc': 'Treasury' });
99
106
  expect(v2.workflowPolicies).to.deep.equal([]);
107
+ expect(v2.withdrawManagers).to.deep.equal({
108
+ '0x6756e091ae798a8e51e12e27ee8facdf': [
109
+ { assetAddress: '0xa0b8', chainId: '1', manager: '0x1a3b', label: 'C-AC' },
110
+ ],
111
+ });
112
+ });
113
+ it('carries pool.links.documents through migration', () => {
114
+ const legacy = legacyFixture();
115
+ legacy.pool.links = {
116
+ executiveSummary: null,
117
+ documents: [{ key: 'tos', label: 'Terms', href: 'https://x.io/tos' }],
118
+ };
119
+ const v2 = migratePoolMetadataToV2(legacy);
120
+ expect(v2.pool.links.documents).to.deep.equal([{ key: 'tos', label: 'Terms', href: 'https://x.io/tos' }]);
121
+ });
122
+ it('drops a stray pool.report (singular) non-schema field', () => {
123
+ const legacy = legacyFixture();
124
+ legacy.pool.report = { author: { name: '', title: '', avatar: null }, url: '' };
125
+ const v2 = migratePoolMetadataToV2(legacy);
126
+ expect(v2.pool).to.not.have.property('report');
127
+ });
128
+ it('trims whitespace from migrated text values', () => {
129
+ // legacyFixture's Auditor value carries a trailing space.
130
+ const serviceProviders = migratePoolMetadataToV2(legacyFixture()).pool.factsheet.keyFacts.find((g) => g.id === 'service-providers');
131
+ const auditor = serviceProviders.items.find((i) => i.label === 'Auditor');
132
+ expect(auditor.value).to.deep.equal({ kind: 'text', text: 'MHA Cayman' });
100
133
  });
101
- it('strips reportFile from kept poolRatings', () => {
134
+ it('preserves poolRatings verbatim as a typed field (incl. reportFile)', () => {
102
135
  const v2 = migratePoolMetadataToV2(legacyFixture());
103
136
  expect(v2.pool.poolRatings).to.deep.equal([
104
- { agency: 'Moodys', value: 'Aaa', reportUrl: 'https://x.io/r1' },
137
+ {
138
+ agency: 'Moodys',
139
+ value: 'Aaa',
140
+ reportUrl: 'https://x.io/r1',
141
+ reportFile: { uri: 'ipfs://Qm1', mime: 'application/pdf' },
142
+ },
105
143
  { agency: 'S&P', value: 'AAA' },
106
144
  ]);
107
145
  });
108
- it('projects key facts in order, including valueRef apy and categories', () => {
146
+ it('projects a titled "Key facts" group (incl. seeded wallet infra + networks) and a "Service providers" group', () => {
109
147
  const { keyFacts } = migratePoolMetadataToV2(legacyFixture()).pool.factsheet;
110
148
  expect(keyFacts).to.deep.equal([
111
- { label: 'Issuer', value: 'Acme Issuer' },
112
- { label: 'Asset type', value: 'Public credit - T-Bills' },
113
- { label: 'APY', valueRef: 'apy' },
114
- { label: 'Pool structure', value: 'revolving' },
115
- { label: 'Average asset maturity', value: '63 days' },
116
- { label: 'Expense ratio', value: '0.25%' },
117
- { label: 'Min. investment', value: '2,500' },
118
- { label: 'Trustee', value: 'Acme Trust' },
119
- { label: 'Auditor', value: 'Acme Audit' },
149
+ {
150
+ type: 'keyFactGroup',
151
+ id: 'key-facts',
152
+ title: 'Key facts',
153
+ items: [
154
+ { label: 'Issuer', value: { kind: 'text', text: 'Acme Issuer' } },
155
+ { label: 'Asset type', value: { kind: 'text', text: 'Public credit - T-Bills' } },
156
+ { label: 'APY', value: { kind: 'ref', ref: 'apy' } },
157
+ { label: 'Pool structure', value: { kind: 'text', text: 'revolving' } },
158
+ { label: 'Average asset maturity', value: { kind: 'text', text: '63 days' } },
159
+ { label: 'Expense ratio', value: { kind: 'text', text: '0.25%' } },
160
+ { label: 'Min. investment', value: { kind: 'text', text: '2,500' } },
161
+ // Investment Manager is absorbed into Issuer (no row); the other three are service providers.
162
+ { label: 'Wallet infrastructure', value: { kind: 'icons', icons: [{ source: 'app', key: 'fordefi' }] } },
163
+ { label: 'Available networks', value: { kind: 'ref', ref: 'availableNetworks' } },
164
+ { label: 'Ratings', value: { kind: 'ref', ref: 'ratings' } },
165
+ ],
166
+ },
167
+ {
168
+ type: 'keyFactGroup',
169
+ id: 'service-providers',
170
+ title: 'Service providers',
171
+ items: [
172
+ { label: 'Portfolio Manager', value: { kind: 'text', text: 'Janus Henderson Investors' } },
173
+ { label: 'Fund Administrator', value: { kind: 'text', text: 'Trident Trust Company (Cayman) Ltd' } },
174
+ { label: 'Auditor', value: { kind: 'text', text: 'MHA Cayman' } },
175
+ ],
176
+ },
120
177
  ]);
121
178
  });
122
- it('projects description, details and rating reports into body blocks', () => {
179
+ it('absorbs the Investment Manager category into the Issuer key fact (drops its own row)', () => {
180
+ const { keyFacts } = migratePoolMetadataToV2(legacyFixture()).pool.factsheet;
181
+ const allItems = keyFacts.flatMap((g) => g.items);
182
+ expect(allItems.filter((i) => i.label === 'Issuer')).to.have.length(1);
183
+ expect(allItems.some((i) => i.value.kind === 'text' && i.value.text === 'Anemoy Asset Management Ltd.')).to.equal(false);
184
+ });
185
+ it('seeds wallet infrastructure + available networks even with no categories or ratings', () => {
186
+ const legacy = legacyFixture();
187
+ legacy.pool.issuer.categories = [];
188
+ legacy.pool.poolRatings = [];
189
+ const { keyFacts } = migratePoolMetadataToV2(legacy).pool.factsheet;
190
+ expect(keyFacts).to.have.length(1); // no Service providers group
191
+ const labels = keyFacts[0].items.map((i) => i.label);
192
+ expect(labels).to.include('Wallet infrastructure');
193
+ expect(labels).to.include('Available networks');
194
+ expect(labels).to.not.include('Ratings');
195
+ });
196
+ it('migrates cleanly when issuer.categories is absent (real legacy docs omit it)', () => {
197
+ const legacy = legacyFixture();
198
+ delete legacy.pool.issuer.categories;
199
+ expect(() => migratePoolMetadataToV2(legacy)).to.not.throw();
200
+ const { keyFacts } = migratePoolMetadataToV2(legacy).pool.factsheet;
201
+ expect(keyFacts).to.have.length(1); // Key facts only, no Service providers group
202
+ });
203
+ it('keeps unknown categories in "Key facts" and omits the Service providers group when none match', () => {
204
+ const legacy = legacyFixture();
205
+ legacy.pool.issuer.categories = [{ type: 'Historical default rate', value: '0%' }];
206
+ const { keyFacts } = migratePoolMetadataToV2(legacy).pool.factsheet;
207
+ expect(keyFacts).to.have.length(1);
208
+ expect(keyFacts[0].title).to.equal('Key facts');
209
+ expect(keyFacts[0].items.some((i) => i.label === 'Historical default rate')).to.equal(true);
210
+ });
211
+ it('matches service-provider category types case- and separator-insensitively', () => {
212
+ const legacy = legacyFixture();
213
+ legacy.pool.issuer.categories = [{ type: 'Fund_Administrator', value: 'Trident Trust' }];
214
+ const groups = migratePoolMetadataToV2(legacy).pool.factsheet.keyFacts;
215
+ const serviceProviders = groups.find((g) => g.id === 'service-providers');
216
+ expect(serviceProviders?.items).to.deep.equal([
217
+ { label: 'Fund Administrator', value: { kind: 'text', text: 'Trident Trust' } },
218
+ ]);
219
+ });
220
+ it('projects description and details into body blocks (no Documents block in body)', () => {
123
221
  const { body } = migratePoolMetadataToV2(legacyFixture()).pool.factsheet;
124
222
  expect(body).to.deep.equal([
125
223
  { type: 'text', id: 'overview', title: 'Overview', body: 'A long issuer description.' },
126
224
  { type: 'text', id: 'detail-0', title: 'Strategy', body: 'We do things.' },
127
225
  { type: 'text', id: 'detail-1', title: 'Risks', body: 'Things can go wrong.' },
128
- { type: 'text', id: 'documents', title: 'Documents', body: '- [Moodys rating report](ipfs://Qm1)' },
129
226
  { type: 'section', id: 'performance', ref: 'onchainMetrics' },
130
227
  ]);
131
228
  });
132
- it('drops unsafe document URI schemes and escapes the rating label', () => {
229
+ it('builds the Overview card with logo and a Factsheet linkRef when those source fields exist', () => {
230
+ const legacy = legacyFixture();
231
+ legacy.pool.issuer.logo = { uri: 'ipfs://QmLogo', mime: 'image/png' };
232
+ legacy.pool.links = { executiveSummary: { uri: 'ipfs://QmFactsheet', mime: 'application/pdf' } };
233
+ const overview = migratePoolMetadataToV2(legacy).pool.factsheet.body.find((b) => b.id === 'overview');
234
+ expect(overview).to.deep.equal({
235
+ type: 'text',
236
+ id: 'overview',
237
+ title: 'Overview',
238
+ body: 'A long issuer description.',
239
+ logo: { uri: 'ipfs://QmLogo', mime: 'image/png' },
240
+ links: [{ label: 'Factsheet', target: { kind: 'linkRef', linkRef: 'executiveSummary' } }],
241
+ });
242
+ });
243
+ it('omits the Overview logo and Factsheet link when their source fields are absent/empty', () => {
244
+ const legacy = legacyFixture();
245
+ legacy.pool.issuer.logo = { uri: '', mime: 'image/png' }; // empty uri => treated as absent
246
+ legacy.pool.links = { executiveSummary: null };
247
+ const overview = migratePoolMetadataToV2(legacy).pool.factsheet.body.find((b) => b.id === 'overview');
248
+ expect(overview).to.deep.equal({
249
+ type: 'text',
250
+ id: 'overview',
251
+ title: 'Overview',
252
+ body: 'A long issuer description.',
253
+ });
254
+ });
255
+ it('builds a Documents tile section (full-width) from rating reports, dropping unsafe URIs', () => {
133
256
  const legacy = legacyFixture();
134
257
  legacy.pool.poolRatings = [
135
- { agency: 'Evil [x](javascript:alert(1))', reportFile: { uri: 'javascript:alert(1)', mime: 'text/html' } },
258
+ { agency: 'Evil', reportFile: { uri: 'javascript:alert(1)', mime: 'text/html' } },
136
259
  { agency: 'Fitch', reportFile: { uri: 'https://x.io/r2', mime: 'application/pdf' } },
137
260
  ];
138
- const documents = migratePoolMetadataToV2(legacy).pool.factsheet.body.find((b) => b.id === 'documents');
261
+ const { body, sections } = migratePoolMetadataToV2(legacy).pool.factsheet;
262
+ expect(body.some((b) => b.id === 'documents')).to.equal(false); // not in the header region
263
+ const documents = sections.find((s) => s.id === 'documents');
139
264
  expect(documents).to.deep.equal({
140
- type: 'text',
265
+ type: 'documents',
141
266
  id: 'documents',
142
267
  title: 'Documents',
143
- body: '- [Fitch rating report](https://x.io/r2)',
268
+ items: [
269
+ {
270
+ title: 'Fitch rating report',
271
+ target: { kind: 'file', file: { uri: 'https://x.io/r2', mime: 'application/pdf' } },
272
+ },
273
+ ],
144
274
  });
145
275
  });
146
- it('omits the Documents block entirely when no rating has a safe URI', () => {
276
+ it('omits the Documents block entirely when no rating has a safe report URI', () => {
147
277
  const legacy = legacyFixture();
148
278
  legacy.pool.poolRatings = [{ agency: 'X', reportFile: { uri: 'javascript:alert(1)', mime: 'text/html' } }];
149
- const documents = migratePoolMetadataToV2(legacy).pool.factsheet.body.find((b) => b.id === 'documents');
150
- expect(documents).to.equal(undefined);
279
+ const { body, sections } = migratePoolMetadataToV2(legacy).pool.factsheet;
280
+ expect(body.some((b) => b.id === 'documents')).to.equal(false);
281
+ expect((sections ?? []).some((s) => s.id === 'documents')).to.equal(false);
282
+ });
283
+ it('omits sections (app renders defaults) when there is no documents block or holdings blob', () => {
284
+ const legacy = legacyFixture();
285
+ legacy.pool.poolRatings = []; // no rating reports => no Documents section
286
+ expect(migratePoolMetadataToV2(legacy).pool.factsheet.sections).to.equal(undefined);
287
+ });
288
+ it('folds the legacy holdings blob into a table section, faithfully, dropping no column', () => {
289
+ const legacy = legacyFixture();
290
+ legacy.pool.poolRatings = []; // isolate the holdings section
291
+ legacy.holdings = {
292
+ headers: ['Asset', 'ISIN', 'Amount'],
293
+ data: [
294
+ { Asset: 'T-Bill 2026', ISIN: 'US912796RW0', Amount: 1_000_000 },
295
+ { Asset: 'T-Bill 2027', ISIN: null, Amount: '2500000' },
296
+ ],
297
+ };
298
+ const v2 = migratePoolMetadataToV2(legacy);
299
+ expect(v2.pool.factsheet.sections).to.deep.equal([
300
+ {
301
+ type: 'table',
302
+ id: 'holdings',
303
+ title: 'Holdings',
304
+ headers: ['Asset', 'ISIN', 'Amount'],
305
+ rows: [
306
+ ['T-Bill 2026', 'US912796RW0', 1_000_000], // number stays a number
307
+ ['T-Bill 2027', '', '2500000'], // null -> '', numeric string stays a string
308
+ ],
309
+ },
310
+ ]);
311
+ // holdings is no longer a top-level field on the v2 document.
312
+ expect(v2).to.not.have.property('holdings');
151
313
  });
152
- it('omits sections so the app renders defaults', () => {
153
- expect(migratePoolMetadataToV2(legacyFixture()).pool.factsheet.sections).to.equal(undefined);
314
+ it('skips the holdings table when headers are empty or data is missing', () => {
315
+ const emptyHeaders = legacyFixture();
316
+ emptyHeaders.pool.poolRatings = [];
317
+ emptyHeaders.holdings = { headers: [], data: [{ x: 1 }] };
318
+ expect(migratePoolMetadataToV2(emptyHeaders).pool.factsheet.sections).to.equal(undefined);
319
+ const missingData = legacyFixture();
320
+ missingData.pool.poolRatings = [];
321
+ missingData.holdings = { headers: ['A'] };
322
+ expect(migratePoolMetadataToV2(missingData).pool.factsheet.sections).to.equal(undefined);
154
323
  });
155
324
  it('is idempotent and returns v2 input unchanged', () => {
156
325
  const once = migratePoolMetadataToV2(legacyFixture());
@@ -163,6 +332,87 @@ describe('migratePoolMetadataToV2', () => {
163
332
  expect(isPoolMetadataV2(v2)).to.equal(true);
164
333
  expect(() => parsePoolMetadataV2(v2)).to.not.throw();
165
334
  });
335
+ describe('intra-v2 upgrade (documents written by an earlier SDK)', () => {
336
+ // The shape an SDK <= 1.16.0 produced: flat keyFacts, chart series/dataRef, top-level holdings.
337
+ const oldV2 = () => ({
338
+ version: 2,
339
+ pool: {
340
+ name: 'Old V2 Pool',
341
+ factsheet: {
342
+ keyFacts: [
343
+ { label: 'Issuer', value: 'Acme' },
344
+ { label: 'APY', valueRef: 'apy' },
345
+ ],
346
+ body: [
347
+ { type: 'chart', id: 'c1', chartType: 'line', series: [{ name: 's', data: [{ x: 1, y: 2 }] }] },
348
+ { type: 'chart', id: 'c2', chartType: 'donut', dataRef: 'apyVsBenchmarks' },
349
+ {
350
+ type: 'tabGroup',
351
+ id: 'tg',
352
+ tabs: [
353
+ {
354
+ label: 'T',
355
+ block: {
356
+ type: 'chart',
357
+ id: 'c3',
358
+ chartType: 'bar',
359
+ series: [{ name: 's', data: [{ label: 'a', value: 1 }] }],
360
+ },
361
+ },
362
+ ],
363
+ },
364
+ ],
365
+ },
366
+ },
367
+ shareClasses: {},
368
+ holdings: { headers: ['A'], data: [{ A: 'x' }] },
369
+ });
370
+ it('upgrades flat keyFacts into a single Key facts group with discriminated values', () => {
371
+ const { keyFacts } = migratePoolMetadataToV2(oldV2()).pool.factsheet;
372
+ expect(keyFacts).to.deep.equal([
373
+ {
374
+ type: 'keyFactGroup',
375
+ id: 'key-facts',
376
+ title: 'Key facts',
377
+ items: [
378
+ { label: 'Issuer', value: { kind: 'text', text: 'Acme' } },
379
+ { label: 'APY', value: { kind: 'ref', ref: 'apy' } },
380
+ ],
381
+ },
382
+ ]);
383
+ });
384
+ it('upgrades chart series/dataRef into discriminated data (incl. inside tab groups)', () => {
385
+ const body = migratePoolMetadataToV2(oldV2()).pool.factsheet.body;
386
+ const c1 = body.find((b) => b.id === 'c1');
387
+ expect(c1.data).to.deep.equal({ kind: 'inline', series: [{ name: 's', data: [{ x: 1, y: 2 }] }] });
388
+ expect(c1).to.not.have.property('series');
389
+ const c2 = body.find((b) => b.id === 'c2');
390
+ expect(c2.data).to.deep.equal({ kind: 'ref', dataRef: 'apyVsBenchmarks' });
391
+ const tg = body.find((b) => b.id === 'tg');
392
+ expect(tg.tabs[0].block.data).to.deep.equal({
393
+ kind: 'inline',
394
+ series: [{ name: 's', data: [{ label: 'a', value: 1 }] }],
395
+ });
396
+ });
397
+ it('folds a top-level holdings blob into a factsheet table section and removes the top-level field', () => {
398
+ const v2 = migratePoolMetadataToV2(oldV2());
399
+ expect(v2).to.not.have.property('holdings');
400
+ const holdings = v2.pool.factsheet.sections.find((s) => s.id === 'holdings');
401
+ expect(holdings).to.deep.equal({
402
+ type: 'table',
403
+ id: 'holdings',
404
+ title: 'Holdings',
405
+ headers: ['A'],
406
+ rows: [['x']],
407
+ });
408
+ });
409
+ it('produces a document that passes the current validator, and is then idempotent', () => {
410
+ const upgraded = migratePoolMetadataToV2(oldV2());
411
+ expect(() => parsePoolMetadataV2(upgraded)).to.not.throw();
412
+ // Already current shape -> returned unchanged (same reference).
413
+ expect(migratePoolMetadataToV2(upgraded)).to.equal(upgraded);
414
+ });
415
+ });
166
416
  });
167
417
  describe('parsePoolMetadataV2', () => {
168
418
  it('accepts the v2 mock exercising every block type', () => {
@@ -177,17 +427,17 @@ describe('parsePoolMetadataV2', () => {
177
427
  it('rejects a missing pool name', () => {
178
428
  expect(() => parsePoolMetadataV2({ version: 2, pool: {} })).to.throw(/pool.name/);
179
429
  });
180
- it('rejects a chart with both series and dataRef', () => {
430
+ it('rejects a chart whose data has an invalid kind', () => {
181
431
  const bad = clone(mockPoolMetadataV2);
182
- const chart = bad.pool.factsheet.body.find((b) => b.id === 'live-chart');
183
- chart.series = [{ name: 's', data: [{ label: 'a', value: 1 }] }];
184
- expect(() => parsePoolMetadataV2(bad)).to.throw(/exactly one of/);
432
+ const chart = bad.pool.factsheet.body.find((b) => b.id === 'inline-chart');
433
+ chart.data = { kind: 'live' };
434
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/data has an invalid kind/);
185
435
  });
186
- it('rejects a chart with neither series nor dataRef', () => {
436
+ it('rejects a chart missing its data object', () => {
187
437
  const bad = clone(mockPoolMetadataV2);
188
438
  const chart = bad.pool.factsheet.body.find((b) => b.id === 'inline-chart');
189
- delete chart.series;
190
- expect(() => parsePoolMetadataV2(bad)).to.throw(/exactly one of/);
439
+ delete chart.data;
440
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/data must be an object/);
191
441
  });
192
442
  it('rejects an unknown section ref (closed, SDK-enforced registry)', () => {
193
443
  const bad = clone(mockPoolMetadataV2);
@@ -216,11 +466,11 @@ describe('parsePoolMetadataV2', () => {
216
466
  bad.pool.factsheet.body[0].type = 'video';
217
467
  expect(() => parsePoolMetadataV2(bad)).to.throw(/unknown block type/);
218
468
  });
219
- it('rejects an unknown chart dataRef (closed, SDK-enforced registry)', () => {
469
+ it('rejects an unknown chart data.dataRef (closed, SDK-enforced registry)', () => {
220
470
  const bad = clone(mockPoolMetadataV2);
221
471
  const chart = bad.pool.factsheet.body.find((b) => b.id === 'live-chart');
222
- chart.dataRef = 'someFutureDataset';
223
- expect(() => parsePoolMetadataV2(bad)).to.throw(/dataRef is invalid/);
472
+ chart.data = { kind: 'ref', dataRef: 'someFutureDataset' };
473
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/data.dataRef is invalid/);
224
474
  });
225
475
  it('rejects an unknown live-table indexer metric (closed, SDK-enforced registry)', () => {
226
476
  const bad = clone(mockPoolMetadataV2);
@@ -236,5 +486,176 @@ describe('parsePoolMetadataV2', () => {
236
486
  };
237
487
  expect(() => parsePoolMetadataV2(minimal)).to.not.throw();
238
488
  });
489
+ it('rejects a flat KeyFact[] right column (groups only, no back-compat)', () => {
490
+ const bad = clone(mockPoolMetadataV2);
491
+ bad.pool.factsheet.keyFacts = [{ label: 'Issuer', value: { kind: 'text', text: 'x' } }];
492
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/keyFacts\[0\].type must be 'keyFactGroup'/);
493
+ });
494
+ it('rejects a key fact whose value has an unknown kind', () => {
495
+ const bad = clone(mockPoolMetadataV2);
496
+ bad.pool.factsheet.keyFacts[0].items[0].value = { kind: 'live' };
497
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/value has an invalid kind/);
498
+ });
499
+ it('rejects an unknown KeyFactValue ref (closed, SDK-enforced registry)', () => {
500
+ const bad = clone(mockPoolMetadataV2);
501
+ bad.pool.factsheet.keyFacts[0].items[0].value = { kind: 'ref', ref: 'someFutureRef' };
502
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/value.ref is invalid/);
503
+ });
504
+ it('accepts the known KeyFactValue refs apy and availableNetworks', () => {
505
+ const ok = clone(mockPoolMetadataV2);
506
+ ok.pool.factsheet.keyFacts[0].items[0].value = { kind: 'ref', ref: 'availableNetworks' };
507
+ expect(() => parsePoolMetadataV2(ok)).to.not.throw();
508
+ });
509
+ it('rejects an icons key fact with a malformed IconRef', () => {
510
+ const bad = clone(mockPoolMetadataV2);
511
+ bad.pool.factsheet.keyFacts[1].items[0].value = { kind: 'icons', icons: [{ source: 'nope' }] };
512
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/icons\[0\] has an invalid source/);
513
+ });
514
+ it('accepts both app and metadata IconRefs (app icon key is shape-validated)', () => {
515
+ const ok = clone(mockPoolMetadataV2);
516
+ ok.pool.factsheet.keyFacts[1].items[0].value = {
517
+ kind: 'icons',
518
+ icons: [
519
+ { source: 'app', key: 'any-future-icon', label: 'X' },
520
+ { source: 'metadata', file: { uri: 'ipfs://Qm', mime: 'image/svg+xml' } },
521
+ ],
522
+ };
523
+ expect(() => parsePoolMetadataV2(ok)).to.not.throw();
524
+ });
525
+ it('rejects a key fact group missing items', () => {
526
+ const bad = clone(mockPoolMetadataV2);
527
+ delete bad.pool.factsheet.keyFacts[0].items;
528
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/keyFacts\[0\].items must be an array/);
529
+ });
530
+ // --- #482: geo-restrictions, documents/accordion blocks, link targets, combined visibility ---
531
+ it('accepts geo-restricted visibility (single + combined) and a valid geoRestrictions list', () => {
532
+ expect(() => parsePoolMetadataV2(mockPoolMetadataV2)).to.not.throw();
533
+ });
534
+ it('accepts geo-restricted elements even when pool.geoRestrictions is absent (nothing restricted)', () => {
535
+ const doc = clone(mockPoolMetadataV2);
536
+ delete doc.pool.geoRestrictions;
537
+ expect(() => parsePoolMetadataV2(doc)).to.not.throw();
538
+ });
539
+ it('rejects geoRestrictions regions that are not ISO 3166-1 alpha-2', () => {
540
+ const bad = clone(mockPoolMetadataV2);
541
+ bad.pool.geoRestrictions = { regions: ['US', 'USA'] };
542
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/geoRestrictions.regions\[1\] must be an ISO 3166-1 alpha-2 code/);
543
+ });
544
+ it('rejects an invalid visibility scalar', () => {
545
+ const bad = clone(mockPoolMetadataV2);
546
+ bad.pool.factsheet.keyFacts[0].visibility = 'continent-blocked';
547
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/visibility/);
548
+ });
549
+ it('rejects a visibility array containing a non-gate (public/hidden or unknown)', () => {
550
+ const bad = clone(mockPoolMetadataV2);
551
+ bad.pool.factsheet.keyFacts[0].visibility = ['whitelisted', 'public'];
552
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/invalid visibility gate/);
553
+ });
554
+ it('rejects a visibility array with duplicate gates', () => {
555
+ const bad = clone(mockPoolMetadataV2);
556
+ bad.pool.factsheet.keyFacts[0].visibility = ['whitelisted', 'whitelisted'];
557
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/duplicate visibility gate/);
558
+ });
559
+ it('rejects an empty visibility array', () => {
560
+ const bad = clone(mockPoolMetadataV2);
561
+ bad.pool.factsheet.keyFacts[0].visibility = [];
562
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/visibility array must not be empty/);
563
+ });
564
+ it('rejects a link target with an unknown kind', () => {
565
+ const bad = clone(mockPoolMetadataV2);
566
+ const docs = bad.pool.factsheet.body.find((b) => b.id === 'docs');
567
+ docs.items[0].target = { kind: 'mailto', mailto: 'x@y.z' };
568
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/target has an invalid kind/);
569
+ });
570
+ it('rejects a documents item missing its target', () => {
571
+ const bad = clone(mockPoolMetadataV2);
572
+ const docs = bad.pool.factsheet.body.find((b) => b.id === 'docs');
573
+ delete docs.items[0].target;
574
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/items\[0\].target must be an object/);
575
+ });
576
+ it('rejects an accordion item whose inner block is not a leaf type', () => {
577
+ const bad = clone(mockPoolMetadataV2);
578
+ const accordion = bad.pool.factsheet.body.find((b) => b.id === 'details-accordion');
579
+ accordion.items[0].block = { type: 'documents', id: 'nope', items: [] };
580
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/block has an invalid type/);
581
+ });
582
+ it('rejects an accordion item with a non-boolean defaultOpen', () => {
583
+ const bad = clone(mockPoolMetadataV2);
584
+ const accordion = bad.pool.factsheet.body.find((b) => b.id === 'details-accordion');
585
+ accordion.items[0].defaultOpen = 'yes';
586
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/defaultOpen must be a boolean/);
587
+ });
588
+ it('rejects an overview text-block link with a malformed target', () => {
589
+ const bad = clone(mockPoolMetadataV2);
590
+ const overview = bad.pool.factsheet.body.find((b) => b.id === 'overview');
591
+ overview.links[0].target = { kind: 'linkRef' }; // missing linkRef string
592
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/linkRef must be a string/);
593
+ });
594
+ // --- pool.links.documents (reusable named documents) ---
595
+ it('accepts pool.links.documents (file and href docs)', () => {
596
+ expect(() => parsePoolMetadataV2(mockPoolMetadataV2)).to.not.throw();
597
+ });
598
+ it('rejects a link document setting both file and href', () => {
599
+ const bad = clone(mockPoolMetadataV2);
600
+ bad.pool.links.documents = [
601
+ { key: 'x', label: 'X', file: { uri: 'ipfs://Q', mime: 'application/pdf' }, href: 'https://x.io' },
602
+ ];
603
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/exactly one of `file` or `href`/);
604
+ });
605
+ it('rejects a link document setting neither file nor href', () => {
606
+ const bad = clone(mockPoolMetadataV2);
607
+ bad.pool.links.documents = [{ key: 'x', label: 'X' }];
608
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/exactly one of `file` or `href`/);
609
+ });
610
+ it('rejects duplicate link-document keys', () => {
611
+ const bad = clone(mockPoolMetadataV2);
612
+ bad.pool.links.documents = [
613
+ { key: 'dup', label: 'A', href: 'https://a.io' },
614
+ { key: 'dup', label: 'B', href: 'https://b.io' },
615
+ ];
616
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/is duplicated/);
617
+ });
618
+ it('rejects a link-document key colliding with a built-in', () => {
619
+ const bad = clone(mockPoolMetadataV2);
620
+ bad.pool.links.documents = [{ key: 'website', label: 'W', href: 'https://w.io' }];
621
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/collides with a built-in/);
622
+ });
623
+ it('rejects a link document with an empty key', () => {
624
+ const bad = clone(mockPoolMetadataV2);
625
+ bad.pool.links.documents = [{ key: '', label: 'X', href: 'https://x.io' }];
626
+ expect(() => parsePoolMetadataV2(bad)).to.throw(/key must be a non-empty string/);
627
+ });
628
+ });
629
+ describe('resolveLinkTarget', () => {
630
+ const links = mockPoolMetadataV2.pool.links;
631
+ it('resolves inline file and href targets', () => {
632
+ expect(resolveLinkTarget(links, { kind: 'file', file: { uri: 'ipfs://Q', mime: 'application/pdf' } })).to.deep.equal({
633
+ file: { uri: 'ipfs://Q', mime: 'application/pdf' },
634
+ });
635
+ expect(resolveLinkTarget(links, { kind: 'href', href: 'https://x.io' })).to.deep.equal({ href: 'https://x.io' });
636
+ });
637
+ it('resolves built-in linkRefs (executiveSummary -> file, website/forum -> url)', () => {
638
+ expect(resolveLinkTarget(links, { kind: 'linkRef', linkRef: 'executiveSummary' })).to.deep.equal({
639
+ file: links.executiveSummary,
640
+ });
641
+ expect(resolveLinkTarget(links, { kind: 'linkRef', linkRef: 'website' })).to.deep.equal({
642
+ href: 'https://newsilver.com',
643
+ });
644
+ expect(resolveLinkTarget(links, { kind: 'linkRef', linkRef: 'forum' })).to.deep.equal({
645
+ href: 'https://gov.centrifuge.io/tag/newsilver',
646
+ });
647
+ });
648
+ it('resolves named documents by key (file or href)', () => {
649
+ expect(resolveLinkTarget(links, { kind: 'linkRef', linkRef: 'prospectus' })).to.deep.equal({
650
+ file: { uri: 'ipfs://QmProspectusDoc', mime: 'application/pdf' },
651
+ });
652
+ expect(resolveLinkTarget(links, { kind: 'linkRef', linkRef: 'termsOfService' })).to.deep.equal({
653
+ href: 'https://example.com/tos',
654
+ });
655
+ });
656
+ it('returns null for an unknown linkRef or missing links', () => {
657
+ expect(resolveLinkTarget(links, { kind: 'linkRef', linkRef: 'nope' })).to.equal(null);
658
+ expect(resolveLinkTarget(undefined, { kind: 'linkRef', linkRef: 'website' })).to.equal(null);
659
+ });
239
660
  });
240
661
  //# sourceMappingURL=poolMetadataMigration.test.js.map