@fias/arche-sdk 2.19.1 → 2.20.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.
@@ -110,9 +110,11 @@ jest.mock('react-dom', () => ({
110
110
  let mockSingletonBridge = null;
111
111
  jest.mock('./bridge', () => ({
112
112
  __esModule: true,
113
- // Real exports the hooks module needs: only getBridge. resetBridge isn't
114
- // referenced from hooks.ts.
113
+ // Real exports the hooks module needs: getBridge, plus the coded error
114
+ // class the hooks throw for refusals they can make without a round-trip.
115
+ // resetBridge isn't referenced from hooks.ts.
115
116
  getBridge: jest.fn(() => mockSingletonBridge),
117
+ FiasBridgeError: jest.requireActual('./bridge').FiasBridgeError,
116
118
  FiasBridge: jest.fn(),
117
119
  resetBridge: jest.fn(),
118
120
  }));
@@ -238,6 +240,24 @@ describe('SDK hooks', () => {
238
240
  });
239
241
  });
240
242
  });
243
+ describe('hook return identity', () => {
244
+ // These two returns MUST be memoized. The natural way to consume them is
245
+ // `useEffect(() => { void storage.readFile(...) }, [storage])`, and a
246
+ // fresh object literal every render turns that into an infinite loop —
247
+ // effect, setState, render, effect. It cost a live QA session to find.
248
+ //
249
+ // The react mock here stubs `useMemo` as a pass-through, so identity
250
+ // across renders cannot be asserted from this harness. What it CAN pin is
251
+ // that the return goes through `useMemo` at all, which is the thing a
252
+ // future edit would undo by reaching for an object literal again.
253
+ it.each([['useFiasStorage'], ['useFiasNavigation']])('%s memoizes its returned API object', (hookName) => {
254
+ const react = require('react');
255
+ react.useMemo.mockClear();
256
+ const hooks = require('./hooks');
257
+ hooks[hookName]();
258
+ expect(react.useMemo).toHaveBeenCalled();
259
+ });
260
+ });
241
261
  describe('useFiasStorage', () => {
242
262
  it('returns storage API methods that call bridge.request', async () => {
243
263
  const { useFiasStorage } = require('./hooks');
@@ -289,6 +309,115 @@ describe('SDK hooks', () => {
289
309
  expect(res.document.name).toBe('a.pdf');
290
310
  });
291
311
  });
312
+ describe('useVaultUserDocuments — edit access and in-place saves', () => {
313
+ const DOC = 'vdoc_0123456789abcdef0123456789abcdef';
314
+ const receipt = { documentId: DOC, sizeBytes: 5, updatedAt: 'now', revision: 'c:new' };
315
+ it('pick({ access }) forwards the access asked for; the default asks for nothing extra', async () => {
316
+ const { useVaultUserDocuments } = require('./hooks');
317
+ const docs = useVaultUserDocuments();
318
+ mockBridge.request.mockResolvedValue({ canceled: true });
319
+ await docs.pick({ maxDocuments: 3, access: 'write' });
320
+ await docs.pick();
321
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_pick', {
322
+ maxDocuments: 3,
323
+ access: 'write',
324
+ });
325
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'vault_documents_pick', {
326
+ maxDocuments: undefined,
327
+ access: undefined,
328
+ });
329
+ });
330
+ it('pick({ accept }) forwards the accepted types as a plain array', async () => {
331
+ const { useVaultUserDocuments } = require('./hooks');
332
+ mockBridge.request.mockResolvedValue({ canceled: true });
333
+ await useVaultUserDocuments().pick({ accept: ['image/*', '.pdf'] });
334
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_pick', {
335
+ maxDocuments: undefined,
336
+ access: undefined,
337
+ accept: ['image/*', '.pdf'],
338
+ });
339
+ });
340
+ it('requestEditAccess names ONE document and asks for write — through the same host-mediated op', async () => {
341
+ const { useVaultUserDocuments } = require('./hooks');
342
+ mockBridge.request.mockResolvedValue({ canceled: true });
343
+ await useVaultUserDocuments().requestEditAccess(DOC);
344
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_pick', {
345
+ maxDocuments: 1,
346
+ access: 'write',
347
+ documentId: DOC,
348
+ });
349
+ });
350
+ it('a small string saves over the entity lane with its expectedRevision', async () => {
351
+ const { useVaultUserDocuments } = require('./hooks');
352
+ mockBridge.request.mockResolvedValue(output(receipt));
353
+ const res = await useVaultUserDocuments().saveContent({
354
+ documentId: DOC,
355
+ content: 'hello',
356
+ expectedRevision: 'c:old',
357
+ });
358
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.VAULT_USER_DOCUMENTS_ENTITY_ID, 'save_consented_document_content', {
359
+ documentId: DOC,
360
+ content: 'hello',
361
+ expectedRevision: 'c:old',
362
+ }));
363
+ expect(res.revision).toBe('c:new');
364
+ });
365
+ it.each([
366
+ ['bytes', new Uint8Array([1, 2, 3]), 'binary', Buffer.from([1, 2, 3]).toString('base64')],
367
+ ['a string past the bridge body', 'x'.repeat(200001), 'text', null],
368
+ ])('%s go to the host-orchestrated lane, aimed at the USER’S files and never able to create a document', async (_label, content, contentKind, expectedBase64) => {
369
+ const { useVaultUserDocuments } = require('./hooks');
370
+ mockBridge.request.mockResolvedValue(receipt);
371
+ await useVaultUserDocuments().saveContent({
372
+ documentId: DOC,
373
+ content,
374
+ expectedRevision: 'c:old',
375
+ });
376
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
377
+ const [type, payload] = mockBridge.request.mock.calls[0];
378
+ expect(type).toBe('vault_documents_upload');
379
+ expect(Object.keys(payload).sort()).toEqual([
380
+ 'bytes',
381
+ 'contentKind',
382
+ 'expectedRevision',
383
+ 'saveTarget',
384
+ 'saveToDocumentId',
385
+ ]);
386
+ expect(payload).toMatchObject({
387
+ saveToDocumentId: DOC,
388
+ saveTarget: 'user',
389
+ contentKind,
390
+ expectedRevision: 'c:old',
391
+ });
392
+ if (expectedBase64)
393
+ expect(payload.bytes).toBe(expectedBase64);
394
+ });
395
+ });
396
+ describe('saveContent — refused locally past the 15 MB cap', () => {
397
+ const DOC = 'vdoc_0123456789abcdef0123456789abcdef';
398
+ it.each([
399
+ ['useVaultDocuments', 'useVaultDocuments'],
400
+ ['useVaultUserDocuments', 'useVaultUserDocuments'],
401
+ ])('%s: rejects with OWN_DOCUMENT_SAVE_TOO_LARGE before base64-encoding or sending anything', async (_label, hookName) => {
402
+ const hooks = require('./hooks');
403
+ const tooBig = new Uint8Array(15 * 1024 * 1024 + 1);
404
+ await expect(hooks[hookName]().saveContent({
405
+ documentId: DOC,
406
+ content: tooBig,
407
+ expectedRevision: 'c:old',
408
+ })).rejects.toMatchObject({ name: 'FiasBridgeError', code: 'OWN_DOCUMENT_SAVE_TOO_LARGE' });
409
+ expect(mockBridge.request).not.toHaveBeenCalled();
410
+ });
411
+ it('content exactly AT the cap is sent', async () => {
412
+ const { useVaultDocuments } = require('./hooks');
413
+ mockBridge.request.mockResolvedValue({ documentId: DOC, sizeBytes: 1, updatedAt: 'now' });
414
+ await useVaultDocuments().saveContent({
415
+ documentId: DOC,
416
+ content: new Uint8Array(15 * 1024 * 1024),
417
+ });
418
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
419
+ });
420
+ });
292
421
  describe('useVaultDocuments.readBytes (arche-owned binary reads)', () => {
293
422
  it('invokes get_own_document_bytes on the Vault Arche Documents entity and decodes byte-exact', async () => {
294
423
  const { useVaultDocuments } = require('./hooks');
@@ -341,6 +470,143 @@ describe('SDK hooks', () => {
341
470
  updatedAt: '2026-09-10T00:00:00.000Z',
342
471
  });
343
472
  });
473
+ // ── one method, lane picked from the argument ────────────────────────────
474
+ const DOC_ID = 'vdoc_0123456789abcdef0123456789abcdef';
475
+ const receipt = { documentId: DOC_ID, sizeBytes: 1, updatedAt: 'x', revision: 'c:new' };
476
+ /**
477
+ * REGRESSION GUARD. The text lane was chosen on raw UTF-8 size alone, but
478
+ * the text travels as a JSON string inside a 256 KB request: quotes,
479
+ * backslashes and newlines double, other control characters become six
480
+ * bytes. 150 KB of newlines is 300 KB on the wire — an opaque 413.
481
+ */
482
+ it.each([
483
+ ['newlines (2 bytes each on the wire)', '\n'.repeat(150000)],
484
+ ['quotes', '"'.repeat(150000)],
485
+ ['control characters (6 bytes each)', '\u0001'.repeat(60000)],
486
+ ])('text under 200 KB that would NOT fit the request once JSON-escaped goes to the host lane: %s', async (_label, content) => {
487
+ const { useVaultDocuments } = require('./hooks');
488
+ mockBridge.request.mockResolvedValue(receipt);
489
+ expect(Buffer.byteLength(content, 'utf-8')).toBeLessThanOrEqual(200000);
490
+ await useVaultDocuments().saveContent({ documentId: DOC_ID, content });
491
+ const [type, payload] = mockBridge.request.mock.calls[0];
492
+ expect(type).toBe('vault_documents_upload');
493
+ expect(payload.contentKind).toBe('text');
494
+ expect(Buffer.from(payload.bytes, 'base64').toString('utf-8')).toBe(content);
495
+ });
496
+ it('the same amount of ORDINARY text still rides the small lane', async () => {
497
+ const { useVaultDocuments } = require('./hooks');
498
+ mockBridge.request.mockResolvedValue(output(receipt));
499
+ await useVaultDocuments().saveContent({ documentId: DOC_ID, content: 'x'.repeat(150000) });
500
+ expect(mockBridge.request.mock.calls[0][0]).toBe('entity_invoke');
501
+ });
502
+ it('write() has no second lane, so it refuses what cannot travel — by name, not as a 413', async () => {
503
+ const { useVaultDocuments } = require('./hooks');
504
+ const docs = useVaultDocuments();
505
+ await expect(docs.write({ name: 'a.txt', content: '\n'.repeat(150000) })).rejects.toMatchObject({
506
+ code: 'INVALID_PARAMS',
507
+ message: expect.stringMatching(/upload\(\)/),
508
+ });
509
+ await expect(docs.write({ name: 'a.txt', content: 'x'.repeat(200001) })).rejects.toMatchObject({
510
+ code: 'INVALID_PARAMS',
511
+ });
512
+ expect(mockBridge.request).not.toHaveBeenCalled();
513
+ mockBridge.request.mockResolvedValue({ documentId: DOC_ID });
514
+ await expect(docs.write({ name: 'a.txt', content: 'x'.repeat(150000) })).resolves.toEqual({
515
+ documentId: DOC_ID,
516
+ });
517
+ });
518
+ const decode = (b64) => Buffer.from(b64, 'base64');
519
+ /** The `[op, payload]` of the n-th bridge request, typed. */
520
+ const sent = (n = 0) => mockBridge.request.mock.calls[n];
521
+ it('sends small text with expectedRevision on the body lane', async () => {
522
+ const { useVaultDocuments } = require('./hooks');
523
+ mockBridge.request.mockResolvedValue(output(receipt));
524
+ const res = await useVaultDocuments().saveContent({
525
+ documentId: DOC_ID,
526
+ content: 'hello',
527
+ expectedRevision: 'c:old',
528
+ });
529
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.VAULT_ARCHE_DOCUMENTS_ENTITY_ID, 'save_own_document_content', {
530
+ documentId: DOC_ID,
531
+ content: 'hello',
532
+ expectedRevision: 'c:old',
533
+ }));
534
+ expect(res.revision).toBe('c:new');
535
+ });
536
+ it.each([
537
+ ['Uint8Array', () => new Uint8Array([0x25, 0x50, 0x00, 0xff])],
538
+ ['ArrayBuffer', () => new Uint8Array([0x25, 0x50, 0x00, 0xff]).buffer],
539
+ ['Blob', () => new Blob([new Uint8Array([0x25, 0x50, 0x00, 0xff])])],
540
+ ])('routes %s content through the host-uploaded lane, byte-exact, as binary', async (_l, make) => {
541
+ const { useVaultDocuments } = require('./hooks');
542
+ mockBridge.request.mockResolvedValue(receipt);
543
+ const res = await useVaultDocuments().saveContent({
544
+ documentId: DOC_ID,
545
+ content: make(),
546
+ contentType: 'application/pdf',
547
+ expectedRevision: 'c:old',
548
+ });
549
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
550
+ const [op, payload] = sent();
551
+ expect(op).toBe('vault_documents_upload');
552
+ expect(payload).toEqual({
553
+ saveToDocumentId: DOC_ID,
554
+ bytes: expect.any(String),
555
+ mimeType: 'application/pdf',
556
+ contentKind: 'binary',
557
+ expectedRevision: 'c:old',
558
+ });
559
+ expect(Array.from(decode(payload.bytes))).toEqual([0x25, 0x50, 0x00, 0xff]);
560
+ // NOT unwrapped from `{ output }` — the host returns the receipt itself.
561
+ expect(res).toEqual(receipt);
562
+ });
563
+ /**
564
+ * REGRESSION GUARD. A host that predates in-place saves handles
565
+ * `vault_documents_upload` by CREATING a document, and requires `name` to
566
+ * do it. Sending no `name` makes such a host refuse the message; sending
567
+ * one would make it silently fork a new document on every save — the
568
+ * exact behaviour this lane exists to remove.
569
+ */
570
+ it('never sends create-path fields on the save lane, so an older host fails closed instead of forking a document', async () => {
571
+ const { useVaultDocuments } = require('./hooks');
572
+ mockBridge.request.mockResolvedValue(receipt);
573
+ await useVaultDocuments().saveContent({ documentId: DOC_ID, content: new Uint8Array([1]) });
574
+ const payload = mockBridge.request.mock.calls[0][1];
575
+ for (const key of [
576
+ 'name',
577
+ 'tags',
578
+ 'folderPath',
579
+ 'sensitivity',
580
+ 'replacesDocumentId',
581
+ 'userVisible',
582
+ ]) {
583
+ expect(payload).not.toHaveProperty(key);
584
+ }
585
+ });
586
+ it('auto-routes text past the 200 KB body ceiling through the host lane AS TEXT, rather than failing', async () => {
587
+ const { useVaultDocuments } = require('./hooks');
588
+ mockBridge.request.mockResolvedValue(receipt);
589
+ const big = 'a'.repeat(200001);
590
+ await useVaultDocuments().saveContent({ documentId: DOC_ID, content: big });
591
+ const [op, payload] = sent();
592
+ expect(op).toBe('vault_documents_upload');
593
+ // 'text' keeps the must-be-a-text-type rule in force at any size.
594
+ expect(payload.contentKind).toBe('text');
595
+ expect(decode(payload.bytes).toString('utf-8')).toBe(big);
596
+ });
597
+ it('measures the ENCODED size: multi-byte text under 200k chars but over 200 KB takes the host lane', async () => {
598
+ const { useVaultDocuments } = require('./hooks');
599
+ mockBridge.request.mockResolvedValue(receipt);
600
+ const content = '€'.repeat(70000); // 210 000 bytes, 70 000 chars
601
+ await useVaultDocuments().saveContent({ documentId: DOC_ID, content });
602
+ expect(mockBridge.request.mock.calls[0][0]).toBe('vault_documents_upload');
603
+ });
604
+ it('keeps text at exactly the ceiling on the body lane', async () => {
605
+ const { useVaultDocuments } = require('./hooks');
606
+ mockBridge.request.mockResolvedValue(output(receipt));
607
+ await useVaultDocuments().saveContent({ documentId: DOC_ID, content: 'a'.repeat(200000) });
608
+ expect(mockBridge.request.mock.calls[0][0]).toBe('entity_invoke');
609
+ });
344
610
  });
