@fias/arche-sdk 1.11.0 → 1.13.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.
@@ -608,6 +608,256 @@ describe('SDK hooks', () => {
608
608
  // 1 initial + 1 delete + 1 fresh fetch (cache cleared by delete)
609
609
  expect(mockBridge.request).toHaveBeenCalledTimes(3);
610
610
  });
611
+ it('write forwards sensitivity / folderPath / replacesDocumentId', async () => {
612
+ const { useVaultDocuments } = freshHooks();
613
+ const api = useVaultDocuments();
614
+ mockBridge.request.mockResolvedValueOnce({ documentId: 'vdoc_w' });
615
+ await api.write({
616
+ name: 'sensitive.md',
617
+ content: '# secret',
618
+ contentType: 'text/markdown',
619
+ tags: ['private'],
620
+ sensitivity: 'confidential',
621
+ folderPath: '/My-Fias/Arches/arche_test',
622
+ replacesDocumentId: 'vdoc_prev',
623
+ });
624
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_write', {
625
+ name: 'sensitive.md',
626
+ content: '# secret',
627
+ contentType: 'text/markdown',
628
+ tags: ['private'],
629
+ sensitivity: 'confidential',
630
+ folderPath: '/My-Fias/Arches/arche_test',
631
+ replacesDocumentId: 'vdoc_prev',
632
+ });
633
+ });
634
+ it('search forwards query + options to vault_documents_search', async () => {
635
+ const { useVaultDocuments } = freshHooks();
636
+ const api = useVaultDocuments();
637
+ mockBridge.request.mockResolvedValueOnce({ matches: [] });
638
+ const res = await api.search('insurance policy', {
639
+ topK: 5,
640
+ minSimilarity: 0.7,
641
+ includeContent: false,
642
+ });
643
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_search', {
644
+ query: 'insurance policy',
645
+ topK: 5,
646
+ minSimilarity: 0.7,
647
+ includeContent: false,
648
+ });
649
+ expect(res).toEqual({ matches: [] });
650
+ });
651
+ it('attach posts the regarding target to vault_documents_attach', async () => {
652
+ const { useVaultDocuments } = freshHooks();
653
+ const api = useVaultDocuments();
654
+ mockBridge.request.mockResolvedValueOnce({
655
+ referenceId: 'vdref_xyz',
656
+ attachedAt: '2026-05-21T00:00:00Z',
657
+ });
658
+ const ref = await api.attach('vdoc_a', {
659
+ regardingType: 'vault_business_contact',
660
+ regardingId: 'bcon_abc',
661
+ });
662
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_attach', {
663
+ documentId: 'vdoc_a',
664
+ regardingType: 'vault_business_contact',
665
+ regardingId: 'bcon_abc',
666
+ });
667
+ expect(ref.referenceId).toBe('vdref_xyz');
668
+ });
669
+ it('detach posts documentId + referenceId to vault_documents_detach', async () => {
670
+ const { useVaultDocuments } = freshHooks();
671
+ const api = useVaultDocuments();
672
+ mockBridge.request.mockResolvedValueOnce({ success: true });
673
+ await api.detach('vdoc_a', 'vdref_xyz');
674
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_detach', {
675
+ documentId: 'vdoc_a',
676
+ referenceId: 'vdref_xyz',
677
+ });
678
+ });
679
+ it('uploadInit posts the full param set to vault_documents_upload_init', async () => {
680
+ const { useVaultDocuments } = freshHooks();
681
+ const api = useVaultDocuments();
682
+ mockBridge.request.mockResolvedValueOnce({
683
+ documentId: 'vdoc_u',
684
+ uploadUrl: 'https://s3/up',
685
+ requiredHeaders: { 'Content-Type': 'application/pdf' },
686
+ uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
687
+ });
688
+ const init = await api.uploadInit({
689
+ name: 'report.pdf',
690
+ mimeType: 'application/pdf',
691
+ sizeBytes: 1024,
692
+ checksumSha256: 'a'.repeat(64),
693
+ tags: ['report'],
694
+ sensitivity: 'confidential',
695
+ folderPath: '/My-Fias/Arches/arche_test',
696
+ replacesDocumentId: undefined,
697
+ });
698
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload_init', {
699
+ name: 'report.pdf',
700
+ mimeType: 'application/pdf',
701
+ sizeBytes: 1024,
702
+ checksumSha256: 'a'.repeat(64),
703
+ tags: ['report'],
704
+ sensitivity: 'confidential',
705
+ folderPath: '/My-Fias/Arches/arche_test',
706
+ replacesDocumentId: undefined,
707
+ });
708
+ expect(init.documentId).toBe('vdoc_u');
709
+ });
710
+ it('uploadFinalize posts documentId + checksum to vault_documents_upload_finalize', async () => {
711
+ const { useVaultDocuments } = freshHooks();
712
+ const api = useVaultDocuments();
713
+ mockBridge.request.mockResolvedValueOnce({
714
+ documentId: 'vdoc_u',
715
+ status: 'uploaded',
716
+ });
717
+ const fin = await api.uploadFinalize({
718
+ documentId: 'vdoc_u',
719
+ checksumSha256: 'b'.repeat(64),
720
+ });
721
+ expect(mockBridge.request).toHaveBeenCalledWith('vault_documents_upload_finalize', {
722
+ documentId: 'vdoc_u',
723
+ checksumSha256: 'b'.repeat(64),
724
+ });
725
+ expect(fin.status).toBe('uploaded');
726
+ });
727
+ it('upload() orchestrates SHA-256 + presigned PUT + finalize, stripping Content-Length', async () => {
728
+ const { useVaultDocuments } = freshHooks();
729
+ const api = useVaultDocuments();
730
+ // Mock crypto.subtle.digest to return a known 32-byte digest.
731
+ const fakeDigest = new Uint8Array(32);
732
+ for (let i = 0; i < 32; i++)
733
+ fakeDigest[i] = i;
734
+ const originalCrypto = globalThis.crypto;
735
+ globalThis.crypto = {
736
+ subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
737
+ };
738
+ // Mock fetch.
739
+ const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
740
+ const originalFetch = globalThis.fetch;
741
+ globalThis.fetch = fetchMock;
742
+ try {
743
+ mockBridge.request.mockResolvedValueOnce({
744
+ documentId: 'vdoc_u',
745
+ uploadUrl: 'https://s3/up',
746
+ requiredHeaders: {
747
+ 'Content-Type': 'application/octet-stream',
748
+ 'Content-Length': '4',
749
+ 'x-amz-checksum-sha256': 'aaa',
750
+ 'x-amz-tagging': 'pending=true',
751
+ },
752
+ uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
753
+ });
754
+ mockBridge.request.mockResolvedValueOnce({
755
+ documentId: 'vdoc_u',
756
+ status: 'uploaded',
757
+ });
758
+ const bytes = new Uint8Array([1, 2, 3, 4]);
759
+ const result = await api.upload(bytes, {
760
+ name: 'tiny.bin',
761
+ mimeType: 'application/octet-stream',
762
+ });
763
+ // SDK rendered the digest as lowercase hex.
764
+ const expectedHex = Array.from(fakeDigest)
765
+ .map((b) => b.toString(16).padStart(2, '0'))
766
+ .join('');
767
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_upload_init', expect.objectContaining({
768
+ sizeBytes: 4,
769
+ checksumSha256: expectedHex,
770
+ }));
771
+ // Browser fetch was called with Content-Length stripped from the
772
+ // headers (browser auto-computes it from body.byteLength).
773
+ expect(fetchMock).toHaveBeenCalledTimes(1);
774
+ const fetchOpts = fetchMock.mock.calls[0][1];
775
+ const headerKeys = Object.keys(fetchOpts.headers).map((k) => k.toLowerCase());
776
+ expect(headerKeys).not.toContain('content-length');
777
+ expect(headerKeys).toContain('content-type');
778
+ expect(headerKeys).toContain('x-amz-checksum-sha256');
779
+ // Finalize used the same hex digest.
780
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'vault_documents_upload_finalize', {
781
+ documentId: 'vdoc_u',
782
+ checksumSha256: expectedHex,
783
+ });
784
+ expect(result.status).toBe('uploaded');
785
+ }
786
+ finally {
787
+ globalThis.crypto = originalCrypto;
788
+ globalThis.fetch = originalFetch;
789
+ }
790
+ });
791
+ it('upload() throws on non-2xx PUT response', async () => {
792
+ const { useVaultDocuments } = freshHooks();
793
+ const api = useVaultDocuments();
794
+ const fakeDigest = new Uint8Array(32);
795
+ const originalCrypto = globalThis.crypto;
796
+ globalThis.crypto = {
797
+ subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
798
+ };
799
+ const fetchMock = jest.fn().mockResolvedValue({
800
+ ok: false,
801
+ status: 403,
802
+ statusText: 'Forbidden',
803
+ text: () => Promise.resolve('signature mismatch'),
804
+ });
805
+ const originalFetch = globalThis.fetch;
806
+ globalThis.fetch = fetchMock;
807
+ try {
808
+ mockBridge.request.mockResolvedValueOnce({
809
+ documentId: 'vdoc_u',
810
+ uploadUrl: 'https://s3/up',
811
+ requiredHeaders: { 'Content-Type': 'application/octet-stream' },
812
+ uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
813
+ });
814
+ await expect(api.upload(new Uint8Array([1, 2, 3]), {
815
+ name: 'x.bin',
816
+ mimeType: 'application/octet-stream',
817
+ })).rejects.toThrow(/403/);
818
+ }
819
+ finally {
820
+ globalThis.crypto = originalCrypto;
821
+ globalThis.fetch = originalFetch;
822
+ }
823
+ });
824
+ it('upload() accepts a raw ArrayBuffer as well as a Uint8Array', async () => {
825
+ const { useVaultDocuments } = freshHooks();
826
+ const api = useVaultDocuments();
827
+ const fakeDigest = new Uint8Array(32);
828
+ const originalCrypto = globalThis.crypto;
829
+ globalThis.crypto = {
830
+ subtle: { digest: jest.fn().mockResolvedValue(fakeDigest.buffer) },
831
+ };
832
+ const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
833
+ const originalFetch = globalThis.fetch;
834
+ globalThis.fetch = fetchMock;
835
+ try {
836
+ mockBridge.request.mockResolvedValueOnce({
837
+ documentId: 'vdoc_u',
838
+ uploadUrl: 'https://s3/up',
839
+ requiredHeaders: {},
840
+ uploadUrlExpiresAt: '2026-05-21T00:05:00Z',
841
+ });
842
+ mockBridge.request.mockResolvedValueOnce({
843
+ documentId: 'vdoc_u',
844
+ status: 'uploaded',
845
+ });
846
+ const buffer = new ArrayBuffer(8);
847
+ const view = new Uint8Array(buffer);
848
+ view.set([1, 2, 3, 4, 5, 6, 7, 8]);
849
+ const result = await api.upload(buffer, {
850
+ name: 'ab.bin',
851
+ mimeType: 'application/octet-stream',
852
+ });
853
+ expect(result.status).toBe('uploaded');
854
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'vault_documents_upload_init', expect.objectContaining({ sizeBytes: 8 }));
855
+ }
856
+ finally {
857
+ globalThis.crypto = originalCrypto;
858
+ globalThis.fetch = originalFetch;
859
+ }
860
+ });
611
861
  });
