@fias/arche-sdk 2.27.0 → 2.29.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.
@@ -68,6 +68,8 @@ function createMockBridge() {
68
68
  getCurrentPath: jest.fn().mockReturnValue('/'),
69
69
  getTextModels: jest.fn().mockReturnValue(null),
70
70
  onTextModelsUpdate: jest.fn().mockReturnValue(() => { }),
71
+ getHolder: jest.fn().mockReturnValue(null),
72
+ onHolderReady: jest.fn().mockReturnValue(() => { }),
71
73
  onThemeUpdate: jest.fn().mockReturnValue(() => { }),
72
74
  onNavigationUpdate: jest.fn().mockReturnValue(() => { }),
73
75
  onStepNavigationUpdate: jest.fn().mockReturnValue(() => { }),
@@ -763,6 +765,167 @@ describe('SDK hooks', () => {
763
765
  expect(useTextModels()).toEqual([]);
764
766
  });
765
767
  });
768
+ describe('useFiasHolder', () => {
769
+ it('returns the init-payload workspace and subscribes for its arrival', () => {
770
+ const { useFiasHolder } = require('./hooks');
771
+ const holder = { type: 'org', role: 'member' };
772
+ mockBridge.getHolder.mockReturnValue(holder);
773
+ expect(useFiasHolder()).toEqual(holder);
774
+ expect(mockBridge.onHolderReady).toHaveBeenCalled();
775
+ });
776
+ it('returns null before init', () => {
777
+ const { useFiasHolder } = require('./hooks');
778
+ mockBridge.getHolder.mockReturnValue(null);
779
+ expect(useFiasHolder()).toBeNull();
780
+ });
781
+ });
782
+ describe('useFiasWorkspaces().openForWorkspace', () => {
783
+ it('asks for the acting workspace by flag, never by id', async () => {
784
+ const { useFiasWorkspaces } = require('./hooks');
785
+ const ws = { workspaceId: 'ws_1', displayName: 'Acme', role: 'member', holderBacked: true };
786
+ mockBridge.request.mockResolvedValue(output({ workspace: ws }));
787
+ await expect(useFiasWorkspaces().openForWorkspace()).resolves.toEqual(ws);
788
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.PLUGIN_WORKSPACES_ENTITY_ID, 'create', { forActingHolder: true }));
789
+ });
790
+ });
791
+ describe('useFiasConnections', () => {
792
+ it('lists through the connections entity', async () => {
793
+ const { useFiasConnections } = require('./hooks');
794
+ const accounts = [{ connectionId: 'conn_1', provider: 'x', status: 'active' }];
795
+ mockBridge.request.mockResolvedValue(output({ connections: accounts }));
796
+ await expect(useFiasConnections().list()).resolves.toEqual(accounts);
797
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.CONNECTIONS_ENTITY_ID, 'list'));
798
+ });
799
+ it('connects through the client-side entity, waiting on the person', async () => {
800
+ const { useFiasConnections } = require('./hooks');
801
+ mockBridge.request.mockResolvedValue({ connectionId: 'conn_1', provider: 'x' });
802
+ await expect(useFiasConnections().connect('x')).resolves.toEqual({
803
+ connectionId: 'conn_1',
804
+ provider: 'x',
805
+ });
806
+ const [op, payload, timeout] = mockBridge.request.mock.calls[0];
807
+ expect(op).toBe('client_entity_invoke');
808
+ expect(payload).toEqual({
809
+ entityId: entity_ops_1.CONNECTIONS_CONNECT_ENTITY_ID,
810
+ input: { provider: 'x' },
811
+ });
812
+ expect(timeout).toBeGreaterThanOrEqual(5 * 60 * 1000);
813
+ });
814
+ it('turns a refusal result into a typed error', async () => {
815
+ const { useFiasConnections } = require('./hooks');
816
+ mockBridge.request.mockResolvedValue({
817
+ error: { code: 'PERMISSION_DENIED', message: 'no connections:use' },
818
+ });
819
+ await expect(useFiasConnections().connect('x')).rejects.toMatchObject({
820
+ code: 'PERMISSION_DENIED',
821
+ });
822
+ });
823
+ it('releases its own access', async () => {
824
+ const { useFiasConnections } = require('./hooks');
825
+ mockBridge.request.mockResolvedValue(output({ released: true }));
826
+ await useFiasConnections().release('conn_1');
827
+ expect(mockBridge.request).toHaveBeenCalledWith(...entityOp(entity_ops_1.CONNECTIONS_ENTITY_ID, 'release', { connectionId: 'conn_1' }));
828
+ });
829
+ });
830
+ describe('the outward guard round-trip (ADR-029 §4)', () => {
831
+ const PACT = `pact_${'a'.repeat(32)}`;
832
+ const publish = { connectionId: 'conn_1', posts: [{ text: 'Hi' }] };
833
+ it('a confirmation answer asks the host, then re-sends the identical call naming the approval', async () => {
834
+ const { useFiasX } = require('./hooks');
835
+ mockBridge.request
836
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
837
+ .mockResolvedValueOnce({ approved: true })
838
+ .mockResolvedValueOnce(output({ posts: [{ postId: '1', url: 'u' }], complete: true }));
839
+ await expect(useFiasX().publishPost(publish)).resolves.toEqual({
840
+ posts: [{ postId: '1', url: 'u' }],
841
+ complete: true,
842
+ });
843
+ const [first, confirm, resend] = mockBridge.request.mock.calls;
844
+ expect(first).toEqual(entityOp(entity_ops_1.X_ENTITY_ID, 'publish_post', publish));
845
+ expect(confirm[0]).toBe('client_entity_invoke');
846
+ expect(confirm[1]).toEqual({
847
+ entityId: entity_ops_1.ACTION_CONFIRMATION_ENTITY_ID,
848
+ input: { pendingActionId: PACT },
849
+ });
850
+ expect(confirm[2]).toBeGreaterThanOrEqual(5 * 60 * 1000);
851
+ expect(resend[0]).toBe('entity_invoke');
852
+ expect(resend[1]).toEqual({
853
+ entityId: entity_ops_1.X_ENTITY_ID,
854
+ payload: { op: 'publish_post', ...publish },
855
+ pendingActionId: PACT,
856
+ });
857
+ // The approved call acts outside Fias and may take a while; it is not cut at the default.
858
+ expect(resend[2]).toBeGreaterThanOrEqual(60 * 1000);
859
+ });
860
+ it('a decline rejects with USER_DECLINED and sends nothing more', async () => {
861
+ const { useFiasX } = require('./hooks');
862
+ mockBridge.request
863
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
864
+ .mockResolvedValueOnce({ approved: false });
865
+ await expect(useFiasX().publishPost(publish)).rejects.toMatchObject({
866
+ code: 'USER_DECLINED',
867
+ });
868
+ expect(mockBridge.request).toHaveBeenCalledTimes(2);
869
+ });
870
+ it("the host's refusal is a typed error", async () => {
871
+ const { useFiasLinkedIn } = require('./hooks');
872
+ mockBridge.request
873
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
874
+ .mockResolvedValueOnce({ error: { code: 'CONFIRM_UNAVAILABLE', message: 'no host' } });
875
+ await expect(useFiasLinkedIn().publishPost({ connectionId: 'conn_1', text: 'Hi' })).rejects.toMatchObject({ code: 'CONFIRM_UNAVAILABLE' });
876
+ });
877
+ });
878
+ describe('useFiasSchedules (ADR-029 §5)', () => {
879
+ it('schedules one call, approved first, and lists and cancels its own', async () => {
880
+ const { useFiasSchedules } = require('./hooks');
881
+ const PACT = `pact_${'b'.repeat(32)}`;
882
+ const fireAt = new Date('2030-01-01T09:00:00Z');
883
+ const payload = { connectionId: 'conn_1', posts: [{ text: 'Later' }] };
884
+ mockBridge.request
885
+ .mockResolvedValueOnce({ output: null, confirmationRequired: { pendingActionId: PACT } })
886
+ .mockResolvedValueOnce({ approved: true })
887
+ .mockResolvedValueOnce(output({ scheduleId: 's1', fireAt: fireAt.toISOString() }));
888
+ await expect(useFiasSchedules().schedule({
889
+ entityId: entity_ops_1.X_ENTITY_ID,
890
+ verb: 'publish_post',
891
+ payload,
892
+ fireAt,
893
+ })).resolves.toEqual({ scheduleId: 's1', fireAt: fireAt.toISOString() });
894
+ // The Date is sent as ISO, and the re-send names the approval.
895
+ expect(mockBridge.request.mock.calls[0]).toEqual(entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'schedule', {
896
+ entityId: entity_ops_1.X_ENTITY_ID,
897
+ verb: 'publish_post',
898
+ payload,
899
+ fireAt: fireAt.toISOString(),
900
+ }));
901
+ expect(mockBridge.request.mock.calls[2][1]).toMatchObject({ pendingActionId: PACT });
902
+ mockBridge.request.mockResolvedValueOnce(output({ schedules: [{ scheduleId: 's1' }] }));
903
+ await expect(useFiasSchedules().list()).resolves.toEqual([{ scheduleId: 's1' }]);
904
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'list', {}));
905
+ mockBridge.request.mockResolvedValueOnce(output({ schedules: [] }));
906
+ await useFiasSchedules().list({ limit: 100, scheduleIds: ['s1'] });
907
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'list', { limit: 100, scheduleIds: ['s1'] }));
908
+ mockBridge.request.mockResolvedValueOnce(output({ cancelled: true }));
909
+ await useFiasSchedules().cancel('s1');
910
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.SCHEDULED_INVOCATIONS_ENTITY_ID, 'cancel', { scheduleId: 's1' }));
911
+ });
912
+ });
913
+ describe('useFiasX / useFiasLinkedIn reads', () => {
914
+ it('unwrap each read through its entity verb', async () => {
915
+ const { useFiasX, useFiasLinkedIn } = require('./hooks');
916
+ mockBridge.request.mockResolvedValueOnce(output({ users: [{ id: '1' }] }));
917
+ await expect(useFiasX().searchUsers('conn_1', 'acme')).resolves.toEqual([{ id: '1' }]);
918
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.X_ENTITY_ID, 'search_users', { connectionId: 'conn_1', query: 'acme' }));
919
+ mockBridge.request.mockResolvedValueOnce(output({ organizations: [{ id: '2' }] }));
920
+ await expect(useFiasLinkedIn().searchOrganizations('conn_1', 'acme')).resolves.toEqual([
921
+ { id: '2' },
922
+ ]);
923
+ expect(mockBridge.request).toHaveBeenLastCalledWith(...entityOp(entity_ops_1.LINKEDIN_ENTITY_ID, 'search_organizations', {
924
+ connectionId: 'conn_1',
925
+ query: 'acme',
926
+ }));
927
+ });
928
+ });
766
929
  describe('NLLB translation entity', () => {
767
930
  it('exposes the NLLB_LANGUAGES picker list keyed by FLORES code', () => {
768
931
  const { NLLB_LANGUAGES } = require('./hooks');
@@ -1798,7 +1961,231 @@ describe('SDK hooks', () => {
1798
1961
  api.setCurrentStep('step-b');
1799
1962
  });
1800
1963
  });
1964
+ describe('useArcheAssetBytes', () => {
1965
+ const SIGNED = '/v1/arches/arc_1/assets/aset_1?m=mfst_1&u=usr_1&exp=1&sig=x';
1966
+ const inFiveMinutes = () => Math.floor(Date.now() / 1000) + 300;
1967
+ const entry = (assetId, expiresAt = inFiveMinutes()) => ({
1968
+ assetId,
1969
+ signedUrl: SIGNED.replace('aset_1', assetId),
1970
+ expiresAt,
1971
+ });
1972
+ const bytesResult = (size = 1) => ({
1973
+ contentType: 'audio/mpeg',
1974
+ bytes: new ArrayBuffer(size),
1975
+ sizeBytes: size,
1976
+ });
1977
+ /** The `signedUrls` of the nth bridge call (0-based). */
1978
+ const urlsOfCall = (n) => mockBridge.request.mock.calls[n][1].input.signedUrls;
1979
+ const readCalls = () => mockBridge.request.mock.calls.filter(([type]) => type === 'client_entity_invoke');
1980
+ it('mints a signed URL, then has the host read it through the asset-bytes entity', async () => {
1981
+ const { useArcheAssetBytes, ARCHE_ASSET_BYTES_ENTITY_ID } = require('./hooks');
1982
+ const bytes = new Uint8Array([1, 2]).buffer;
1983
+ mockBridge.request
1984
+ .mockResolvedValueOnce({ assetId: 'aset_1', signedUrl: SIGNED, expiresAt: inFiveMinutes() })
1985
+ .mockResolvedValueOnce({ results: [{ contentType: 'audio/mpeg', bytes, sizeBytes: 2 }] });
1986
+ const result = await useArcheAssetBytes().getBytes('aset_1');
1987
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'arche_assets_get_url', {
1988
+ assetId: 'aset_1',
1989
+ });
1990
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'client_entity_invoke', {
1991
+ entityId: ARCHE_ASSET_BYTES_ENTITY_ID,
1992
+ input: { verb: 'read', signedUrls: [SIGNED] },
1993
+ }, expect.any(Number));
1994
+ expect(result).toEqual({ contentType: 'audio/mpeg', bytes, sizeBytes: 2 });
1995
+ });
1996
+ it("reuses a listed entry's signed URL — one round trip, no get_url", async () => {
1997
+ const { useArcheAssetBytes } = require('./hooks');
1998
+ mockBridge.request.mockResolvedValueOnce({ results: [bytesResult()] });
1999
+ await useArcheAssetBytes().getBytes(entry('aset_1'));
2000
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
2001
+ expect(urlsOfCall(0)).toEqual([SIGNED]);
2002
+ });
2003
+ it('mints a fresh URL for an entry whose URL is about to expire', async () => {
2004
+ const { useArcheAssetBytes } = require('./hooks');
2005
+ const fresh = SIGNED.replace('exp=1', 'exp=2');
2006
+ mockBridge.request
2007
+ .mockResolvedValueOnce({ assetId: 'aset_1', signedUrl: fresh })
2008
+ .mockResolvedValueOnce({ results: [bytesResult()] });
2009
+ await useArcheAssetBytes().getBytes(entry('aset_1', Math.floor(Date.now() / 1000) + 5));
2010
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'arche_assets_get_url', {
2011
+ assetId: 'aset_1',
2012
+ });
2013
+ expect(urlsOfCall(1)).toEqual([fresh]);
2014
+ });
2015
+ it('retries once with a fresh URL when a reused one has lapsed', async () => {
2016
+ const { useArcheAssetBytes } = require('./hooks');
2017
+ const fresh = SIGNED.replace('exp=1', 'exp=2');
2018
+ mockBridge.request
2019
+ .mockResolvedValueOnce({
2020
+ results: [{ error: { code: 'ASSET_URL_EXPIRED', message: 'expired' } }],
2021
+ })
2022
+ .mockResolvedValueOnce({ assetId: 'aset_1', signedUrl: fresh })
2023
+ .mockResolvedValueOnce({ results: [bytesResult()] });
2024
+ const result = await useArcheAssetBytes().getBytes(entry('aset_1'));
2025
+ expect(result.sizeBytes).toBe(1);
2026
+ expect(mockBridge.request).toHaveBeenCalledTimes(3);
2027
+ expect(urlsOfCall(2)).toEqual([fresh]);
2028
+ });
2029
+ it('turns a refused call into a typed ArcheAssetError', async () => {
2030
+ const { useArcheAssetBytes, ArcheAssetError } = require('./hooks');
2031
+ mockBridge.request.mockResolvedValueOnce({
2032
+ error: { code: 'ASSETS_UNAVAILABLE_IN_PREVIEW', message: 'not in preview' },
2033
+ });
2034
+ const err = await useArcheAssetBytes()
2035
+ .getBytes(entry('aset_1'))
2036
+ .catch((e) => e);
2037
+ expect(err).toBeInstanceOf(ArcheAssetError);
2038
+ expect(err).toMatchObject({
2039
+ code: 'ASSETS_UNAVAILABLE_IN_PREVIEW',
2040
+ message: 'not in preview',
2041
+ });
2042
+ });
2043
+ it("turns one URL's error result into a typed ArcheAssetError", async () => {
2044
+ const { useArcheAssetBytes } = require('./hooks');
2045
+ mockBridge.request.mockResolvedValueOnce({
2046
+ results: [{ error: { code: 'ASSET_URL_FORBIDDEN', message: 'not this arche' } }],
2047
+ });
2048
+ await expect(useArcheAssetBytes().getBytes(entry('aset_1'))).rejects.toMatchObject({
2049
+ code: 'ASSET_URL_FORBIDDEN',
2050
+ message: 'not this arche',
2051
+ });
2052
+ });
2053
+ it('names both permissions when the bridge denies one', async () => {
2054
+ const { useArcheAssetBytes } = require('./hooks');
2055
+ mockBridge.request.mockRejectedValueOnce(new Error('Permission denied: entities:client_invoke not granted'));
2056
+ const err = await useArcheAssetBytes()
2057
+ .getBytes('aset_1')
2058
+ .catch((e) => e);
2059
+ expect(err).toMatchObject({ code: 'PERMISSION_DENIED' });
2060
+ expect(err.message).toMatch(/assets:read.*entities:client_invoke/);
2061
+ });
2062
+ it('maps a rate-limit rejection to RATE_LIMIT and anything else to ASSET_UNAVAILABLE', async () => {
2063
+ const { useArcheAssetBytes } = require('./hooks');
2064
+ mockBridge.request.mockRejectedValueOnce(new Error('Rate limit exceeded'));
2065
+ await expect(useArcheAssetBytes().getBytes('a')).rejects.toMatchObject({
2066
+ code: 'RATE_LIMIT',
2067
+ });
2068
+ mockBridge.request.mockRejectedValueOnce(new Error('Asset not found: a'));
2069
+ await expect(useArcheAssetBytes().getBytes('a')).rejects.toMatchObject({
2070
+ code: 'ASSET_UNAVAILABLE',
2071
+ });
2072
+ });
2073
+ it("maps the bridge's runaway-loop block to RATE_LIMIT and points at getMany", async () => {
2074
+ const { useArcheAssetBytes } = require('./hooks');
2075
+ mockBridge.request.mockRejectedValueOnce(new Error('Bridge call "client_entity_invoke" (entity: x) temporarily blocked — rapid-fire loop detected.'));
2076
+ const err = await useArcheAssetBytes()
2077
+ .getBytes(entry('aset_1'))
2078
+ .catch((e) => e);
2079
+ expect(err).toMatchObject({ code: 'RATE_LIMIT' });
2080
+ expect(err.message).toMatch(/getMany/);
2081
+ });
2082
+ it('sends getBytes calls made together as one host call', async () => {
2083
+ const { useArcheAssetBytes } = require('./hooks');
2084
+ mockBridge.request.mockResolvedValueOnce({
2085
+ results: [bytesResult(1), bytesResult(2), bytesResult(3)],
2086
+ });
2087
+ const { getBytes } = useArcheAssetBytes();
2088
+ const results = await Promise.all(['a1', 'a2', 'a3'].map((id) => getBytes(entry(id))));
2089
+ expect(readCalls()).toHaveLength(1);
2090
+ expect(urlsOfCall(0)).toHaveLength(3);
2091
+ expect(results.map((r) => r.sizeBytes)).toEqual([1, 2, 3]);
2092
+ });
2093
+ it('gives each caller of the same asset its own buffer (decodeAudioData detaches it)', async () => {
2094
+ const { useArcheAssetBytes } = require('./hooks');
2095
+ mockBridge.request.mockResolvedValueOnce({ results: [bytesResult(4)] });
2096
+ const { getBytes } = useArcheAssetBytes();
2097
+ const [first, second] = await Promise.all([getBytes(entry('a1')), getBytes(entry('a1'))]);
2098
+ expect(urlsOfCall(0)).toHaveLength(1);
2099
+ expect(first.bytes).not.toBe(second.bytes);
2100
+ expect(second.bytes.byteLength).toBe(4);
2101
+ });
2102
+ describe('getMany', () => {
2103
+ it('reads 150 sounds in two calls — never one per sound', async () => {
2104
+ const { useArcheAssetBytes } = require('./hooks');
2105
+ mockBridge.request.mockImplementation(async (_type, payload) => ({
2106
+ results: payload.input.signedUrls.map(() => bytesResult()),
2107
+ }));
2108
+ const entries = Array.from({ length: 150 }, (_, i) => entry(`a${i}`));
2109
+ const loaded = await useArcheAssetBytes().getMany(entries);
2110
+ expect(readCalls()).toHaveLength(2);
2111
+ expect(urlsOfCall(0)).toHaveLength(100);
2112
+ expect(urlsOfCall(1)).toHaveLength(50);
2113
+ expect(Object.keys(loaded)).toHaveLength(150);
2114
+ expect(loaded.a149).toMatchObject({ sizeBytes: 1 });
2115
+ });
2116
+ it('returns a failed asset as its ArcheAssetError without failing the rest', async () => {
2117
+ const { useArcheAssetBytes, ArcheAssetError } = require('./hooks');
2118
+ mockBridge.request.mockResolvedValueOnce({
2119
+ results: [
2120
+ bytesResult(),
2121
+ { error: { code: 'ASSET_UNAVAILABLE', message: 'the asset could not be read (404)' } },
2122
+ ],
2123
+ });
2124
+ const loaded = await useArcheAssetBytes().getMany([entry('a1'), entry('a2')]);
2125
+ expect(loaded.a1).toMatchObject({ sizeBytes: 1 });
2126
+ expect(loaded.a2).toBeInstanceOf(ArcheAssetError);
2127
+ expect(loaded.a2).toMatchObject({ code: 'ASSET_UNAVAILABLE' });
2128
+ });
2129
+ it('rejects when the read is refused as a whole', async () => {
2130
+ const { useArcheAssetBytes } = require('./hooks');
2131
+ mockBridge.request.mockResolvedValueOnce({
2132
+ error: { code: 'ASSETS_UNAVAILABLE_IN_PREVIEW', message: 'not in preview' },
2133
+ });
2134
+ await expect(useArcheAssetBytes().getMany([entry('a1')])).rejects.toMatchObject({
2135
+ code: 'ASSETS_UNAVAILABLE_IN_PREVIEW',
2136
+ });
2137
+ });
2138
+ it('reads again, together, the assets that did not fit the call byte budget', async () => {
2139
+ const { useArcheAssetBytes } = require('./hooks');
2140
+ const overBudget = { error: { code: 'ASSET_TOO_LARGE', message: 'read it on its own' } };
2141
+ mockBridge.request
2142
+ .mockResolvedValueOnce({ results: [bytesResult(), overBudget, overBudget] })
2143
+ .mockResolvedValueOnce({ results: [bytesResult(), overBudget] })
2144
+ .mockResolvedValueOnce({ results: [bytesResult()] });
2145
+ const loaded = await useArcheAssetBytes().getMany([entry('a1'), entry('a2'), entry('a3')]);
2146
+ expect(urlsOfCall(1)).toHaveLength(2);
2147
+ expect(urlsOfCall(2)).toHaveLength(1);
2148
+ expect(Object.values(loaded).every((r) => !(r instanceof Error))).toBe(true);
2149
+ });
2150
+ it('refreshes at most 10 lapsed URLs per call and asks for a fresh list past that', async () => {
2151
+ const { useArcheAssetBytes } = require('./hooks');
2152
+ mockBridge.request.mockImplementation(async (type, payload) => type === 'arche_assets_get_url'
2153
+ ? { assetId: payload.assetId, signedUrl: SIGNED }
2154
+ : {
2155
+ results: payload.input.signedUrls.map(() => bytesResult()),
2156
+ });
2157
+ const lapsed = Math.floor(Date.now() / 1000) - 1;
2158
+ const entries = Array.from({ length: 15 }, (_, i) => entry(`a${i}`, lapsed));
2159
+ const loaded = await useArcheAssetBytes().getMany(entries);
2160
+ const minted = mockBridge.request.mock.calls.filter(([type]) => type === 'arche_assets_get_url');
2161
+ expect(minted).toHaveLength(10);
2162
+ const expired = Object.values(loaded).filter((r) => r instanceof Error && r.code === 'ASSET_URL_EXPIRED');
2163
+ expect(expired).toHaveLength(5);
2164
+ expect(expired[0].message).toMatch(/list the assets again/);
2165
+ });
2166
+ });
2167
+ });
1801
2168
  describe('useArcheAssets', () => {
2169
+ // Compile-time only (`pnpm typecheck:tests` fails if these drift): code
2170
+ // written before the library held sounds keeps its image types.
2171
+ function listingTypes(api) {
2172
+ return async () => {
2173
+ const images = await api.list({ limit: 10 });
2174
+ const width = images.entries[0].width;
2175
+ const refreshed = await api.getUrl('aset_1');
2176
+ const height = refreshed.height;
2177
+ const mixed = await api.list({ kinds: ['image', 'audio'] });
2178
+ // @ts-expect-error a listing with kinds may hold sounds, which have no width
2179
+ const unchecked = mixed.entries[0].width;
2180
+ const first = mixed.entries[0];
2181
+ const narrowed = first.contentType.startsWith('image/') && first.width !== null ? first.width : undefined;
2182
+ const sound = await api.getUrl('aset_2');
2183
+ return [width, height, unchecked, narrowed, sound];
2184
+ };
2185
+ }
2186
+ it('keeps an images-only listing typed as images', () => {
2187
+ expect(typeof listingTypes).toBe('function');
2188
+ });
1802
2189
  it('list forwards to arche_assets_list with the cursor/limit options', async () => {
1803
2190
  const { useArcheAssets } = require('./hooks');
1804
2191
  mockBridge.request.mockResolvedValueOnce({ entries: [], nextCursor: null });
@@ -1821,6 +2208,17 @@ describe('SDK hooks', () => {
1821
2208
  tags: ['category:background'],
1822
2209
  });
1823
2210
  });
2211
+ it('list and index forward the kinds filter', async () => {
2212
+ const { useArcheAssets } = require('./hooks');
2213
+ mockBridge.request
2214
+ .mockResolvedValueOnce({ entries: [], nextCursor: null })
2215
+ .mockResolvedValueOnce({ groups: [] });
2216
+ const api = useArcheAssets();
2217
+ await api.list({ kinds: ['audio'] });
2218
+ await api.index({ kinds: ['image', 'audio'] });
2219
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'arche_assets_list', expect.objectContaining({ kinds: ['audio'] }));
2220
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'arche_assets_index', expect.objectContaining({ kinds: ['image', 'audio'] }));
2221
+ });
1824
2222
  it('index forwards to arche_assets_index with tags + groupBy', async () => {
1825
2223
  const { useArcheAssets } = require('./hooks');
1826
2224
  mockBridge.request.mockResolvedValueOnce({ groups: [] });
@@ -1846,6 +2244,100 @@ describe('SDK hooks', () => {
1846
2244
  expect(asset.assetId).toBe('aset_1');
1847
2245
  });
1848
2246
  });