345
611
  describe('useEntityInvocation', () => {
346
612
  it('returns invoke function and initial state', () => {
@@ -535,6 +801,19 @@ describe('SDK hooks', () => {
535
801
  await Promise.resolve();
536
802
  expect(mockSetState).toHaveBeenCalledWith(42);
537
803
  });
804
+ it('keeps a value set before the mount read resolves (the read must not revert it)', async () => {
805
+ let resolveRead;
806
+ mockBridge.request.mockImplementationOnce(() => new Promise((r) => (resolveRead = r)));
807
+ const { usePersistentState } = require('./hooks');
808
+ const [, setValue] = usePersistentState('choice', 'a');
809
+ setValue('b'); // the user acts while the read is still in flight
810
+ mockSetState.mockClear();
811
+ resolveRead(output({ content: JSON.stringify('stored'), exists: true }));
812
+ await Promise.resolve();
813
+ await Promise.resolve();
814
+ await Promise.resolve();
815
+ expect(mockSetState).not.toHaveBeenCalledWith('stored');
816
+ });
538
817
  it('falls back to the initial value when the mount storage_read is denied/fails', async () => {
539
818
  // e.g. the plugin didn't declare `storage:sandbox`. Must not become a
540
819
  // silent unhandled rejection, and must not clobber the initial value.
@@ -715,6 +994,39 @@ describe('SDK hooks', () => {
715
994
  documentId: 'vdoc_2',
716
995
  });
717
996
  });
997
+ it("upload({ destination: 'documents' }) asks the host for its Save dialog", async () => {
998
+ const { useVaultDocuments } = freshHooks();
999
+ const api = useVaultDocuments();
1000
+ const saved = {
1001
+ documentId: 'vdoc_s',
1002
+ status: 'uploaded',
1003
+ name: 'Report (2).pdf',
1004
+ access: 'read',
1005
+ };
1006
+ mockBridge.request.mockResolvedValueOnce(saved);
1007
+ const res = await api.upload(new Uint8Array([1, 2]), {
1008
+ name: 'Report.pdf',
1009
+ mimeType: 'application/pdf',
1010
+ destination: 'documents',
1011
+ });
1012
+ expect(res).toEqual(saved);
1013
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload', expect.objectContaining({ name: 'Report.pdf', destination: 'documents' }));
1014
+ });
1015
+ it('saveCopy rides the update op with only the saveCopy options', async () => {
1016
+ const { useVaultDocuments } = freshHooks();
1017
+ const api = useVaultDocuments();
1018
+ mockBridge.request.mockResolvedValue({ documentId: 'vdoc_c', status: 'uploaded' });
1019
+ await api.saveCopy('vdoc_own', { suggestedName: 'Final.png' });
1020
+ expect(mockBridge.request).toHaveBeenLastCalledWith('vault_documents_update', {
1021
+ documentId: 'vdoc_own',
1022
+ saveCopy: { suggestedName: 'Final.png' },
1023
+ });
1024
+ await api.saveCopy('vdoc_own');
1025
+ expect(mockBridge.request).toHaveBeenLastCalledWith('vault_documents_update', {
1026
+ documentId: 'vdoc_own',
1027
+ saveCopy: {},
1028
+ });
1029
+ });
718
1030
  it('getDownloadUrl caches a still-valid presigned URL', async () => {
719
1031
  const { useVaultDocuments } = freshHooks();
720
1032
  const api = useVaultDocuments();
@@ -1405,6 +1717,106 @@ describe('SDK hooks', () => {
1405
1717
  expect(asset.assetId).toBe('aset_1');
1406
1718
  });
1407
1719
  });
1720
+ describe('useFiasContent (content packs, ADR-021)', () => {
1721
+ const ENTITY = 'ent_intg_plugin_content_00000000';
1722
+ const load = () => {
1723
+ const { useFiasContent, FiasContentError } = require('./hooks');
1724
+ return { content: useFiasContent(), FiasContentError };
1725
+ };
1726
+ it('lists through the generic client_entity_invoke op, verb in the input', async () => {
1727
+ const { content } = load();
1728
+ const files = [{ path: 'lessons/01.md', sizeBytes: 5, mimeType: 'text/markdown' }];
1729
+ mockBridge.request.mockResolvedValueOnce({ files });
1730
+ expect(await content.list('lessons/')).toEqual(files);
1731
+ expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'list', prefix: 'lessons/' } }, 120000);
1732
+ });
1733
+ it('reads text and parses JSON', async () => {
1734
+ const { content } = load();
1735
+ mockBridge.request.mockResolvedValueOnce({
1736
+ results: { 'a.md': { text: '# A', mimeType: 'text/markdown' } },
1737
+ });
1738
+ expect(await content.getText('a.md')).toBe('# A');
1739
+ mockBridge.request.mockResolvedValueOnce({
1740
+ results: { 'c.json': { text: '{"n":1}', mimeType: 'application/json' } },
1741
+ });
1742
+ expect(await content.getJson('c.json')).toEqual({ n: 1 });
1743
+ });
1744
+ it('throws a coded FiasContentError for a per-file and a whole-call failure', async () => {
1745
+ const { content, FiasContentError } = load();
1746
+ mockBridge.request.mockResolvedValueOnce({
1747
+ results: { 'x.md': { error: { code: 'CONTENT_NOT_FOUND', message: 'not in pack' } } },
1748
+ });
1749
+ const perFile = await content.getText('x.md').catch((e) => e);
1750
+ expect(perFile).toBeInstanceOf(FiasContentError);
1751
+ expect(perFile).toMatchObject({ code: 'CONTENT_NOT_FOUND', path: 'x.md' });
1752
+ mockBridge.request.mockResolvedValueOnce({
1753
+ error: { code: 'CONTENT_UNAVAILABLE_IN_PREVIEW', message: 'preview' },
1754
+ });
1755
+ await expect(content.list()).rejects.toMatchObject({
1756
+ code: 'CONTENT_UNAVAILABLE_IN_PREVIEW',
1757
+ });
1758
+ });
1759
+ it.each([
1760
+ ['Permission denied: entities:client_invoke', undefined, 'PERMISSION_DENIED'],
1761
+ ['Rate limit exceeded for client_entity_invoke', undefined, 'RATE_LIMIT'],
1762
+ ['Bridge request timed out', 'TIMEOUT', 'CONTENT_UNAVAILABLE'],
1763
+ ])('maps a bridge failure (%s, code %s) to %s', async (message, code, expected) => {
1764
+ const { content } = load();
1765
+ mockBridge.request.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
1766
+ await expect(content.list()).rejects.toMatchObject({ code: expected });
1767
+ });
1768
+ it('reads bytes, and batches with per-file errors', async () => {
1769
+ const { content, FiasContentError } = load();
1770
+ const bytes = new Uint8Array([1, 2]).buffer;
1771
+ mockBridge.request.mockResolvedValueOnce({
1772
+ results: { 'a.bin': { bytes, mimeType: 'application/octet-stream' } },
1773
+ });
1774
+ expect(await content.getBytes('a.bin')).toBe(bytes);
1775
+ mockBridge.request.mockResolvedValueOnce({
1776
+ results: {
1777
+ 'a.md': { text: '# A', mimeType: 'text/markdown' },
1778
+ 'b.png': { error: { code: 'CONTENT_NOT_TEXT', message: 'image/png, not text' } },
1779
+ },
1780
+ });
1781
+ const many = await content.getMany(['a.md', 'b.png', 'gone.md'], 'text');
1782
+ expect(many['a.md']).toBe('# A');
1783
+ expect(many['b.png']).toMatchObject({ code: 'CONTENT_NOT_TEXT', path: 'b.png' });
1784
+ expect(many['gone.md']).toBeInstanceOf(FiasContentError);
1785
+ });
1786
+ it('builds one typed blob: URL per path from host-cached bytes', async () => {
1787
+ const { content } = load();
1788
+ const created = [];
1789
+ const original = URL.createObjectURL;
1790
+ URL.createObjectURL = jest.fn((blob) => {
1791
+ created.push(blob);
1792
+ return 'blob:plugin/1';
1793
+ });
1794
+ try {
1795
+ mockBridge.request.mockResolvedValueOnce({
1796
+ results: {
1797
+ 'img/cover-unique.webp': { bytes: new ArrayBuffer(3), mimeType: 'image/webp' },
1798
+ },
1799
+ });
1800
+ expect(await content.getObjectUrl('img/cover-unique.webp')).toBe('blob:plugin/1');
1801
+ expect(await content.getObjectUrl('img/cover-unique.webp')).toBe('blob:plugin/1');
1802
+ expect(created).toHaveLength(1);
1803
+ expect(created[0].type).toBe('image/webp');
1804
+ }
1805
+ finally {
1806
+ URL.createObjectURL = original;
1807
+ }
1808
+ });
1809
+ it('returns a signed streaming URL with its expiry', async () => {
1810
+ const { content } = load();
1811
+ mockBridge.request.mockResolvedValueOnce({
1812
+ results: { 'a.ogg': { url: 'https://cdn/x?sig', expiresAt: '2026-09-24T00:00:00Z' } },
1813
+ });
1814
+ expect(await content.getUrl('a.ogg')).toEqual({
1815
+ url: 'https://cdn/x?sig',
1816
+ expiresAt: '2026-09-24T00:00:00Z',
1817
+ });
1818
+ });
1819
+ });
1408
1820
  });
1409
1821
  describe('useDataSubscription (ADR-012 Phase M4)', () => {
1410
1822
  const flush = async () => {