diff --git a/android/build.gradle b/android/build.gradle index 613a87a..07d6d3d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -53,4 +53,5 @@ android { dependencies { implementation("circle.programmablewallet:sdk:1.0.1102") implementation("com.github.bumptech.glide:glide:4.16.0") + testImplementation("junit:junit:4.13.2") } diff --git a/android/src/main/java/com/circlefin/programmablewalletrnsdk/ProgrammablewalletRnSdkModule.kt b/android/src/main/java/com/circlefin/programmablewalletrnsdk/ProgrammablewalletRnSdkModule.kt index 3da024f..66fda7e 100644 --- a/android/src/main/java/com/circlefin/programmablewalletrnsdk/ProgrammablewalletRnSdkModule.kt +++ b/android/src/main/java/com/circlefin/programmablewalletrnsdk/ProgrammablewalletRnSdkModule.kt @@ -21,6 +21,7 @@ package com.circlefin.programmablewalletrnsdk import android.util.Log import circle.programmablewallet.sdk.WalletSdk import circle.programmablewallet.sdk.WalletSdk.init +import circle.programmablewallet.sdk.api.ApiError import circle.programmablewallet.sdk.api.ExecuteEvent import circle.programmablewallet.sdk.api.SocialProvider import circle.programmablewallet.sdk.api.SocialCallback @@ -88,6 +89,20 @@ class ProgrammablewalletRnSdkModule : Module(), EventListener { WalletSdk.Configuration(endpoint, appId, settings) ) promise.resolve(emptyMap()) + } catch (e: ApiError) { + // Surface the real ApiError code + message to JS. The converted + // message (convertApiErrorToMap) applies setErrorStringMap overrides + // — unlike PromiseCallback's dismiss branch, which uses the raw + // error.message; keep that distinction if consolidating paths. + // resolveInitErrorMessage falls back to the raw error.message when + // the converted message is blank (e.g. a blank override) or absent, + // then to a generic default, so the JS error contract (message + // always present and UI-readable) always holds. + val message = RecordsHelper.resolveInitErrorMessage( + RecordsHelper.convertApiErrorToMap(e)["message"] as? String, + e.message + ) + promise.reject(CodedException(e.code.value.toString(), message, e)) } catch (e: Throwable) { promise.reject(CodedException(e)) } diff --git a/android/src/main/java/com/circlefin/programmablewalletrnsdk/RecordsHelper.kt b/android/src/main/java/com/circlefin/programmablewalletrnsdk/RecordsHelper.kt index 94b686f..ea9d3d7 100644 --- a/android/src/main/java/com/circlefin/programmablewalletrnsdk/RecordsHelper.kt +++ b/android/src/main/java/com/circlefin/programmablewalletrnsdk/RecordsHelper.kt @@ -312,6 +312,22 @@ object RecordsHelper { ) } + /** + * Resolve the JS-facing error message for initSdk failures. + * + * [convertedMessage] is `convertApiErrorToMap(error)["message"]`, which is + * `customMessage ?: error.message` with no blank check — so a **blank** + * `setErrorStringMap` override is returned as-is (not treated as absent). + * The [rawMessage] fallback (the SDK's `error.message`) is therefore NOT + * dead code: it recovers the raw message when the override is blank as well + * as when the converted message is absent. Falls back to a generic default + * so the JS error contract (message always present and UI-readable) holds. + */ + fun resolveInitErrorMessage(convertedMessage: String?, rawMessage: String?): String = + convertedMessage?.takeIf { it.isNotBlank() } + ?: rawMessage?.takeIf { it.isNotBlank() } + ?: "SDK initialization failed" + fun convertExecuteWarningToMap(warning: ExecuteWarning): Map { return mapOf( "warningType" to warning.warningType, diff --git a/android/src/test/java/com/circlefin/programmablewalletrnsdk/RecordsHelperTest.kt b/android/src/test/java/com/circlefin/programmablewalletrnsdk/RecordsHelperTest.kt new file mode 100644 index 0000000..7908177 --- /dev/null +++ b/android/src/test/java/com/circlefin/programmablewalletrnsdk/RecordsHelperTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.circlefin.programmablewalletrnsdk + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [RecordsHelper.resolveInitErrorMessage], the pure fallback + * used by initSdk's ApiError catch to build the JS-facing error message. + */ +class RecordsHelperTest { + + @Test + fun usesConvertedMessageWhenNonBlank() { + assertEquals( + "Invalid appId: bad", + RecordsHelper.resolveInitErrorMessage("Invalid appId: bad", "raw sdk message"), + ) + } + + @Test + fun fallsBackToRawMessageWhenConvertedIsBlankOverride() { + // A blank setErrorStringMap override is returned as-is by + // convertApiErrorToMap (customMessage ?: error.message, no blank check), + // so the raw error.message fallback must recover the useful message. + assertEquals( + "raw sdk message", + RecordsHelper.resolveInitErrorMessage(" ", "raw sdk message"), + ) + } + + @Test + fun fallsBackToRawMessageWhenConvertedIsAbsent() { + assertEquals( + "raw sdk message", + RecordsHelper.resolveInitErrorMessage(null, "raw sdk message"), + ) + } + + @Test + fun fallsBackToGenericDefaultWhenBothBlankOrNull() { + assertEquals( + "SDK initialization failed", + RecordsHelper.resolveInitErrorMessage("", " "), + ) + assertEquals( + "SDK initialization failed", + RecordsHelper.resolveInitErrorMessage(null, null), + ) + } +} diff --git a/ios/ProgrammablewalletRnSdkModule.swift b/ios/ProgrammablewalletRnSdkModule.swift index 793a905..ad1a4d9 100644 --- a/ios/ProgrammablewalletRnSdkModule.swift +++ b/ios/ProgrammablewalletRnSdkModule.swift @@ -59,29 +59,43 @@ public class ProgrammablewalletRnSdkModule: Module { } // Initialize SDK with configuration - AsyncFunction("initSdk") { (configuration: [String: Any]) async throws -> Void in - - WalletSdk.shared.setLayoutProvider(self) - WalletSdk.shared.setDelegate(self) - WalletSdk.shared.setErrorMessenger(self) - + // Unlike execute/performLogin/performLogout, this does not dispatch to + // the main queue: setConfiguration only validates input and assigns + // properties (no UIKit work), so it is safe to run inline. + AsyncFunction("initSdk") { (configuration: [String: Any], promise: Promise) in + var endPoint = configuration["endpoint"] as? String ?? "" if endPoint.last == "/" { endPoint.removeLast() } let appId = configuration["appId"] as? String ?? "" - + var enableBiometricsPin = false if let settingsManagement = configuration["settingsManagement"] as? [String: Any], let enableBio = settingsManagement["enableBiometricsPin"] as? Bool { enableBiometricsPin = enableBio } - + let settings = WalletSdk.SettingsManagement(enableBiometricsPin: enableBiometricsPin) let sdkConfig = WalletSdk.Configuration(endPoint: endPoint, appId: appId, settingsManagement: settings) - - try WalletSdk.shared.setConfiguration(sdkConfig) - return + + do { + try WalletSdk.shared.setConfiguration(sdkConfig) + // Only wire the shared singleton to this module after config + // validation succeeds, so a failed configure doesn't leave the + // singleton wired ahead of a valid configuration. + WalletSdk.shared.setLayoutProvider(self) + WalletSdk.shared.setDelegate(self) + WalletSdk.shared.setErrorMessenger(self) + promise.resolve(nil) + } catch let error as ApiError { + // Surface the real ApiError code + errorString to JS, consistent with + // the other Promise-based operations (performLogin / performLogout). + promise.reject(String(error.errorCode.rawValue), self._bridgePromiseErrorMessage(error)) + } catch { + let nsError = error as NSError + promise.reject(String(nsError.code), nsError.localizedDescription) + } } // Set security questions for the wallet @@ -156,8 +170,18 @@ public class ProgrammablewalletRnSdkModule: Module { return } DispatchQueue.main.async { - WalletSdk.shared.performLogout(provider: socialProvider) { _ in - promise.resolve(nil) + WalletSdk.shared.performLogout(provider: socialProvider) { logoutResult in + switch logoutResult { + case .success: + promise.resolve(nil) + case .failure(let error): + if let apiError = error as? ApiError { + promise.reject(String(apiError.errorCode.rawValue), self._bridgePromiseErrorMessage(apiError)) + } else { + let nsError = error as NSError + promise.reject(String(nsError.code), nsError.localizedDescription) + } + } } } } diff --git a/package.json b/package.json index 4c667da..58225c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@circle-fin/w3s-pw-react-native-sdk", - "version": "2.2.2", + "version": "2.2.3", "packageManager": "yarn@1.22.22", "description": "React Native SDK for Circle Programmable Wallet", "main": "build/index.js", @@ -106,8 +106,11 @@ }, "resolutions": { "@xmldom/xmldom": "0.8.13", + "form-data": "4.0.6", + "js-yaml": "4.3.0", "node-forge": "1.4.0", - "postcss": "8.5.10", - "undici": "6.25.0" + "postcss": "8.5.22", + "undici": "6.27.0", + "uuid": "11.1.1" } } diff --git a/plugins/__test__/podfile-modifier.test.js b/plugins/__test__/podfile-modifier.test.js new file mode 100644 index 0000000..41aab38 --- /dev/null +++ b/plugins/__test__/podfile-modifier.test.js @@ -0,0 +1,19 @@ +const withPodfileModifier = require('../podfile-modifier') + +describe('withPodfileModifier', () => { + it('is exported as a function', () => { + expect(typeof withPodfileModifier).toBe('function') + }) + + // Spec (docs/specs/rn-sdk-expo-plugins.md): "podfile-modifier and + // apple-signin-entitlements take no options." This guards against + // reintroducing an `options` parameter (CCS-4809). Function#length is + // unreliable here — a defaulted parameter (`options = {}`) does not count + // toward it — so assert on the declared signature instead. + it('declares only the config parameter — no options (spec: no-options contract)', () => { + const source = withPodfileModifier.toString() + const signature = source.slice(0, source.indexOf('=>')) + expect(signature).not.toMatch(/options/) + expect(signature.replace(/[()\s]/g, '')).toBe('config') + }) +}) diff --git a/plugins/podfile-modifier.js b/plugins/podfile-modifier.js index cb41160..f927212 100644 --- a/plugins/podfile-modifier.js +++ b/plugins/podfile-modifier.js @@ -5,7 +5,7 @@ const path = require('path') /** * Simple Podfile modifier plugin for Circle SDK */ -const withPodfileModifier = (config, options = {}) => { +const withPodfileModifier = config => { return withDangerousMod(config, [ 'ios', async config => { diff --git a/src/WalletSdk.ts b/src/WalletSdk.ts index 71d4329..859cbcc 100644 --- a/src/WalletSdk.ts +++ b/src/WalletSdk.ts @@ -142,55 +142,47 @@ export const WalletSdk = ((): IWalletSdk => { successCallback: SuccessCallback, errorCallback: ErrorCallback, ): void { - let settled = false - - const successListener = ProgrammablewalletRnSdk.addListener( - EVENT_NAME_ON_SUCCESS, - (event: unknown) => { - if (settled) return - settled = true - console.debug('[WalletSdk] Execute result') - successCallback(event as SuccessResult) - cleanup() - }, - ) + let cleanup = () => undefined - const errorListener = ProgrammablewalletRnSdk.addListener( - EVENT_NAME_ON_ERROR, - (event: unknown) => { - if (settled) return - settled = true - console.debug('[WalletSdk] Error event received:', event) - const error = normalizeNativeError(event) - errorCallback(error) - cleanup() - }, - ) + const settlement = new Promise((resolve, reject) => { + const successListener = ProgrammablewalletRnSdk.addListener( + EVENT_NAME_ON_SUCCESS, + (event: unknown) => { + resolve(event as SuccessResult) + }, + ) - // Cleanup function to remove listeners - const cleanup = () => { - successListener?.remove() - errorListener?.remove() - } + const errorListener = ProgrammablewalletRnSdk.addListener( + EVENT_NAME_ON_ERROR, + (event: unknown) => { + reject(normalizeNativeError(event)) + }, + ) - // Call native execute method - ProgrammablewalletRnSdk.execute(userToken, encryptionKey, challengeIds) - .then((successResult: SuccessResult) => { - if (settled) return - settled = true - // If Promise resolves but no event was fired, call success callback - console.debug('[WalletSdk] Promise resolved') - successCallback(successResult) - cleanup() - }) - .catch((e: Error) => { - if (settled) return - settled = true - // If Promise rejects but no event was fired, call error callback - console.debug('[WalletSdk] Promise rejected:', e) - errorCallback(e) - cleanup() - }) + cleanup = () => { + successListener?.remove() + errorListener?.remove() + } + + ProgrammablewalletRnSdk.execute( + userToken, + encryptionKey, + challengeIds, + ).then(resolve, reject) + }) + + void settlement + .then( + (successResult: SuccessResult) => { + console.debug('[WalletSdk] Execute result') + successCallback(successResult) + }, + (e: Error) => { + console.debug('[WalletSdk] Execute error:', e) + errorCallback(e) + }, + ) + .finally(cleanup) }, verifyOTP( otpToken: string, @@ -199,40 +191,37 @@ export const WalletSdk = ((): IWalletSdk => { successCallback: LoginSuccessCallback, errorCallback: ErrorCallback, ): void { - let settled = false - - const errorListener = ProgrammablewalletRnSdk.addListener( - EVENT_NAME_ON_ERROR, - (event: unknown) => { - if (settled) return - settled = true - const error = normalizeNativeError(event) - errorCallback(error) - cleanup() - }, - ) + let cleanup = () => undefined - const cleanup = () => { - errorListener?.remove() - } + const settlement = new Promise((resolve, reject) => { + const errorListener = ProgrammablewalletRnSdk.addListener( + EVENT_NAME_ON_ERROR, + (event: unknown) => { + reject(normalizeNativeError(event)) + }, + ) - ProgrammablewalletRnSdk.verifyOTP( - otpToken, - deviceToken, - deviceEncryptionKey, - ) - .then((result: LoginResult) => { - if (settled) return - settled = true - successCallback(result) - cleanup() - }) - .catch((e: Error) => { - if (settled) return - settled = true - errorCallback(e) - cleanup() - }) + cleanup = () => { + errorListener?.remove() + } + + ProgrammablewalletRnSdk.verifyOTP( + otpToken, + deviceToken, + deviceEncryptionKey, + ).then(resolve, reject) + }) + + void settlement + .then( + (result: LoginResult) => { + successCallback(result) + }, + (e: Error) => { + errorCallback(e) + }, + ) + .finally(cleanup) }, performLogin( provider: SocialProvider, @@ -279,60 +268,46 @@ export const WalletSdk = ((): IWalletSdk => { successCallback: SuccessCallback, errorCallback: ErrorCallback, ): void { - let settled = false - - const successListener = ProgrammablewalletRnSdk.addListener( - EVENT_NAME_ON_SUCCESS, - (event: unknown) => { - if (settled) return - settled = true - successCallback(event as SuccessResult) - cleanup() - }, - ) + let cleanup = () => undefined - const errorListener = ProgrammablewalletRnSdk.addListener( - EVENT_NAME_ON_ERROR, - (event: unknown) => { - if (settled) return - settled = true - console.debug( - '[WalletSdk] setBiometricsPin Error event received:', - event, - ) - const error = normalizeNativeError(event) - errorCallback(error) - cleanup() - }, - ) + const settlement = new Promise((resolve, reject) => { + const successListener = ProgrammablewalletRnSdk.addListener( + EVENT_NAME_ON_SUCCESS, + (event: unknown) => { + resolve(event as SuccessResult) + }, + ) - // Cleanup function to remove listeners - const cleanup = () => { - successListener?.remove() - errorListener?.remove() - } + const errorListener = ProgrammablewalletRnSdk.addListener( + EVENT_NAME_ON_ERROR, + (event: unknown) => { + reject(normalizeNativeError(event)) + }, + ) - // Call native setBiometricsPin method - ProgrammablewalletRnSdk.setBiometricsPin(userToken, encryptionKey) - .then((successResult: SuccessResult) => { - if (settled) return - settled = true - // If Promise resolves but no event was fired, call success callback - console.debug( - '[WalletSdk] setBiometricsPin Promise resolved:', - successResult, - ) - successCallback(successResult) - cleanup() - }) - .catch((e: Error) => { - if (settled) return - settled = true - // If Promise rejects but no event was fired, call error callback - console.debug('[WalletSdk] setBiometricsPin Promise rejected:', e) - errorCallback(e) - cleanup() - }) + cleanup = () => { + successListener?.remove() + errorListener?.remove() + } + + ProgrammablewalletRnSdk.setBiometricsPin(userToken, encryptionKey).then( + resolve, + reject, + ) + }) + + void settlement + .then( + (successResult: SuccessResult) => { + console.debug('[WalletSdk] setBiometricsPin result:', successResult) + successCallback(successResult) + }, + (e: Error) => { + console.debug('[WalletSdk] setBiometricsPin error:', e) + errorCallback(e) + }, + ) + .finally(cleanup) }, setDismissOnCallbackMap(map: Map): void { diff --git a/src/__test__/CirclePwSuccessPayload.test.ts b/src/__test__/CirclePwSuccessPayload.test.ts new file mode 100644 index 0000000..7850fa8 --- /dev/null +++ b/src/__test__/CirclePwSuccessPayload.test.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ExecuteResultStatus, + ExecuteResultType, + type CirclePwSuccessPayload, + type ProgrammablewalletRnSdkModuleEvents, + type SuccessResult, +} from '../types' + +const successResult: SuccessResult = { + result: { + resultType: ExecuteResultType.SET_BIOMETRICS_PIN, + status: ExecuteResultStatus.COMPLETE, + }, +} + +const payload: CirclePwSuccessPayload = successResult + +const handleCirclePwOnSuccess: ProgrammablewalletRnSdkModuleEvents['CirclePwOnSuccess'] = + event => { + const typedSuccessResult: SuccessResult = event + + expect(typedSuccessResult.result).toEqual(successResult.result) + } + +describe('CirclePwSuccessPayload', () => { + it('matches the SuccessResult runtime shape', () => { + expect(payload).toEqual(successResult) + + handleCirclePwOnSuccess(payload) + }) +}) diff --git a/src/__test__/WalletSdk.test.ts b/src/__test__/WalletSdk.test.ts index 7e4af70..437ab28 100644 --- a/src/__test__/WalletSdk.test.ts +++ b/src/__test__/WalletSdk.test.ts @@ -45,6 +45,12 @@ function triggerEvent(eventName: string, payload: unknown): void { callbacks.forEach(cb => cb(payload)) } +async function flushMicrotasks(count = 3): Promise { + for (let i = 0; i < count; i += 1) { + await Promise.resolve() + } +} + const mockExecute = jest.fn() const mockSetBiometricsPin = jest.fn() const mockVerifyOTP = jest.fn() @@ -96,7 +102,18 @@ jest.mock('../ProgrammablewalletRnSdkModule', () => mockNativeModule) // Import WalletSdk AFTER mocks are set up import { WalletSdk } from '../WalletSdk' -import { ImageKey, SocialProvider } from '../types' +import { + DateFormat, + ErrorCode, + IconTextConfig, + IconTextsKey, + ImageKey, + InputType, + SocialProvider, + TextConfig, + TextKey, + TextsKey, +} from '../types' import type { Configuration, LoginResult, SuccessResult } from '../types' import { Image } from 'react-native' @@ -144,6 +161,15 @@ beforeEach(() => { ) }) +// jest.clearAllMocks() in beforeEach clears call records but does not uninstall +// jest.spyOn replacements. Tests that spy on console.error / console.warn must +// not leak their spy into later tests on assertion failure — this restores +// every spy after every test so the original console functions are guaranteed +// to come back. +afterEach(() => { + jest.restoreAllMocks() +}) + // --------------------------------------------------------------------------- // init() / setCustomUserAgent() // --------------------------------------------------------------------------- @@ -169,6 +195,55 @@ describe('WalletSdk.init', () => { ) await expect(promise).rejects.toThrow('init failed') }) + + // Coverage boundary: the native bridge is what derives the real code + + // errorString-bearing message from the thrown ApiError — code = + // String(errorCode.rawValue) + _bridgePromiseErrorMessage on iOS, and + // CodedException(code.value, convertApiErrorToMap(...)) on Android. That + // mapping is exercised by the native SDK repos' tests (e.g. iOS + // PW_SDK_6_config_error_tests), not here — this bridge repo has no native + // unit-test harness. The tests below instead lock the JS-side contract: + // init() must PROPAGATE the native rejection verbatim (it is in the + // "propagates errors" list, not an error-swallowing setter), so a future + // regression that swallowed or remapped the rejection here would be caught. + + it('propagates the native rejection verbatim without swallowing or remapping the code', async () => { + // The native reject already collapsed to "1" was the original bug; assert + // the JS wrapper neither swallows the rejection nor rewrites its code. + const nativeError = Object.assign( + new Error('Invalid appId. Invalid appId: bad-app-id'), + { code: '2' }, + ) + mockNativeModule.initSdk.mockRejectedValueOnce(nativeError) + + const rejection = await WalletSdk.init(initConfigWithSettings).then( + () => { + throw new Error('init should have rejected') + }, + (e: Error & { code?: string }) => e, + ) + + expect(rejection).toBe(nativeError) + expect(rejection.code).toBe('2') + expect(rejection.code).not.toBe('1') + expect(rejection.message).toBe('Invalid appId. Invalid appId: bad-app-id') + }) + + it('propagates a network ApiError (155706) rejection from init to the caller', async () => { + const nativeError = Object.assign( + new Error( + 'Network error. A TLS error caused the secure connection to fail', + ), + { code: '155706' }, + ) + mockNativeModule.initSdk.mockRejectedValueOnce(nativeError) + + await expect(WalletSdk.init(initConfigWithSettings)).rejects.toMatchObject({ + code: '155706', + message: + 'Network error. A TLS error caused the secure connection to fail', + }) + }) }) describe('WalletSdk.setCustomUserAgent', () => { @@ -211,12 +286,73 @@ describe('WalletSdk.execute', () => { // Then Promise resolves resolvePromise(mockSuccessResult) - await Promise.resolve() + await flushMicrotasks() expect(successCallback).toHaveBeenCalledTimes(1) expect(errorCallback).not.toHaveBeenCalled() }) + it('delivers success event callbacks on a Promise microtask', async () => { + mockExecute.mockReturnValue(new Promise(() => undefined)) + + const successCallback = jest.fn() + const errorCallback = jest.fn() + + WalletSdk.execute( + 'token', + 'key', + ['challenge-1'], + successCallback, + errorCallback, + ) + + triggerEvent(SUCCESS_EVENT, mockSuccessResult) + + expect(successCallback).not.toHaveBeenCalled() + + await flushMicrotasks() + + expect(successCallback).toHaveBeenCalledTimes(1) + expect(errorCallback).not.toHaveBeenCalled() + }) + + it('fans out one success event to concurrent execute calls', async () => { + mockExecute.mockImplementation( + () => new Promise(() => undefined), + ) + + const firstSuccessCallback = jest.fn() + const firstErrorCallback = jest.fn() + const secondSuccessCallback = jest.fn() + const secondErrorCallback = jest.fn() + + WalletSdk.execute( + 'token', + 'key', + ['challenge-1'], + firstSuccessCallback, + firstErrorCallback, + ) + WalletSdk.execute( + 'token', + 'key', + ['challenge-2'], + secondSuccessCallback, + secondErrorCallback, + ) + + expect(mockExecute).toHaveBeenCalledTimes(2) + expect(listenerMap.get(SUCCESS_EVENT)).toHaveLength(2) + + triggerEvent(SUCCESS_EVENT, mockSuccessResult) + await flushMicrotasks() + + expect(firstSuccessCallback).toHaveBeenCalledTimes(1) + expect(secondSuccessCallback).toHaveBeenCalledTimes(1) + expect(firstErrorCallback).not.toHaveBeenCalled() + expect(secondErrorCallback).not.toHaveBeenCalled() + }) + it('invokes successCallback exactly once when Promise resolves before event fires', async () => { mockExecute.mockResolvedValue(mockSuccessResult) @@ -231,10 +367,10 @@ describe('WalletSdk.execute', () => { errorCallback, ) - // Flush microtask queue so Promise .then runs - await Promise.resolve() + // Flush microtasks so the native Promise settles the outer Promise. + await flushMicrotasks() - // Fire event after Promise already settled + // Fire event after the outer Promise has already completed. triggerEvent(SUCCESS_EVENT, mockSuccessResult) expect(successCallback).toHaveBeenCalledTimes(1) @@ -265,7 +401,7 @@ describe('WalletSdk.execute', () => { // Then Promise rejects rejectPromise(new Error('promise error')) - await Promise.resolve() + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(successCallback).not.toHaveBeenCalled() @@ -298,7 +434,7 @@ describe('WalletSdk.execute', () => { }) rejectPromise(new Error('promise error')) - await Promise.resolve() + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(errorCallback.mock.calls[0][0]).toMatchObject({ @@ -335,7 +471,7 @@ describe('WalletSdk.execute', () => { }) rejectPromise(new Error('promise error')) - await Promise.resolve() + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(errorCallback.mock.calls[0][0]).toMatchObject({ @@ -359,10 +495,10 @@ describe('WalletSdk.execute', () => { errorCallback, ) - // Flush microtask queue so Promise .catch runs - await Promise.resolve() + // Flush microtasks so the native rejection settles the outer Promise. + await flushMicrotasks() - // Fire error event after Promise already rejected + // Fire error event after the outer Promise has already rejected. triggerEvent(ERROR_EVENT, { message: 'native error' }) expect(errorCallback).toHaveBeenCalledTimes(1) @@ -386,8 +522,8 @@ describe('WalletSdk.execute', () => { // Success event fires first triggerEvent(SUCCESS_EVENT, mockSuccessResult) - // Then Promise rejects — settled flag should suppress errorCallback - await Promise.resolve() + // Then Promise rejects — outer Promise settlement should ignore it. + await flushMicrotasks() expect(successCallback).toHaveBeenCalledTimes(1) expect(errorCallback).not.toHaveBeenCalled() @@ -410,14 +546,14 @@ describe('WalletSdk.execute', () => { // Error event fires first triggerEvent(ERROR_EVENT, { message: 'native error' }) - // Then Promise resolves — settled flag should suppress successCallback - await Promise.resolve() + // Then Promise resolves — outer Promise settlement should ignore it. + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(successCallback).not.toHaveBeenCalled() }) - it('removes both listeners after success event', () => { + it('removes both listeners after success event', async () => { let resolvePromise!: (value: SuccessResult) => void mockExecute.mockReturnValue( new Promise(res => { @@ -441,6 +577,8 @@ describe('WalletSdk.execute', () => { triggerEvent(SUCCESS_EVENT, mockSuccessResult) + await flushMicrotasks() + expect(successRemove).toHaveBeenCalledTimes(1) expect(errorRemove).toHaveBeenCalledTimes(1) @@ -448,7 +586,7 @@ describe('WalletSdk.execute', () => { resolvePromise(mockSuccessResult) }) - it('removes both listeners after error event', () => { + it('removes both listeners after error event', async () => { let rejectPromise!: (reason: Error) => void mockExecute.mockReturnValue( new Promise((_, rej) => { @@ -471,6 +609,8 @@ describe('WalletSdk.execute', () => { triggerEvent(ERROR_EVENT, { message: 'native error' }) + await flushMicrotasks() + expect(successRemove).toHaveBeenCalledTimes(1) expect(errorRemove).toHaveBeenCalledTimes(1) @@ -500,12 +640,67 @@ describe('WalletSdk.setBiometricsPin', () => { triggerEvent(SUCCESS_EVENT, mockSuccessResult) resolvePromise(mockSuccessResult) - await Promise.resolve() + await flushMicrotasks() expect(successCallback).toHaveBeenCalledTimes(1) expect(errorCallback).not.toHaveBeenCalled() }) + it('delivers success event callbacks on a Promise microtask', async () => { + mockSetBiometricsPin.mockReturnValue( + new Promise(() => undefined), + ) + + const successCallback = jest.fn() + const errorCallback = jest.fn() + + WalletSdk.setBiometricsPin('token', 'key', successCallback, errorCallback) + + triggerEvent(SUCCESS_EVENT, mockSuccessResult) + + expect(successCallback).not.toHaveBeenCalled() + + await flushMicrotasks() + + expect(successCallback).toHaveBeenCalledTimes(1) + expect(errorCallback).not.toHaveBeenCalled() + }) + + it('fans out one success event to concurrent setBiometricsPin calls', async () => { + mockSetBiometricsPin.mockImplementation( + () => new Promise(() => undefined), + ) + + const firstSuccessCallback = jest.fn() + const firstErrorCallback = jest.fn() + const secondSuccessCallback = jest.fn() + const secondErrorCallback = jest.fn() + + WalletSdk.setBiometricsPin( + 'token', + 'key', + firstSuccessCallback, + firstErrorCallback, + ) + WalletSdk.setBiometricsPin( + 'token', + 'key', + secondSuccessCallback, + secondErrorCallback, + ) + + expect(mockSetBiometricsPin).toHaveBeenCalledTimes(2) + expect(listenerMap.get(SUCCESS_EVENT)).toHaveLength(2) + + triggerEvent(SUCCESS_EVENT, mockSuccessResult) + await flushMicrotasks() + + expect(firstSuccessCallback).toHaveBeenCalledTimes(1) + expect(secondSuccessCallback).toHaveBeenCalledTimes(1) + expect(firstErrorCallback).not.toHaveBeenCalled() + expect(secondErrorCallback).not.toHaveBeenCalled() + }) + it('invokes successCallback exactly once when Promise resolves before event fires', async () => { mockSetBiometricsPin.mockResolvedValue(mockSuccessResult) @@ -514,7 +709,7 @@ describe('WalletSdk.setBiometricsPin', () => { WalletSdk.setBiometricsPin('token', 'key', successCallback, errorCallback) - await Promise.resolve() + await flushMicrotasks() triggerEvent(SUCCESS_EVENT, mockSuccessResult) @@ -538,7 +733,7 @@ describe('WalletSdk.setBiometricsPin', () => { triggerEvent(ERROR_EVENT, { message: 'native error' }) rejectPromise(new Error('promise error')) - await Promise.resolve() + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(successCallback).not.toHaveBeenCalled() @@ -564,7 +759,7 @@ describe('WalletSdk.setBiometricsPin', () => { }) rejectPromise(new Error('promise error')) - await Promise.resolve() + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(errorCallback.mock.calls[0][0]).toMatchObject({ @@ -583,7 +778,7 @@ describe('WalletSdk.setBiometricsPin', () => { WalletSdk.setBiometricsPin('token', 'key', successCallback, errorCallback) - await Promise.resolve() + await flushMicrotasks() triggerEvent(ERROR_EVENT, { message: 'native error' }) @@ -602,8 +797,8 @@ describe('WalletSdk.setBiometricsPin', () => { // Success event fires first triggerEvent(SUCCESS_EVENT, mockSuccessResult) - // Then Promise rejects — settled flag should suppress errorCallback - await Promise.resolve() + // Then Promise rejects — outer Promise settlement should ignore it. + await flushMicrotasks() expect(successCallback).toHaveBeenCalledTimes(1) expect(errorCallback).not.toHaveBeenCalled() @@ -620,14 +815,14 @@ describe('WalletSdk.setBiometricsPin', () => { // Error event fires first triggerEvent(ERROR_EVENT, { message: 'native error' }) - // Then Promise resolves — settled flag should suppress successCallback - await Promise.resolve() + // Then Promise resolves — outer Promise settlement should ignore it. + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) expect(successCallback).not.toHaveBeenCalled() }) - it('removes both listeners after success event', () => { + it('removes both listeners after success event', async () => { let resolvePromise!: (value: SuccessResult) => void mockSetBiometricsPin.mockReturnValue( new Promise(res => { @@ -644,13 +839,15 @@ describe('WalletSdk.setBiometricsPin', () => { triggerEvent(SUCCESS_EVENT, mockSuccessResult) + await flushMicrotasks() + expect(successRemove).toHaveBeenCalledTimes(1) expect(errorRemove).toHaveBeenCalledTimes(1) resolvePromise(mockSuccessResult) }) - it('removes both listeners after error event', () => { + it('removes both listeners after error event', async () => { let rejectPromise!: (reason: Error) => void mockSetBiometricsPin.mockReturnValue( new Promise((_, rej) => { @@ -667,6 +864,8 @@ describe('WalletSdk.setBiometricsPin', () => { triggerEvent(ERROR_EVENT, { message: 'native error' }) + await flushMicrotasks() + expect(successRemove).toHaveBeenCalledTimes(1) expect(errorRemove).toHaveBeenCalledTimes(1) @@ -706,13 +905,151 @@ describe('WalletSdk.verifyOTP', () => { // Error event fires first triggerEvent(ERROR_EVENT, { message: 'native error' }) - // Then Promise also rejects — without settled flag, errorCallback fires twice + // Then Promise also rejects. The already-completed outer Promise ignores it. rejectPromise(new Error('promise error')) - // Flush all pending microtasks (.catch + .finally) - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() + + expect(errorCallback).toHaveBeenCalledTimes(1) + expect(successCallback).not.toHaveBeenCalled() + }) + + it('delivers error event callbacks on a Promise microtask', async () => { + mockVerifyOTP.mockReturnValue(new Promise(() => undefined)) + + const successCallback = jest.fn() + const errorCallback = jest.fn() + + WalletSdk.verifyOTP( + 'otp', + 'deviceToken', + 'encKey', + successCallback, + errorCallback, + ) + + triggerEvent(ERROR_EVENT, { message: 'native error' }) + + expect(errorCallback).not.toHaveBeenCalled() + + await flushMicrotasks() + + expect(errorCallback).toHaveBeenCalledTimes(1) + expect(successCallback).not.toHaveBeenCalled() + }) + + it('settles verifyOTP error channel from an overlapping execute error event', async () => { + mockVerifyOTP.mockImplementation( + () => new Promise(() => undefined), + ) + mockExecute.mockImplementation( + () => new Promise(() => undefined), + ) + + const verifySuccessCallback = jest.fn() + const verifyErrorCallback = jest.fn() + const executeSuccessCallback = jest.fn() + const executeErrorCallback = jest.fn() + + WalletSdk.verifyOTP( + 'otp', + 'deviceToken', + 'encKey', + verifySuccessCallback, + verifyErrorCallback, + ) + WalletSdk.execute( + 'token', + 'key', + ['challenge-1'], + executeSuccessCallback, + executeErrorCallback, + ) + + expect(mockVerifyOTP).toHaveBeenCalledTimes(1) + expect(mockExecute).toHaveBeenCalledTimes(1) + expect(listenerMap.get(ERROR_EVENT)).toHaveLength(2) + + triggerEvent(ERROR_EVENT, { message: 'execute native error' }) + await flushMicrotasks() + + expect(verifyErrorCallback).toHaveBeenCalledTimes(1) + expect(verifyErrorCallback.mock.calls[0][0]).toMatchObject({ + message: 'execute native error', + }) + expect(executeErrorCallback).toHaveBeenCalledTimes(1) + expect(verifySuccessCallback).not.toHaveBeenCalled() + expect(executeSuccessCallback).not.toHaveBeenCalled() + }) + + it('normalizes numeric native error event codes to strings', async () => { + let rejectPromise!: (reason: Error) => void + mockVerifyOTP.mockReturnValue( + new Promise((_, rej) => { + rejectPromise = rej + }), + ) + + const successCallback = jest.fn() + const errorCallback = jest.fn() + + WalletSdk.verifyOTP( + 'otp', + 'deviceToken', + 'encKey', + successCallback, + errorCallback, + ) + + triggerEvent(ERROR_EVENT, { + code: 155706, + message: 'Network error', + }) + + rejectPromise(new Error('promise error')) + await flushMicrotasks() expect(errorCallback).toHaveBeenCalledTimes(1) + expect(errorCallback.mock.calls[0][0]).toMatchObject({ + code: '155706', + message: 'Network error', + }) + expect(successCallback).not.toHaveBeenCalled() + }) + + it('preserves native error payload details on verifyOTP errors', async () => { + let rejectPromise!: (reason: Error) => void + mockVerifyOTP.mockReturnValue( + new Promise((_, rej) => { + rejectPromise = rej + }), + ) + + const successCallback = jest.fn() + const errorCallback = jest.fn() + + WalletSdk.verifyOTP( + 'otp', + 'deviceToken', + 'encKey', + successCallback, + errorCallback, + ) + + triggerEvent(ERROR_EVENT, { + code: 155706, + errorString: 'URLSession timed out while connecting to api.circle.com', + message: 'Network error', + }) + + rejectPromise(new Error('promise error')) + await flushMicrotasks() + + expect(errorCallback).toHaveBeenCalledTimes(1) + expect(errorCallback.mock.calls[0][0]).toMatchObject({ + code: '155706', + errorString: 'URLSession timed out while connecting to api.circle.com', + message: 'Network error', + }) expect(successCallback).not.toHaveBeenCalled() }) @@ -730,11 +1067,10 @@ describe('WalletSdk.verifyOTP', () => { errorCallback, ) - // Flush microtask queue so Promise .catch + .finally run - await Promise.resolve() - await Promise.resolve() + // Flush microtasks so the native rejection settles the outer Promise. + await flushMicrotasks() - // Error event fires after — settled flag should suppress it + // Error event fires after — outer Promise settlement should ignore it. triggerEvent(ERROR_EVENT, { message: 'native error' }) expect(errorCallback).toHaveBeenCalledTimes(1) @@ -755,15 +1091,35 @@ describe('WalletSdk.verifyOTP', () => { errorCallback, ) - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() expect(successCallback).toHaveBeenCalledTimes(1) expect(successCallback).toHaveBeenCalledWith(mockLoginResult) expect(errorCallback).not.toHaveBeenCalled() }) - it('removes error listener via .remove() after error event fires', () => { + it('does not invoke successCallback when a CirclePwOnSuccess event fires', async () => { + mockVerifyOTP.mockReturnValue(new Promise(() => undefined)) + + const successCallback = jest.fn() + const errorCallback = jest.fn() + + WalletSdk.verifyOTP( + 'otp', + 'deviceToken', + 'encKey', + successCallback, + errorCallback, + ) + + triggerEvent(SUCCESS_EVENT, mockSuccessResult) + await flushMicrotasks() + + expect(successCallback).not.toHaveBeenCalled() + expect(errorCallback).not.toHaveBeenCalled() + }) + + it('removes error listener via .remove() after error event fires', async () => { let rejectPromise!: (reason: Error) => void mockVerifyOTP.mockReturnValue( new Promise((_, rej) => { @@ -786,6 +1142,8 @@ describe('WalletSdk.verifyOTP', () => { triggerEvent(ERROR_EVENT, { message: 'native error' }) + await flushMicrotasks() + expect(errorRemove).toHaveBeenCalledTimes(1) // Prevent unhandled rejection @@ -808,8 +1166,7 @@ describe('WalletSdk.verifyOTP', () => { const [errorRemove] = removeMocks.slice(-1) - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() expect(errorRemove).toHaveBeenCalledTimes(1) }) @@ -830,8 +1187,7 @@ describe('WalletSdk.verifyOTP', () => { const [errorRemove] = removeMocks.slice(-1) - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() expect(errorRemove).toHaveBeenCalledTimes(1) }) @@ -889,6 +1245,230 @@ describe('WalletSdk.setImageMap', () => { }) }) +// --------------------------------------------------------------------------- +// setIconTextConfigsMap() +// --------------------------------------------------------------------------- + +describe('WalletSdk.setIconTextConfigsMap', () => { + it('serializes a valid image source to its resolved URI alongside textConfig', () => { + mockResolveAssetSource.mockReturnValue({ uri: 'mock://icon' }) + + const textConfig = new TextConfig('hello', '#ffffff', 'Inter') + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, textConfig)], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: 'mock://icon', textConfig: { ...textConfig } }, + ], + }) + }) + + it('passes image: null when the image source is null or undefined', () => { + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [ + new IconTextConfig(null as any, new TextConfig('a')), + new IconTextConfig(undefined as any, new TextConfig('b')), + ], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: null, textConfig: { ...new TextConfig('a') } }, + { image: null, textConfig: { ...new TextConfig('b') } }, + ], + }) + }) + + it('defaults missing textConfig to an empty object', () => { + mockResolveAssetSource.mockReturnValue({ uri: 'mock://icon' }) + + // Bypass the IconTextConfig constructor to simulate a caller-supplied + // entry without a textConfig field — exercises the `textConfig = {}` default. + const configWithoutTextConfig = { image: 1 } as unknown as IconTextConfig + const map = new Map([ + [IconTextsKey.securityConfirmationItems, [configWithoutTextConfig]], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: 'mock://icon', textConfig: {} }, + ], + }) + }) + + it('serializes mixed entries (with image and without image)', () => { + mockResolveAssetSource.mockReturnValueOnce({ uri: 'mock://first' }) + // Second entry has a null image source — getImageUrl is short-circuited + // before resolveAssetSource is reached, so no second return is consumed. + + const firstText = new TextConfig('first') + const secondText = new TextConfig('second') + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [ + new IconTextConfig(1 as any, firstText), + new IconTextConfig(null as any, secondText), + ], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: 'mock://first', textConfig: { ...firstText } }, + { image: null, textConfig: { ...secondText } }, + ], + }) + }) + + it('swallows errors thrown by Image.resolveAssetSource without calling native', () => { + mockResolveAssetSource.mockImplementation(() => { + throw new Error('resolveAssetSource boom') + }) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, new TextConfig('x'))], + ], + ]) + + expect(() => WalletSdk.setIconTextConfigsMap(map)).not.toThrow() + expect(mockNativeModule.setIconTextConfigsMap).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith( + 'setIconTextConfigsMap Error:', + expect.any(Error), + ) + + errorSpy.mockRestore() + // jest.clearAllMocks() (in beforeEach) clears call records but does NOT + // reset the implementation we installed via mockImplementation, so restore + // it explicitly to avoid leaking the throwing behaviour to later tests. + mockResolveAssetSource.mockReset() + mockResolveAssetSource.mockReturnValue({ uri: 'mock://asset' }) + }) + + it('does not call Image.resolveAssetSource when image is falsy and emits image: null', () => { + // Contract this test verifies: when config.image is falsy, + // Image.resolveAssetSource is never invoked and the serialized payload + // emits image: null. The implementation may achieve the short-circuit + // via either guard (the `image ? ... : null` ternary in + // setIconTextConfigsMap or getImageUrl's own `if (!source)` early + // return); we only assert the observable behaviour, not which guard fires. + const firstText = new TextConfig('a') + const secondText = new TextConfig('b') + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [ + new IconTextConfig(null as any, firstText), + new IconTextConfig(undefined as any, secondText), + ], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockResolveAssetSource).not.toHaveBeenCalled() + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: null, textConfig: { ...firstText } }, + { image: null, textConfig: { ...secondText } }, + ], + }) + }) + + it('passes image: null when resolveAssetSource returns null for a truthy image', () => { + // Covers the `!resolved` branch of getImageUrl. The earlier + // null/undefined-image test short-circuits at `if (!source) return null` + // before resolveAssetSource is called, so this is the only case where a + // truthy image reaches resolveAssetSource and receives null back. + mockResolveAssetSource.mockReturnValue(null as any) + + const textConfig = new TextConfig('item') + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, textConfig)], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockResolveAssetSource).toHaveBeenCalledTimes(1) + expect(mockResolveAssetSource).toHaveBeenCalledWith(1) + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: null, textConfig: { ...textConfig } }, + ], + }) + }) + + it('passes image: null when resolveAssetSource returns an empty URI', () => { + // Covers the `resolved.uri.trim() === ''` branch of getImageUrl — + // distinct from the `!resolved` branch covered by the test above. + mockResolveAssetSource.mockReturnValue({ uri: '' }) + + const textConfig = new TextConfig('item') + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, textConfig)], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockResolveAssetSource).toHaveBeenCalledTimes(1) + expect(mockResolveAssetSource).toHaveBeenCalledWith(1) + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledWith({ + [IconTextsKey.securityConfirmationItems]: [ + { image: null, textConfig: { ...textConfig } }, + ], + }) + }) + + it('swallows errors thrown during rawMap iteration without calling native', () => { + // Complements the resolveAssetSource-throws test above by exercising the + // other branch the catch must cover: a failure in rawMap.entries() (or + // any other code before image resolution). A refactor that moved part + // of the work outside the try/catch would be caught here. + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const iterationError = new Error('entries failed') + + const badMap = { + entries: () => { + throw iterationError + }, + } as unknown as Map + + expect(() => WalletSdk.setIconTextConfigsMap(badMap)).not.toThrow() + expect(mockNativeModule.setIconTextConfigsMap).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith( + 'setIconTextConfigsMap Error:', + iterationError, + ) + }) +}) + // --------------------------------------------------------------------------- // init() — user-agent side-effect (AGENTS.md §User-Agent Header) // --------------------------------------------------------------------------- @@ -976,6 +1556,187 @@ describe('WalletSdk.setCustomUserAgent — prefix prepend', () => { }) }) +describe('WalletSdk error-swallowing setters', () => { + const cases = [ + { + methodName: 'setSecurityQuestions', + nativeMethod: 'setSecurityQuestions', + invoke: () => WalletSdk.setSecurityQuestions([{ title: 'Question' }]), + errorMessage: 'setSecurityQuestions failed:', + }, + { + methodName: 'setDebugging', + nativeMethod: 'setDebugging', + invoke: () => WalletSdk.setDebugging(true), + errorMessage: 'setDebugging failed:', + }, + { + methodName: 'setDismissOnCallbackMap', + nativeMethod: 'setDismissOnCallbackMap', + invoke: () => + WalletSdk.setDismissOnCallbackMap(new Map([[ErrorCode.unknown, true]])), + errorMessage: 'setDismissOnCallbackMap failed:', + }, + { + methodName: 'setTextConfigsMap', + nativeMethod: 'setTextConfigsMap', + invoke: () => + WalletSdk.setTextConfigsMap( + new Map([[TextsKey.newPinCodeHeadline, [new TextConfig('Title')]]]), + ), + errorMessage: 'setTextConfigsMap failed:', + }, + { + methodName: 'setTextConfigMap', + nativeMethod: 'setTextConfigMap', + invoke: () => + WalletSdk.setTextConfigMap( + new Map([[TextKey.circlepw_continue, new TextConfig('Continue')]]), + ), + errorMessage: 'setTextConfigMap failed:', + }, + { + methodName: 'setIconTextConfigsMap', + nativeMethod: 'setIconTextConfigsMap', + invoke: () => + WalletSdk.setIconTextConfigsMap( + new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, new TextConfig('Item'))], + ], + ]), + ), + errorMessage: 'setIconTextConfigsMap Error:', + }, + { + methodName: 'setErrorStringMap', + nativeMethod: 'setErrorStringMap', + invoke: () => + WalletSdk.setErrorStringMap( + new Map([[ErrorCode.apiParameterInvalid, 'Invalid parameter']]), + ), + errorMessage: 'setErrorStringMap failed:', + }, + { + methodName: 'setDateFormat', + nativeMethod: 'setDateFormat', + invoke: () => WalletSdk.setDateFormat(DateFormat.YYYYMMDD_HYPHEN), + errorMessage: 'setDateFormat failed:', + }, + { + methodName: 'setImageMap', + nativeMethod: 'setImageMap', + invoke: () => WalletSdk.setImageMap(new Map([[ImageKey.naviBack, 1]])), + errorMessage: 'setImageMap failed:', + }, + { + methodName: 'moveTaskToFront', + nativeMethod: 'moveTaskToFront', + invoke: () => WalletSdk.moveTaskToFront(), + errorMessage: 'moveTaskToFront failed:', + }, + { + methodName: 'moveRnTaskToFront', + nativeMethod: 'moveRnTaskToFront', + invoke: () => WalletSdk.moveRnTaskToFront(), + errorMessage: 'moveRnTaskToFront failed:', + }, + ] as const + + it.each(cases)( + '$methodName swallows native errors and logs the thrown value', + ({ nativeMethod, invoke, errorMessage }) => { + const nativeError = new Error(`${nativeMethod} native error`) + const nativeMock = mockNativeModule[nativeMethod] as jest.Mock + nativeMock.mockImplementationOnce(() => { + throw nativeError + }) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + try { + expect(invoke).not.toThrow() + expect(nativeMock).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith(errorMessage, nativeError) + } finally { + errorSpy.mockRestore() + } + }, + ) +}) + +// --------------------------------------------------------------------------- +// bridgeSafe-serializing setters — happy-path delegation +// (error paths are covered by the parameterized suite above) +// --------------------------------------------------------------------------- + +describe('WalletSdk.setDismissOnCallbackMap', () => { + it('serializes the Map to a plain object and delegates to the native module', () => { + const map = new Map([ + [ErrorCode.unknown, true], + [ErrorCode.apiParameterInvalid, false], + ]) + + expect(() => WalletSdk.setDismissOnCallbackMap(map)).not.toThrow() + + expect(mockNativeModule.setDismissOnCallbackMap).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setDismissOnCallbackMap).toHaveBeenCalledWith({ + [ErrorCode.unknown]: true, + [ErrorCode.apiParameterInvalid]: false, + }) + }) +}) + +describe('WalletSdk.setTextConfigsMap', () => { + it('serializes the Map to a plain object and delegates to the native module', () => { + const headlineConfig = new TextConfig('Headline', '#ffffff', 'Inter') + const map = new Map([ + [TextsKey.newPinCodeHeadline, [headlineConfig]], + ]) + + expect(() => WalletSdk.setTextConfigsMap(map)).not.toThrow() + + expect(mockNativeModule.setTextConfigsMap).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setTextConfigsMap).toHaveBeenCalledWith({ + [TextsKey.newPinCodeHeadline]: [{ ...headlineConfig }], + }) + }) +}) + +describe('WalletSdk.setTextConfigMap', () => { + it('serializes the Map to a plain object and delegates to the native module', () => { + const continueConfig = new TextConfig('Continue', '#000000', 'Inter') + const map = new Map([ + [TextKey.circlepw_continue, continueConfig], + ]) + + expect(() => WalletSdk.setTextConfigMap(map)).not.toThrow() + + expect(mockNativeModule.setTextConfigMap).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setTextConfigMap).toHaveBeenCalledWith({ + [TextKey.circlepw_continue]: { ...continueConfig }, + }) + }) +}) + +describe('WalletSdk.setErrorStringMap', () => { + it('serializes the Map to a plain object and delegates to the native module', () => { + const map = new Map([ + [ErrorCode.apiParameterInvalid, 'Invalid parameter'], + [ErrorCode.unknown, 'Something went wrong'], + ]) + + expect(() => WalletSdk.setErrorStringMap(map)).not.toThrow() + + expect(mockNativeModule.setErrorStringMap).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setErrorStringMap).toHaveBeenCalledWith({ + [ErrorCode.apiParameterInvalid]: 'Invalid parameter', + [ErrorCode.unknown]: 'Something went wrong', + }) + }) +}) + // --------------------------------------------------------------------------- // performLogin() — Promise-only settlement // --------------------------------------------------------------------------- @@ -1082,3 +1843,252 @@ describe('WalletSdk.performLogout', () => { expect(completedCallback).not.toHaveBeenCalled() }) }) + +// --------------------------------------------------------------------------- +// setIconTextConfigsMap() — image resolution and null-preservation +// (spec: docs/specs/rn-sdk-api-surface.md §setIconTextConfigsMap, +// docs/specs/rn-sdk-patterns.md §setIconTextConfigsMap) +// --------------------------------------------------------------------------- + +describe('WalletSdk.setIconTextConfigsMap', () => { + it('calls Image.resolveAssetSource once per IconTextConfig image', () => { + mockResolveAssetSource.mockReturnValue({ uri: 'mock://asset' }) + + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [ + new IconTextConfig(1 as any, new TextConfig('item 1')), + new IconTextConfig(2 as any, new TextConfig('item 2')), + ], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockResolveAssetSource).toHaveBeenCalledTimes(2) + expect(mockResolveAssetSource).toHaveBeenNthCalledWith(1, 1) + expect(mockResolveAssetSource).toHaveBeenNthCalledWith(2, 2) + }) + + it('forwards image: null when resolveAssetSource returns null (entry NOT dropped)', () => { + mockResolveAssetSource + .mockReturnValueOnce({ uri: 'mock://valid' }) + .mockReturnValueOnce(null) + + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [ + new IconTextConfig(1 as any, new TextConfig('valid')), + new IconTextConfig(2 as any, new TextConfig('null-resolve')), + ], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + expect(mockNativeModule.setIconTextConfigsMap).toHaveBeenCalledTimes(1) + const arg = mockNativeModule.setIconTextConfigsMap.mock.calls[0][0] + const entries = arg[IconTextsKey.securityConfirmationItems] + expect(entries).toHaveLength(2) + expect(entries[0].image).toBe('mock://valid') + expect(entries[1].image).toBeNull() + }) + + it('forwards image: null when resolveAssetSource returns empty URI (entry NOT dropped)', () => { + mockResolveAssetSource.mockReturnValue({ uri: '' }) + + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, new TextConfig('empty-uri'))], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + const arg = mockNativeModule.setIconTextConfigsMap.mock.calls[0][0] + const entries = arg[IconTextsKey.securityConfirmationItems] + expect(entries).toHaveLength(1) + expect(entries[0].image).toBeNull() + }) + + it('forwards the resolved URI string to the native module', () => { + mockResolveAssetSource.mockReturnValue({ uri: 'mock://icon-resolved' }) + + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(42 as any, new TextConfig('item'))], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + const arg = mockNativeModule.setIconTextConfigsMap.mock.calls[0][0] + expect(arg[IconTextsKey.securityConfirmationItems][0].image).toBe( + 'mock://icon-resolved', + ) + }) + + it('passes textConfig through unchanged', () => { + mockResolveAssetSource.mockReturnValue({ uri: 'mock://asset' }) + + const textConfig = new TextConfig('My Label', '#FF0000', 'CustomFont') + + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, textConfig)], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + const arg = mockNativeModule.setIconTextConfigsMap.mock.calls[0][0] + const sent = arg[IconTextsKey.securityConfirmationItems][0].textConfig + expect(sent.text).toBe('My Label') + expect(sent.textColor).toBe('#FF0000') + expect(sent.font).toBe('CustomFont') + }) + + it('serializes Map to a plain Record (not a Map instance) via bridgeSafe', () => { + mockResolveAssetSource.mockReturnValue({ uri: 'mock://asset' }) + + // The production code constructs `processedObj` as a plain `{}` before + // calling bridgeSafe, but leaves nested `textConfig` as the original + // `TextConfig` class instance. Only after bridgeSafe's + // JSON.parse(JSON.stringify(...)) round-trip does the nested instance + // lose its class identity. So we assert on the nested shape as well — + // that's what proves bridgeSafe actually ran. + const map = new Map([ + [ + IconTextsKey.securityConfirmationItems, + [new IconTextConfig(1 as any, new TextConfig('item'))], + ], + ]) + + WalletSdk.setIconTextConfigsMap(map) + + const arg = mockNativeModule.setIconTextConfigsMap.mock.calls[0][0] + expect(arg).not.toBeInstanceOf(Map) + expect(Object.getPrototypeOf(arg)).toBe(Object.prototype) + + const innerTextConfig = + arg[IconTextsKey.securityConfirmationItems][0].textConfig + expect(innerTextConfig).not.toBeInstanceOf(TextConfig) + expect(Object.getPrototypeOf(innerTextConfig)).toBe(Object.prototype) + }) +}) + +describe('WalletSdk.moveTaskToFront', () => { + it('delegates to the native module exactly once and does not throw', () => { + expect(() => WalletSdk.moveTaskToFront()).not.toThrow() + + expect(mockNativeModule.moveTaskToFront).toHaveBeenCalledTimes(1) + expect(mockNativeModule.moveTaskToFront).toHaveBeenCalledWith() + }) +}) + +describe('WalletSdk.moveRnTaskToFront', () => { + it('delegates to the native module exactly once and does not throw', () => { + expect(() => WalletSdk.moveRnTaskToFront()).not.toThrow() + + expect(mockNativeModule.moveRnTaskToFront).toHaveBeenCalledTimes(1) + expect(mockNativeModule.moveRnTaskToFront).toHaveBeenCalledWith() + }) +}) + +// --------------------------------------------------------------------------- +// setSecurityQuestions() +// --------------------------------------------------------------------------- + +describe('WalletSdk.setSecurityQuestions', () => { + beforeEach(() => { + mockNativeModule.setSecurityQuestions.mockClear() + }) + + it('normalizes mixed input shapes and delegates the result to the native module', () => { + // The public signature declares `SecurityQuestion[]`, but the runtime + // accepts tuples, capitalized keys, and bare primitives via + // toPlainSecurityQuestion. Cast through `unknown` to feed the looser + // shapes that the normalization path is meant to handle. + const mixed = [ + ['What is your pet name?', InputType.text], + { title: 'When were you born?', inputType: 1 }, + 'Bare string question', + ] as unknown as Parameters[0] + WalletSdk.setSecurityQuestions(mixed) + + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledWith([ + expect.objectContaining({ + title: 'What is your pet name?', + inputType: InputType.text, + }), + expect.objectContaining({ + title: 'When were you born?', + inputType: InputType.datePicker, + }), + expect.objectContaining({ + title: 'Bare string question', + inputType: InputType.text, + }), + ]) + }) + + it.each([ + ['null', null], + ['undefined', undefined], + ])( + 'forwards an empty array to the native module when given %s', + (_label, input) => { + // The `securityQuestions || []` guard only takes the `[]` branch for + // falsy input. A non-empty array (happy path) and an empty array are + // both truthy, so this is the sole case that exercises that branch. + WalletSdk.setSecurityQuestions( + input as unknown as Parameters< + typeof WalletSdk.setSecurityQuestions + >[0], + ) + + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledWith([]) + }, + ) + + it('forwards an empty array to the native module when given an empty array', () => { + WalletSdk.setSecurityQuestions([]) + + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledTimes(1) + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledWith([]) + }) + + it('swallows errors thrown by the native setSecurityQuestions and logs the thrown value', () => { + const nativeError = new Error('native boom') + mockNativeModule.setSecurityQuestions.mockImplementationOnce(() => { + throw nativeError + }) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + try { + expect(() => + WalletSdk.setSecurityQuestions([ + { title: 'Q?', inputType: InputType.text }, + ]), + ).not.toThrow() + expect(mockNativeModule.setSecurityQuestions).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + 'setSecurityQuestions failed:', + nativeError, + ) + } finally { + // Guarantee the console.error spy is restored even if an assertion + // above throws — otherwise the spy would leak to later tests in the + // file and silently swallow real error logs. + errorSpy.mockRestore() + } + }) +}) diff --git a/src/__test__/iosPerformLogoutBridge.test.ts b/src/__test__/iosPerformLogoutBridge.test.ts new file mode 100644 index 0000000..424abbd --- /dev/null +++ b/src/__test__/iosPerformLogoutBridge.test.ts @@ -0,0 +1,63 @@ +declare const __dirname: string +declare function require(moduleName: string): unknown + +const { readFileSync } = require('fs') as { + readFileSync: (path: string, encoding: 'utf8') => string +} +const { resolve } = require('path') as { + resolve: (...paths: string[]) => string +} + +function getAsyncFunctionBlock(source: string, name: string): string { + const startToken = `AsyncFunction("${name}")` + const startIndex = source.indexOf(startToken) + + if (startIndex === -1) { + throw new Error(`Could not find ${startToken} in iOS module source`) + } + + const remainder = source.slice(startIndex + startToken.length) + const nextFunctionMatch = remainder.match( + /\n\s*(?:AsyncFunction|Function)\("/, + ) + const endIndex = nextFunctionMatch?.index + + return source.slice( + startIndex, + endIndex === undefined + ? undefined + : startIndex + startToken.length + endIndex, + ) +} + +describe('iOS performLogout bridge', () => { + it('rejects the Promise when the native logout completion fails', () => { + const source = readFileSync( + resolve(__dirname, '../../ios/ProgrammablewalletRnSdkModule.swift'), + 'utf8', + ) + const performLogoutBlock = getAsyncFunctionBlock(source, 'performLogout') + + expect(performLogoutBlock).toMatch( + /WalletSdk\.shared\.performLogout\s*\(\s*provider:\s*socialProvider\s*\)\s*\{\s*logoutResult\s+in/, + ) + expect(performLogoutBlock).toMatch(/switch\s+logoutResult/) + expect(performLogoutBlock).toMatch(/case\s+\.success\s*:/) + expect(performLogoutBlock).toMatch(/promise\.resolve\s*\(\s*nil\s*\)/) + expect(performLogoutBlock).toMatch( + /case\s+\.failure\s*\(\s*let\s+error\s*\)/, + ) + expect(performLogoutBlock).toMatch( + /if\s+let\s+apiError\s*=\s*error\s+as\?\s*ApiError/, + ) + expect(performLogoutBlock).toMatch( + /promise\.reject\s*\(\s*String\s*\(\s*apiError\.errorCode\.rawValue\s*\)\s*,\s*self\._bridgePromiseErrorMessage\s*\(\s*apiError\s*\)\s*\)/, + ) + expect(performLogoutBlock).toMatch( + /let\s+nsError\s*=\s*error\s+as\s+NSError/, + ) + expect(performLogoutBlock).toMatch( + /promise\.reject\s*\(\s*String\s*\(\s*nsError\.code\s*\)\s*,\s*nsError\.localizedDescription\s*\)/, + ) + }) +}) diff --git a/src/__test__/utils/securityQuestionUtils.test.ts b/src/__test__/utils/securityQuestionUtils.test.ts index d24d5ef..70b60c2 100644 --- a/src/__test__/utils/securityQuestionUtils.test.ts +++ b/src/__test__/utils/securityQuestionUtils.test.ts @@ -16,140 +16,214 @@ * limitations under the License. */ -import { toPlainSecurityQuestion, normalizeInputType, getStringFromUnknown } from '../../utils/securityQuestionUtils' +import { + getStringFromUnknown, + normalizeInputType, + toPlainSecurityQuestion, +} from '../../utils/securityQuestionUtils' import { InputType } from '../../types' describe('securityQuestionUtils', () => { - describe('getStringFromUnknown', () => { - it('handles null and undefined', () => { - expect(getStringFromUnknown(null)).toBe('') - expect(getStringFromUnknown(undefined)).toBe('') - }) - - it('handles objects with name property', () => { - expect(getStringFromUnknown({ name: 'Test Object' })).toBe('Test Object') - }) - - it('uses toString for other values', () => { - expect(getStringFromUnknown(123)).toBe('123') - expect(getStringFromUnknown(true)).toBe('true') - }) - }) - - describe('normalizeInputType', () => { - it('returns InputType directly if already correct', () => { - expect(normalizeInputType(InputType.text)).toBe(InputType.text) - expect(normalizeInputType(InputType.datePicker)).toBe(InputType.datePicker) - }) - - it('handles string inputs', () => { - expect(normalizeInputType('text')).toBe(InputType.text) - expect(normalizeInputType('date')).toBe(InputType.datePicker) - expect(normalizeInputType('birthday')).toBe(InputType.datePicker) - }) - - it('handles numeric inputs', () => { - expect(normalizeInputType(0)).toBe(InputType.text) - expect(normalizeInputType(1)).toBe(InputType.datePicker) - }) - - it('handles null and undefined', () => { - expect(normalizeInputType(null)).toBeUndefined() - expect(normalizeInputType(undefined)).toBeUndefined() - }) - - it('defaults to text for unexpected values', () => { - expect(normalizeInputType({})).toBe(InputType.text) - expect(normalizeInputType([])).toBe(InputType.text) - expect(normalizeInputType(2)).toBe(InputType.text) // not a valid enum value - }) - }) - - describe('toPlainSecurityQuestion', () => { - it('handles array input format correctly', () => { - // Test tuple format [title, inputType] - const result1 = toPlainSecurityQuestion(['What is your pet name?', InputType.text]) - expect(result1.title).toBe('What is your pet name?') - expect(result1.inputType).toBe(InputType.text) - - const result2 = toPlainSecurityQuestion(['When is your birthday?', InputType.datePicker]) - expect(result2.title).toBe('When is your birthday?') - expect(result2.inputType).toBe(InputType.datePicker) - - // Test string input type - const result3 = toPlainSecurityQuestion(['Birth date?', 'date']) - expect(result3.title).toBe('Birth date?') - expect(result3.inputType).toBe(InputType.datePicker) - - // Test numeric input type - const result4 = toPlainSecurityQuestion(['Question?', 1]) - expect(result4.title).toBe('Question?') - expect(result4.inputType).toBe(InputType.datePicker) - }) - - it('handles object input format correctly', () => { - // Standard object format - const result1 = toPlainSecurityQuestion({ title: 'What is your pet name?', inputType: InputType.text }) - expect(result1.title).toBe('What is your pet name?') - expect(result1.inputType).toBe(InputType.text) - - // Capitalized property names - const result2 = toPlainSecurityQuestion({ Title: 'Question with capital T', InputType: InputType.datePicker }) - expect(result2.title).toBe('Question with capital T') - expect(result2.inputType).toBe(InputType.datePicker) - - // String input type - const result3 = toPlainSecurityQuestion({ title: 'Birth date?', inputType: 'date' }) - expect(result3.title).toBe('Birth date?') - expect(result3.inputType).toBe(InputType.datePicker) - - // Using getter methods - const objWithGetters = { - getTitle: () => 'Title from getter', - getInputType: () => InputType.datePicker - } - const result4 = toPlainSecurityQuestion(objWithGetters) - expect(result4.title).toBe('Title from getter') - expect(result4.inputType).toBe(InputType.datePicker) - }) - - it('handles string and primitive input correctly', () => { - // Direct string - const result1 = toPlainSecurityQuestion('Simple question string') - expect(result1.title).toBe('Simple question string') - expect(result1.inputType).toBe(InputType.text) // Default is text - - // Number - const result2 = toPlainSecurityQuestion(123) - expect(result2.title).toBe('123') - expect(result2.inputType).toBe(InputType.text) - - // Boolean - const result3 = toPlainSecurityQuestion(true) - expect(result3.title).toBe('true') - expect(result3.inputType).toBe(InputType.text) - }) - - it('handles empty or falsy input correctly', () => { - // Empty array - const result1 = toPlainSecurityQuestion([]) - expect(result1.title).toBe('') - expect(result1.inputType).toBe(InputType.text) - - // Empty object - const result2 = toPlainSecurityQuestion({}) - expect(result2.title).toBe('') - expect(result2.inputType).toBe(InputType.text) - - // null - const result3 = toPlainSecurityQuestion(null) - expect(result3.title).toBe('') - expect(result3.inputType).toBe(InputType.text) - - // undefined - const result4 = toPlainSecurityQuestion(undefined) - expect(result4.title).toBe('') - expect(result4.inputType).toBe(InputType.text) - }) + describe('getStringFromUnknown', () => { + it('returns an empty string for nullish values', () => { + expect(getStringFromUnknown(null)).toBe('') + expect(getStringFromUnknown(undefined)).toBe('') }) + + it('returns the name property for objects with a string name', () => { + expect(getStringFromUnknown({ name: 'date' })).toBe('date') + }) + + it('falls back to toString for values without a string name', () => { + expect(getStringFromUnknown(42)).toBe('42') + expect( + getStringFromUnknown({ + toString: () => 'custom date value', + }), + ).toBe('custom date value') + }) + }) + + describe('normalizeInputType', () => { + let warnSpy: jest.SpyInstance + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('returns undefined for nullish values', () => { + expect(normalizeInputType(null)).toBeUndefined() + expect(normalizeInputType(undefined)).toBeUndefined() + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('returns enum input types unchanged', () => { + expect(normalizeInputType(InputType.text)).toBe(InputType.text) + expect(normalizeInputType(InputType.datePicker)).toBe( + InputType.datePicker, + ) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('maps date-like strings to datePicker', () => { + expect(normalizeInputType('birthday')).toBe(InputType.datePicker) + expect(normalizeInputType('dateOfBirth')).toBe(InputType.datePicker) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('maps text strings to text without warning', () => { + expect(normalizeInputType('text')).toBe(InputType.text) + expect(normalizeInputType('')).toBe(InputType.text) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('warns once and defaults unexpected strings to text', () => { + expect(normalizeInputType('other')).toBe(InputType.text) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected inputType string: "other"'), + ) + }) + + it('maps numeric input types without warning', () => { + expect(normalizeInputType(0)).toBe(InputType.text) + expect(normalizeInputType(1)).toBe(InputType.datePicker) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('warns once and defaults unexpected numbers to text', () => { + expect(normalizeInputType(2)).toBe(InputType.text) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected inputType number: 2'), + ) + }) + + it('maps objects with date hints to datePicker', () => { + expect( + normalizeInputType({ + toString: () => 'datePicker', + }), + ).toBe(InputType.datePicker) + expect(normalizeInputType({ name: 'dateOfBirth' })).toBe( + InputType.datePicker, + ) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('defaults named objects without a date hint to text', () => { + expect(normalizeInputType({ name: 'city' })).toBe(InputType.text) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected inputType: object'), + ) + }) + + it('warns once and defaults arbitrary objects to text', () => { + expect(normalizeInputType({})).toBe(InputType.text) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected inputType: object'), + ) + }) + }) + + describe('toPlainSecurityQuestion', () => { + it('handles array input format correctly', () => { + const textQuestion = toPlainSecurityQuestion([ + 'What is your pet name?', + InputType.text, + ]) + expect(textQuestion.title).toBe('What is your pet name?') + expect(textQuestion.inputType).toBe(InputType.text) + + const dateQuestion = toPlainSecurityQuestion([ + 'When is your birthday?', + InputType.datePicker, + ]) + expect(dateQuestion.title).toBe('When is your birthday?') + expect(dateQuestion.inputType).toBe(InputType.datePicker) + + const stringInputTypeQuestion = toPlainSecurityQuestion([ + 'Birth date?', + 'date', + ]) + expect(stringInputTypeQuestion.title).toBe('Birth date?') + expect(stringInputTypeQuestion.inputType).toBe(InputType.datePicker) + + const numericInputTypeQuestion = toPlainSecurityQuestion(['Question?', 1]) + expect(numericInputTypeQuestion.title).toBe('Question?') + expect(numericInputTypeQuestion.inputType).toBe(InputType.datePicker) + }) + + it('handles object input format correctly', () => { + const textQuestion = toPlainSecurityQuestion({ + title: 'What is your pet name?', + inputType: InputType.text, + }) + expect(textQuestion.title).toBe('What is your pet name?') + expect(textQuestion.inputType).toBe(InputType.text) + + const capitalizedQuestion = toPlainSecurityQuestion({ + Title: 'Question with capital T', + InputType: InputType.datePicker, + }) + expect(capitalizedQuestion.title).toBe('Question with capital T') + expect(capitalizedQuestion.inputType).toBe(InputType.datePicker) + + const stringInputTypeQuestion = toPlainSecurityQuestion({ + title: 'Birth date?', + inputType: 'date', + }) + expect(stringInputTypeQuestion.title).toBe('Birth date?') + expect(stringInputTypeQuestion.inputType).toBe(InputType.datePicker) + + const getterQuestion = toPlainSecurityQuestion({ + getTitle: () => 'Title from getter', + getInputType: () => InputType.datePicker, + }) + expect(getterQuestion.title).toBe('Title from getter') + expect(getterQuestion.inputType).toBe(InputType.datePicker) + }) + + it('handles string and primitive input correctly', () => { + const stringQuestion = toPlainSecurityQuestion('Simple question string') + expect(stringQuestion.title).toBe('Simple question string') + expect(stringQuestion.inputType).toBe(InputType.text) + + const numberQuestion = toPlainSecurityQuestion(123) + expect(numberQuestion.title).toBe('123') + expect(numberQuestion.inputType).toBe(InputType.text) + + const booleanQuestion = toPlainSecurityQuestion(true) + expect(booleanQuestion.title).toBe('true') + expect(booleanQuestion.inputType).toBe(InputType.text) + }) + + it('handles empty or falsy input correctly', () => { + const emptyArrayQuestion = toPlainSecurityQuestion([]) + expect(emptyArrayQuestion.title).toBe('') + expect(emptyArrayQuestion.inputType).toBe(InputType.text) + + const emptyObjectQuestion = toPlainSecurityQuestion({}) + expect(emptyObjectQuestion.title).toBe('') + expect(emptyObjectQuestion.inputType).toBe(InputType.text) + + const nullQuestion = toPlainSecurityQuestion(null) + expect(nullQuestion.title).toBe('') + expect(nullQuestion.inputType).toBe(InputType.text) + + const undefinedQuestion = toPlainSecurityQuestion(undefined) + expect(undefinedQuestion.title).toBe('') + expect(undefinedQuestion.inputType).toBe(InputType.text) + }) + }) }) diff --git a/src/types.ts b/src/types.ts index 9571c82..1fd97e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,9 +29,7 @@ export type CirclePwEventPayload = { name: ExecuteEvent } -export type CirclePwSuccessPayload = { - [key: string]: SuccessResult -} +export type CirclePwSuccessPayload = SuccessResult export type CirclePwErrorPayload = { code?: string | number