@fias/arche-sdk 2.29.0 → 2.30.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.
@@ -71,7 +71,13 @@ function createMockBridge() {
71
71
  getHolder: jest.fn().mockReturnValue(null),
72
72
  onHolderReady: jest.fn().mockReturnValue(() => { }),
73
73
  onThemeUpdate: jest.fn().mockReturnValue(() => { }),
74
+ onAccessUpdate: jest.fn().mockReturnValue(() => { }),
74
75
  onNavigationUpdate: jest.fn().mockReturnValue(() => { }),
76
+ getChrome: jest.fn().mockReturnValue(null),
77
+ hasChromeSupport: jest.fn().mockReturnValue(false),
78
+ onChromeUpdate: jest.fn().mockReturnValue(() => { }),
79
+ setChrome: jest.fn(),
80
+ navigateHost: jest.fn(),
75
81
  onStepNavigationUpdate: jest.fn().mockReturnValue(() => { }),
76
82
  registerPreviewState: jest.fn().mockReturnValue(() => { }),
77
83
  onSubscriptionEvent: jest.fn().mockReturnValue(() => { }),
@@ -244,6 +250,66 @@ describe('SDK hooks', () => {
244
250
  });
245
251
  });
246
252
  });
253
+ describe('useFiasChrome', () => {
254
+ const HOST_STATE = {
255
+ focusMode: true,
256
+ focusModeAvailable: true,
257
+ sidebarAvailable: true,
258
+ sidebarCollapsed: false,
259
+ hostExpandedBar: true,
260
+ hostExpandButton: false,
261
+ };
262
+ it('is inert and unsupported before init / on an older host, and still renders', () => {
263
+ const { useFiasChrome } = require('./hooks');
264
+ const chrome = useFiasChrome();
265
+ expect(chrome.supported).toBe(false);
266
+ expect(chrome.focusMode).toBe(false);
267
+ expect(chrome.focusModeAvailable).toBe(false);
268
+ expect(chrome.sidebarAvailable).toBe(false);
269
+ expect(chrome.hostExpandedBar).toBe(true);
270
+ expect(mockBridge.onChromeUpdate).toHaveBeenCalled();
271
+ });
272
+ it('spreads the host state and reports supported once delivered', () => {
273
+ mockBridge.getChrome.mockReturnValue(HOST_STATE);
274
+ mockBridge.hasChromeSupport.mockReturnValue(true);
275
+ const { useFiasChrome } = require('./hooks');
276
+ const chrome = useFiasChrome();
277
+ expect(chrome.supported).toBe(true);
278
+ expect(chrome.focusMode).toBe(true);
279
+ expect(chrome.hostExpandButton).toBe(false);
280
+ });
281
+ it('TAKES THE CHROME OVER on mount — the host drops its default expand button', () => {
282
+ const { useFiasChrome } = require('./hooks');
283
+ useFiasChrome();
284
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ hostExpandButton: false });
285
+ });
286
+ it('setters send one-field chrome_set patches; navigation uses the closed enum', () => {
287
+ const { useFiasChrome } = require('./hooks');
288
+ const chrome = useFiasChrome();
289
+ chrome.setFocusMode(true);
290
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ focusMode: true });
291
+ chrome.setSidebarCollapsed(true);
292
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ sidebarCollapsed: true });
293
+ chrome.setHostExpandedBar(false);
294
+ expect(mockBridge.setChrome).toHaveBeenCalledWith({ hostExpandedBar: false });
295
+ chrome.goToFiasHome();
296
+ expect(mockBridge.navigateHost).toHaveBeenCalledWith('home');
297
+ chrome.goToWorktop();
298
+ expect(mockBridge.navigateHost).toHaveBeenCalledWith('worktop');
299
+ chrome.openAssistant();
300
+ expect(mockBridge.openAssistant).toHaveBeenCalled();
301
+ });
302
+ it('cleanup unsubscribes from chrome updates', () => {
303
+ const unsubscribe = jest.fn();
304
+ mockBridge.onChromeUpdate.mockReturnValue(unsubscribe);
305
+ const { useFiasChrome } = require('./hooks');
306
+ useFiasChrome();
307
+ // The LAST captured cleanup is the takeover effect's (none) or the
308
+ // subscription's — walk the captured one if present.
309
+ capturedEffectCleanup?.();
310
+ expect(unsubscribe).toHaveBeenCalled();
311
+ });
312
+ });
247
313
  describe('hook return identity', () => {
248
314
  // These two returns MUST be memoized. The natural way to consume them is
249
315
  // `useEffect(() => { void storage.readFile(...) }, [storage])`, and a
@@ -788,6 +854,205 @@ describe('SDK hooks', () => {
788
854
  expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'create', { forActingHolder: true }));
789
855
  });
