diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index e856df603..480a8ddd0 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -7,13 +7,16 @@ import { Platform } from 'react-native'; import { Adapters, + createListenersMap, reactNativeScannerAdapter, + SdkEvents, SelfClientProvider as SDKSelfClientProvider, type TrackEventParams, webScannerShim, type WsConn, } from '@selfxyz/mobile-sdk-alpha'; +import { navigationRef } from '@/navigation'; import { unsafe_getPrivateKey } from '@/providers/authProvider'; import { selfClientDocumentsAdapter } from '@/providers/passportDataProvider'; import analytics from '@/utils/analytics'; @@ -95,8 +98,64 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { [], ); + const appListeners = useMemo(() => { + const { map, addListener } = createListenersMap(); + + addListener(SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, () => { + if (navigationRef.isReady()) { + navigationRef.navigate('DocumentDataNotFound'); + } + }); + + addListener(SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS, () => { + setTimeout(() => { + if (navigationRef.isReady()) { + navigationRef.navigate('AccountVerifiedSuccess'); + } + }, 3000); + }); + + addListener( + SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, + async ({ hasValidDocument }) => { + setTimeout(() => { + if (navigationRef.isReady()) { + if (hasValidDocument) { + navigationRef.navigate('Home'); + } else { + navigationRef.navigate('Launch'); + } + } + }, 3000); + }, + ); + + addListener( + SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, + ({ passportData }) => { + if (navigationRef.isReady()) { + navigationRef.navigate('UnsupportedDocument', { + passportData, + } as any); + } + }, + ); + + addListener(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, () => { + if (navigationRef.isReady()) { + navigationRef.navigate('AccountRecoveryChoice'); + } + }); + + return map; + }, []); + return ( - + {children} ); diff --git a/app/src/utils/proving/provingMachine.ts b/app/src/utils/proving/provingMachine.ts index e3166aa13..99048b622 100644 --- a/app/src/utils/proving/provingMachine.ts +++ b/app/src/utils/proving/provingMachine.ts @@ -39,6 +39,7 @@ import { import { hasAnyValidRegisteredDocument, loadSelectedDocument, + SdkEvents, SelfClient, } from '@selfxyz/mobile-sdk-alpha'; import { @@ -47,7 +48,6 @@ import { } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; import { useProtocolStore } from '@selfxyz/mobile-sdk-alpha/stores'; -import { navigationRef } from '@/navigation'; // will need to be passed in from selfClient import { clearPassportData, @@ -207,6 +207,11 @@ interface ProvingState { _handleWsOpen: () => void; _handleWsError: (error: Event) => void; _handleWsClose: (event: CloseEvent) => void; + + _handlePassportNotSupported: (selfClient: SelfClient) => void; + _handleAccountRecoveryChoice: (selfClient: SelfClient) => void; + _handleAccountVerifiedSuccess: (selfClient: SelfClient) => void; + _handlePassportDataNotFound: (selfClient: SelfClient) => void; } export const useProvingStore = create((set, get) => { @@ -239,16 +244,14 @@ export const useProvingStore = create((set, get) => { if (state.value === 'post_proving') { get().postProving(selfClient); } + if ( get().circuitType !== 'disclose' && (state.value === 'error' || state.value === 'failure') ) { - setTimeout(() => { - if (navigationRef.isReady()) { - get()._handleRegisterErrorOrFailure(selfClient); - } - }, 3000); + get()._handleRegisterErrorOrFailure(selfClient); } + if (state.value === 'completed') { trackEvent(ProofEvents.PROOF_COMPLETED, { circuitType: get().circuitType, @@ -266,33 +269,27 @@ export const useProvingStore = create((set, get) => { })(); } - if (get().circuitType !== 'disclose' && navigationRef.isReady()) { - setTimeout(() => { - navigationRef.navigate('AccountVerifiedSuccess'); - }, 3000); + if (get().circuitType !== 'disclose') { + get()._handleAccountVerifiedSuccess(selfClient); } + if (get().circuitType === 'disclose') { useSelfAppStore.getState().handleProofResult(true); } } + if (state.value === 'passport_not_supported') { - if (navigationRef.isReady()) { - const currentPassportData = get().passportData; - (navigationRef as any).navigate('UnsupportedDocument', { - passportData: currentPassportData, - }); - } + get()._handlePassportNotSupported(selfClient); } + if (state.value === 'account_recovery_choice') { - if (navigationRef.isReady()) { - navigationRef.navigate('AccountRecoveryChoice'); - } + get()._handleAccountRecoveryChoice(selfClient); } + if (state.value === 'passport_data_not_found') { - if (navigationRef.isReady()) { - navigationRef.navigate('DocumentDataNotFound'); - } + get()._handlePassportDataNotFound(selfClient); } + if (state.value === 'failure') { if (get().circuitType === 'disclose') { const { error_code, reason } = get(); @@ -433,17 +430,13 @@ export const useProvingStore = create((set, get) => { try { const hasValid = await hasAnyValidRegisteredDocument(selfClient); - if (navigationRef.isReady()) { - if (hasValid) { - navigationRef.navigate('Home'); - } else { - navigationRef.navigate('Launch'); - } - } - } catch { - if (navigationRef.isReady()) { - navigationRef.navigate('Launch'); - } + selfClient.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, { + hasValidDocument: hasValid, + }); + } catch (error) { + selfClient.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, { + hasValidDocument: false, + }); } }, @@ -1101,6 +1094,24 @@ export const useProvingStore = create((set, get) => { }, }; }, + + _handlePassportNotSupported: (selfClient: SelfClient) => { + selfClient.emit(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, { + passportData: get().passportData as PassportData, + }); + }, + + _handleAccountRecoveryChoice: (selfClient: SelfClient) => { + selfClient.emit(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED); + }, + + _handleAccountVerifiedSuccess: (selfClient: SelfClient) => { + selfClient.emit(SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS); + }, + + _handlePassportDataNotFound: (selfClient: SelfClient) => { + selfClient.emit(SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND); + }, }; }); diff --git a/app/tests/utils/proving/provingMachine.test.ts b/app/tests/utils/proving/provingMachine.test.ts index fcc8a03a6..a930d224e 100644 --- a/app/tests/utils/proving/provingMachine.test.ts +++ b/app/tests/utils/proving/provingMachine.test.ts @@ -4,7 +4,8 @@ import { act, renderHook } from '@testing-library/react-native'; -import type { SelfClient } from '@selfxyz/mobile-sdk-alpha'; +import { PassportData } from '@selfxyz/common/types'; +import { SdkEvents, type SelfClient } from '@selfxyz/mobile-sdk-alpha'; import { useProvingStore } from '@/utils/proving/provingMachine'; @@ -16,8 +17,12 @@ jest.mock('@/navigation', () => ({ })); jest.mock('@selfxyz/mobile-sdk-alpha', () => { + const actual = jest.requireActual('@selfxyz/mobile-sdk-alpha'); + return { + ...actual, loadSelectedDocument: jest.fn().mockResolvedValue(null), + hasAnyValidRegisteredDocument: jest.fn().mockResolvedValue(true), }; }); @@ -30,7 +35,11 @@ describe('provingMachine registration completion', () => { const { result: initHook } = renderHook(() => useProvingStore(state => state.init), ); - const selfClient = {} as SelfClient; + const emitMock = jest.fn(); + + const selfClient = { + emit: emitMock, + } as unknown as SelfClient; expect(initHook.current).toBeDefined(); @@ -43,5 +52,100 @@ describe('provingMachine registration completion', () => { ); expect(provingStoreHook.current).toBe('passport_data_not_found'); + expect(emitMock).toHaveBeenCalledWith( + SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, + ); + }); +}); + +describe('events', () => { + it('emits PROVING_MACHINE_PASSPORT_NOT_SUPPORTED', async () => { + const emitMock = jest.fn(); + const mockPassportData = { + mrz: 'mrz', + dsc: 'dsc', + eContent: [1, 2, 3], + signedAttr: [1, 2, 3], + encryptedDigest: [1, 2, 3], + } as PassportData; + + const selfClient = { + emit: emitMock, + } as unknown as SelfClient; + + await act(async () => { + useProvingStore.setState({ passportData: mockPassportData }); + useProvingStore.getState()._handlePassportNotSupported(selfClient); + }); + + expect(emitMock).toHaveBeenCalledWith( + SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, + { + passportData: mockPassportData, + }, + ); + }); + + it('emits PROVING_MACHINE_ACCOUNT_RECOVERY_CHOICE', async () => { + const emitMock = jest.fn(); + const selfClient = { + emit: emitMock, + } as unknown as SelfClient; + + await act(async () => { + useProvingStore.getState()._handleAccountRecoveryChoice(selfClient); + }); + + expect(emitMock).toHaveBeenCalledWith( + SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, + ); + }); + + it('emits PROVING_MACHINE_ACCOUNT_VERIFIED_SUCCESS', async () => { + const emitMock = jest.fn(); + const selfClient = { + emit: emitMock, + } as unknown as SelfClient; + + await act(async () => { + useProvingStore.getState()._handleAccountVerifiedSuccess(selfClient); + }); + + expect(emitMock).toHaveBeenCalledWith( + SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS, + ); + }); + + it('emits PROVING_MACHINE_PASSPORT_DATA_NOT_FOUND', async () => { + const emitMock = jest.fn(); + const selfClient = { + emit: emitMock, + } as unknown as SelfClient; + + await act(async () => { + useProvingStore.getState()._handlePassportDataNotFound(selfClient); + }); + + expect(emitMock).toHaveBeenCalledWith( + SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, + ); + }); + + it('emits PROVING_MACHINE_REGISTER_ERROR_OR_FAILURE', async () => { + const emitMock = jest.fn(); + const selfClient = { + emit: emitMock, + } as unknown as SelfClient; + + await act(async () => { + useProvingStore.getState()._handleRegisterErrorOrFailure(selfClient); + }); + + expect(emitMock).toHaveBeenCalledWith( + SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, + { + hasValidDocument: true, + }, + ); }); }); diff --git a/packages/mobile-sdk-alpha/src/browser.ts b/packages/mobile-sdk-alpha/src/browser.ts index 086eebcae..cfee8c608 100644 --- a/packages/mobile-sdk-alpha/src/browser.ts +++ b/packages/mobile-sdk-alpha/src/browser.ts @@ -21,8 +21,6 @@ export type { ProofRequest, RegistrationInput, RegistrationStatus, - SDKEvent, - SDKEventMap, ScanMode, ScanOpts, ScanResult, @@ -43,9 +41,11 @@ export type { QRProofOptions } from './qr'; export type { SdkErrorCategory } from './errors'; export { SCANNER_ERROR_CODES, notImplemented, sdkError } from './errors'; +export { SdkEvents } from './types/events'; + export { SelfClientContext, SelfClientProvider, useSelfClient } from './context'; -export { createSelfClient } from './client'; +export { createListenersMap, createSelfClient } from './client'; export { defaultConfig } from './config/defaults'; diff --git a/packages/mobile-sdk-alpha/src/client.ts b/packages/mobile-sdk-alpha/src/client.ts index df13adf5c..88c164554 100644 --- a/packages/mobile-sdk-alpha/src/client.ts +++ b/packages/mobile-sdk-alpha/src/client.ts @@ -8,6 +8,7 @@ import { defaultConfig } from './config/defaults'; import { mergeConfig } from './config/merge'; import { notImplemented } from './errors'; import { extractMRZInfo as parseMRZInfo } from './processing/mrz'; +import { SDKEvent, SDKEventMap, SdkEvents } from './types/events'; import type { Adapters, Config, @@ -18,15 +19,12 @@ import type { RegistrationStatus, ScanOpts, ScanResult, - SDKEvent, - SDKEventMap, SelfClient, Unsubscribe, ValidationInput, ValidationResult, } from './types/public'; import { TrackEventParams } from './types/public'; - /** * Optional adapter implementations used when a consumer does not provide their * own. These defaults are intentionally minimal no-ops suitable for tests and @@ -51,6 +49,21 @@ const optionalDefaults: Required> const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] as const; +export const createListenersMap = (): { + map: Map void>>; + addListener: (event: E, cb: (payload: SDKEventMap[E]) => any) => void; +} => { + const map = new Map void>>(); + + const addListener = (event: E, cb: (payload: SDKEventMap[E]) => void) => { + const set = map.get(event) ?? new Set(); + set.add(cb as any); + map.set(event, set); + }; + + return { map, addListener }; +}; + /** * Creates a fully configured {@link SelfClient} instance. * @@ -58,7 +71,15 @@ const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] * provided configuration with sensible defaults. Missing optional adapters are * filled with benign no-op implementations. */ -export function createSelfClient({ config, adapters }: { config: Config; adapters: Adapters }): SelfClient { +export function createSelfClient({ + config, + adapters, + listeners, +}: { + config: Config; + adapters: Adapters; + listeners: Map void>>; +}): SelfClient { const cfg = mergeConfig(defaultConfig, config); for (const name of REQUIRED_ADAPTERS) { @@ -66,17 +87,17 @@ export function createSelfClient({ config, adapters }: { config: Config; adapter } const _adapters = { ...optionalDefaults, ...adapters }; - const listeners = new Map void>>(); + const _listeners = new Map void>>(); function on(event: E, cb: (payload: SDKEventMap[E]) => void): Unsubscribe { - const set = listeners.get(event) ?? new Set(); + const set = _listeners.get(event) ?? new Set(); set.add(cb as any); - listeners.set(event, set); + _listeners.set(event, set); return () => set.delete(cb as any); } function emit(event: E, payload: SDKEventMap[E]): void { - const set = listeners.get(event); + const set = _listeners.get(event); if (!set) return; for (const cb of Array.from(set)) { try { @@ -87,6 +108,12 @@ export function createSelfClient({ config, adapters }: { config: Config; adapter } } + for (const [event, set] of listeners ?? []) { + for (const cb of Array.from(set)) { + on(event, cb); + } + } + async function scanDocument(opts: ScanOpts & { signal?: AbortSignal }): Promise { return _adapters.scanner.scan(opts); } @@ -114,7 +141,7 @@ export function createSelfClient({ config, adapters }: { config: Config; adapter if (!adapters.network) throw notImplemented('network'); if (!adapters.crypto) throw notImplemented('crypto'); const timeoutMs = opts.timeoutMs ?? cfg.timeouts?.proofMs ?? defaultConfig.timeouts.proofMs; - void _adapters.clock.sleep(timeoutMs!, opts.signal).then(() => emit('error', new Error('timeout'))); + void _adapters.clock.sleep(timeoutMs!, opts.signal).then(() => emit(SdkEvents.ERROR, new Error('timeout'))); return { id: 'stub', status: 'pending', diff --git a/packages/mobile-sdk-alpha/src/context.tsx b/packages/mobile-sdk-alpha/src/context.tsx index 87877af01..0f1ff7402 100644 --- a/packages/mobile-sdk-alpha/src/context.tsx +++ b/packages/mobile-sdk-alpha/src/context.tsx @@ -5,6 +5,7 @@ import { createContext, type PropsWithChildren, useContext, useMemo } from 'react'; import { createSelfClient } from './client'; +import { SdkEvents } from './types/events'; import type { Adapters, Config, SelfClient } from './types/public'; /** @@ -29,6 +30,10 @@ export interface SelfClientProviderProps { * be replaced with default no-op implementations. */ adapters: Adapters; + /** + * Map of event listeners. + */ + listeners: Map void>>; } export { SelfClientContext }; @@ -40,8 +45,13 @@ export { SelfClientContext }; * Consumers should ensure that `config` and `adapters` are referentially stable * (e.g. wrapped in `useMemo`) to avoid recreating the client on every render. */ -export function SelfClientProvider({ config, adapters, children }: PropsWithChildren) { - const client = useMemo(() => createSelfClient({ config, adapters }), [config, adapters]); +export function SelfClientProvider({ + config, + adapters, + listeners, + children, +}: PropsWithChildren) { + const client = useMemo(() => createSelfClient({ config, adapters, listeners }), [config, adapters, listeners]); return {children}; } diff --git a/packages/mobile-sdk-alpha/src/entry/index.tsx b/packages/mobile-sdk-alpha/src/entry/index.tsx index da200cf4d..c5107560c 100644 --- a/packages/mobile-sdk-alpha/src/entry/index.tsx +++ b/packages/mobile-sdk-alpha/src/entry/index.tsx @@ -5,16 +5,18 @@ import type { ReactNode } from 'react'; import { SelfClientProvider } from '../context'; +import { SDKEvent } from '../types/events'; import type { Adapters, Config } from '../types/public'; export interface SelfMobileSdkProps { config: Config; adapters: Adapters; + listeners: Map void>>; children?: ReactNode; } -export const SelfMobileSdk = ({ config, adapters, children }: SelfMobileSdkProps) => ( - +export const SelfMobileSdk = ({ config, adapters, listeners, children }: SelfMobileSdkProps) => ( + {children} ); diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index a52bd63d2..cfd13c70f 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -22,8 +22,6 @@ export type { ProofRequest, RegistrationInput, RegistrationStatus, - SDKEvent, - SDKEventMap, ScanMode, ScanOpts, ScanResult, @@ -71,13 +69,15 @@ export { PassportCameraScreen } from './components/screens/PassportCameraScreen' export { QRCodeScreen } from './components/screens/QRCodeScreen'; +export { SdkEvents } from './types/events'; + // Context and Client export { SelfClientContext, SelfClientProvider, useSelfClient } from './context'; // Components export { SelfMobileSdk } from './entry'; -export { createSelfClient } from './client'; +export { createListenersMap, createSelfClient } from './client'; export { defaultConfig } from './config/defaults'; diff --git a/packages/mobile-sdk-alpha/src/types/events.ts b/packages/mobile-sdk-alpha/src/types/events.ts new file mode 100644 index 000000000..30175dd5d --- /dev/null +++ b/packages/mobile-sdk-alpha/src/types/events.ts @@ -0,0 +1,35 @@ +// 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 type { PassportData, Progress } from './public'; + +export enum SdkEvents { + ERROR = 'ERROR', + PROGRESS = 'PROGRESS', + STATE = 'STATE', + + PROVING_PASSPORT_DATA_NOT_FOUND = 'PROVING_PASSPORT_DATA_NOT_FOUND', + PROVING_ACCOUNT_VERIFIED_SUCCESS = 'PROVING_ACCOUNT_VERIFIED_SUCCESS', + PROVING_REGISTER_ERROR_OR_FAILURE = 'PROVING_REGISTER_ERROR_OR_FAILURE', + PROVING_PASSPORT_NOT_SUPPORTED = 'PROVING_PASSPORT_NOT_SUPPORTED', + PROVING_ACCOUNT_RECOVERY_REQUIRED = 'PROVING_ACCOUNT_RECOVERY_REQUIRED', +} + +export interface SDKEventMap { + [SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND]: undefined; + [SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS]: undefined; + [SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE]: { + hasValidDocument: boolean; + }; + [SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED]: { + passportData: PassportData; + }; + [SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED]: undefined; + + [SdkEvents.PROGRESS]: Progress; + [SdkEvents.STATE]: string; + [SdkEvents.ERROR]: Error; +} + +export type SDKEvent = keyof SDKEventMap; diff --git a/packages/mobile-sdk-alpha/src/types/public.ts b/packages/mobile-sdk-alpha/src/types/public.ts index 8f6a6f8ce..ea9e17487 100644 --- a/packages/mobile-sdk-alpha/src/types/public.ts +++ b/packages/mobile-sdk-alpha/src/types/public.ts @@ -4,6 +4,8 @@ import type { DocumentCatalog, PassportData } from '@selfxyz/common/utils/types'; +import { SDKEvent, SDKEventMap } from './events'; + export type { PassportValidationCallbacks } from '../validation/document'; export type { DocumentCatalog, PassportData }; export interface Config { @@ -134,13 +136,6 @@ export interface RegistrationStatus { reason?: string; } -export interface SDKEventMap { - progress: Progress; - state: string; - error: Error; -} -export type SDKEvent = keyof SDKEventMap; - export type ScanMode = 'mrz' | 'nfc' | 'qr'; export type ScanOpts = @@ -202,8 +197,8 @@ export interface SelfClient { trackEvent(event: string, payload?: TrackEventParams): void; getPrivateKey(): Promise; hasPrivateKey(): Promise; - on(event: E, cb: (payload: SDKEventMap[E]) => void): Unsubscribe; - emit(event: E, payload: SDKEventMap[E]): void; + on(event: E, cb: (payload?: SDKEventMap[E]) => void): Unsubscribe; + emit(event: E, payload?: SDKEventMap[E]): void; loadDocumentCatalog(): Promise; loadDocumentById(id: string): Promise; diff --git a/packages/mobile-sdk-alpha/tests/client-mrz.test.ts b/packages/mobile-sdk-alpha/tests/client-mrz.test.ts index 8330641ae..2deb18f72 100644 --- a/packages/mobile-sdk-alpha/tests/client-mrz.test.ts +++ b/packages/mobile-sdk-alpha/tests/client-mrz.test.ts @@ -9,7 +9,7 @@ import { badCheckDigitsMRZ, expectedMRZResult, invalidMRZ, mockAdapters, sampleM describe('createSelfClient API', () => { it('creates a client instance with expected methods', () => { - const client = createSelfClient({ config: {}, adapters: mockAdapters }); + const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() }); expect(typeof client.extractMRZInfo).toBe('function'); expect(typeof client.registerDocument).toBe('function'); @@ -17,7 +17,7 @@ describe('createSelfClient API', () => { }); it('parses MRZ data correctly', () => { - const client = createSelfClient({ config: {}, adapters: mockAdapters }); + const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() }); const info = client.extractMRZInfo(sampleMRZ); expect(info.documentNumber).toBe(expectedMRZResult.documentNumber); @@ -28,6 +28,7 @@ describe('createSelfClient API', () => { const clientWithAllAdapters = createSelfClient({ config: {}, adapters: mockAdapters, + listeners: new Map(), }); expect(clientWithAllAdapters).toBeDefined(); @@ -35,12 +36,12 @@ describe('createSelfClient API', () => { }); it('throws MrzParseError for malformed MRZ input', () => { - const client = createSelfClient({ config: {}, adapters: mockAdapters }); + const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() }); expect(() => client.extractMRZInfo(invalidMRZ)).toThrowError(MrzParseError); }); it('flags invalid check digits', () => { - const client = createSelfClient({ config: {}, adapters: mockAdapters }); + const client = createSelfClient({ config: {}, adapters: mockAdapters, listeners: new Map() }); const info = client.extractMRZInfo(badCheckDigitsMRZ); expect(info.validation?.overall).toBe(false); }); diff --git a/packages/mobile-sdk-alpha/tests/client.test.ts b/packages/mobile-sdk-alpha/tests/client.test.ts index 26364a8eb..2b091ad28 100644 --- a/packages/mobile-sdk-alpha/tests/client.test.ts +++ b/packages/mobile-sdk-alpha/tests/client.test.ts @@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, ScannerAdapter } from '../src'; -import { createSelfClient } from '../src/index'; -import { AuthAdapter } from '../src/types/public'; +import { createListenersMap, createSelfClient, SdkEvents } from '../src/index'; +import { AuthAdapter, PassportData } from '../src/types/public'; describe('createSelfClient', () => { // Test eager validation during client creation @@ -47,7 +47,11 @@ describe('createSelfClient', () => { }); it('creates client successfully with all required adapters', () => { - const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } }); + const client = createSelfClient({ + config: {}, + adapters: { scanner, network, crypto, documents, auth }, + listeners: new Map(), + }); expect(client).toBeTruthy(); }); @@ -56,6 +60,7 @@ describe('createSelfClient', () => { const client = createSelfClient({ config: {}, adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth }, + listeners: new Map(), }); const result = await client.scanDocument({ mode: 'qr' }); expect(result).toEqual({ mode: 'qr', data: 'self://ok' }); @@ -68,6 +73,7 @@ describe('createSelfClient', () => { const client = createSelfClient({ config: {}, adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth }, + listeners: new Map(), }); await expect(client.scanDocument({ mode: 'qr' })).rejects.toBe(err); }); @@ -76,7 +82,11 @@ describe('createSelfClient', () => { const network = { http: { fetch: vi.fn() }, ws: { connect: vi.fn() } } as any; const crypto = { hash: vi.fn(), sign: vi.fn() } as any; const scanner = { scan: vi.fn() } as any; - const client = createSelfClient({ config: {}, adapters: { network, crypto, scanner, documents, auth } }); + const client = createSelfClient({ + config: {}, + adapters: { network, crypto, scanner, documents, auth }, + listeners: new Map(), + }); const handle = await client.generateProof({ type: 'register', payload: {} }); expect(handle.id).toBe('stub'); expect(handle.status).toBe('pending'); @@ -85,26 +95,49 @@ describe('createSelfClient', () => { }); it('emits and unsubscribes events', () => { - const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } }); - const cb = vi.fn(); - const originalSet = Map.prototype.set; - let eventSet: Set<(p: any) => void> | undefined; - Map.prototype.set = function (key: any, value: any) { - if (key === 'progress') eventSet = value; - return originalSet.call(this, key, value); - }; - const unsub = client.on('progress', cb); - Map.prototype.set = originalSet; - - eventSet?.forEach(fn => fn({ step: 'one' })); - expect(cb).toHaveBeenCalledWith({ step: 'one' }); - unsub(); - eventSet?.forEach(fn => fn({ step: 'two' })); - expect(cb).toHaveBeenCalledTimes(1); + const listeners = createListenersMap(); + + const passportNotSupportedListener = vi.fn(); + const accountRecoveryChoiceListener = vi.fn(); + const anotherAccountRecoveryChoiceListener = vi.fn(); + + listeners.addListener(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, passportNotSupportedListener); + listeners.addListener(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, accountRecoveryChoiceListener); + listeners.addListener(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, anotherAccountRecoveryChoiceListener); + + const client = createSelfClient({ + config: {}, + adapters: { scanner, network, crypto, documents, auth }, + listeners: listeners.map, + }); + + client.emit(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, { passportData: { mrz: 'test' } as PassportData }); + client.emit(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED); + client.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, { hasValidDocument: true }); + + expect(accountRecoveryChoiceListener).toHaveBeenCalledTimes(1); + expect(accountRecoveryChoiceListener).toHaveBeenCalledWith(undefined); + expect(anotherAccountRecoveryChoiceListener).toHaveBeenCalledTimes(1); + expect(anotherAccountRecoveryChoiceListener).toHaveBeenCalledWith(undefined); + + expect(passportNotSupportedListener).toHaveBeenCalledWith({ passportData: { mrz: 'test' } }); + expect(passportNotSupportedListener).toHaveBeenCalledTimes(1); + + client.emit(SdkEvents.PROVING_PASSPORT_NOT_SUPPORTED, { passportData: { mrz: 'test' } as PassportData }); + client.emit(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED); + client.emit(SdkEvents.PROVING_REGISTER_ERROR_OR_FAILURE, { hasValidDocument: true }); + + expect(passportNotSupportedListener).toHaveBeenCalledTimes(2); + expect(accountRecoveryChoiceListener).toHaveBeenCalledTimes(2); + expect(anotherAccountRecoveryChoiceListener).toHaveBeenCalledTimes(2); }); it('parses MRZ via client', () => { - const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } }); + const client = createSelfClient({ + config: {}, + adapters: { scanner, network, crypto, documents, auth }, + listeners: new Map(), + }); const sample = `P { }); it('returns stub registration status', async () => { - const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } }); + const client = createSelfClient({ + config: {}, + adapters: { scanner, network, crypto, documents, auth }, + listeners: new Map(), + }); await expect(client.registerDocument({} as any)).resolves.toEqual({ registered: false, reason: 'SELF_REG_STATUS_STUB', @@ -131,6 +168,7 @@ describe('createSelfClient', () => { analytics: { trackEvent }, auth: { getPrivateKey: () => Promise.resolve('stubbed-private-key') }, }, + listeners: new Map(), }); client.trackEvent('test_event'); @@ -146,6 +184,7 @@ describe('createSelfClient', () => { const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } }, + listeners: new Map(), }); await expect(client.getPrivateKey()).resolves.toBe('stubbed-private-key'); @@ -155,6 +194,7 @@ describe('createSelfClient', () => { const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } }, + listeners: new Map(), }); await expect(client.hasPrivateKey()).resolves.toBe(true); }); diff --git a/packages/mobile-sdk-alpha/tests/documents/utils.test.ts b/packages/mobile-sdk-alpha/tests/documents/utils.test.ts index 6bc09835a..a1f080652 100644 --- a/packages/mobile-sdk-alpha/tests/documents/utils.test.ts +++ b/packages/mobile-sdk-alpha/tests/documents/utils.test.ts @@ -11,6 +11,7 @@ import { createSelfClient, defaultConfig, DocumentsAdapter, loadSelectedDocument const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAdapter): SelfClient => { return createSelfClient({ config: defaultConfig, + listeners: new Map(), adapters: { auth: { getPrivateKey: async () => null, diff --git a/packages/mobile-sdk-alpha/tests/entry.test.tsx b/packages/mobile-sdk-alpha/tests/entry.test.tsx index 2ce502809..939127cd5 100644 --- a/packages/mobile-sdk-alpha/tests/entry.test.tsx +++ b/packages/mobile-sdk-alpha/tests/entry.test.tsx @@ -20,7 +20,7 @@ function Consumer() { describe('SelfMobileSdk Entry Component', () => { it('provides client to children and enables MRZ parsing', () => { render( - + , ); @@ -31,7 +31,7 @@ describe('SelfMobileSdk Entry Component', () => { it('renders children correctly', () => { const testMessage = 'Test Child Component'; render( - +
{testMessage}
, ); diff --git a/packages/mobile-sdk-alpha/tests/provider.test.tsx b/packages/mobile-sdk-alpha/tests/provider.test.tsx index d23baaa2a..9331d5d23 100644 --- a/packages/mobile-sdk-alpha/tests/provider.test.tsx +++ b/packages/mobile-sdk-alpha/tests/provider.test.tsx @@ -15,7 +15,7 @@ import { renderHook } from '@testing-library/react'; describe('SelfClientProvider Context', () => { it('provides client through context with MRZ parsing capability', () => { const wrapper = ({ children }: { children: ReactNode }) => ( - + {children} ); @@ -38,8 +38,10 @@ describe('SelfClientProvider Context', () => { const spy = vi.spyOn(clientModule, 'createSelfClient'); const config = {}; const adapters = mockAdapters; + const listeners = new Map(); + const wrapper = ({ children }: { children: ReactNode }) => ( - + {children} );