@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.
- package/dist/bridge.d.ts +32 -6
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +73 -2
- package/dist/bridge.js.map +1 -1
- package/dist/bridge.test.js +71 -0
- package/dist/bridge.test.js.map +1 -1
- package/dist/entity-ops.d.ts +10 -0
- package/dist/entity-ops.d.ts.map +1 -1
- package/dist/entity-ops.js +11 -1
- package/dist/entity-ops.js.map +1 -1
- package/dist/generated/permissions.d.ts +1 -1
- package/dist/generated/permissions.d.ts.map +1 -1
- package/dist/generated/permissions.js +6 -0
- package/dist/generated/permissions.js.map +1 -1
- package/dist/generated/surfaces.d.ts +119 -5
- package/dist/generated/surfaces.d.ts.map +1 -1
- package/dist/generated/surfaces.js +14 -6
- package/dist/generated/surfaces.js.map +1 -1
- package/dist/hooks.d.ts +140 -18
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +611 -38
- package/dist/hooks.js.map +1 -1
- package/dist/hooks.test.js +539 -0
- package/dist/hooks.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +495 -21
- package/dist/types.d.ts +432 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/use-fias-access.render.test.d.ts +12 -0
- package/dist/use-fias-access.render.test.d.ts.map +1 -0
- package/dist/use-fias-access.render.test.js +407 -0
- package/dist/use-fias-access.render.test.js.map +1 -0
- package/package.json +3 -2
- package/templates/default/AGENTS.md +282 -7
- package/templates/default/CLAUDE.md +282 -7
package/dist/hooks.test.js
CHANGED
|
@@ -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(() => {
|