790
856
  });
857
+ // ADR-034 phase 0b. The real-render cases (stale answers across workspace
858
+ // switches, the render before effects run, refetch) live in
859
+ // use-fias-access.render.test.tsx, which CI runs through the package's
860
+ // `test:render` script; these mock React and run under the root config.
861
+ describe('useFiasAccess', () => {
862
+ const settle = () => new Promise((r) => setTimeout(r, 0));
863
+ const setCalls = () => mockSetState.mock.calls.map((c) => c[0]);
864
+ it('asks the workspaces entity for my_access with the workspace id', async () => {
865
+ const { useFiasAccess } = require('./hooks');
866
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
867
+ mockBridge.request.mockResolvedValue(output({ access }));
868
+ useFiasAccess('ws_1');
869
+ await settle();
870
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'my_access', { workspaceId: 'ws_1' }));
871
+ expect(setCalls()).toContainEqual({ workspaceId: 'ws_1', access, error: null });
872
+ });
873
+ it.each(['NOT_A_MEMBER', 'WORKSPACE_NOT_FOUND'])('%s becomes tier: null', async (code) => {
874
+ const { useFiasAccess } = require('./hooks');
875
+ const { FiasBridgeError } = jest.requireActual('./bridge');
876
+ mockBridge.request.mockRejectedValue(new FiasBridgeError('refused', code));
877
+ useFiasAccess('ws_1');
878
+ await settle();
879
+ expect(setCalls()).toContainEqual({
880
+ workspaceId: 'ws_1',
881
+ access: { mode: 'tier', workspaceId: 'ws_1', tier: null, expiresAt: null },
882
+ error: null,
883
+ });
884
+ });
885
+ it('surfaces any other failure as an error', async () => {
886
+ const { useFiasAccess } = require('./hooks');
887
+ const { FiasBridgeError } = jest.requireActual('./bridge');
888
+ mockBridge.request.mockRejectedValue(new FiasBridgeError('slow down', 'RATE_LIMIT'));
889
+ useFiasAccess('ws_1');
890
+ await settle();
891
+ expect(setCalls()).toContainEqual({
892
+ workspaceId: 'ws_1',
893
+ access: null,
894
+ error: expect.objectContaining({ code: 'RATE_LIMIT' }),
895
+ });
896
+ });
897
+ it('asks nothing without a workspace', async () => {
898
+ const { useFiasAccess } = require('./hooks');
899
+ const state = useFiasAccess(null);
900
+ await settle();
901
+ expect(mockBridge.request).not.toHaveBeenCalled();
902
+ expect(state).toMatchObject({ access: null, isLoading: false, error: null });
903
+ });
904
+ it('asks again when the host says the person’s access changed (ADR-034)', async () => {
905
+ const { useFiasAccess } = require('./hooks');
906
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
907
+ mockBridge.request.mockResolvedValue(output({ access }));
908
+ useFiasAccess('ws_1');
909
+ await settle();
910
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
911
+ const listener = mockBridge.onAccessUpdate.mock.calls[0][0];
912
+ listener();
913
+ await settle();
914
+ expect(mockBridge.request).toHaveBeenCalledTimes(2);
915
+ });
916
+ it('a failed background refetch keeps the answer it had', async () => {
917
+ const { useFiasAccess } = require('./hooks');
918
+ const { FiasBridgeError } = jest.requireActual('./bridge');
919
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
920
+ mockBridge.request.mockResolvedValueOnce(output({ access }));
921
+ useFiasAccess('ws_1');
922
+ await settle();
923
+ mockSetState.mockClear();
924
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('slow down', 'RATE_LIMIT'));
925
+ const subscriptions = mockBridge.onAccessUpdate.mock.calls;
926
+ subscriptions[subscriptions.length - 1][0]();
927
+ await settle();
928
+ // No blanking answer was written, and nothing flipped to loading.
929
+ expect(setCalls()).not.toContainEqual(expect.objectContaining({ access: null }));
930
+ expect(setCalls()).not.toContain(true);
931
+ });
932
+ it('window focus asks again at most once per 30 s', async () => {
933
+ const listeners = {};
934
+ const win = {
935
+ addEventListener: jest.fn((type, fn) => (listeners[type] = fn)),
936
+ removeEventListener: jest.fn(),
937
+ };
938
+ globalThis.window = win;
939
+ try {
940
+ const { useFiasAccess } = require('./hooks');
941
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
942
+ mockBridge.request.mockResolvedValue(output({ access }));
943
+ useFiasAccess('ws_1');
944
+ await settle();
945
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
946
+ listeners.focus();
947
+ await settle();
948
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
949
+ const now = Date.now();
950
+ jest.spyOn(Date, 'now').mockReturnValue(now + 31000);
951
+ listeners.focus();
952
+ await settle();
953
+ expect(mockBridge.request).toHaveBeenCalledTimes(2);
954
+ }
955
+ finally {
956
+ delete globalThis.window;
957
+ jest.restoreAllMocks();
958
+ }
959
+ });
960
+ it('opens the host’s access sheet with no input, and asks again only if it changed', async () => {
961
+ const { useFiasAccess } = require('./hooks');
962
+ const access = { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null };
963
+ mockBridge.request.mockResolvedValue(output({ access }));
964
+ const state = useFiasAccess('ws_1');
965
+ await settle();
966
+ mockBridge.request.mockClear();
967
+ mockBridge.request.mockResolvedValueOnce({ changed: false });
968
+ await expect(state.openAccessDialog()).resolves.toEqual({ changed: false });
969
+ expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: 'ent_intg_app_access_dialog_00000', input: {} }, expect.any(Number));
970
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
971
+ mockBridge.request.mockResolvedValueOnce({ changed: true });
972
+ mockBridge.request.mockResolvedValue(output({ access }));
973
+ await expect(state.openAccessDialog()).resolves.toEqual({ changed: true });
974
+ await settle();
975
+ expect(mockBridge.request).toHaveBeenCalledTimes(3);
976
+ });
977
+ it('a host without the sheet (an older Fias) is ACCESS_UNAVAILABLE, not a bare bridge error', async () => {
978
+ const { useFiasAccess } = require('./hooks');
979
+ const { FiasBridgeError } = jest.requireActual('./bridge');
980
+ mockBridge.request.mockResolvedValue(output({ access: null }));
981
+ const state = useFiasAccess('ws_1');
982
+ await settle();
983
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('no client-side executor registered', 'BRIDGE_ERROR'));
984
+ await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'ACCESS_UNAVAILABLE' });
985
+ });
986
+ it('an older dev harness without the sheet is ACCESS_UNAVAILABLE too', async () => {
987
+ const { useFiasAccess } = require('./hooks');
988
+ const { FiasBridgeError } = jest.requireActual('./bridge');
989
+ mockBridge.request.mockResolvedValue(output({ access: null }));
990
+ const state = useFiasAccess('ws_1');
991
+ await settle();
992
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('client_entity_invoke: no client-side executor for ent_intg_app_access_dialog_00000 in the dev harness', 'BRIDGE_ERROR'));
993
+ await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'ACCESS_UNAVAILABLE' });
994
+ });
995
+ it('any other bridge error (e.g. a missing permission) passes through unchanged', async () => {
996
+ const { useFiasAccess } = require('./hooks');
997
+ const { FiasBridgeError } = jest.requireActual('./bridge');
998
+ mockBridge.request.mockResolvedValue(output({ access: null }));
999
+ const state = useFiasAccess('ws_1');
1000
+ await settle();
1001
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('Permission denied: data:workspace not granted', 'BRIDGE_ERROR'));
1002
+ await expect(state.openAccessDialog()).rejects.toMatchObject({
1003
+ code: 'BRIDGE_ERROR',
1004
+ message: 'Permission denied: data:workspace not granted',
1005
+ });
1006
+ });
1007
+ it('the sheet’s refusal is a typed error', async () => {
1008
+ const { useFiasAccess } = require('./hooks');
1009
+ mockBridge.request.mockResolvedValue(output({ access: null }));
1010
+ const state = useFiasAccess('ws_1');
1011
+ await settle();
1012
+ mockBridge.request.mockResolvedValueOnce({
1013
+ error: { code: 'NOT_IN_WORKSPACE', message: 'Personal' },
1014
+ });
1015
+ await expect(state.openAccessDialog()).rejects.toMatchObject({ code: 'NOT_IN_WORKSPACE' });
1016
+ });
1017
+ });
1018
+ describe('useCreativeLibrary().pick (ADR-035 D8)', () => {
1019
+ const PICKED = {
1020
+ item: { itemId: 'vci_' + 'a'.repeat(32), kind: 'style', name: 'Ink', version: 2 },
1021
+ };
1022
+ it('asks the host’s picker for a kind, waiting on the person', async () => {
1023
+ const { useCreativeLibrary } = require('./hooks');
1024
+ mockBridge.request.mockResolvedValue(PICKED);
1025
+ await expect(useCreativeLibrary().pick({ kind: 'style' })).resolves.toEqual(PICKED);
1026
+ const [op, payload, timeout] = mockBridge.request.mock.calls[0];
1027
+ expect(op).toBe('client_entity_invoke');
1028
+ expect(payload).toEqual({ entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID, input: { kind: 'style' } });
1029
+ expect(timeout).toBeGreaterThanOrEqual(5 * 60 * 1000);
1030
+ });
1031
+ it('sends nothing when no kind is asked, and passes a closed sheet through', async () => {
1032
+ const { useCreativeLibrary } = require('./hooks');
1033
+ mockBridge.request.mockResolvedValue({ canceled: true });
1034
+ await expect(useCreativeLibrary().pick()).resolves.toEqual({ canceled: true });
1035
+ expect(mockBridge.request.mock.calls[0][1]).toEqual({
1036
+ entityId: entity_ops_1.CREATIVE_PICKER_ENTITY_ID,
1037
+ input: {},
1038
+ });
1039
+ });
1040
+ it('turns a refusal result into a typed error', async () => {
1041
+ const { useCreativeLibrary } = require('./hooks');
1042
+ mockBridge.request.mockResolvedValue({
1043
+ error: { code: 'SHEET_BUSY', message: 'Another request is waiting' },
1044
+ });
1045
+ await expect(useCreativeLibrary().pick()).rejects.toMatchObject({ code: 'SHEET_BUSY' });
1046
+ });
1047
+ it('a host with no picker answers PICKER_UNAVAILABLE', async () => {
1048
+ const { useCreativeLibrary } = require('./hooks');
1049
+ const { FiasBridgeError } = jest.requireActual('./bridge');
1050
+ mockBridge.request.mockRejectedValue(new FiasBridgeError('no client-side executor registered for x', 'BRIDGE_ERROR'));
1051
+ await expect(useCreativeLibrary().pick()).rejects.toMatchObject({
1052
+ code: 'PICKER_UNAVAILABLE',
1053
+ });
1054
+ });
1055
+ });
791
1056
  describe('useFiasConnections', () => {
792
1057
  it('lists through the connections entity', async () => {
793
1058
  const { useFiasConnections } = require('./hooks');
@@ -1212,6 +1477,7 @@ describe('SDK hooks', () => {
1212
1477
  __esModule: true,
1213
1478
  getBridge: () => mockSingletonBridge,
1214
1479
  FiasBridge: jest.fn(),
1480
+ FiasBridgeError: jest.requireActual('./bridge').FiasBridgeError,
1215
1481
  resetBridge: jest.fn(),
1216
1482
  }));
1217
1483
  jest.doMock('react', () => require('react'));
@@ -1337,6 +1603,136 @@ describe('SDK hooks', () => {
1337
1603
  expect(second.url).toBe('https://example.com/new');
1338
1604
  expect(mockBridge.request).toHaveBeenCalledTimes(2);
1339
1605
  });
1606
+ it('a changed access answer forgets cached download URLs (ADR-034)', async () => {
1607
+ const { useVaultDocuments, useFiasAccess } = freshHooks();
1608
+ const api = useVaultDocuments();
1609
+ mockBridge.request.mockResolvedValueOnce({
1610
+ url: 'https://example.com/minted-under-old-access',
1611
+ expiresAt: Date.now() + 10 * 60000,
1612
+ });
1613
+ await api.getDownloadUrl('file_c');
1614
+ const answer = (permissions) => ({
1615
+ output: {
1616
+ access: {
1617
+ mode: 'roles',
1618
+ workspaceId: 'ws_1',
1619
+ tier: 'member',
1620
+ expiresAt: null,
1621
+ roles: [],
1622
+ permissions,
1623
+ },
1624
+ },
1625
+ });
1626
+ const settle = () => new Promise((r) => setTimeout(r, 0));
1627
+ mockBridge.request.mockResolvedValueOnce(answer(['ledger.read']));
1628
+ useFiasAccess('ws_1');
1629
+ await settle();
1630
+ // The same answer again keeps the cache…
1631
+ mockBridge.request.mockResolvedValueOnce(answer(['ledger.read']));
1632
+ const subscriptions = mockBridge.onAccessUpdate.mock.calls;
1633
+ const listener = subscriptions[subscriptions.length - 1][0];
1634
+ listener();
1635
+ await settle();
1636
+ mockBridge.request.mockClear();
1637
+ await api.getDownloadUrl('file_c');
1638
+ expect(mockBridge.request).not.toHaveBeenCalled();
1639
+ // …a different one does not.
1640
+ mockBridge.request.mockResolvedValueOnce(answer([]));
1641
+ listener();
1642
+ await settle();
1643
+ mockBridge.request.mockClear();
1644
+ mockBridge.request.mockResolvedValueOnce({
1645
+ url: 'https://example.com/fresh',
1646
+ expiresAt: Date.now() + 10 * 60000,
1647
+ });
1648
+ await expect(api.getDownloadUrl('file_c')).resolves.toMatchObject({
1649
+ url: 'https://example.com/fresh',
1650
+ });
1651
+ });
1652
+ it('losing the workspace (NOT_A_MEMBER) forgets cached download URLs too (ADR-034)', async () => {
1653
+ const { useVaultDocuments, useFiasAccess } = freshHooks();
1654
+ const { FiasBridgeError } = jest.requireActual('./bridge');
1655
+ const api = useVaultDocuments();
1656
+ mockBridge.request.mockResolvedValueOnce({
1657
+ url: 'https://example.com/old',
1658
+ expiresAt: Date.now() + 10 * 60000,
1659
+ });
1660
+ await api.getDownloadUrl('file_d');
1661
+ const settle = () => new Promise((r) => setTimeout(r, 0));
1662
+ mockBridge.request.mockResolvedValueOnce({
1663
+ output: { access: { mode: 'tier', workspaceId: 'ws_1', tier: 'member', expiresAt: null } },
1664
+ });
1665
+ useFiasAccess('ws_1');
1666
+ await settle();
1667
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('gone', 'NOT_A_MEMBER'));
1668
+ const subscriptions = mockBridge.onAccessUpdate.mock.calls;
1669
+ subscriptions[subscriptions.length - 1][0]();
1670
+ await settle();
1671
+ mockBridge.request.mockClear();
1672
+ mockBridge.request.mockResolvedValueOnce({
1673
+ url: 'https://example.com/new',
1674
+ expiresAt: Date.now() + 10 * 60000,
1675
+ });
1676
+ await expect(api.getDownloadUrl('file_d')).resolves.toMatchObject({
1677
+ url: 'https://example.com/new',
1678
+ });
1679
+ });
1680
+ it('a remounted access hook still forgets the caches — on no access, and on a changed answer', async () => {
1681
+ const { useVaultDocuments, useFiasAccess } = freshHooks();
1682
+ const { FiasBridgeError } = jest.requireActual('./bridge');
1683
+ const api = useVaultDocuments();
1684
+ const settle = () => new Promise((r) => setTimeout(r, 0));
1685
+ const cacheUrl = async (documentId, url) => {
1686
+ mockBridge.request.mockResolvedValueOnce({ url, expiresAt: Date.now() + 600000 });
1687
+ await api.getDownloadUrl(documentId);
1688
+ };
1689
+ const fresh = async (documentId, url) => {
1690
+ mockBridge.request.mockClear();
1691
+ mockBridge.request.mockResolvedValueOnce({ url, expiresAt: Date.now() + 600000 });
1692
+ await expect(api.getDownloadUrl(documentId)).resolves.toMatchObject({ url });
1693
+ };
1694
+ // A brand-new hook whose FIRST answer is "no access" (the old one unmounted).
1695
+ await cacheUrl('file_f', 'https://example.com/old');
1696
+ mockBridge.request.mockRejectedValueOnce(new FiasBridgeError('gone', 'NOT_A_MEMBER'));
1697
+ useFiasAccess('ws_9');
1698
+ await settle();
1699
+ await fresh('file_f', 'https://example.com/new');
1700
+ // One hook sees an answer; a later, new hook sees a different one.
1701
+ const answer = (tier) => ({
1702
+ output: { access: { mode: 'tier', workspaceId: 'ws_8', tier, expiresAt: null } },
1703
+ });
1704
+ mockBridge.request.mockResolvedValueOnce(answer('admin'));
1705
+ useFiasAccess('ws_8');
1706
+ await settle();
1707
+ await cacheUrl('file_g', 'https://example.com/old-g');
1708
+ // The first hook unmounts (instances of one workspace share an answer
1709
+ // while any is mounted), then a new one mounts.
1710
+ capturedEffectCleanup?.();
1711
+ mockBridge.request.mockResolvedValueOnce(answer('viewer'));
1712
+ useFiasAccess('ws_8');
1713
+ await settle();
1714
+ await fresh('file_g', 'https://example.com/new-g');
1715
+ });
1716
+ it('a fetch already in flight when the caches are forgotten does not refill them', async () => {
1717
+ const hooks = freshHooks();
1718
+ const api = hooks.useVaultDocuments();
1719
+ let answer;
1720
+ mockBridge.request.mockImplementationOnce(() => new Promise((r) => (answer = r)));
1721
+ const pending = api.getDownloadUrl('file_e');
1722
+ hooks.clearSdkReadCaches();
1723
+ answer({
1724
+ url: 'https://example.com/minted-before',
1725
+ expiresAt: new Date(Date.now() + 600000).toISOString(),
1726
+ });
1727
+ await pending;
1728
+ mockBridge.request.mockResolvedValueOnce({
1729
+ url: 'https://example.com/after',
1730
+ expiresAt: new Date(Date.now() + 600000).toISOString(),
1731
+ });
1732
+ await expect(api.getDownloadUrl('file_e')).resolves.toMatchObject({
1733
+ url: 'https://example.com/after',
1734
+ });
1735
+ });
1340
1736
  it('getDownloadUrl dedups concurrent in-flight calls', async () => {
1341
1737
  const { useVaultDocuments } = freshHooks();
1342
1738
  const api = useVaultDocuments();
@@ -1698,6 +2094,47 @@ describe('SDK hooks', () => {
1698
2094
  data: { score: 100 },
1699
2095
  }));
1700
2096
  });
2097
+ it('put with a null key creates: no key is sent, the audience is, and the server’s key comes back (ADR-034 phase 2)', async () => {
2098
+ const { useFiasDataStore } = require('./hooks');
2099
+ const ds = useFiasDataStore();
2100
+ mockBridge.request.mockResolvedValueOnce(output({ key: 'rec_0123456789abcdef0123456789abcdef', version: 1, audience: 'board' }));
2101
+ const result = await ds.put('papers', null, { t: 1 }, { workspaceId: 'ws_1', audience: 'board' });
2102
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_DATASTORE_ENTITY_ID, 'put', {
2103
+ collection: 'papers',
2104
+ data: { t: 1 },
2105
+ workspaceId: 'ws_1',
2106
+ audience: 'board',
2107
+ }));
2108
+ expect(result).toEqual({
2109
+ key: 'rec_0123456789abcdef0123456789abcdef',
2110
+ version: 1,
2111
+ audience: 'board',
2112
+ });
2113
+ });
2114
+ it('create is a keyless put; a host that predates server keys is refused, never guessed at', async () => {
2115
+ const { useFiasDataStore } = require('./hooks');
2116
+ const ds = useFiasDataStore();
2117
+ mockBridge.request.mockResolvedValueOnce(output({ key: 'rec_1', version: 1, audience: 'staff' }));
2118
+ await expect(ds.create('papers', { t: 1 }, { workspaceId: 'ws_1', audience: 'staff' })).resolves.toMatchObject({ key: 'rec_1', audience: 'staff' });
2119
+ mockBridge.request.mockResolvedValueOnce(output({ version: 1 }));
2120
+ await expect(ds.create('papers', { t: 1 }, { workspaceId: 'ws_1', audience: 'staff' })).rejects.toMatchObject({ code: 'AUDIENCES_UNAVAILABLE' });
2121
+ });
2122
+ it('get, query and search forward an audience only when one is named', async () => {
2123
+ const { useFiasDataStore } = require('./hooks');
2124
+ const ds = useFiasDataStore();
2125
+ mockBridge.request.mockResolvedValue(output({ data: null, documents: [], matches: [] }));
2126
+ await ds.get('papers', 'k', { workspaceId: 'ws_1', audience: 'staff' });
2127
+ await ds.query('papers', {}, { workspaceId: 'ws_1', audience: 'staff' });
2128
+ await ds.search('papers', 'q', {}, { workspaceId: 'ws_1', audience: 'staff' });
2129
+ await ds.get('papers', 'k', { workspaceId: 'ws_1' });
2130
+ const payloads = mockBridge.request.mock.calls.map((c) => c[1].payload);
2131
+ expect(payloads.slice(0, 3).map((p) => p.audience)).toEqual([
2132
+ 'staff',
2133
+ 'staff',
2134
+ 'staff',
2135
+ ]);
2136
+ expect(payloads[3]).not.toHaveProperty('audience');
2137
+ });
1701
2138
  it('get unwraps { data } from the bridge envelope', async () => {
1702
2139
  const { useFiasDataStore } = require('./hooks');
1703
2140
  const ds = useFiasDataStore();
@@ -2474,6 +2911,19 @@ describe('useDataSubscription (ADR-012 Phase M4)', () => {
2474
2911
  expect(onInvalidate).toHaveBeenCalledTimes(1);
2475
2912
  expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
2476
2913
  });
2914
+ it('names the audience to watch, where a collection has audiences (ADR-034 phase 2)', async () => {
2915
+ subscribed();
2916
+ const { useDataSubscription } = require('./hooks');
2917
+ useDataSubscription('papers', jest.fn().mockResolvedValue(undefined), {
2918
+ workspaceId: 'ws_1',
2919
+ audience: 'board',
2920
+ });
2921
+ await flush();
2922
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
2923
+ entityId: 'ent_intg_plugin_datastore_000000',
2924
+ payload: { op: 'subscribe', collection: 'papers', workspaceId: 'ws_1', audience: 'board' },
2925
+ });
2926
+ });
2477
2927
  it('runs onInvalidate single-flight with exactly one trailing call', async () => {
2478
2928
  subscribed();
2479
2929
  let resolveFirst = () => undefined;
@@ -2521,6 +2971,95 @@ describe('useDataSubscription (ADR-012 Phase M4)', () => {
2521
2971
  expect(onInvalidate).not.toHaveBeenCalled();
2522
2972
  });
2523
2973
  });