612
862
  describe('useSurface', () => {
613
863
  it('invokes the surface_invoke bridge op with the given surfaceKey', async () => {
@@ -632,5 +882,386 @@ describe('SDK hooks', () => {
632
882
  await expect(api.invoke({ text: 'hi' })).rejects.toThrow('rate limited');
633
883
  });
634
884
  });
885
+ describe('useImageGeneration', () => {
886
+ it('returns initial state from useState', () => {
887
+ const { useImageGeneration } = require('./hooks');
888
+ const api = useImageGeneration();
889
+ expect(api.isLoading).toBe(false);
890
+ expect(api.result).toBeNull();
891
+ expect(api.error).toBeNull();
892
+ expect(typeof api.generate).toBe('function');
893
+ });
894
+ it('generate calls bridge.request with image_generate + 120s timeout and returns the result', async () => {
895
+ const { useImageGeneration } = require('./hooks');
896
+ mockBridge.request.mockResolvedValue({ images: [{ url: 'https://x/img.png' }] });
897
+ const api = useImageGeneration();
898
+ const result = await api.generate({ prompt: 'a cat' });
899
+ expect(mockBridge.request).toHaveBeenCalledWith('image_generate', { prompt: 'a cat' }, 120000);
900
+ expect(result.images[0].url).toBe('https://x/img.png');
901
+ });
902
+ it('rethrows and wraps non-Error rejections', async () => {
903
+ const { useImageGeneration } = require('./hooks');
904
+ mockBridge.request.mockRejectedValue('content policy violation');
905
+ const api = useImageGeneration();
906
+ await expect(api.generate({ prompt: 'x' })).rejects.toThrow('content policy violation');
907
+ });
908
+ });
909
+ describe('useAudioGeneration', () => {
910
+ it('returns initial state', () => {
911
+ const { useAudioGeneration } = require('./hooks');
912
+ const api = useAudioGeneration();
913
+ expect(api.isLoading).toBe(false);
914
+ expect(api.result).toBeNull();
915
+ expect(api.error).toBeNull();
916
+ });
917
+ it('generate calls bridge.request with audio_generate + 180s timeout', async () => {
918
+ const { useAudioGeneration } = require('./hooks');
919
+ mockBridge.request.mockResolvedValue({ clips: [{ url: 'https://x/clip.mp3' }] });
920
+ const api = useAudioGeneration();
921
+ await api.generate({ prompt: 'lofi beat' });
922
+ expect(mockBridge.request).toHaveBeenCalledWith('audio_generate', { prompt: 'lofi beat' }, 180000);
923
+ });
924
+ it('rethrows bridge errors', async () => {
925
+ const { useAudioGeneration } = require('./hooks');
926
+ mockBridge.request.mockRejectedValue(new Error('Lyria timeout'));
927
+ const api = useAudioGeneration();
928
+ await expect(api.generate({ prompt: 'x' })).rejects.toThrow('Lyria timeout');
929
+ });
930
+ });
931
+ describe('useBackgroundRemoval', () => {
932
+ it('returns initial state from useState', () => {
933
+ const { useBackgroundRemoval } = require('./hooks');
934
+ const api = useBackgroundRemoval();
935
+ expect(api.isLoading).toBe(false);
936
+ expect(api.result).toBeNull();
937
+ expect(api.error).toBeNull();
938
+ expect(typeof api.removeBackground).toBe('function');
939
+ });
940
+ it('removeBackground calls bridge.request with image_remove_background + 120s timeout and returns the result blob', async () => {
941
+ const { useBackgroundRemoval } = require('./hooks');
942
+ const cutout = new Blob([new Uint8Array([9])], { type: 'image/png' });
943
+ mockBridge.request.mockResolvedValue({ image: cutout });
944
+ const api = useBackgroundRemoval();
945
+ const input = new Blob([new Uint8Array([1])], { type: 'image/png' });
946
+ const result = await api.removeBackground(input);
947
+ expect(mockBridge.request).toHaveBeenCalledWith('image_remove_background', { image: input }, 120000);
948
+ expect(result).toBe(cutout);
949
+ });
950
+ it('rejects inputs over 25 MiB without calling the bridge', async () => {
951
+ const { useBackgroundRemoval } = require('./hooks');
952
+ // 25 MiB + 1 byte. Build a backing buffer (cheap — happy-dom Blob
953
+ // reads `size` from the constructor input length without
954
+ // materializing the array contents).
955
+ const oversized = new Blob([new Uint8Array(25 * 1024 * 1024 + 1)], { type: 'image/png' });
956
+ const api = useBackgroundRemoval();
957
+ await expect(api.removeBackground(oversized)).rejects.toThrow('25 MiB');
958
+ expect(mockBridge.request).not.toHaveBeenCalled();
959
+ });
960
+ it('rethrows bridge errors and wraps non-Error rejections', async () => {
961
+ const { useBackgroundRemoval } = require('./hooks');
962
+ mockBridge.request.mockRejectedValue('rate limit hit');
963
+ const api = useBackgroundRemoval();
964
+ await expect(api.removeBackground(new Blob([new Uint8Array([1])], { type: 'image/png' }))).rejects.toThrow('rate limit hit');
965
+ });
966
+ });
967
+ describe('useFiasDataStore', () => {
968
+ it('createCollection forwards name + default userScope', async () => {
969
+ const { useFiasDataStore } = require('./hooks');
970
+ const ds = useFiasDataStore();
971
+ mockBridge.request.mockResolvedValueOnce({
972
+ collection: { name: 'scores', userScope: 'user' },
973
+ });
974
+ const coll = await ds.createCollection('scores');
975
+ expect(mockBridge.request).toHaveBeenCalledWith('data_create_collection', {
976
+ name: 'scores',
977
+ userScope: 'user',
978
+ });
979
+ expect(coll.name).toBe('scores');
980
+ });
981
+ it('createCollection honors an explicit userScope=shared', async () => {
982
+ const { useFiasDataStore } = require('./hooks');
983
+ const ds = useFiasDataStore();
984
+ mockBridge.request.mockResolvedValueOnce({
985
+ collection: { name: 'global', userScope: 'shared' },
986
+ });
987
+ await ds.createCollection('global', { userScope: 'shared' });
988
+ expect(mockBridge.request).toHaveBeenCalledWith('data_create_collection', {
989
+ name: 'global',
990
+ userScope: 'shared',
991
+ });
992
+ });
993
+ it('listCollections returns the array unwrapped from the bridge envelope', async () => {
994
+ const { useFiasDataStore } = require('./hooks');
995
+ const ds = useFiasDataStore();
996
+ mockBridge.request.mockResolvedValueOnce({
997
+ collections: [{ name: 'a' }, { name: 'b' }],
998
+ });
999
+ const list = await ds.listCollections();
1000
+ expect(list).toHaveLength(2);
1001
+ expect(mockBridge.request).toHaveBeenCalledWith('data_list_collections', {});
1002
+ });
1003
+ it('deleteCollection forwards the name', async () => {
1004
+ const { useFiasDataStore } = require('./hooks');
1005
+ const ds = useFiasDataStore();
1006
+ mockBridge.request.mockResolvedValueOnce(undefined);
1007
+ await ds.deleteCollection('scores');
1008
+ expect(mockBridge.request).toHaveBeenCalledWith('data_delete_collection', {
1009
+ name: 'scores',
1010
+ });
1011
+ });
1012
+ it('put forwards collection / key / data', async () => {
1013
+ const { useFiasDataStore } = require('./hooks');
1014
+ const ds = useFiasDataStore();
1015
+ mockBridge.request.mockResolvedValueOnce(undefined);
1016
+ await ds.put('scores', 'level-1', { score: 100 });
1017
+ expect(mockBridge.request).toHaveBeenCalledWith('data_put', {
1018
+ collection: 'scores',
1019
+ key: 'level-1',
1020
+ data: { score: 100 },
1021
+ });
1022
+ });
1023
+ it('get unwraps { data } from the bridge envelope', async () => {
1024
+ const { useFiasDataStore } = require('./hooks');
1025
+ const ds = useFiasDataStore();
1026
+ mockBridge.request.mockResolvedValueOnce({ data: { score: 50 } });
1027
+ const result = await ds.get('scores', 'level-1');
1028
+ expect(result).toEqual({ score: 50 });
1029
+ });
1030
+ it('get returns null when the bridge says data is null', async () => {
1031
+ const { useFiasDataStore } = require('./hooks');
1032
+ const ds = useFiasDataStore();
1033
+ mockBridge.request.mockResolvedValueOnce({ data: null });
1034
+ const result = await ds.get('scores', 'missing');
1035
+ expect(result).toBeNull();
1036
+ });
1037
+ it('query forwards collection + options to data_query', async () => {
1038
+ const { useFiasDataStore } = require('./hooks');
1039
+ const ds = useFiasDataStore();
1040
+ mockBridge.request.mockResolvedValueOnce({ documents: [], nextCursor: null });
1041
+ await ds.query('scores', {
1042
+ filters: [{ field: 'score', op: 'gte', value: 50 }],
1043
+ orderBy: { field: 'score', direction: 'desc' },
1044
+ limit: 10,
1045
+ });
1046
+ expect(mockBridge.request).toHaveBeenCalledWith('data_query', {
1047
+ collection: 'scores',
1048
+ filters: [{ field: 'score', op: 'gte', value: 50 }],
1049
+ orderBy: { field: 'score', direction: 'desc' },
1050
+ limit: 10,
1051
+ });
1052
+ });
1053
+ it('delete forwards collection + key', async () => {
1054
+ const { useFiasDataStore } = require('./hooks');
1055
+ const ds = useFiasDataStore();
1056
+ mockBridge.request.mockResolvedValueOnce(undefined);
1057
+ await ds.delete('scores', 'level-1');
1058
+ expect(mockBridge.request).toHaveBeenCalledWith('data_delete', {
1059
+ collection: 'scores',
1060
+ key: 'level-1',
1061
+ });
1062
+ });
1063
+ });
1064
+ describe('useFiasStore', () => {
1065
+ it('fetches products + entitlements on mount and exposes them', async () => {
1066
+ const { useFiasStore } = require('./hooks');
1067
+ const products = [{ productIdentifier: 'gems_100', type: 'consumable', priceCents: 99 }];
1068
+ const entitlements = [{ productIdentifier: 'premium', isActive: true }];
1069
+ mockBridge.request.mockResolvedValueOnce(products).mockResolvedValueOnce(entitlements);
1070
+ const api = useFiasStore();
1071
+ // Effect kicks the two bridge calls.
1072
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'store_get_products', {});
1073
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'store_get_entitlements', {});
1074
+ expect(typeof api.purchase).toBe('function');
1075
+ });
1076
+ it('hasEntitlement checks productIdentifier + isActive', () => {
1077
+ const { useFiasStore } = require('./hooks');
1078
+ // Default mockBridge.request returns {}; we don't need products/ents
1079
+ // populated to test the predicate — the closure is over `entitlements`
1080
+ // from useState. Default state is []; hasEntitlement returns false.
1081
+ const api = useFiasStore();
1082
+ expect(api.hasEntitlement('anything')).toBe(false);
1083
+ });
1084
+ it('purchase posts productIdentifier + options and returns the result', async () => {
1085
+ const { useFiasStore } = require('./hooks');
1086
+ // First two calls satisfy the mount effect.
1087
+ mockBridge.request
1088
+ .mockResolvedValueOnce([])
1089
+ .mockResolvedValueOnce([])
1090
+ .mockResolvedValueOnce({ success: true, purchaseId: 'p_1' });
1091
+ const api = useFiasStore();
1092
+ const result = await api.purchase('gems_100');
1093
+ expect(mockBridge.request).toHaveBeenLastCalledWith('store_purchase', {
1094
+ productIdentifier: 'gems_100',
1095
+ });
1096
+ expect(result.purchaseId).toBe('p_1');
1097
+ });
1098
+ it('purchase forwards options.quantity to the bridge', async () => {
1099
+ const { useFiasStore } = require('./hooks');
1100
+ mockBridge.request
1101
+ .mockResolvedValueOnce([])
1102
+ .mockResolvedValueOnce([])
1103
+ .mockResolvedValueOnce({ success: true });
1104
+ const api = useFiasStore();
1105
+ await api.purchase('gems_100', { quantity: 3 });
1106
+ expect(mockBridge.request).toHaveBeenLastCalledWith('store_purchase', {
1107
+ productIdentifier: 'gems_100',
1108
+ quantity: 3,
1109
+ });
1110
+ });
1111
+ it('getPurchaseHistory unwraps { purchases }', async () => {
1112
+ const { useFiasStore } = require('./hooks');
1113
+ mockBridge.request
1114
+ .mockResolvedValueOnce([])
1115
+ .mockResolvedValueOnce([])
1116
+ .mockResolvedValueOnce({ purchases: [{ purchaseId: 'p_1' }] });
1117
+ const api = useFiasStore();
1118
+ const history = await api.getPurchaseHistory();
1119
+ expect(history).toHaveLength(1);
1120
+ expect(mockBridge.request).toHaveBeenLastCalledWith('store_get_purchase_history', {});
1121
+ });
1122
+ it('restorePurchases returns the restored entitlements array directly', async () => {
1123
+ const { useFiasStore } = require('./hooks');
1124
+ mockBridge.request
1125
+ .mockResolvedValueOnce([])
1126
+ .mockResolvedValueOnce([])
1127
+ .mockResolvedValueOnce([{ productIdentifier: 'premium', isActive: true }]);
1128
+ const api = useFiasStore();
1129
+ const restored = await api.restorePurchases();
1130
+ expect(restored).toHaveLength(1);
1131
+ expect(mockBridge.request).toHaveBeenLastCalledWith('store_restore', {});
1132
+ });
1133
+ it('swallows a mount-time bridge rejection and clears isLoading', async () => {
1134
+ const { useFiasStore } = require('./hooks');
1135
+ mockBridge.request.mockRejectedValue(new Error('network down'));
1136
+ useFiasStore();
1137
+ // Effect rejection is caught — no uncaught promise here. The test
1138
+ // passes if no unhandled rejection is emitted by the test runner.
1139
+ await new Promise((r) => setTimeout(r, 0));
1140
+ });
1141
+ });
1142
+ describe('useFiasNavigation', () => {
1143
+ it('seeds currentPath from bridge.getCurrentPath() and subscribes to updates', () => {
1144
+ mockBridge.getCurrentPath.mockReturnValue('/home');
1145
+ const { useFiasNavigation } = require('./hooks');
1146
+ const api = useFiasNavigation();
1147
+ expect(api.currentPath).toBe('/home');
1148
+ expect(mockBridge.onNavigationUpdate).toHaveBeenCalled();
1149
+ });
1150
+ it('navigateTo dispatches a `navigate` bridge request with the path', async () => {
1151
+ const { useFiasNavigation } = require('./hooks');
1152
+ mockBridge.request.mockResolvedValue(undefined);
1153
+ const api = useFiasNavigation();
1154
+ api.navigateTo('/profile');
1155
+ expect(mockBridge.request).toHaveBeenCalledWith('navigate', { path: '/profile' });
1156
+ });
1157
+ it('cleanup unsubscribes from navigation updates', () => {
1158
+ const unsubscribe = jest.fn();
1159
+ mockBridge.onNavigationUpdate.mockReturnValue(unsubscribe);
1160
+ const { useFiasNavigation } = require('./hooks');
1161
+ useFiasNavigation();
1162
+ // capturedEffectCleanup is set inside the mocked useEffect; trigger it.
1163
+ capturedEffectCleanup?.();
1164
+ expect(unsubscribe).toHaveBeenCalled();
1165
+ });
1166
+ });
1167
+ describe('usePersistentState', () => {
1168
+ it('reads from storage on mount and applies the parsed value', async () => {
1169
+ mockBridge.request.mockResolvedValue({
1170
+ content: JSON.stringify({ score: 50 }),
1171
+ exists: true,
1172
+ });
1173
+ const { usePersistentState } = require('./hooks');
1174
+ const [value, setValue] = usePersistentState('game-state', { score: 0 });
1175
+ expect(value).toEqual({ score: 0 }); // initial (useState returns initial)
1176
+ expect(mockBridge.request).toHaveBeenCalledWith('storage_read', {
1177
+ path: '__state/game-state',
1178
+ });
1179
+ // setValue is exposed regardless of load completion.
1180
+ expect(typeof setValue).toBe('function');
1181
+ });
1182
+ it('tolerates a corrupted JSON read and keeps initial value', async () => {
1183
+ mockBridge.request.mockResolvedValue({
1184
+ content: 'not-valid-json{',
1185
+ exists: true,
1186
+ });
1187
+ const { usePersistentState } = require('./hooks');
1188
+ const [value] = usePersistentState('x', { count: 1 });
1189
+ // initial state from useState (the mocked react.useState returns
1190
+ // the initial value directly).
1191
+ expect(value).toEqual({ count: 1 });
1192
+ await new Promise((r) => setTimeout(r, 0));
1193
+ });
1194
+ it('setValue with a function variant resolves prev from valueRef', () => {
1195
+ mockBridge.request.mockResolvedValue({ content: null, exists: false });
1196
+ const { usePersistentState } = require('./hooks');
1197
+ const [, setValue] = usePersistentState('counter', 0);
1198
+ // Calling setValue with a function should not throw and should
1199
+ // schedule a debounced write. The mocked react.useState's setter
1200
+ // doesn't actually mutate, but the writer.schedule call path runs.
1201
+ setValue((prev) => prev + 5);
1202
+ // No bridge.request call yet — schedule is debounced via setTimeout.
1203
+ });
1204
+ });
1205
+ describe('useStepNavigation', () => {
1206
+ it('returns initial step from the argument', () => {
1207
+ const { useStepNavigation } = require('./hooks');
1208
+ const api = useStepNavigation('step-1');
1209
+ expect(api.currentStep).toBe('step-1');
1210
+ expect(mockBridge.onStepNavigationUpdate).toHaveBeenCalled();
1211
+ });
1212
+ it('defaults currentStep to null when no initial step is given', () => {
1213
+ const { useStepNavigation } = require('./hooks');
1214
+ const api = useStepNavigation();
1215
+ expect(api.currentStep).toBeNull();
1216
+ });
1217
+ it('with persistKey, reads __state/<key> on mount', async () => {
1218
+ mockBridge.request.mockResolvedValue({
1219
+ content: JSON.stringify('step-2'),
1220
+ exists: true,
1221
+ });
1222
+ const { useStepNavigation } = require('./hooks');
1223
+ useStepNavigation(undefined, { persistKey: 'wizard' });
1224
+ expect(mockBridge.request).toHaveBeenCalledWith('storage_read', {
1225
+ path: '__state/wizard',
1226
+ });
1227
+ });
1228
+ it('setCurrentStep updates state and schedules a persistent write when persistKey is set', () => {
1229
+ const { useStepNavigation } = require('./hooks');
1230
+ const api = useStepNavigation(undefined, { persistKey: 'wizard' });
1231
+ api.setCurrentStep('step-3');
1232
+ // No throw — the writer is scheduled internally.
1233
+ });
1234
+ it('setCurrentStep without persistKey does not schedule writes', () => {
1235
+ const { useStepNavigation } = require('./hooks');
1236
+ const api = useStepNavigation('step-a');
1237
+ api.setCurrentStep('step-b');
1238
+ });
1239
+ });
1240
+ describe('useArcheAssets', () => {
1241
+ it('list forwards to arche_assets_list with the cursor/limit options', async () => {
1242
+ const { useArcheAssets } = require('./hooks');
1243
+ mockBridge.request.mockResolvedValueOnce({ entries: [], nextCursor: null });
1244
+ const api = useArcheAssets();
1245
+ await api.list({ limit: 25, cursor: '50' });
1246
+ expect(mockBridge.request).toHaveBeenCalledWith('arche_assets_list', {
1247
+ cursor: '50',
1248
+ limit: 25,
1249
+ });
1250
+ });
1251
+ it('getUrl posts assetId to arche_assets_get_url', async () => {
1252
+ const { useArcheAssets } = require('./hooks');
1253
+ mockBridge.request.mockResolvedValueOnce({
1254
+ assetId: 'aset_1',
1255
+ signedUrl: 'https://x',
1256
+ expiresAt: 1700000000000,
1257
+ });
1258
+ const api = useArcheAssets();
1259
+ const asset = await api.getUrl('aset_1');
1260
+ expect(mockBridge.request).toHaveBeenCalledWith('arche_assets_get_url', {
1261
+ assetId: 'aset_1',
1262
+ });
1263
+ expect(asset.assetId).toBe('aset_1');
1264
+ });
1265
+ });
635
1266
  });
636
1267
  //# sourceMappingURL=hooks.test.js.map