From 5fd9c5fa4e24b50437554bc0157c2fe40b189398 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 8 Jan 2026 13:33:42 -0800 Subject: [PATCH] cache document load for proving flow --- app/src/providers/passportDataProvider.tsx | 10 + .../DocumentSelectorForProvingScreen.tsx | 26 +- .../verification/ProofRequestStatusScreen.tsx | 4 + app/src/screens/verification/ProveScreen.tsx | 24 +- .../verification/ProvingScreenRouter.tsx | 32 ++- app/src/stores/documentCacheStore.ts | 92 +++++++ .../DocumentSelectorForProvingScreen.test.tsx | 101 ++++++- .../verification/ProvingScreenRouter.test.tsx | 108 ++++++-- .../src/stores/documentCacheStore.test.ts | 254 ++++++++++++++++++ 9 files changed, 616 insertions(+), 35 deletions(-) create mode 100644 app/src/stores/documentCacheStore.ts create mode 100644 app/tests/src/stores/documentCacheStore.test.ts diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index 8ce305cff..8f5739fff 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -67,6 +67,7 @@ import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { createKeychainOptions } from '@/integrations/keychain'; import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; // Create safe wrapper functions to prevent undefined errors during early initialization // These need to be declared early to avoid dependency issues @@ -330,6 +331,9 @@ export async function deleteDocument(documentId: string): Promise { } catch { console.log(`Document ${documentId} not found or already cleared`); } + + // Clear document cache since catalog has changed + useDocumentCacheStore.getState().clearCache(); } export async function getAvailableDocumentTypes(): Promise { @@ -831,6 +835,9 @@ export async function storeDocumentWithDeduplication( catalog.selectedDocumentId = contentHash; await saveDocumentCatalogDirectlyToKeychain(catalog); + // Clear document cache since a new document was added + useDocumentCacheStore.getState().clearCache(); + return contentHash; } // Duplicate function. prefer one in mobile sdk @@ -853,6 +860,9 @@ export async function updateDocumentRegistrationState( console.log( `Updated registration state for document ${documentId}: ${isRegistered}`, ); + + // Clear document cache since registration state changed + useDocumentCacheStore.getState().clearCache(); } else { console.warn(`Document ${documentId} not found in catalog`); } diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx index 3b4efa5ab..c347674db 100644 --- a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -44,6 +44,7 @@ import { } from '@/components/proof-request'; import type { RootStackParamList } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; import { getDisclosureItems } from '@/utils/disclosureUtils'; import { formatUserId } from '@/utils/formatUserId'; @@ -136,6 +137,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => { const selfApp = useSelfAppStore(state => state.selfApp); const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = usePassport(); + const { getCache, setCache, isValid } = useDocumentCacheStore(); const [documentCatalog, setDocumentCatalog] = useState({ documents: [], @@ -238,8 +240,19 @@ const DocumentSelectorForProvingScreen: React.FC = () => { setLoading(true); setError(null); try { - const catalog = await loadDocumentCatalog(); - const docs = await getAllDocuments(); + // Try to use cached data first + let catalog, docs; + const cachedData = isValid() ? getCache() : null; + + if (cachedData) { + catalog = cachedData.catalog; + docs = cachedData.allDocuments; + } else { + // Load fresh data and cache it + catalog = await loadDocumentCatalog(); + docs = await getAllDocuments(); + setCache(catalog, docs); + } // Don't update state if this request was aborted if (controller.signal.aborted) { @@ -261,7 +274,14 @@ const DocumentSelectorForProvingScreen: React.FC = () => { setLoading(false); } } - }, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]); + }, [ + getAllDocuments, + getCache, + isValid, + loadDocumentCatalog, + pickInitialDocument, + setCache, + ]); useFocusEffect( useCallback(() => { diff --git a/app/src/screens/verification/ProofRequestStatusScreen.tsx b/app/src/screens/verification/ProofRequestStatusScreen.tsx index 6a7af0e93..da975d565 100644 --- a/app/src/screens/verification/ProofRequestStatusScreen.tsx +++ b/app/src/screens/verification/ProofRequestStatusScreen.tsx @@ -34,6 +34,7 @@ import { import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; import { getWhiteListedDisclosureAddresses } from '@/services/points/utils'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; @@ -68,6 +69,9 @@ const SuccessScreen: React.FC = () => { const onOkPress = useCallback(async () => { buttonTap(); + // Clear document cache when proving flow completes + useDocumentCacheStore.getState().clearCache(); + if (whitelistedPoints !== null) { navigation.navigate('Gratification', { points: whitelistedPoints, diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 88531b3bf..06d801ce1 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -50,6 +50,7 @@ import { getPointsAddress, getWhiteListedDisclosureAddresses, } from '@/services/points'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; import { getDisclosureItems } from '@/utils/disclosureUtils'; @@ -103,11 +104,23 @@ const ProveScreen: React.FC = () => { const { addProofHistory } = useProofHistoryStore(); const { loadDocumentCatalog } = usePassport(); + const { getCache, isValid } = useDocumentCacheStore(); useEffect(() => { const addHistory = async () => { if (provingStore.uuid && selectedApp) { - const catalog = await loadDocumentCatalog(); + // Try to use cached catalog first + let catalog; + const cachedData = isValid() ? getCache() : null; + + if (cachedData) { + catalog = cachedData.catalog; + } else { + catalog = await loadDocumentCatalog(); + // Note: We don't have allDocuments here, so we only partially cache + // This is okay since upstream screens will have the full cache + } + const selectedDocumentId = catalog.selectedDocumentId; addProofHistory({ @@ -125,7 +138,14 @@ const ProveScreen: React.FC = () => { } }; addHistory(); - }, [addProofHistory, provingStore.uuid, selectedApp, loadDocumentCatalog]); + }, [ + addProofHistory, + getCache, + isValid, + loadDocumentCatalog, + provingStore.uuid, + selectedApp, + ]); useEffect(() => { if (isContentShorterThanScrollView) { diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx index bcd9b97f8..c1eec0831 100644 --- a/app/src/screens/verification/ProvingScreenRouter.tsx +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -18,15 +18,17 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import { proofRequestColors } from '@/components/proof-request'; import type { RootStackParamList } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; import { useSettingStore } from '@/stores/settingStore'; /** * Router screen for the proving flow that decides whether to skip the document selector. * * This screen: - * 1. Loads document catalog and counts valid documents - * 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle) - * 3. Routes to appropriate screen: + * 1. Loads document catalog and counts valid documents (or uses cache) + * 2. Caches loaded data for downstream screens to avoid duplicate loads + * 3. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle) + * 4. Routes to appropriate screen: * - No valid documents -> DocumentDataNotFound * - Skip enabled -> auto-select and go to Prove * - Otherwise -> DocumentSelectorForProving @@ -38,6 +40,7 @@ const ProvingScreenRouter: React.FC = () => { usePassport(); const { skipDocumentSelector, skipDocumentSelectorIfSingle } = useSettingStore(); + const { getCache, setCache, isValid } = useDocumentCacheStore(); const [error, setError] = useState(null); const abortControllerRef = useRef(null); @@ -56,8 +59,19 @@ const ProvingScreenRouter: React.FC = () => { setError(null); try { - const catalog = await loadDocumentCatalog(); - const docs = await getAllDocuments(); + // Try to use cached data first + let catalog, docs; + const cachedData = isValid() ? getCache() : null; + + if (cachedData) { + catalog = cachedData.catalog; + docs = cachedData.allDocuments; + } else { + // Load fresh data and cache it + catalog = await loadDocumentCatalog(); + docs = await getAllDocuments(); + setCache(catalog, docs); + } // Don't continue if this request was aborted if (controller.signal.aborted) { @@ -120,8 +134,11 @@ const ProvingScreenRouter: React.FC = () => { } }, [ getAllDocuments, + getCache, + isValid, loadDocumentCatalog, navigation, + setCache, setSelectedDocument, skipDocumentSelector, skipDocumentSelectorIfSingle, @@ -135,10 +152,13 @@ const ProvingScreenRouter: React.FC = () => { }, [loadAndRoute]), ); - // Cleanup abort controller on unmount + // Cleanup abort controller and optionally cache on unmount useEffect(() => { return () => { abortControllerRef.current?.abort(); + // Note: We don't clear cache here because user might be navigating + // to DocumentSelectorForProving or Prove which need the cache. + // Cache will be cleared on proving completion or document changes. }; }, []); diff --git a/app/src/stores/documentCacheStore.ts b/app/src/stores/documentCacheStore.ts new file mode 100644 index 000000000..8e091ebdc --- /dev/null +++ b/app/src/stores/documentCacheStore.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { create } from 'zustand'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; + +interface DocumentCacheState { + catalog: DocumentCatalog | null; + allDocuments: Record< + string, + { data: IDDocument; metadata: DocumentMetadata } + > | null; + timestamp: number | null; + + // Actions + setCache: ( + catalog: DocumentCatalog, + allDocuments: Record< + string, + { data: IDDocument; metadata: DocumentMetadata } + >, + ) => void; + getCache: () => { + catalog: DocumentCatalog; + allDocuments: Record< + string, + { data: IDDocument; metadata: DocumentMetadata } + >; + } | null; + clearCache: () => void; + isValid: (maxAgeMs?: number) => boolean; +} + +const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Temporary cache store for document data used during the proving flow. + * This prevents duplicate loads across ProvingScreenRouter, DocumentSelectorForProving, and ProveScreen. + * + * Cache is automatically invalidated after 5 minutes (configurable). + * Cache should be cleared when: + * - User exits the proving flow + * - Documents are added/removed + * - Document data is updated + */ +export const useDocumentCacheStore = create()( + (set, get) => ({ + catalog: null, + allDocuments: null, + timestamp: null, + + setCache: (catalog, allDocuments) => + set({ + catalog, + allDocuments, + timestamp: Date.now(), + }), + + getCache: () => { + const state = get(); + if (!state.catalog || !state.allDocuments) { + return null; + } + return { + catalog: state.catalog, + allDocuments: state.allDocuments, + }; + }, + + clearCache: () => + set({ + catalog: null, + allDocuments: null, + timestamp: null, + }), + + isValid: (maxAgeMs = DEFAULT_MAX_AGE_MS) => { + const state = get(); + if (!state.catalog || !state.allDocuments || !state.timestamp) { + return false; + } + const age = Date.now() - state.timestamp; + return age < maxAgeMs; + }, + }), +); diff --git a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx index d99cd3f9d..5b058f2a4 100644 --- a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx +++ b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx @@ -18,6 +18,7 @@ import { import { usePassport } from '@/providers/passportDataProvider'; import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; // Mock useFocusEffect to behave like useEffect in tests // Note: We use a closure-based approach to avoid requiring React (prevents OOM per test-memory-optimization rules) @@ -61,6 +62,10 @@ jest.mock('@/providers/passportDataProvider', () => ({ usePassport: jest.fn(), })); +jest.mock('@/stores/documentCacheStore', () => ({ + useDocumentCacheStore: jest.fn(), +})); + const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation >; @@ -75,6 +80,9 @@ const mockIsDocumentValidForProving = typeof isDocumentValidForProving >; const mockUsePassport = usePassport as jest.MockedFunction; +const mockUseDocumentCacheStore = useDocumentCacheStore as jest.MockedFunction< + typeof useDocumentCacheStore +>; type MockDocumentEntry = { metadata: DocumentMetadata; @@ -136,6 +144,10 @@ const mockNavigate = jest.fn(); const mockLoadDocumentCatalog = jest.fn(); const mockGetAllDocuments = jest.fn(); const mockSetSelectedDocument = jest.fn(); +const mockGetCache = jest.fn(); +const mockSetCache = jest.fn(); +const mockIsValid = jest.fn(); +const mockClearCache = jest.fn(); // Stable passport context to prevent infinite re-renders const stablePassportContext = { @@ -169,6 +181,17 @@ describe('DocumentSelectorForProvingScreen', () => { mockUsePassport.mockReturnValue(stablePassportContext as any); + // Default: cache is invalid (force fresh load) + mockUseDocumentCacheStore.mockReturnValue({ + getCache: mockGetCache, + setCache: mockSetCache, + isValid: mockIsValid, + clearCache: mockClearCache, + } as any); + + mockIsValid.mockReturnValue(false); + mockGetCache.mockReturnValue(null); + mockIsDocumentValidForProving.mockImplementation( (_metadata, documentData) => (documentData as { expiryDateSlice?: string } | undefined) @@ -200,11 +223,10 @@ describe('DocumentSelectorForProvingScreen', () => { documents: [passport], selectedDocumentId: 'doc-1', }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([createDocumentEntry(passport)]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); const { getByTestId } = render(); @@ -222,6 +244,9 @@ describe('DocumentSelectorForProvingScreen', () => { // Verify mocks were called expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); expect(mockGetAllDocuments).toHaveBeenCalledTimes(1); + + // Verify cache was set + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); }); it('renders wallet badge when userId is present', async () => { @@ -466,4 +491,74 @@ describe('DocumentSelectorForProvingScreen', () => { consoleErrorSpy.mockRestore(); }); }); + + describe('Cache Integration', () => { + it('uses cached data when cache is valid', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + // Mock cache as valid + mockIsValid.mockReturnValue(true); + mockGetCache.mockReturnValue({ catalog, allDocuments: allDocs }); + + const { getByTestId } = render(); + + // Wait for action bar to render (indicating screen is ready) + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + }); + + // Should NOT load fresh data + expect(mockLoadDocumentCatalog).not.toHaveBeenCalled(); + expect(mockGetAllDocuments).not.toHaveBeenCalled(); + + // Should NOT set cache again + expect(mockSetCache).not.toHaveBeenCalled(); + }); + + it('loads fresh data when cache is invalid', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + // Mock cache as invalid + mockIsValid.mockReturnValue(false); + mockGetCache.mockReturnValue(null); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + }); + + // Should load fresh data + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + expect(mockGetAllDocuments).toHaveBeenCalledTimes(1); + + // Should set cache + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); + }); + }); }); diff --git a/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx index 591a36494..99047c67a 100644 --- a/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx +++ b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx @@ -17,6 +17,7 @@ import { import { usePassport } from '@/providers/passportDataProvider'; import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; import { useSettingStore } from '@/stores/settingStore'; // Mock useFocusEffect to behave like useEffect in tests @@ -47,6 +48,10 @@ jest.mock('@/stores/settingStore', () => ({ useSettingStore: jest.fn(), })); +jest.mock('@/stores/documentCacheStore', () => ({ + useDocumentCacheStore: jest.fn(), +})); + const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation >; @@ -62,11 +67,18 @@ const mockUsePassport = usePassport as jest.MockedFunction; const mockUseSettingStore = useSettingStore as jest.MockedFunction< typeof useSettingStore >; +const mockUseDocumentCacheStore = useDocumentCacheStore as jest.MockedFunction< + typeof useDocumentCacheStore +>; const mockReplace = jest.fn(); const mockLoadDocumentCatalog = jest.fn(); const mockGetAllDocuments = jest.fn(); const mockSetSelectedDocument = jest.fn(); +const mockGetCache = jest.fn(); +const mockSetCache = jest.fn(); +const mockIsValid = jest.fn(); +const mockClearCache = jest.fn(); type MockDocumentEntry = { metadata: DocumentMetadata; @@ -126,6 +138,17 @@ describe('ProvingScreenRouter', () => { skipDocumentSelectorIfSingle: false, } as any); + // Default: cache is invalid (force fresh load) + mockUseDocumentCacheStore.mockReturnValue({ + getCache: mockGetCache, + setCache: mockSetCache, + isValid: mockIsValid, + clearCache: mockClearCache, + } as any); + + mockIsValid.mockReturnValue(false); + mockGetCache.mockReturnValue(null); + mockIsDocumentValidForProving.mockImplementation( (_metadata, documentData) => (documentData as { expiryDateSlice?: string } | undefined) @@ -142,17 +165,21 @@ describe('ProvingScreenRouter', () => { const catalog: DocumentCatalog = { documents: [passport], }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport, 'expired'), + ]); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([createDocumentEntry(passport, 'expired')]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); render(); await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('DocumentDataNotFound'); }); + + // Verify cache was set + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); }); it('auto-selects and routes to Prove when skipping the selector', async () => { @@ -164,6 +191,7 @@ describe('ProvingScreenRouter', () => { const catalog: DocumentCatalog = { documents: [passport], }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); mockUseSettingStore.mockReturnValue({ skipDocumentSelector: true, @@ -171,9 +199,7 @@ describe('ProvingScreenRouter', () => { } as any); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([createDocumentEntry(passport)]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); mockPickBestDocumentToSelect.mockReturnValue('doc-1'); render(); @@ -182,6 +208,9 @@ describe('ProvingScreenRouter', () => { expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); expect(mockReplace).toHaveBeenCalledWith('Prove'); }); + + // Verify cache was set + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); }); it('routes to the document selector when skipping is disabled', async () => { @@ -193,17 +222,19 @@ describe('ProvingScreenRouter', () => { const catalog: DocumentCatalog = { documents: [passport], }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([createDocumentEntry(passport)]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); render(); await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving'); }); + + // Verify cache was set + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); }); it('shows error state when document loading fails', async () => { @@ -230,6 +261,7 @@ describe('ProvingScreenRouter', () => { const catalog: DocumentCatalog = { documents: [passport], }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); mockUseSettingStore.mockReturnValue({ skipDocumentSelector: false, @@ -237,9 +269,7 @@ describe('ProvingScreenRouter', () => { } as any); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([createDocumentEntry(passport)]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); mockPickBestDocumentToSelect.mockReturnValue('doc-1'); render(); @@ -248,6 +278,9 @@ describe('ProvingScreenRouter', () => { expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); expect(mockReplace).toHaveBeenCalledWith('Prove'); }); + + // Verify cache was set + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); }); it('shows document selector when skipDocumentSelectorIfSingle is true with multiple valid documents', async () => { @@ -264,6 +297,10 @@ describe('ProvingScreenRouter', () => { const catalog: DocumentCatalog = { documents: [passport1, passport2], }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport1), + createDocumentEntry(passport2), + ]); mockUseSettingStore.mockReturnValue({ skipDocumentSelector: false, @@ -271,12 +308,7 @@ describe('ProvingScreenRouter', () => { } as any); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([ - createDocumentEntry(passport1), - createDocumentEntry(passport2), - ]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); render(); @@ -286,6 +318,9 @@ describe('ProvingScreenRouter', () => { // Should NOT auto-select since there are multiple documents expect(mockSetSelectedDocument).not.toHaveBeenCalled(); + + // Verify cache was set + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); }); it('falls back to document selector when setSelectedDocument fails', async () => { @@ -297,6 +332,7 @@ describe('ProvingScreenRouter', () => { const catalog: DocumentCatalog = { documents: [passport], }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); mockUseSettingStore.mockReturnValue({ skipDocumentSelector: true, @@ -304,9 +340,7 @@ describe('ProvingScreenRouter', () => { } as any); mockLoadDocumentCatalog.mockResolvedValue(catalog); - mockGetAllDocuments.mockResolvedValue( - createAllDocuments([createDocumentEntry(passport)]), - ); + mockGetAllDocuments.mockResolvedValue(allDocs); mockPickBestDocumentToSelect.mockReturnValue('doc-1'); mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); @@ -316,5 +350,37 @@ describe('ProvingScreenRouter', () => { expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving'); }); + + // Verify cache was still set before the selection failure + expect(mockSetCache).toHaveBeenCalledWith(catalog, allDocs); + }); + + it('uses cached data when cache is valid', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + // Mock cache as valid + mockIsValid.mockReturnValue(true); + mockGetCache.mockReturnValue({ catalog, allDocuments: allDocs }); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving'); + }); + + // Should NOT load fresh data + expect(mockLoadDocumentCatalog).not.toHaveBeenCalled(); + expect(mockGetAllDocuments).not.toHaveBeenCalled(); + + // Should NOT set cache again + expect(mockSetCache).not.toHaveBeenCalled(); }); }); diff --git a/app/tests/src/stores/documentCacheStore.test.ts b/app/tests/src/stores/documentCacheStore.test.ts new file mode 100644 index 000000000..87e5eefc0 --- /dev/null +++ b/app/tests/src/stores/documentCacheStore.test.ts @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { act } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; + +import { useDocumentCacheStore } from '@/stores/documentCacheStore'; + +describe('documentCacheStore', () => { + beforeEach(() => { + // Reset store state before each test + act(() => { + useDocumentCacheStore.setState({ + catalog: null, + allDocuments: null, + timestamp: null, + }); + }); + }); + + describe('setCache and getCache', () => { + it('sets and retrieves cached data', () => { + const catalog: DocumentCatalog = { + documents: [ + { + id: 'doc-1', + documentType: 'us', + documentCategory: 'passport', + data: 'mock-data', + mock: false, + } as DocumentMetadata, + ], + selectedDocumentId: 'doc-1', + }; + + const allDocuments: Record< + string, + { data: IDDocument; metadata: DocumentMetadata } + > = { + 'doc-1': { + data: { + documentType: 'us', + documentCategory: 'passport', + mock: false, + } as any, + metadata: catalog.documents[0], + }, + }; + + act(() => { + useDocumentCacheStore.getState().setCache(catalog, allDocuments); + }); + + const cached = useDocumentCacheStore.getState().getCache(); + + expect(cached).toEqual({ catalog, allDocuments }); + expect(useDocumentCacheStore.getState().timestamp).toBeTruthy(); + }); + + it('returns null when cache is empty', () => { + const cached = useDocumentCacheStore.getState().getCache(); + expect(cached).toBeNull(); + }); + + it('returns null when catalog is missing', () => { + act(() => { + useDocumentCacheStore.setState({ + catalog: null, + allDocuments: {} as any, + timestamp: Date.now(), + }); + }); + + const cached = useDocumentCacheStore.getState().getCache(); + expect(cached).toBeNull(); + }); + + it('returns null when allDocuments is missing', () => { + act(() => { + useDocumentCacheStore.setState({ + catalog: { documents: [] } as any, + allDocuments: null, + timestamp: Date.now(), + }); + }); + + const cached = useDocumentCacheStore.getState().getCache(); + expect(cached).toBeNull(); + }); + }); + + describe('isValid', () => { + it('returns false when cache is empty', () => { + const isValid = useDocumentCacheStore.getState().isValid(); + expect(isValid).toBe(false); + }); + + it('returns true when cache is fresh', () => { + const catalog: DocumentCatalog = { + documents: [], + }; + const allDocuments = {}; + + act(() => { + useDocumentCacheStore.getState().setCache(catalog, allDocuments); + }); + + const isValid = useDocumentCacheStore.getState().isValid(); + expect(isValid).toBe(true); + }); + + it('returns false when cache is expired (default 5 minutes)', () => { + const catalog: DocumentCatalog = { + documents: [], + }; + const allDocuments = {}; + + // Set cache with expired timestamp + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 - 1000; // 5 minutes + 1 second + + act(() => { + useDocumentCacheStore.setState({ + catalog, + allDocuments, + timestamp: fiveMinutesAgo, + }); + }); + + const isValid = useDocumentCacheStore.getState().isValid(); + expect(isValid).toBe(false); + }); + + it('respects custom maxAge parameter', () => { + const catalog: DocumentCatalog = { + documents: [], + }; + const allDocuments = {}; + + // Set cache with timestamp 30 seconds ago + const thirtySecondsAgo = Date.now() - 30 * 1000; + + act(() => { + useDocumentCacheStore.setState({ + catalog, + allDocuments, + timestamp: thirtySecondsAgo, + }); + }); + + // Should be valid with 1 minute maxAge + expect(useDocumentCacheStore.getState().isValid(60 * 1000)).toBe(true); + + // Should be invalid with 10 second maxAge + expect(useDocumentCacheStore.getState().isValid(10 * 1000)).toBe(false); + }); + + it('returns false when timestamp is missing', () => { + act(() => { + useDocumentCacheStore.setState({ + catalog: { documents: [] } as any, + allDocuments: {}, + timestamp: null, + }); + }); + + const isValid = useDocumentCacheStore.getState().isValid(); + expect(isValid).toBe(false); + }); + }); + + describe('clearCache', () => { + it('clears all cached data', () => { + const catalog: DocumentCatalog = { + documents: [], + }; + const allDocuments = {}; + + act(() => { + useDocumentCacheStore.getState().setCache(catalog, allDocuments); + }); + + // Verify cache is set + expect(useDocumentCacheStore.getState().getCache()).not.toBeNull(); + + act(() => { + useDocumentCacheStore.getState().clearCache(); + }); + + // Verify cache is cleared + expect(useDocumentCacheStore.getState().catalog).toBeNull(); + expect(useDocumentCacheStore.getState().allDocuments).toBeNull(); + expect(useDocumentCacheStore.getState().timestamp).toBeNull(); + expect(useDocumentCacheStore.getState().getCache()).toBeNull(); + }); + + it('can clear already empty cache', () => { + act(() => { + useDocumentCacheStore.getState().clearCache(); + }); + + expect(useDocumentCacheStore.getState().getCache()).toBeNull(); + }); + }); + + describe('cache invalidation scenarios', () => { + it('correctly identifies barely valid cache (just under maxAge)', () => { + const catalog: DocumentCatalog = { + documents: [], + }; + const allDocuments = {}; + + // Set cache with timestamp just under 5 minutes ago + const justUnderFiveMinutes = Date.now() - (5 * 60 * 1000 - 1000); // 4 minutes 59 seconds + + act(() => { + useDocumentCacheStore.setState({ + catalog, + allDocuments, + timestamp: justUnderFiveMinutes, + }); + }); + + const isValid = useDocumentCacheStore.getState().isValid(); + expect(isValid).toBe(true); + }); + + it('correctly identifies barely invalid cache (just over maxAge)', () => { + const catalog: DocumentCatalog = { + documents: [], + }; + const allDocuments = {}; + + // Set cache with timestamp just over 5 minutes ago + const justOverFiveMinutes = Date.now() - (5 * 60 * 1000 + 1000); // 5 minutes 1 second + + act(() => { + useDocumentCacheStore.setState({ + catalog, + allDocuments, + timestamp: justOverFiveMinutes, + }); + }); + + const isValid = useDocumentCacheStore.getState().isValid(); + expect(isValid).toBe(false); + }); + }); +});