2974
+ describe('useEntitySubscription (ADR-012 Phase M4 — any subscribe-kind verb)', () => {
2975
+ const flush = async () => {
2976
+ await Promise.resolve();
2977
+ await Promise.resolve();
2978
+ await Promise.resolve();
2979
+ };
2980
+ let mockBridge;
2981
+ beforeEach(() => {
2982
+ mockBridge = createMockBridge();
2983
+ mockContextValue = mockBridge;
2984
+ mockSingletonBridge = mockBridge;
2985
+ });
2986
+ it('forwards the entity id and the verb payload verbatim, then refetches once live', async () => {
2987
+ mockBridge.request.mockResolvedValue({
2988
+ output: { kind: 'subscription', subscriptionId: 'lsub_chat' },
2989
+ });
2990
+ const onInvalidate = jest.fn().mockResolvedValue(undefined);
2991
+ const { useEntitySubscription } = require('./hooks');
2992
+ useEntitySubscription('ent_intg_litcraft_player_0000000', { op: 'subscribe_guild_chat' }, onInvalidate);
2993
+ await flush();
2994
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
2995
+ entityId: 'ent_intg_litcraft_player_0000000',
2996
+ payload: { op: 'subscribe_guild_chat' },
2997
+ });
2998
+ expect(mockBridge.onSubscriptionEvent).toHaveBeenCalledWith('lsub_chat', expect.any(Function));
2999
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
3000
+ expect(mockSetState).toHaveBeenCalledWith({ status: 'active' });
3001
+ });
3002
+ it('enabled: false subscribes to nothing and reports unsubscribed', async () => {
3003
+ const onInvalidate = jest.fn();
3004
+ const { useEntitySubscription } = require('./hooks');
3005
+ useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate, { enabled: false });
3006
+ await flush();
3007
+ expect(mockBridge.request).not.toHaveBeenCalled();
3008
+ expect(onInvalidate).not.toHaveBeenCalled();
3009
+ expect(mockSetState).toHaveBeenCalledWith({ status: 'ended', endedReason: 'unsubscribed' });
3010
+ });
3011
+ it('sends the payload exactly as given (key order is identity-neutral, not rewritten)', async () => {
3012
+ mockBridge.request.mockResolvedValue({
3013
+ output: { kind: 'subscription', subscriptionId: 'lsub_1' },
3014
+ });
3015
+ const { useEntitySubscription } = require('./hooks');
3016
+ useEntitySubscription('ent_x', { workspaceId: 'ws_1', op: 'subscribe_x' }, jest.fn());
3017
+ await flush();
3018
+ expect(mockBridge.request).toHaveBeenCalledWith('entity_invoke', {
3019
+ entityId: 'ent_x',
3020
+ payload: { op: 'subscribe_x', workspaceId: 'ws_1' },
3021
+ });
3022
+ });
3023
+ it('minRefetchIntervalMs caps refetches to one per interval with one trailing run', async () => {
3024
+ jest.useFakeTimers();
3025
+ try {
3026
+ mockBridge.request.mockResolvedValue({
3027
+ output: { kind: 'subscription', subscriptionId: 'lsub_1' },
3028
+ });
3029
+ const onInvalidate = jest.fn().mockResolvedValue(undefined);
3030
+ const { useEntitySubscription } = require('./hooks');
3031
+ useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate, {
3032
+ minRefetchIntervalMs: 2000,
3033
+ });
3034
+ await flush();
3035
+ expect(onInvalidate).toHaveBeenCalledTimes(1); // the subscribe-then-fetch run
3036
+ const eventCb = mockBridge.onSubscriptionEvent.mock.calls[0][1];
3037
+ eventCb();
3038
+ eventCb();
3039
+ eventCb();
3040
+ await flush();
3041
+ expect(onInvalidate).toHaveBeenCalledTimes(1); // inside the interval: deferred, not dropped
3042
+ await jest.advanceTimersByTimeAsync(2000);
3043
+ expect(onInvalidate).toHaveBeenCalledTimes(2); // exactly one trailing run at the interval's end
3044
+ capturedEffectCleanup?.();
3045
+ }
3046
+ finally {
3047
+ jest.useRealTimers();
3048
+ }
3049
+ });
3050
+ it('a rejected handshake ends with transport_error and never refetches', async () => {
3051
+ mockBridge.request.mockRejectedValue(new Error('NOT_A_MEMBER'));
3052
+ const onInvalidate = jest.fn();
3053
+ const { useEntitySubscription } = require('./hooks');
3054
+ useEntitySubscription('ent_x', { op: 'subscribe_x' }, onInvalidate);
3055
+ await flush();
3056
+ expect(mockSetState).toHaveBeenCalledWith({
3057
+ status: 'ended',
3058
+ endedReason: 'transport_error',
3059
+ });
3060
+ expect(onInvalidate).not.toHaveBeenCalled();
3061
+ });
3062
+ });
2524
3063
  describe('useFiasAIActions', () => {
2525
3064
  let mockBridge;
2526
3065
  beforeEach(() => {