2247
+ describe('useLocalStore (ADR-031)', () => {
2248
+ const ENTITY = 'ent_intg_plugin_local_store_0000';
2249
+ const load = () => {
2250
+ const { useLocalStore, FiasLocalStoreError } = require('./hooks');
2251
+ return { store: useLocalStore(), FiasLocalStoreError };
2252
+ };
2253
+ it('puts through the generic client_entity_invoke op, verb in the input', async () => {
2254
+ const { store } = load();
2255
+ mockBridge.request.mockResolvedValueOnce({ sizeBytes: 7 });
2256
+ expect(await store.put('session/1', { a: 1 })).toEqual({ sizeBytes: 7 });
2257
+ expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'put', key: 'session/1', value: { a: 1 } } }, 120000);
2258
+ });
2259
+ it('reads each kind back through the typed getters, and null for a missing key', async () => {
2260
+ const { store } = load();
2261
+ const meta = { key: 'k', sizeBytes: 1, updatedAt: '2026-10-13T00:00:00.000Z' };
2262
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'hi' } });
2263
+ expect(await store.getText('k')).toBe('hi');
2264
+ const bytes = new Uint8Array([1, 2]).buffer;
2265
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: bytes } });
2266
+ expect(await store.getBytes('k')).toBe(bytes);
2267
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: { n: 1 } } });
2268
+ expect(await store.getJson('k')).toEqual({ n: 1 });
2269
+ mockBridge.request.mockResolvedValueOnce({ entry: null });
2270
+ expect(await store.getJson('k')).toBeNull();
2271
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'raw' } });
2272
+ expect(await store.get('k')).toEqual({ ...meta, value: 'raw' });
2273
+ });
2274
+ it('getJson parses a text entry, so put(JSON.stringify(x)) reads back like put(x)', async () => {
2275
+ const { store, FiasLocalStoreError } = load();
2276
+ const meta = { key: 'k', sizeBytes: 7, updatedAt: '2026-10-13T00:00:00.000Z' };
2277
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: '{"n":1}' } });
2278
+ expect(await store.getJson('k')).toEqual({ n: 1 });
2279
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'not json' } });
2280
+ const err = await store.getJson('k').catch((e) => e);
2281
+ expect(err).toBeInstanceOf(FiasLocalStoreError);
2282
+ expect(err).toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2283
+ });
2284
+ it('the typed getters refuse the wrong kind with LOCAL_STORE_WRONG_KIND', async () => {
2285
+ const { store } = load();
2286
+ const meta = { key: 'k', sizeBytes: 2, updatedAt: '2026-10-13T00:00:00.000Z' };
2287
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: { n: 1 } } });
2288
+ await expect(store.getText('k')).rejects.toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2289
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'text' } });
2290
+ await expect(store.getBytes('k')).rejects.toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2291
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: new ArrayBuffer(1) } });
2292
+ await expect(store.getJson('k')).rejects.toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2293
+ });
2294
+ it('throws a coded FiasLocalStoreError when the host returns { error }', async () => {
2295
+ const { store, FiasLocalStoreError } = load();
2296
+ mockBridge.request.mockResolvedValueOnce({
2297
+ error: { code: 'LOCAL_STORE_QUOTA_EXCEEDED', message: 'over budget' },
2298
+ });
2299
+ const err = await store.put('k', 'v').catch((e) => e);
2300
+ expect(err).toBeInstanceOf(FiasLocalStoreError);
2301
+ expect(err).toMatchObject({ code: 'LOCAL_STORE_QUOTA_EXCEEDED', message: 'over budget' });
2302
+ });
2303
+ it.each([
2304
+ ['Permission denied: storage:local not granted', undefined, 'PERMISSION_DENIED'],
2305
+ ['refused', 'PERMISSION_DENIED', 'PERMISSION_DENIED'],
2306
+ ['Rate limit exceeded for ent_intg_plugin_local_store_0000', undefined, 'RATE_LIMIT'],
2307
+ ['slow down', 'RATE_LIMIT', 'RATE_LIMIT'],
2308
+ ['Bridge request timed out', 'TIMEOUT', 'LOCAL_STORE_UNAVAILABLE'],
2309
+ ])('maps a bridge failure (%s, code %s) to %s', async (message, code, expected) => {
2310
+ const { store } = load();
2311
+ mockBridge.request.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
2312
+ await expect(store.list()).rejects.toMatchObject({ code: expected });
2313
+ });
2314
+ it('lists, deletes, clears and estimates with the documented shapes', async () => {
2315
+ const { store } = load();
2316
+ const entries = [{ key: 'a', sizeBytes: 1, updatedAt: '2026-10-13T00:00:00.000Z' }];
2317
+ mockBridge.request.mockResolvedValueOnce({ entries });
2318
+ expect(await store.list('a')).toEqual(entries);
2319
+ expect(mockBridge.request).toHaveBeenLastCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'list', prefix: 'a' } }, 120000);
2320
+ mockBridge.request.mockResolvedValueOnce({});
2321
+ await expect(store.delete('a')).resolves.toBeUndefined();
2322
+ mockBridge.request.mockResolvedValueOnce({ deleted: 3 });
2323
+ expect(await store.clear()).toEqual({ deleted: 3 });
2324
+ mockBridge.request.mockResolvedValueOnce({ usedBytes: 10, budgetBytes: 100 });
2325
+ expect(await store.estimate()).toEqual({ usedBytes: 10, budgetBytes: 100 });
2326
+ });
2327
+ it('getMany reads several entries in one call, in order, null for a missing key', async () => {
2328
+ const { store } = load();
2329
+ const a = { key: 'a', value: 'x', sizeBytes: 1, updatedAt: '2026-10-14T00:00:00.000Z' };
2330
+ mockBridge.request.mockResolvedValueOnce({ entries: [a, null] });
2331
+ expect(await store.getMany(['a', 'gone'])).toEqual([a, null]);
2332
+ expect(mockBridge.request).toHaveBeenLastCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'get_many', keys: ['a', 'gone'] } }, 120000);
2333
+ mockBridge.request.mockResolvedValueOnce({
2334
+ error: { code: 'LOCAL_STORE_VALUE_TOO_LARGE', message: 'read fewer keys at a time' },
2335
+ });
2336
+ await expect(store.getMany(['a', 'b'])).rejects.toMatchObject({
2337
+ code: 'LOCAL_STORE_VALUE_TOO_LARGE',
2338
+ });
2339
+ });
2340
+ });
1849
2341
  describe('useFiasContent (content packs, ADR-021)', () => {
1850
2342
  const ENTITY = 'ent_intg_plugin_content_00000000';
1851
2343
  const load = () => {