diff --git a/.gitignore b/.gitignore index 356417c19..f042551bd 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ ios/generated android/generated # Iterable +.env.local .env .xcode.env.local coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e4a71be..6c9fc8682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ +## 2.2.0-alpha.0 (2025-10-21) + +### Updates +- Updated Android SDK version to [3.6.1](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.6.1) +- Updated iOS SDK version to [6.6.1](https://github.com/Iterable/swift-sdk/releases/tag/6.6.1) +- Added JWT Capabilities: + - Added `Iterable.authhManager`, which manages the authentication flow + - Added `IterableRetryBackoff` and `IterableAuthFailureReason` enums + - Added `onJWTError` and `retryPolicy` for control over JWT flow +- Moved all native calls to `IterableApi.ts` +- Added JWT example to our example app + +### Fixes +- Created a standalone `IterableLogger` to avoid circular dependencies + ## 2.1.0-beta.1 -## Fixes +### Fixes - Add Temporary fix for circular paths, which break expo ([9c09743](https://github.com/Iterable/react-native-sdk/commit/9c09743)) ## 2.1.0-beta.0 diff --git a/Iterable-React-Native-SDK.podspec b/Iterable-React-Native-SDK.podspec index 884417e1f..0d023409f 100644 --- a/Iterable-React-Native-SDK.podspec +++ b/Iterable-React-Native-SDK.podspec @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.private_header_files = "ios/**/*.h" # Load Iterables iOS SDK as a dependency - s.dependency "Iterable-iOS-SDK", "6.5.4.1" + s.dependency "Iterable-iOS-SDK", "6.6.1" # Basic Swift support s.pod_target_xcconfig = { diff --git a/android/build.gradle b/android/build.gradle index a4f938ffc..d546cce98 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -105,7 +105,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - api "com.iterable:iterableapi:3.5.2" + api "com.iterable:iterableapi:3.6.1" // api project(":iterableapi") // links to local android SDK repo rather than by release } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 981358be6..57cf9a0b8 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -18,6 +18,7 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.iterable.iterableapi.AuthFailure; import com.iterable.iterableapi.InboxSessionManager; import com.iterable.iterableapi.IterableAction; import com.iterable.iterableapi.IterableActionContext; @@ -572,6 +573,26 @@ public String onAuthTokenRequested() { } } + @Override + public void onAuthFailure(AuthFailure authFailure) { + // Create a JSON object for the authFailure object + JSONObject messageJson = new JSONObject(); + try { + messageJson.put("userKey", authFailure.userKey); + messageJson.put("failedAuthToken", authFailure.failedAuthToken); + messageJson.put("failedRequestTime", authFailure.failedRequestTime); + messageJson.put("failureReason", authFailure.failureReason.name()); + WritableMap eventData = Serialization.convertJsonToMap(messageJson); + sendEvent(EventName.handleAuthFailureCalled.name(), eventData); + } catch (JSONException e) { + IterableLogger.v(TAG, "Failed to set authToken"); + } + } + + public void pauseAuthRetries(boolean pauseRetry) { + IterableApi.getInstance().pauseAuthRetries(pauseRetry); + } + @Override public void onTokenRegistrationSuccessful(String authToken) { IterableLogger.v(TAG, "authToken successfully set"); @@ -579,12 +600,6 @@ public void onTokenRegistrationSuccessful(String authToken) { sendEvent(EventName.handleAuthSuccessCalled.name(), null); } - @Override - public void onTokenRegistrationFailed(Throwable object) { - IterableLogger.v(TAG, "Failed to set authToken"); - sendEvent(EventName.handleAuthFailureCalled.name(), null); - } - public void addListener(String eventName) { // Keep: Required for RN built in Event Emitter Calls. } diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 3a1f536a6..92c549554 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -24,6 +24,7 @@ import com.iterable.iterableapi.IterableInboxSession; import com.iterable.iterableapi.IterableLogger; import com.iterable.iterableapi.RNIterableInternal; +import com.iterable.iterableapi.RetryPolicy; import org.json.JSONArray; import org.json.JSONException; @@ -94,7 +95,7 @@ static CommerceItem commerceItemFromMap(JSONObject itemMap) throws JSONException categories[i] = categoriesArray.getString(i); } } - + return new CommerceItem(itemMap.getString("id"), itemMap.getString("name"), itemMap.getDouble("price"), @@ -216,9 +217,17 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setDataRegion(iterableDataRegion); } - - if (iterableContextJSON.has("encryptionEnforced")) { - configBuilder.setEncryptionEnforced(iterableContextJSON.optBoolean("encryptionEnforced")); + + if (iterableContextJSON.has("retryPolicy")) { + JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); + int maxRetry = retryPolicyJson.getInt("maxRetry"); + long retryInterval = retryPolicyJson.getLong("retryInterval"); + String retryBackoff = retryPolicyJson.getString("retryBackoff"); + RetryPolicy.Type retryPolicyType = RetryPolicy.Type.LINEAR; + if (retryBackoff.equals("EXPONENTIAL")) { + retryPolicyType = RetryPolicy.Type.EXPONENTIAL; + } + configBuilder.setAuthRetryPolicy(new RetryPolicy(maxRetry, retryInterval, retryPolicyType)); } return configBuilder; @@ -257,7 +266,13 @@ static JSONObject actionContextToJson(IterableActionContext iterableActionContex } static IterableInboxSession.Impression inboxImpressionFromMap(JSONObject impressionMap) throws JSONException { - return new IterableInboxSession.Impression(impressionMap.getString("messageId"), + // Add null check for messageId to prevent NullPointerException + String messageId = impressionMap.optString("messageId", null); + if (messageId == null || messageId.isEmpty()) { + throw new JSONException("messageId is null or empty"); + } + + return new IterableInboxSession.Impression(messageId, impressionMap.getBoolean("silentInbox"), impressionMap.optInt("displayCount", 0), (float) impressionMap.optDouble("duration", 0) @@ -271,8 +286,13 @@ static List impressionsFromReadableArray(Readab JSONArray impressionJsonArray = convertArrayToJson(array); for (int i = 0; i < impressionJsonArray.length(); i++) { - JSONObject impressionObj = impressionJsonArray.getJSONObject(i); - list.add(inboxImpressionFromMap(impressionObj)); + try { + JSONObject impressionObj = impressionJsonArray.getJSONObject(i); + list.add(inboxImpressionFromMap(impressionObj)); + } catch (JSONException e) { + // Skip invalid entries instead of failing completely + IterableLogger.w(TAG, "Skipping invalid impression at index " + i + ": " + e.getLocalizedMessage()); + } } } catch (JSONException e) { IterableLogger.e(TAG, "Failed converting to JSONObject"); @@ -286,7 +306,7 @@ static List impressionsFromReadableArray(Readab // --------------------------------------------------------------------------------------- // region React Native JSON conversion methods // obtained from https://gist.github.com/viperwarp/2beb6bbefcc268dee7ad - + static WritableMap convertJsonToMap(JSONObject jsonObject) throws JSONException { WritableMap map = new WritableNativeMap(); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index 4386e0d7f..f145bab10 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -7,6 +7,8 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.iterable.iterableapi.AuthFailure; +import com.iterable.iterableapi.IterableLogger; public class RNIterableAPIModule extends NativeRNIterableAPISpec { private final ReactApplicationContext reactContext; @@ -217,6 +219,11 @@ public void passAlongAuthToken(@Nullable String authToken) { moduleImpl.passAlongAuthToken(authToken); } + @Override + public void pauseAuthRetries(boolean pauseRetry) { + moduleImpl.pauseAuthRetries(pauseRetry); + } + public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { moduleImpl.sendEvent(eventName, eventData); } diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index 27b04ea17..c3a72339b 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -223,6 +223,11 @@ public void passAlongAuthToken(@Nullable String authToken) { moduleImpl.passAlongAuthToken(authToken); } + @ReactMethod + public void pauseAuthRetries(boolean pauseRetry) { + moduleImpl.pauseAuthRetries(pauseRetry); + } + public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { moduleImpl.sendEvent(eventName, eventData); diff --git a/example/.env.example b/example/.env.example index e1efc4169..5b5663df6 100644 --- a/example/.env.example +++ b/example/.env.example @@ -9,11 +9,19 @@ # 4. Fill in the following fields: # - Name: A descriptive name for the API key # - Type: Mobile -# - JWT authentication: Leave **unchecked** (IMPORTANT) +# - JWT authentication: Whether or not you want to use JWT # 5. Click "Create API Key" -# 6. Copy the generated API key -# 7. Replace the placeholder text next to `ITBL_API_KEY=` with the copied API key +# 6. Copy the generated API key and replace the placeholder text next to +# `ITBL_API_KEY=` with the copied API key +# 7. If you chose to enable JWT authentication, copy the JWT secret and and +# replace the placeholder text next to `ITBL_JWT_SECRET=` with the copied +# JWT secret ITBL_API_KEY=replace_this_with_your_iterable_api_key +# Your JWT Secret, created when making your API key (see above) +ITBL_JWT_SECRET=replace_this_with_your_jwt_secret +# Is your api token JWT Enabled? +# Must be set to 'true' to enable JWT authentication +ITBL_IS_JWT_ENABLED=true # Your Iterable user ID or email address -ITBL_ID=replace_this_with_your_user_id_or_email \ No newline at end of file +ITBL_ID=replace_this_with_your_user_id_or_email diff --git a/example/README.md b/example/README.md index 4ba5d0e6d..819bc6826 100644 --- a/example/README.md +++ b/example/README.md @@ -23,7 +23,8 @@ _example app directory_. To do so, run the following: ```bash cd ios -pod install +bundle install +bundle exec pod install ``` Once this is done, `cd` back into the _example app directory_: @@ -40,12 +41,18 @@ In it, you will find: ```shell ITBL_API_KEY=replace_this_with_your_iterable_api_key +ITBL_JWT_SECRET=replace_this_with_your_jwt_secret +ITBL_IS_JWT_ENABLED=true ITBL_ID=replace_this_with_your_user_id_or_email ``` -Replace `replace_this_with_your_iterable_api_key` with your _mobile_ Iterable API key, -and replace `replace_this_with_your_user_id_or_email` with the email or user id -that you use to log into Iterable. +- Replace `replace_this_with_your_iterable_api_key` with your **_mobile_ +Iterable API key** +- Replace `replace_this_with_your_jwt_secret` with your **JWT Secret** (if you +have a JWT-enabled API key) +- Set `ITBL_IS_JWT_ENABLED` to true if you have a JWT-enabled key, and false if you do not. +- Replace `replace_this_with_your_user_id_or_email` with the **email or user +id** that you use to log into Iterable. Follow the steps below if you do not have a mobile Iterable API key. @@ -54,12 +61,12 @@ To add an API key, do the following: 1. Sign into your Iterable account 2. Go to [Integrations > API Keys](https://app.iterable.com/settings/apiKeys) 3. Click "New API Key" in the top right corner - 4. Fill in the followsing fields: + 4. Fill in the following fields: - Name: A descriptive name for the API key - Type: Mobile - - JWT authentication: Leave **unchecked** (IMPORTANT) + - JWT authentication: Check to enable JWT authentication. If enabled, will need to create a [JWT generator](https://support.iterable.com/hc/en-us/articles/360050801231-JWT-Enabled-API-Keys#sample-python-code-for-jwt-generation) to generate the JWT token. 5. Click "Create API Key" - 6. Copy the generated API key + 6. Copy the generated API key and JWT secret into your _.env_ file ## Step 3: Start the Metro Server diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java b/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java new file mode 100644 index 000000000..4fa760e33 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java @@ -0,0 +1,106 @@ +package com.iterable; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Base64.Encoder; + +/** +* Utility class to generate JWTs for use with the Iterable API +* +* @author engineering@iterable.com +*/ +public class IterableJwtGenerator { + static Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + + private static final String algorithm = "HmacSHA256"; + + // Iterable enforces a 1-year maximum token lifetime + private static final Duration maxTokenLifetime = Duration.ofDays(365); + + private static long millisToSeconds(long millis) { + return millis / 1000; + } + + private static final String encodedHeader = encoder.encodeToString( + "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8) + ); + + /** + * Generates a JWT from the provided secret, header, and payload. Does not + * validate the header or payload. + * + * @param secret Your organization's shared secret with Iterable + * @param payload The JSON payload + * + * @return a signed JWT + */ + public static String generateToken(String secret, String payload) { + try { + String encodedPayload = encoder.encodeToString( + payload.getBytes(StandardCharsets.UTF_8) + ); + String encodedHeaderAndPayload = encodedHeader + "." + encodedPayload; + + // HMAC setup + Mac hmac = Mac.getInstance(algorithm); + SecretKeySpec keySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), algorithm + ); + hmac.init(keySpec); + + String signature = encoder.encodeToString( + hmac.doFinal( + encodedHeaderAndPayload.getBytes(StandardCharsets.UTF_8) + ) + ); + + return encodedHeaderAndPayload + "." + signature; + + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + + /** + * Generates a JWT (issued now, expires after the provided duration). + * + * @param secret Your organization's shared secret with Iterable. + * @param duration The token's expiration time. Up to one year. + * @param email The email to included in the token, or null. + * @param userId The userId to include in the token, or null. + * + * @return A JWT string + */ + public static String generateToken( + String secret, Duration duration, String email, String userId) { + + if (duration.compareTo(maxTokenLifetime) > 0) + throw new IllegalArgumentException( + "Duration must be one year or less." + ); + + if ((userId != null && email != null) || (userId == null && email == null)) + throw new IllegalArgumentException( + "The token must include a userId or email, but not both." + ); + + long now = millisToSeconds(System.currentTimeMillis()); + + String payload; + if (userId != null) + payload = String.format( + "{ \"userId\": \"%s\", \"iat\": %d, \"exp\": %d }", + userId, now, now + millisToSeconds(duration.toMillis()) + ); + else + payload = String.format( + "{ \"email\": \"%s\", \"iat\": %d, \"exp\": %d }", + email, now, now + millisToSeconds(duration.toMillis()) + ); + + return generateToken(secret, payload); + } +} diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt new file mode 100644 index 000000000..8a6f7f018 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt @@ -0,0 +1,37 @@ +package iterable.reactnativesdk.example + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.Promise +import com.iterable.IterableJwtGenerator +import java.time.Duration + +class JwtTokenModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return NAME + } + + @ReactMethod + fun generateJwtToken( + secret: String, + durationMs: Double, + email: String?, + userId: String?, + promise: Promise + ) { + try { + val duration = Duration.ofMillis(durationMs.toLong()) + val token = IterableJwtGenerator.generateToken(secret, duration, email, userId) + promise.resolve(token) + } catch (e: Exception) { + promise.reject("JWT_GENERATION_ERROR", e.message, e) + } + } + + companion object { + const val NAME = "JwtTokenModule" + } +} + diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt new file mode 100644 index 000000000..e05384909 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt @@ -0,0 +1,34 @@ +package iterable.reactnativesdk.example + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class JwtTokenPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == JwtTokenModule.NAME) { + JwtTokenModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[JwtTokenModule.NAME] = ReactModuleInfo( + JwtTokenModule.NAME, + JwtTokenModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + true, // hasConstants + false // isCxxModule + ) + moduleInfos + } + } +} + diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt index d0fba2035..9c004c88b 100644 --- a/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt @@ -20,6 +20,7 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: // add(MyReactNativePackage()) + add(JwtTokenPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/example/ios/IterableJwtGenerator.swift b/example/ios/IterableJwtGenerator.swift new file mode 100644 index 000000000..8e4a9cbe7 --- /dev/null +++ b/example/ios/IterableJwtGenerator.swift @@ -0,0 +1,95 @@ +// +// IterableJwtGenerator.swift +// ReactNativeSdkExample +// +// Utility class to generate JWTs for use with the Iterable API +// + +import CryptoKit +import Foundation + +@objcMembers public final class IterableJwtGenerator: NSObject { + + private struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + /// Base64 URL encode without padding (URL-safe base64 encoding for JWT) + private static func urlEncodedBase64(_ data: Data) -> String { + let base64 = data.base64EncodedString() + return + base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Generic JWT generation helper that works with any Encodable payload + private static func generateJwt(secret: String, payload: T) -> String { + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = urlEncodedBase64(headerJsonData) + + let payloadJsonData = try! JSONEncoder().encode(payload) + let payloadBase64 = urlEncodedBase64(payloadJsonData) + + let toSign = Data((headerBase64 + "." + payloadBase64).utf8) + + if #available(iOS 13.0, *) { + let privateKey = SymmetricKey(data: Data(secret.utf8)) + let signature = HMAC.authenticationCode(for: toSign, using: privateKey) + let signatureBase64 = urlEncodedBase64(Data(signature)) + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + + public static func generateJwtForEmail(secret: String, iat: Int, exp: Int, email: String) + -> String + { + struct Payload: Encodable { + var email: String + var iat: Int + var exp: Int + } + + return generateJwt(secret: secret, payload: Payload(email: email, iat: iat, exp: exp)) + } + + public static func generateJwtForUserId(secret: String, iat: Int, exp: Int, userId: String) + -> String + { + struct Payload: Encodable { + var userId: String + var iat: Int + var exp: Int + } + + return generateJwt(secret: secret, payload: Payload(userId: userId, iat: iat, exp: exp)) + } + + public static func generateToken( + secret: String, durationMs: Int64, email: String?, userId: String? + ) throws -> String { + // Convert durationMs from milliseconds to seconds + let durationSeconds = Double(durationMs) / 1000.0 + let currentTime = Date().timeIntervalSince1970 + + if userId != nil { + return generateJwtForUserId( + secret: secret, iat: Int(currentTime), + exp: Int(currentTime + durationSeconds), userId: userId!) + } else if email != nil { + return generateJwtForEmail( + secret: secret, iat: Int(currentTime), + exp: Int(currentTime + durationSeconds), email: email!) + } else { + throw NSError( + domain: "JWTGenerator", code: 6, + userInfo: [NSLocalizedDescriptionKey: "No email or userId provided"]) + } + } +} diff --git a/example/ios/JwtTokenModule.mm b/example/ios/JwtTokenModule.mm new file mode 100644 index 000000000..10390bc43 --- /dev/null +++ b/example/ios/JwtTokenModule.mm @@ -0,0 +1,25 @@ +// +// JwtTokenModule.m +// ReactNativeSdkExample +// +// React Native module bridge for JWT token generation +// + +#import + +@interface RCT_EXTERN_MODULE(JwtTokenModule, NSObject) + +RCT_EXTERN_METHOD(generateJwtToken:(NSString *)secret + durationMs:(double)durationMs + email:(NSString *)email + userId:(NSString *)userId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +@end + diff --git a/example/ios/JwtTokenModule.swift b/example/ios/JwtTokenModule.swift new file mode 100644 index 000000000..5c121143b --- /dev/null +++ b/example/ios/JwtTokenModule.swift @@ -0,0 +1,40 @@ +// +// JwtTokenModule.swift +// ReactNativeSdkExample +// +// React Native module to generate JWT tokens +// + +import Foundation +import React + +@objc(JwtTokenModule) +class JwtTokenModule: NSObject { + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc + func generateJwtToken( + _ secret: String, + durationMs: Double, + email: String?, + userId: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + do { + let token = try IterableJwtGenerator.generateToken( + secret: secret, + durationMs: Int64(durationMs), + email: email, + userId: userId + ) + resolve(token) + } catch { + reject("JWT_GENERATION_ERROR", error.localizedDescription, error) + } + } +} diff --git a/example/ios/ReactNativeSdkExample-Bridging-Header.h b/example/ios/ReactNativeSdkExample-Bridging-Header.h index 339994e93..856694030 100644 --- a/example/ios/ReactNativeSdkExample-Bridging-Header.h +++ b/example/ios/ReactNativeSdkExample-Bridging-Header.h @@ -2,3 +2,6 @@ // Use this file to import your target's public headers that you would like to // expose to Swift. // + +#import +#import diff --git a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj index fe2da2b97..766c9b681 100644 --- a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj +++ b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj @@ -10,8 +10,11 @@ 00E356F31AD99517003FC87E /* ReactNativeSdkExampleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* ReactNativeSdkExampleTests.m */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 779227342DFA3FB500D69EC0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779227332DFA3FB500D69EC0 /* AppDelegate.swift */; }; - 77F63EC390061314C0718D51 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F395BEFC7809290D1773C84F /* libPods-ReactNativeSdkExample.a */; }; + 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */; }; + 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */; }; + 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + 81F6A9EA0E1CCC1AD730C5D9 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */; }; A3A40C20801B8F02005FA4C0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */; }; /* End PBXBuildFile section */ @@ -34,14 +37,17 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = ReactNativeSdkExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeSdkExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeSdkExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 627A5082522E8122626A42E9 /* Pods-ReactNativeSdkExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.debug.xcconfig"; sourceTree = ""; }; + 3A95ED4563D4389808EDEA8F /* Pods-ReactNativeSdkExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.debug.xcconfig"; sourceTree = ""; }; + 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeSdkExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 779227312DFA3FB500D69EC0 /* ReactNativeSdkExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExample-Bridging-Header.h"; sourceTree = ""; }; 779227322DFA3FB500D69EC0 /* ReactNativeSdkExampleTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExampleTests-Bridging-Header.h"; sourceTree = ""; }; 779227332DFA3FB500D69EC0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ReactNativeSdkExample/AppDelegate.swift; sourceTree = ""; }; + 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableJwtGenerator.swift; sourceTree = ""; }; + 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = JwtTokenModule.mm; sourceTree = ""; }; + 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JwtTokenModule.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ReactNativeSdkExample/LaunchScreen.storyboard; sourceTree = ""; }; - C37A515B34C484F156F48110 /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; + EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; - F395BEFC7809290D1773C84F /* libPods-ReactNativeSdkExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeSdkExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,7 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 77F63EC390061314C0718D51 /* libPods-ReactNativeSdkExample.a in Frameworks */, + 81F6A9EA0E1CCC1AD730C5D9 /* libPods-ReactNativeSdkExample.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -99,7 +105,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - F395BEFC7809290D1773C84F /* libPods-ReactNativeSdkExample.a */, + 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */, ); name = Frameworks; sourceTree = ""; @@ -114,6 +120,9 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */, + 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */, + 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */, 13B07FAE1A68108700A75B9A /* ReactNativeSdkExample */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* ReactNativeSdkExampleTests */, @@ -138,8 +147,8 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - 627A5082522E8122626A42E9 /* Pods-ReactNativeSdkExample.debug.xcconfig */, - C37A515B34C484F156F48110 /* Pods-ReactNativeSdkExample.release.xcconfig */, + 3A95ED4563D4389808EDEA8F /* Pods-ReactNativeSdkExample.debug.xcconfig */, + EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -169,13 +178,13 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "ReactNativeSdkExample" */; buildPhases = ( - 00A09C8D745F4A4962CFCB16 /* [CP] Check Pods Manifest.lock */, + B07642200E1BCDE7A80934E9 /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 70E3A2A47E764F7A78602595 /* [CP] Embed Pods Frameworks */, - EDF40E5EF2B0A60C77B1B71B /* [CP] Copy Pods Resources */, + 756F1571292F7FB66FB0F625 /* [CP] Embed Pods Frameworks */, + C5D9D662E100C568A4F9922D /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -244,28 +253,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 00A09C8D745F4A4962CFCB16 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-ReactNativeSdkExample-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -282,41 +269,50 @@ shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; - 24A6D3DBDA584D8F55796A6D /* [CP] Copy Pods Resources */ = { + 756F1571292F7FB66FB0F625 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 89B6BEF2485B9536DDD45973 /* [CP] Embed Pods Frameworks */ = { + B07642200E1BCDE7A80934E9 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ReactNativeSdkExample-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - EDF40E5EF2B0A60C77B1B71B /* [CP] Copy Pods Resources */ = { + C5D9D662E100C568A4F9922D /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -324,10 +320,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources.sh\"\n"; @@ -349,6 +349,9 @@ buildActionMask = 2147483647; files = ( 779227342DFA3FB500D69EC0 /* AppDelegate.swift in Sources */, + 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */, + 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */, + 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -423,7 +426,7 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 627A5082522E8122626A42E9 /* Pods-ReactNativeSdkExample.debug.xcconfig */; + baseConfigurationReference = 3A95ED4563D4389808EDEA8F /* Pods-ReactNativeSdkExample.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -453,7 +456,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C37A515B34C484F156F48110 /* Pods-ReactNativeSdkExample.release.xcconfig */; + baseConfigurationReference = EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -552,10 +555,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -628,10 +628,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/example/ios/ReactNativeSdkExample/AppDelegate.swift b/example/ios/ReactNativeSdkExample/AppDelegate.swift index 5b9504eb5..677a4fa9d 100644 --- a/example/ios/ReactNativeSdkExample/AppDelegate.swift +++ b/example/ios/ReactNativeSdkExample/AppDelegate.swift @@ -5,10 +5,13 @@ // Created by Loren Posen on 6/11/25. // +import UIKit import React -import ReactAppDependencyProvider import React_RCTAppDelegate -import UIKit +import ReactAppDependencyProvider +import UserNotifications + +import IterableSDK @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -21,6 +24,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + ITBInfo() + let delegate = ReactNativeDelegate() let factory = RCTReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider() @@ -36,8 +41,65 @@ class AppDelegate: UIResponder, UIApplicationDelegate { launchOptions: launchOptions ) + setupUserNotificationCenter() + return true } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + ITBInfo() + IterableAPI.register(token: deviceToken) + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError + error: Error) { + ITBInfo("error: \(error)") + } + + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + ITBInfo() + IterableAppIntegration.application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completionHandler) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + ITBInfo() + guard let url = userActivity.webpageURL else { + return false + } + + return IterableAPI.handle(universalLink: url) + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + ITBInfo() + return RCTLinkingManager.application(app, open: url, options: options) + } + + private func setupUserNotificationCenter() { + UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().getNotificationSettings { settings in + if settings.authorizationStatus != .authorized { + ITBInfo("Not authorized") + // not authorized, ask for permission + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, _ in + ITBInfo("auth: \(success)") + if success { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + // TODO: Handle error etc. + } + } else { + // already authorized + ITBInfo("Already authorized") + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + } } class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { @@ -46,10 +108,22 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { } override func bundleURL() -> URL? { - #if DEBUG - RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") - #else - Bundle.main.url(forResource: "main", withExtension: "jsbundle") - #endif +#if DEBUG + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + // App is running in the foreground + public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .badge, .sound]) + } + + // The method will be called on the delegate when the user responded to the notification by opening the application, dismissing the notification or choosing a UNNotificationAction. The delegate must be set before the application returns from applicationDidFinishLaunching:. + public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + IterableAppIntegration.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler) } } diff --git a/example/src/NativeJwtTokenModule.ts b/example/src/NativeJwtTokenModule.ts new file mode 100644 index 000000000..464cdc37b --- /dev/null +++ b/example/src/NativeJwtTokenModule.ts @@ -0,0 +1,45 @@ +import { NativeModules, TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + generateJwtToken( + secret: string, + durationMs: number, + email: string | null, + userId: string | null + ): Promise; +} + +// Try to use TurboModule if available (New Architecture) +// Fall back to NativeModules (Old Architecture) +const isTurboModuleEnabled = + '__turboModuleProxy' in global && + (global as Record).__turboModuleProxy != null; + +let JwtTokenModule: Spec | null = null; + +try { + JwtTokenModule = isTurboModuleEnabled + ? TurboModuleRegistry.getEnforcing('JwtTokenModule') + : NativeModules.JwtTokenModule; +} catch { + // Module not available - will throw error when used + console.warn('JwtTokenModule native module is not available yet'); +} + +// Create a proxy that throws a helpful error when methods are called +const createModuleProxy = (): Spec => { + const handler: ProxyHandler = { + get(_target, prop) { + if (!JwtTokenModule) { + throw new Error( + `JwtTokenModule native module is not available. Make sure the native module is properly linked and the app has been rebuilt.\n\nFor iOS: Add Swift files to Xcode project (see SETUP_GUIDE.md)\nFor Android: Ensure JwtTokenPackage is registered in MainApplication.kt` + ); + } + return JwtTokenModule[prop as keyof Spec]; + }, + }; + return new Proxy({} as Spec, handler); +}; + +export default createModuleProxy(); diff --git a/example/src/hooks/useIterableApp.tsx b/example/src/hooks/useIterableApp.tsx index 32270003c..c215ff22f 100644 --- a/example/src/hooks/useIterableApp.tsx +++ b/example/src/hooks/useIterableApp.tsx @@ -1,10 +1,10 @@ import type { StackNavigationProp } from '@react-navigation/stack'; import { - type FunctionComponent, createContext, useCallback, useContext, useState, + type FunctionComponent, } from 'react'; import { Alert } from 'react-native'; @@ -14,10 +14,13 @@ import { IterableConfig, IterableInAppShowResponse, IterableLogLevel, + IterableRetryBackoff, + IterableAuthFailureReason, } from '@iterable/react-native-sdk'; import { Route } from '../constants/routes'; import type { RootStackParamList } from '../types/navigation'; +import NativeJwtTokenModule from '../NativeJwtTokenModule'; type Navigation = StackNavigationProp; @@ -84,6 +87,8 @@ const IterableAppContext = createContext({ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const getIsEmail = (id: string) => EMAIL_REGEX.test(id); + export const IterableAppProvider: FunctionComponent< React.PropsWithChildren > = ({ children }) => { @@ -96,11 +101,28 @@ export const IterableAppProvider: FunctionComponent< const [apiKey, setApiKey] = useState( process.env.ITBL_API_KEY ); - const [userId, setUserId] = useState(process.env.ITBL_ID ?? null); + const [userId, setUserId] = useState( + process.env.ITBL_ID ?? null + ); const [loginInProgress, setLoginInProgress] = useState(false); const getUserId = useCallback(() => userId ?? process.env.ITBL_ID, [userId]); + const getJwtToken = useCallback(async () => { + const id = userId ?? process.env.ITBL_ID; + const idType = getIsEmail(id as string) ? 'email' : 'userId'; + const secret = process.env.ITBL_JWT_SECRET ?? ''; + const duration = 1000 * 60 * 60 * 24; // 1 day in milliseconds + const jwtToken = await NativeJwtTokenModule.generateJwtToken( + secret, + duration, + idType === 'email' ? (id as string) : null, // Email (can be null if userId is provided) + idType === 'userId' ? (id as string) : null // UserId (can be null if email is provided) + ); + + return jwtToken; + }, [userId]); + const login = useCallback(() => { const id = userId ?? process.env.ITBL_ID; @@ -108,8 +130,7 @@ export const IterableAppProvider: FunctionComponent< setLoginInProgress(true); - const isEmail = EMAIL_REGEX.test(id); - const fn = isEmail ? Iterable.setEmail : Iterable.setUserId; + const fn = getIsEmail(id) ? Iterable.setEmail : Iterable.setUserId; fn(id); setIsLoggedIn(true); @@ -120,10 +141,34 @@ export const IterableAppProvider: FunctionComponent< const initialize = useCallback( (navigation: Navigation) => { + if (getUserId()) { + login(); + } + const config = new IterableConfig(); config.inAppDisplayInterval = 1.0; // Min gap between in-apps. No need to set this in production. + config.retryPolicy = { + maxRetry: 5, + retryInterval: 10, + retryBackoff: IterableRetryBackoff.LINEAR, + }; + + config.onJWTError = (authFailure) => { + console.log('onJWTError', authFailure); + + const failureReason = + typeof authFailure.failureReason === 'string' + ? authFailure.failureReason + : IterableAuthFailureReason[authFailure.failureReason]; + + Alert.alert( + `Error fetching JWT: ${failureReason}`, + `Token: ${authFailure.failedAuthToken}` + ); + }; + config.urlHandler = (url: string) => { const routeNames = [Route.Commerce, Route.Inbox, Route.User]; for (const route of routeNames) { @@ -149,6 +194,17 @@ export const IterableAppProvider: FunctionComponent< config.inAppHandler = () => IterableInAppShowResponse.show; + if ( + process.env.ITBL_IS_JWT_ENABLED === 'true' && + process.env.ITBL_JWT_SECRET + ) { + config.authHandler = async () => { + const token = await getJwtToken(); + // return 'SomethingNotValid'; // Uncomment this to test the failure callback + return token; + }; + } + setItblConfig(config); const key = apiKey ?? process.env.ITBL_API_KEY; @@ -163,11 +219,8 @@ export const IterableAppProvider: FunctionComponent< .then((isSuccessful) => { setIsInitialized(isSuccessful); - if (!isSuccessful) + if (!isSuccessful) { return Promise.reject('`Iterable.initialize` failed'); - - if (getUserId()) { - login(); } return isSuccessful; @@ -180,19 +233,10 @@ export const IterableAppProvider: FunctionComponent< setIsInitialized(false); setLoginInProgress(false); return Promise.reject(err); - }) - .finally(() => { - // For some reason, ios is throwing an error on initialize. - // To temporarily fix this, we're using the finally block to login. - // MOB-10419: Find out why initialize is throwing an error on ios - setIsInitialized(true); - if (getUserId()) { - login(); - } - return Promise.resolve(true); }); }, - [apiKey, getUserId, login] + // eslint-disable-next-line react-hooks/exhaustive-deps + [getUserId, apiKey, login, getJwtToken, userId] ); const logout = useCallback(() => { diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index a7490f2ee..91955f797 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -273,6 +273,10 @@ - (void)passAlongAuthToken:(NSString *_Nullable)authToken { [_swiftAPI passAlongAuthToken:authToken]; } +- (void)pauseAuthRetries:(BOOL)pauseRetry { + [_swiftAPI pauseAuthRetries:pauseRetry]; +} + - (void)wakeApp { // Placeholder function -- this method is only used in Android } @@ -499,6 +503,10 @@ - (void)wakeApp { [_swiftAPI passAlongAuthToken:authToken]; } +RCT_EXPORT_METHOD(pauseAuthRetries : (BOOL)pauseRetry) { + [_swiftAPI pauseAuthRetries:pauseRetry]; +} + RCT_EXPORT_METHOD(wakeApp) { // Placeholder function -- this method is only used in Android } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 163e34199..80c0a9cab 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -215,7 +215,8 @@ import React ITBError("Could not find message with id: \(messageId)") return } - IterableAPI.track(inAppOpen: message, location: InAppLocation.from(number: locationNumber as NSNumber)) + IterableAPI.track( + inAppOpen: message, location: InAppLocation.from(number: locationNumber as NSNumber)) } @objc(trackInAppClick:location:clickedUrl:) @@ -414,8 +415,10 @@ import React templateId: Double ) { ITBInfo() - let finalCampaignId: NSNumber? = (campaignId as NSNumber).intValue <= 0 ? nil : campaignId as NSNumber - let finalTemplateId: NSNumber? = (templateId as NSNumber).intValue <= 0 ? nil : templateId as NSNumber + let finalCampaignId: NSNumber? = + (campaignId as NSNumber).intValue <= 0 ? nil : campaignId as NSNumber + let finalTemplateId: NSNumber? = + (templateId as NSNumber).intValue <= 0 ? nil : templateId as NSNumber IterableAPI.updateSubscriptions( emailListIds, unsubscribedChannelIds: unsubscribedChannelIds, @@ -480,10 +483,16 @@ import React @objc(passAlongAuthToken:) public func passAlongAuthToken(authToken: String?) { ITBInfo() - passedAuthToken = authToken + self.passedAuthToken = authToken authHandlerSemaphore.signal() } + @objc(pauseAuthRetries:) + public func pauseAuthRetries(pauseRetry: Bool) { + ITBInfo() + IterableAPI.pauseAuthRetries(pauseRetry) + } + // MARK: Private private var shouldEmit = false private let _methodQueue = DispatchQueue(label: String(describing: ReactIterableAPI.self)) @@ -531,7 +540,9 @@ import React iterableConfig.inAppDelegate = self } - if let authHandlerPresent = configDict["authHandlerPresent"] as? Bool, authHandlerPresent { + if let authHandlerPresent = configDict["authHandlerPresent"] as? Bool, + authHandlerPresent == true + { iterableConfig.authDelegate = self } @@ -662,6 +673,20 @@ extension ReactIterableAPI: IterableInAppDelegate { } extension ReactIterableAPI: IterableAuthDelegate { + public func onAuthFailure(_ authFailure: IterableSDK.AuthFailure) { + ITBInfo() + + var failureDict: [String: Any] = [:] + failureDict["userKey"] = authFailure.userKey + failureDict["failedAuthToken"] = authFailure.failedAuthToken + failureDict["failedRequestTime"] = authFailure.failedRequestTime + failureDict["failureReason"] = authFailure.failureReason.rawValue + + delegate?.sendEvent( + withName: EventName.handleAuthFailureCalled.rawValue, + body: failureDict) + } + public func onAuthTokenRequested(completion: @escaping AuthTokenRetrievalHandler) { ITBInfo() DispatchQueue.global(qos: .userInitiated).async { @@ -682,6 +707,8 @@ extension ReactIterableAPI: IterableAuthDelegate { DispatchQueue.main.async { completion(nil) } + // TODO: RN should be able to handle nil case as well. Or we can wrap this up under one of the existing AuthFailure. But again, its not a authFailure in this one. Its a timeout error. + // TODO: Create a Dictionary representing AuthFailure object due to `null` auth token and pass it in body instead of passing `nil` self.delegate?.sendEvent( withName: EventName.handleAuthFailureCalled.rawValue, body: nil as Any?) diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index 478262924..3f837ab2c 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -94,6 +94,18 @@ extension IterableConfig { } } + if let retryPolicyDict = dict["retryPolicy"] as? [AnyHashable: Any] { + if let maxRetry = retryPolicyDict["maxRetry"] as? Int, + let retryInterval = retryPolicyDict["retryInterval"] as? TimeInterval, + let retryBackoffString = retryPolicyDict["retryBackoff"] as? String + { + let retryBackoffType: RetryPolicy.BackoffType = + retryBackoffString == "EXPONENTIAL" ? .exponential : .linear + config.retryPolicy = RetryPolicy( + maxRetry: maxRetry, retryInterval: retryInterval, retryBackoff: retryBackoffType) + } + } + return config } diff --git a/package.json b/package.json index 6336697df..668caa89a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iterable/react-native-sdk", - "version": "2.1.0-beta.1", + "version": "2.2.0-alpha.0", "description": "Iterable SDK for React Native.", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -84,7 +84,7 @@ "commitlint": "^19.6.1", "del-cli": "^5.1.0", "eslint": "^8.51.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-jest": "^28.9.0", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-tsdoc": "^0.3.0", diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 390263153..c7f325677 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -16,10 +16,10 @@ export class MockRNIterableAPI { }); } - static setEmail(email: string, authToken?: string): void { + static setEmail = jest.fn((email: string, authToken?: string): void => { MockRNIterableAPI.email = email; MockRNIterableAPI.token = authToken; - } + }); static async getUserId(): Promise { return await new Promise((resolve) => { @@ -27,10 +27,10 @@ export class MockRNIterableAPI { }); } - static setUserId(userId: string, authToken?: string): void { + static setUserId = jest.fn((userId: string, authToken?: string): void => { MockRNIterableAPI.userId = userId; MockRNIterableAPI.token = authToken; - } + }); static disableDeviceForCurrentUser = jest.fn(); @@ -62,20 +62,24 @@ export class MockRNIterableAPI { }); } - static setAttributionInfo(attributionInfo?: IterableAttributionInfo): void { - MockRNIterableAPI.attributionInfo = attributionInfo; - } + static setAttributionInfo = jest.fn( + (attributionInfo?: IterableAttributionInfo): void => { + MockRNIterableAPI.attributionInfo = attributionInfo; + } + ); static initializeWithApiKey = jest.fn().mockResolvedValue(true); static initialize2WithApiKey = jest.fn().mockResolvedValue(true); - static wakeApp = jest.fn() + static wakeApp = jest.fn(); static setInAppShowResponse = jest.fn(); static passAlongAuthToken = jest.fn(); + static pauseAuthRetries = jest.fn(); + static async getInAppMessages(): Promise { return await new Promise((resolve) => { resolve(MockRNIterableAPI.messages); @@ -84,14 +88,16 @@ export class MockRNIterableAPI { static setAutoDisplayPaused = jest.fn(); - static async showMessage( - _message: IterableInAppMessage, - _consume: boolean - ): Promise { - return await new Promise((resolve) => { - resolve(MockRNIterableAPI.clickedUrl); - }); - } + static showMessage = jest.fn( + async ( + _messageId: string, + _consume: boolean + ): Promise => { + return await new Promise((resolve) => { + resolve(MockRNIterableAPI.clickedUrl); + }); + } + ); static removeMessage = jest.fn(); @@ -107,6 +113,22 @@ export class MockRNIterableAPI { static updateSubscriptions = jest.fn(); + static getInboxMessages = jest.fn( + async (): Promise => { + return await new Promise((resolve) => { + resolve(MockRNIterableAPI.messages); + }); + } + ); + + static startSession = jest.fn(); + + static endSession = jest.fn(); + + static updateVisibleRows = jest.fn(); + + static getHtmlInAppContentForMessage = jest.fn(); + // set messages function is to set the messages static property // this is for testing purposes only static setMessages(messages: IterableInAppMessage[]): void { diff --git a/src/__tests__/IterableInApp.test.ts b/src/__tests__/IterableInApp.test.ts index b4a157413..bddb3f3f9 100644 --- a/src/__tests__/IterableInApp.test.ts +++ b/src/__tests__/IterableInApp.test.ts @@ -1,7 +1,5 @@ import { NativeEventEmitter } from 'react-native'; -import { IterableLogger } from '../core'; - import { MockRNIterableAPI } from '../__mocks__/MockRNIterableAPI'; import { @@ -21,7 +19,6 @@ import { describe('Iterable In App', () => { beforeEach(() => { jest.clearAllMocks(); - Iterable.logger = new IterableLogger(new IterableConfig()); }); test('trackInAppOpen_params_methodCalledWithParams', () => { @@ -202,9 +199,11 @@ describe('Iterable In App', () => { // WHEN the simulated local queue is set to the in-app messages MockRNIterableAPI.setMessages(messages); // THEN Iterable.inAppManager.getMessages returns the list of in-app messages - return await Iterable.inAppManager?.getMessages().then((messagesObtained) => { - expect(messagesObtained).toEqual(messages); - }); + return await Iterable.inAppManager + ?.getMessages() + .then((messagesObtained) => { + expect(messagesObtained).toEqual(messages); + }); }); test('showMessage_messageAndConsume_returnsClickedUrl', async () => { @@ -222,9 +221,11 @@ describe('Iterable In App', () => { // WHEN the simulated clicked url is set to the clicked url MockRNIterableAPI.setClickedUrl(clickedUrl); // THEN Iterable,inAppManager.showMessage returns the simulated clicked url - return await Iterable.inAppManager?.showMessage(message, consume).then((url) => { - expect(url).toEqual(clickedUrl); - }); + return await Iterable.inAppManager + ?.showMessage(message, consume) + .then((url) => { + expect(url).toEqual(clickedUrl); + }); }); test('removeMessage_params_methodCalledWithParams', () => { diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index de903cece..391fadbb7 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -116,11 +116,11 @@ export interface Spec extends TurboModule { // Auth passAlongAuthToken(authToken?: string | null): void; + pauseAuthRetries(pauseRetry: boolean): void; // Wake app -- android only wakeApp(): void; - // REQUIRED for RCTEventEmitter addListener(eventName: string): void; removeListeners(count: number): void; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 4c044165e..7774b5bba 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1,8 +1,7 @@ -import { NativeEventEmitter, Platform } from "react-native"; +import { NativeEventEmitter, Platform } from 'react-native'; -import { MockLinking } from "../../__mocks__/MockLinking"; -import { MockRNIterableAPI } from "../../__mocks__/MockRNIterableAPI"; -import { IterableLogger } from ".."; +import { MockLinking } from '../../__mocks__/MockLinking'; +import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; // import from the same location that consumers import from import { Iterable, @@ -10,33 +9,25 @@ import { IterableActionContext, IterableActionSource, IterableAttributionInfo, + IterableAuthResponse, IterableCommerceItem, IterableConfig, IterableDataRegion, IterableEventName, - IterableLogLevel, - IterableInAppMessage, IterableInAppCloseSource, IterableInAppDeleteSource, IterableInAppLocation, + IterableInAppMessage, + IterableInAppShowResponse, IterableInAppTrigger, IterableInAppTriggerType, - IterableAuthResponse, - IterableInAppShowResponse, -} from "../.."; -import { TestHelper } from "../../__tests__/TestHelper"; - -const getDefaultConfig = () => { - const config = new IterableConfig(); - config.logReactNativeSdkCalls = false; - return config; -}; + IterableLogLevel, +} from '../..'; +import { TestHelper } from '../../__tests__/TestHelper'; -describe("Iterable", () => { +describe('Iterable', () => { beforeEach(() => { jest.clearAllMocks(); - const config = getDefaultConfig(); - Iterable.logger = new IterableLogger(config); }); afterEach(() => { @@ -55,11 +46,11 @@ describe("Iterable", () => { jest.clearAllTimers(); }); - describe("setEmail", () => { - it("should set the email", async () => { - const result = "user@example.com"; + describe('setEmail', () => { + it('should set the email', async () => { + const result = 'user@example.com'; // GIVEN an email - const email = "user@example.com"; + const email = 'user@example.com'; // WHEN Iterable.setEmail is called with the given email Iterable.setEmail(email); // THEN Iterable.getEmail returns the given email @@ -69,11 +60,11 @@ describe("Iterable", () => { }); }); - describe("setUserId", () => { - it("should set the userId", async () => { - const result = "user1"; + describe('setUserId', () => { + it('should set the userId', async () => { + const result = 'user1'; // GIVEN an userId - const userId = "user1"; + const userId = 'user1'; // WHEN Iterable.setUserId is called with the given userId Iterable.setUserId(userId); // THEN Iterable.getUserId returns the given userId @@ -83,8 +74,8 @@ describe("Iterable", () => { }); }); - describe("disableDeviceForCurrentUser", () => { - it("should disable the device for the current user", () => { + describe('disableDeviceForCurrentUser', () => { + it('should disable the device for the current user', () => { // GIVEN no parameters // WHEN Iterable.disableDeviceForCurrentUser is called Iterable.disableDeviceForCurrentUser(); @@ -93,12 +84,12 @@ describe("Iterable", () => { }); }); - describe("getLastPushPayload", () => { - it("should return the last push payload", async () => { - const result = { var1: "val1", var2: true }; + describe('getLastPushPayload', () => { + it('should return the last push payload', async () => { + const result = { var1: 'val1', var2: true }; // GIVEN no parameters // WHEN the lastPushPayload is set - MockRNIterableAPI.lastPushPayload = { var1: "val1", var2: true }; + MockRNIterableAPI.lastPushPayload = { var1: 'val1', var2: true }; // THEN the lastPushPayload is returned when getLastPushPayload is called return await Iterable.getLastPushPayload().then((payload) => { expect(payload).toEqual(result); @@ -106,14 +97,14 @@ describe("Iterable", () => { }); }); - describe("trackPushOpenWithCampaignId", () => { - it("should track the push open with the campaign id", () => { + describe('trackPushOpenWithCampaignId', () => { + it('should track the push open with the campaign id', () => { // GIVEN the following parameters const campaignId = 123; const templateId = 234; - const messageId = "someMessageId"; + const messageId = 'someMessageId'; const appAlreadyRunning = false; - const dataFields = { dataFieldKey: "dataFieldValue" }; + const dataFields = { dataFieldKey: 'dataFieldValue' }; // WHEN Iterable.trackPushOpenWithCampaignId is called Iterable.trackPushOpenWithCampaignId( campaignId, @@ -133,10 +124,10 @@ describe("Iterable", () => { }); }); - describe("updateCart", () => { - it("should call IterableAPI.updateCart with the correct items", () => { + describe('updateCart', () => { + it('should call IterableAPI.updateCart with the correct items', () => { // GIVEN list of items - const items = [new IterableCommerceItem("id1", "Boba Tea", 18, 26)]; + const items = [new IterableCommerceItem('id1', 'Boba Tea', 18, 26)]; // WHEN Iterable.updateCart is called Iterable.updateCart(items); // THEN corresponding function is called on RNIterableAPI @@ -144,12 +135,12 @@ describe("Iterable", () => { }); }); - describe("trackPurchase", () => { - it("should track the purchase", () => { + describe('trackPurchase', () => { + it('should track the purchase', () => { // GIVEN the following parameters const total = 10; - const items = [new IterableCommerceItem("id1", "Boba Tea", 18, 26)]; - const dataFields = { dataFieldKey: "dataFieldValue" }; + const items = [new IterableCommerceItem('id1', 'Boba Tea', 18, 26)]; + const dataFields = { dataFieldKey: 'dataFieldValue' }; // WHEN Iterable.trackPurchase is called Iterable.trackPurchase(total, items, dataFields); // THEN corresponding function is called on RNIterableAPI @@ -160,23 +151,23 @@ describe("Iterable", () => { ); }); - it("should track the purchase when called with optional fields", () => { + it('should track the purchase when called with optional fields', () => { // GIVEN the following parameters const total = 5; const items = [ new IterableCommerceItem( - "id", - "swordfish", + 'id', + 'swordfish', 64, 1, - "SKU", - "description", - "url", - "imageUrl", - ["sword", "shield"] + 'SKU', + 'description', + 'url', + 'imageUrl', + ['sword', 'shield'] ), ]; - const dataFields = { key: "value" }; + const dataFields = { key: 'value' }; // WHEN Iterable.trackPurchase is called Iterable.trackPurchase(total, items, dataFields); // THEN corresponding function is called on RNIterableAPI @@ -188,11 +179,11 @@ describe("Iterable", () => { }); }); - describe("trackEvent", () => { - it("should call IterableAPI.trackEvent with the correct name and dataFields", () => { + describe('trackEvent', () => { + it('should call IterableAPI.trackEvent with the correct name and dataFields', () => { // GIVEN the following parameters - const name = "EventName"; - const dataFields = { DatafieldKey: "DatafieldValue" }; + const name = 'EventName'; + const dataFields = { DatafieldKey: 'DatafieldValue' }; // WHEN Iterable.trackEvent is called Iterable.trackEvent(name, dataFields); // THEN corresponding function is called on RNIterableAPI @@ -200,12 +191,12 @@ describe("Iterable", () => { }); }); - describe("setAttributionInfo", () => { - it("should set the attribution info", async () => { + describe('setAttributionInfo', () => { + it('should set the attribution info', async () => { // GIVEN attribution info const campaignId = 1234; const templateId = 5678; - const messageId = "qwer"; + const messageId = 'qwer'; // WHEN Iterable.setAttributionInfo is called with the given attribution info Iterable.setAttributionInfo( new IterableAttributionInfo(campaignId, templateId, messageId) @@ -219,10 +210,10 @@ describe("Iterable", () => { }); }); - describe("updateUser", () => { - it("should update the user", () => { + describe('updateUser', () => { + it('should update the user', () => { // GIVEN the following parameters - const dataFields = { field: "value1" }; + const dataFields = { field: 'value1' }; // WHEN Iterable.updateUser is called Iterable.updateUser(dataFields, false); // THEN corresponding function is called on RNIterableAPI @@ -230,20 +221,20 @@ describe("Iterable", () => { }); }); - describe("updateEmail", () => { - it("should call IterableAPI.updateEmail with the correct email", () => { + describe('updateEmail', () => { + it('should call IterableAPI.updateEmail with the correct email', () => { // GIVEN the new email - const newEmail = "woo@newemail.com"; + const newEmail = 'woo@newemail.com'; // WHEN Iterable.updateEmail is called Iterable.updateEmail(newEmail); // THEN corresponding function is called on RNIterableAPI expect(MockRNIterableAPI.updateEmail).toBeCalledWith(newEmail, undefined); }); - it("should call IterableAPI.updateEmail with the correct email and token", () => { + it('should call IterableAPI.updateEmail with the correct email and token', () => { // GIVEN the new email and a token - const newEmail = "woo@newemail.com"; - const newToken = "token2"; + const newEmail = 'woo@newemail.com'; + const newToken = 'token2'; // WHEN Iterable.updateEmail is called Iterable.updateEmail(newEmail, newToken); // THEN corresponding function is called on RNITerableAPI @@ -251,8 +242,8 @@ describe("Iterable", () => { }); }); - describe("iterableConfig", () => { - it("should have default values", () => { + describe('iterableConfig', () => { + it('should have default values', () => { // GIVEN no parameters // WHEN config is initialized const config = new IterableConfig(); @@ -265,7 +256,7 @@ describe("Iterable", () => { expect(config.customActionHandler).toBe(undefined); expect(config.inAppHandler).toBe(undefined); expect(config.authHandler).toBe(undefined); - expect(config.logLevel).toBe(IterableLogLevel.info); + expect(config.logLevel).toBe(IterableLogLevel.debug); expect(config.logReactNativeSdkCalls).toBe(true); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(config.allowedProtocols).toEqual([]); @@ -281,7 +272,7 @@ describe("Iterable", () => { expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.inAppHandlerPresent).toBe(false); expect(configDict.authHandlerPresent).toBe(false); - expect(configDict.logLevel).toBe(IterableLogLevel.info); + expect(configDict.logLevel).toBe(IterableLogLevel.debug); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -291,8 +282,8 @@ describe("Iterable", () => { }); }); - describe("urlHandler", () => { - it("should open the url when canOpenURL returns true and urlHandler returns false", async () => { + describe('urlHandler', () => { + it('should open the url when canOpenURL returns true and urlHandler returns false', async () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); @@ -304,7 +295,7 @@ describe("Iterable", () => { return false; }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN canOpenUrl set to return a promise that resolves to true MockLinking.canOpenURL = jest.fn(async () => { return await new Promise((resolve) => { @@ -312,11 +303,11 @@ describe("Iterable", () => { }); }); MockLinking.openURL.mockReset(); - const expectedUrl = "https://somewhere.com"; - const actionDict = { type: "openUrl" }; + const expectedUrl = 'https://somewhere.com'; + const actionDict = { type: 'openUrl' }; const dict = { url: expectedUrl, - context: { action: actionDict, source: "inApp" }, + context: { action: actionDict, source: 'inApp' }, }; // WHEN handleUrlCalled event is emitted nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); @@ -327,7 +318,7 @@ describe("Iterable", () => { }); }); - it("should not open the url when canOpenURL returns false and urlHandler returns false", async () => { + it('should not open the url when canOpenURL returns false and urlHandler returns false', async () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); @@ -339,7 +330,7 @@ describe("Iterable", () => { return false; }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN canOpenUrl set to return a promise that resolves to false MockLinking.canOpenURL = jest.fn(async () => { return await new Promise((resolve) => { @@ -347,11 +338,11 @@ describe("Iterable", () => { }); }); MockLinking.openURL.mockReset(); - const expectedUrl = "https://somewhere.com"; - const actionDict = { type: "openUrl" }; + const expectedUrl = 'https://somewhere.com'; + const actionDict = { type: 'openUrl' }; const dict = { url: expectedUrl, - context: { action: actionDict, source: "inApp" }, + context: { action: actionDict, source: 'inApp' }, }; // WHEN handleUrlCalled event is emitted nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); @@ -362,7 +353,7 @@ describe("Iterable", () => { }); }); - it("should not open the url when canOpenURL returns true and urlHandler returns true", async () => { + it('should not open the url when canOpenURL returns true and urlHandler returns true', async () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); @@ -374,7 +365,7 @@ describe("Iterable", () => { return true; }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN canOpenUrl set to return a promise that resolves to true MockLinking.canOpenURL = jest.fn(async () => { return await new Promise((resolve) => { @@ -382,11 +373,11 @@ describe("Iterable", () => { }); }); MockLinking.openURL.mockReset(); - const expectedUrl = "https://somewhere.com"; - const actionDict = { type: "openUrl" }; + const expectedUrl = 'https://somewhere.com'; + const actionDict = { type: 'openUrl' }; const dict = { url: expectedUrl, - context: { action: actionDict, source: "inApp" }, + context: { action: actionDict, source: 'inApp' }, }; // WHEN handleUrlCalled event is emitted nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); @@ -398,8 +389,8 @@ describe("Iterable", () => { }); }); - describe("customActionHandler", () => { - it("should be called with the correct action and context", () => { + describe('customActionHandler', () => { + it('should be called with the correct action and context', () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners( @@ -415,10 +406,10 @@ describe("Iterable", () => { } ); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN custom action name and custom action data - const actionName = "zeeActionName"; - const actionData = "zeeActionData"; + const actionName = 'zeeActionName'; + const actionData = 'zeeActionData'; const actionDict = { type: actionName, data: actionData }; const actionSource = IterableActionSource.inApp; const dict = { @@ -440,10 +431,10 @@ describe("Iterable", () => { }); }); - describe("handleAppLink", () => { - it("should call IterableAPI.handleAppLink", () => { + describe('handleAppLink', () => { + it('should call IterableAPI.handleAppLink', () => { // GIVEN a link - const link = "https://somewhere.com/link/something"; + const link = 'https://somewhere.com/link/something'; // WHEN Iterable.handleAppLink is called Iterable.handleAppLink(link); // THEN corresponding function is called on RNITerableAPI @@ -451,8 +442,8 @@ describe("Iterable", () => { }); }); - describe("updateSubscriptions", () => { - it("should call IterableAPI.updateSubscriptions with the correct parameters", () => { + describe('updateSubscriptions', () => { + it('should call IterableAPI.updateSubscriptions with the correct parameters', () => { // GIVEN the following parameters const emailListIds = [1, 2, 3]; const unsubscribedChannelIds = [4, 5, 6]; @@ -481,10 +472,10 @@ describe("Iterable", () => { }); }); - describe("initialize", () => { - it("should call IterableAPI.initializeWithApiKey and save the config", async () => { + describe('initialize', () => { + it('should call IterableAPI.initializeWithApiKey and save the config', async () => { // GIVEN an API key and config - const apiKey = "test-api-key"; + const apiKey = 'test-api-key'; const config = new IterableConfig(); config.logReactNativeSdkCalls = false; config.logLevel = IterableLogLevel.debug; @@ -500,9 +491,9 @@ describe("Iterable", () => { expect(result).toBe(true); }); - it("should give the default config if no config is provided", async () => { + it('should give the default config if no config is provided', async () => { // GIVEN an API key - const apiKey = "test-api-key"; + const apiKey = 'test-api-key'; // WHEN Iterable.initialize is called const result = await Iterable.initialize(apiKey); // THEN corresponding function is called on RNIterableAPI and config is saved @@ -511,13 +502,13 @@ describe("Iterable", () => { }); }); - describe("initialize2", () => { - it("should call IterableAPI.initialize2WithApiKey with an endpoint and save the config", async () => { + describe('initialize2', () => { + it('should call IterableAPI.initialize2WithApiKey with an endpoint and save the config', async () => { // GIVEN an API key, config, and endpoint - const apiKey = "test-api-key"; + const apiKey = 'test-api-key'; const config = new IterableConfig(); config.logReactNativeSdkCalls = false; - const apiEndPoint = "https://api.staging.iterable.com"; + const apiEndPoint = 'https://api.staging.iterable.com'; // WHEN Iterable.initialize2 is called const result = await Iterable.initialize2(apiKey, config, apiEndPoint); // THEN corresponding function is called on RNIterableAPI and config is saved @@ -531,10 +522,10 @@ describe("Iterable", () => { expect(result).toBe(true); }); - it("should give the default config if no config is provided", async () => { + it('should give the default config if no config is provided', async () => { // GIVEN an API key - const apiKey = "test-api-key"; - const apiEndPoint = "https://api.staging.iterable.com"; + const apiKey = 'test-api-key'; + const apiEndPoint = 'https://api.staging.iterable.com'; // WHEN Iterable.initialize is called const result = await Iterable.initialize2(apiKey, undefined, apiEndPoint); // THEN corresponding function is called on RNIterableAPI and config is saved @@ -543,12 +534,12 @@ describe("Iterable", () => { }); }); - describe("wakeApp", () => { - it("should call IterableAPI.wakeApp on Android", () => { + describe('wakeApp', () => { + it('should call IterableAPI.wakeApp on Android', () => { // GIVEN Android platform const originalPlatform = Platform.OS; - Object.defineProperty(Platform, "OS", { - value: "android", + Object.defineProperty(Platform, 'OS', { + value: 'android', writable: true, }); // WHEN Iterable.wakeApp is called @@ -556,17 +547,17 @@ describe("Iterable", () => { // THEN corresponding function is called on RNIterableAPI expect(MockRNIterableAPI.wakeApp).toBeCalled(); // Restore original platform - Object.defineProperty(Platform, "OS", { + Object.defineProperty(Platform, 'OS', { value: originalPlatform, writable: true, }); }); - it("should not call IterableAPI.wakeApp on iOS", () => { + it('should not call IterableAPI.wakeApp on iOS', () => { // GIVEN iOS platform const originalPlatform = Platform.OS; - Object.defineProperty(Platform, "OS", { - value: "ios", + Object.defineProperty(Platform, 'OS', { + value: 'ios', writable: true, }); // WHEN Iterable.wakeApp is called @@ -574,18 +565,18 @@ describe("Iterable", () => { // THEN corresponding function is not called on RNIterableAPI expect(MockRNIterableAPI.wakeApp).not.toBeCalled(); // Restore original platform - Object.defineProperty(Platform, "OS", { + Object.defineProperty(Platform, 'OS', { value: originalPlatform, writable: true, }); }); }); - describe("trackInAppOpen", () => { - it("should call IterableAPI.trackInAppOpen with the correct parameters", () => { + describe('trackInAppOpen', () => { + it('should call IterableAPI.trackInAppOpen with the correct parameters', () => { // GIVEN an in-app message and location const message = new IterableInAppMessage( - "1234", + '1234', 4567, new IterableInAppTrigger(IterableInAppTriggerType.immediate), new Date(), @@ -607,11 +598,11 @@ describe("Iterable", () => { }); }); - describe("trackInAppClick", () => { - it("should call IterableAPI.trackInAppClick with the correct parameters", () => { + describe('trackInAppClick', () => { + it('should call IterableAPI.trackInAppClick with the correct parameters', () => { // GIVEN an in-app message, location, and clicked URL const message = new IterableInAppMessage( - "1234", + '1234', 4567, new IterableInAppTrigger(IterableInAppTriggerType.immediate), new Date(), @@ -623,7 +614,7 @@ describe("Iterable", () => { 0 ); const location = IterableInAppLocation.inApp; - const clickedUrl = "https://www.example.com"; + const clickedUrl = 'https://www.example.com'; // WHEN Iterable.trackInAppClick is called Iterable.trackInAppClick(message, location, clickedUrl); // THEN corresponding function is called on RNIterableAPI @@ -635,11 +626,11 @@ describe("Iterable", () => { }); }); - describe("trackInAppClose", () => { - it("should call IterableAPI.trackInAppClose with the correct parameters", () => { + describe('trackInAppClose', () => { + it('should call IterableAPI.trackInAppClose with the correct parameters', () => { // GIVEN an in-app message, location, and source (no URL) const message = new IterableInAppMessage( - "1234", + '1234', 4567, new IterableInAppTrigger(IterableInAppTriggerType.immediate), new Date(), @@ -663,10 +654,10 @@ describe("Iterable", () => { ); }); - it("should call IterableAPI.trackInAppClose with a clicked URL when provided", () => { + it('should call IterableAPI.trackInAppClose with a clicked URL when provided', () => { // GIVEN an in-app message, location, source, and clicked URL const message = new IterableInAppMessage( - "1234", + '1234', 4567, new IterableInAppTrigger(IterableInAppTriggerType.immediate), new Date(), @@ -679,7 +670,7 @@ describe("Iterable", () => { ); const location = IterableInAppLocation.inApp; const source = IterableInAppCloseSource.back; - const clickedUrl = "https://www.example.com"; + const clickedUrl = 'https://www.example.com'; // WHEN Iterable.trackInAppClose is called Iterable.trackInAppClose(message, location, source, clickedUrl); // THEN corresponding function is called on RNIterableAPI @@ -692,11 +683,11 @@ describe("Iterable", () => { }); }); - describe("inAppConsume", () => { - it("should call IterableAPI.inAppConsume with the correct parameters", () => { + describe('inAppConsume', () => { + it('should call IterableAPI.inAppConsume with the correct parameters', () => { // GIVEN an in-app message, location, and delete source const message = new IterableInAppMessage( - "1234", + '1234', 4567, new IterableInAppTrigger(IterableInAppTriggerType.immediate), new Date(), @@ -720,19 +711,19 @@ describe("Iterable", () => { }); }); - describe("getVersionFromPackageJson", () => { - it("should return the version from the package.json file", () => { + describe('getVersionFromPackageJson', () => { + it('should return the version from the package.json file', () => { // GIVEN no parameters // WHEN Iterable.getVersionFromPackageJson is called const version = Iterable.getVersionFromPackageJson(); // THEN a version string is returned - expect(typeof version).toBe("string"); + expect(typeof version).toBe('string'); expect(version.length).toBeGreaterThan(0); }); }); - describe("setupEventHandlers", () => { - it("should call inAppHandler when handleInAppCalled event is emitted", () => { + describe('setupEventHandlers', () => { + it('should call inAppHandler when handleInAppCalled event is emitted', () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleInAppCalled); @@ -743,10 +734,10 @@ describe("Iterable", () => { return IterableInAppShowResponse.show; }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN message dictionary const messageDict = { - messageId: "1234", + messageId: '1234', campaignId: 4567, trigger: { type: 0 }, createdAt: new Date().toISOString(), @@ -768,8 +759,8 @@ describe("Iterable", () => { ); }); - describe("authHandler", () => { - it("should call authHandler when handleAuthCalled event is emitted", async () => { + describe('authHandler', () => { + it('should call authHandler when handleAuthCalled event is emitted', async () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); @@ -785,14 +776,14 @@ describe("Iterable", () => { const successCallback = jest.fn(); const failureCallback = jest.fn(); const authResponse = new IterableAuthResponse(); - authResponse.authToken = "test-token"; + authResponse.authToken = 'test-token'; authResponse.successCallback = successCallback; authResponse.failureCallback = failureCallback; config.authHandler = jest.fn(() => { return Promise.resolve(authResponse); }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN auth handler returns AuthResponse // WHEN handleAuthCalled event is emitted nativeEmitter.emit(IterableEventName.handleAuthCalled); @@ -801,14 +792,14 @@ describe("Iterable", () => { // THEN passAlongAuthToken is called with the token and success callback is called after timeout return await TestHelper.delayed(1100, () => { expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( - "test-token" + 'test-token' ); expect(successCallback).toBeCalled(); expect(failureCallback).not.toBeCalled(); }); }); - it("should call authHandler when handleAuthFailureCalled event is emitted", async () => { + it('should call authHandler when handleAuthFailureCalled event is emitted', async () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); @@ -824,7 +815,7 @@ describe("Iterable", () => { const successCallback = jest.fn(); const failureCallback = jest.fn(); const authResponse = new IterableAuthResponse(); - authResponse.authToken = "test-token"; + authResponse.authToken = 'test-token'; authResponse.successCallback = successCallback; authResponse.failureCallback = failureCallback; config.authHandler = jest.fn(() => { @@ -832,7 +823,7 @@ describe("Iterable", () => { return Promise.resolve(authResponse); }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN auth handler returns AuthResponse // WHEN handleAuthCalled event is emitted nativeEmitter.emit(IterableEventName.handleAuthCalled); @@ -841,14 +832,14 @@ describe("Iterable", () => { // THEN passAlongAuthToken is called with the token and failure callback is called after timeout return await TestHelper.delayed(1100, () => { expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( - "test-token" + 'test-token' ); expect(failureCallback).toBeCalled(); expect(successCallback).not.toBeCalled(); }); }); - it("should call authHandler when handleAuthCalled event is emitted and returns a string token", async () => { + it('should call authHandler when handleAuthCalled event is emitted and returns a string token', async () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); @@ -856,22 +847,22 @@ describe("Iterable", () => { const config = new IterableConfig(); config.logReactNativeSdkCalls = false; config.authHandler = jest.fn(() => { - return Promise.resolve("string-token"); + return Promise.resolve('string-token'); }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN auth handler returns string token // WHEN handleAuthCalled event is emitted nativeEmitter.emit(IterableEventName.handleAuthCalled); // THEN passAlongAuthToken is called with the string token return await TestHelper.delayed(100, () => { expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( - "string-token" + 'string-token' ); }); }); - it("should call authHandler when handleAuthCalled event is emitted and returns an unexpected response", () => { + it('should call authHandler when handleAuthCalled event is emitted and returns an unexpected response', () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); @@ -879,12 +870,12 @@ describe("Iterable", () => { const config = new IterableConfig(); config.logReactNativeSdkCalls = false; config.authHandler = jest.fn(() => { - return Promise.resolve({ unexpected: "object" } as unknown as + return Promise.resolve({ unexpected: 'object' } as unknown as | string | IterableAuthResponse); }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN auth handler returns unexpected response // WHEN handleAuthCalled event is emitted nativeEmitter.emit(IterableEventName.handleAuthCalled); @@ -892,7 +883,7 @@ describe("Iterable", () => { expect(MockRNIterableAPI.passAlongAuthToken).not.toBeCalled(); }); - it("should call authHandler when handleAuthCalled event is emitted and rejects the promise", () => { + it('should call authHandler when handleAuthCalled event is emitted and rejects the promise', () => { // sets up event emitter const nativeEmitter = new NativeEventEmitter(); nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); @@ -900,10 +891,10 @@ describe("Iterable", () => { const config = new IterableConfig(); config.logReactNativeSdkCalls = false; config.authHandler = jest.fn(() => { - return Promise.reject(new Error("Auth failed")); + return Promise.reject(new Error('Auth failed')); }); // initialize Iterable object - Iterable.initialize("apiKey", config); + Iterable.initialize('apiKey', config); // GIVEN auth handler rejects promise // WHEN handleAuthCalled event is emitted nativeEmitter.emit(IterableEventName.handleAuthCalled); @@ -912,4 +903,261 @@ describe("Iterable", () => { }); }); }); + + describe('authManager', () => { + describe('pauseAuthRetries', () => { + it('should call RNIterableAPI.pauseAuthRetries with true when pauseRetry is true', () => { + // GIVEN pauseRetry is true + const pauseRetry = true; + + // WHEN pauseAuthRetries is called + Iterable.authManager.pauseAuthRetries(pauseRetry); + + // THEN RNIterableAPI.pauseAuthRetries is called with true + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(true); + }); + + it('should call RNIterableAPI.pauseAuthRetries with false when pauseRetry is false', () => { + // GIVEN pauseRetry is false + const pauseRetry = false; + + // WHEN pauseAuthRetries is called + Iterable.authManager.pauseAuthRetries(pauseRetry); + + // THEN RNIterableAPI.pauseAuthRetries is called with false + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(false); + }); + + it('should return the result from RNIterableAPI.pauseAuthRetries', () => { + // GIVEN RNIterableAPI.pauseAuthRetries returns a value + const expectedResult = 'pause-result'; + MockRNIterableAPI.pauseAuthRetries = jest + .fn() + .mockReturnValue(expectedResult); + + // WHEN pauseAuthRetries is called + const result = Iterable.authManager.pauseAuthRetries(true); + + // THEN the result is returned + expect(result).toBe(expectedResult); + }); + }); + + describe('passAlongAuthToken', () => { + it('should call RNIterableAPI.passAlongAuthToken with a valid string token', async () => { + // GIVEN a valid auth token + const authToken = 'valid-jwt-token'; + const expectedResponse = new IterableAuthResponse(); + expectedResponse.authToken = 'new-token'; + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with the token + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(authToken); + expect(result).toBe(expectedResponse); + }); + + it('should call RNIterableAPI.passAlongAuthToken with null token', async () => { + // GIVEN a null auth token + const authToken = null; + const expectedResponse = 'success'; + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with null + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(null); + expect(result).toBe(expectedResponse); + }); + + it('should call RNIterableAPI.passAlongAuthToken with undefined token', async () => { + // GIVEN an undefined auth token + const authToken = undefined; + const expectedResponse = undefined; + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with undefined + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(undefined); + expect(result).toBe(expectedResponse); + }); + + it('should call RNIterableAPI.passAlongAuthToken with empty string token', async () => { + // GIVEN an empty string auth token + const authToken = ''; + const expectedResponse = new IterableAuthResponse(); + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with empty string + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(''); + expect(result).toBe(expectedResponse); + }); + + it('should return IterableAuthResponse when API returns IterableAuthResponse', async () => { + // GIVEN API returns IterableAuthResponse + const authToken = 'test-token'; + const expectedResponse = new IterableAuthResponse(); + expectedResponse.authToken = 'new-token'; + expectedResponse.successCallback = jest.fn(); + expectedResponse.failureCallback = jest.fn(); + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN the result is the expected IterableAuthResponse + expect(result).toBe(expectedResponse); + expect(result).toBeInstanceOf(IterableAuthResponse); + }); + + it('should return string when API returns string', async () => { + // GIVEN API returns string + const authToken = 'test-token'; + const expectedResponse = 'success-string'; + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN the result is the expected string + expect(result).toBe(expectedResponse); + expect(typeof result).toBe('string'); + }); + + it('should return undefined when API returns undefined', async () => { + // GIVEN API returns undefined + const authToken = 'test-token'; + const expectedResponse = undefined; + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN passAlongAuthToken is called + const result = await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN the result is undefined + expect(result).toBeUndefined(); + }); + + it('should handle API rejection and propagate the error', async () => { + // GIVEN API rejects with an error + const authToken = 'test-token'; + const expectedError = new Error('API Error'); + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockRejectedValue(expectedError); + + // WHEN passAlongAuthToken is called + // THEN the error is propagated + await expect( + Iterable.authManager.passAlongAuthToken(authToken) + ).rejects.toThrow('API Error'); + }); + + it('should handle API rejection with network error', async () => { + // GIVEN API rejects with a network error + const authToken = 'test-token'; + const networkError = new Error('Network request failed'); + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockRejectedValue(networkError); + + // WHEN passAlongAuthToken is called + // THEN the network error is propagated + await expect( + Iterable.authManager.passAlongAuthToken(authToken) + ).rejects.toThrow('Network request failed'); + }); + + it('should handle API rejection with timeout error', async () => { + // GIVEN API rejects with a timeout error + const authToken = 'test-token'; + const timeoutError = new Error('Request timeout'); + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockRejectedValue(timeoutError); + + // WHEN passAlongAuthToken is called + // THEN the timeout error is propagated + await expect( + Iterable.authManager.passAlongAuthToken(authToken) + ).rejects.toThrow('Request timeout'); + }); + }); + + describe('integration', () => { + it('should work with both methods in sequence', async () => { + // GIVEN a sequence of operations + const authToken = 'test-token'; + const expectedResponse = new IterableAuthResponse(); + MockRNIterableAPI.pauseAuthRetries = jest + .fn() + .mockReturnValue('paused'); + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValue(expectedResponse); + + // WHEN calling both methods in sequence + const pauseResult = Iterable.authManager.pauseAuthRetries(true); + const tokenResult = + await Iterable.authManager.passAlongAuthToken(authToken); + + // THEN both operations should work correctly + expect(pauseResult).toBe('paused'); + expect(tokenResult).toBe(expectedResponse); + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(true); + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(authToken); + }); + + it('should handle rapid successive calls', async () => { + // GIVEN rapid successive calls + const authToken1 = 'token1'; + const authToken2 = 'token2'; + const response1 = new IterableAuthResponse(); + const response2 = 'success'; + MockRNIterableAPI.passAlongAuthToken = jest + .fn() + .mockResolvedValueOnce(response1) + .mockResolvedValueOnce(response2); + + // WHEN making rapid successive calls + const promise1 = Iterable.authManager.passAlongAuthToken(authToken1); + const promise2 = Iterable.authManager.passAlongAuthToken(authToken2); + const [result1, result2] = await Promise.all([promise1, promise2]); + + // THEN both calls should work correctly + expect(result1).toBe(response1); + expect(result2).toBe(response2); + expect(MockRNIterableAPI.passAlongAuthToken).toHaveBeenCalledTimes(2); + expect(MockRNIterableAPI.passAlongAuthToken).toHaveBeenNthCalledWith( + 1, + authToken1 + ); + expect(MockRNIterableAPI.passAlongAuthToken).toHaveBeenNthCalledWith( + 2, + authToken2 + ); + }); + }); + }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index d49780c4c..75321d4bc 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1,27 +1,21 @@ -/* eslint-disable eslint-comments/no-unlimited-disable */ -import { - Linking, - NativeEventEmitter, - Platform, -} from 'react-native'; +import { Linking, NativeEventEmitter, Platform } from 'react-native'; import { buildInfo } from '../../itblBuildInfo'; import { RNIterableAPI } from '../../api'; -// TODO: Organize these so that there are no circular dependencies -// See https://github.com/expo/expo/issues/35100 +import { IterableInAppManager } from '../../inApp/classes/IterableInAppManager'; import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; import { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; -import { IterableAuthResponseResult, IterableEventName } from '../enums'; - -// Add this type-only import to avoid circular dependency -import type { IterableInAppManager } from '../../inApp/classes/IterableInAppManager'; - +import { IterableAuthResponseResult } from '../enums/IterableAuthResponseResult'; +import { IterableEventName } from '../enums/IterableEventName'; +import type { IterableAuthFailure } from '../types/IterableAuthFailure'; import { IterableAction } from './IterableAction'; import { IterableActionContext } from './IterableActionContext'; +import { IterableApi } from './IterableApi'; import { IterableAttributionInfo } from './IterableAttributionInfo'; +import { IterableAuthManager } from './IterableAuthManager'; import { IterableAuthResponse } from './IterableAuthResponse'; import type { IterableCommerceItem } from './IterableCommerceItem'; import { IterableConfig } from './IterableConfig'; @@ -48,12 +42,6 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); */ /* eslint-enable tsdoc/syntax */ export class Iterable { - /** - * Logger for the Iterable SDK - * Log level is set with {@link IterableLogLevel} - */ - static logger: IterableLogger = new IterableLogger(new IterableConfig()); - /** * Current configuration of the Iterable SDK */ @@ -76,21 +64,20 @@ export class Iterable { * Iterable.inAppManager.showMessage(message, true); * ``` */ - static get inAppManager() { - // Lazy initialization to avoid circular dependency - if (!this._inAppManager) { - // Import here to avoid circular dependency at module level - - const { - IterableInAppManager, - // eslint-disable-next-line - } = require('../../inApp/classes/IterableInAppManager'); - this._inAppManager = new IterableInAppManager(); - } - return this._inAppManager; - } + static inAppManager: IterableInAppManager = new IterableInAppManager(); - private static _inAppManager: IterableInAppManager | undefined; + /** + * Authentication manager for the current user. + * + * This property provides access to authentication functionality including + * pausing the authentication retry mechanism. + * + * @example + * ```typescript + * Iterable.authManager.pauseAuthRetries(true); + * ``` + */ + static authManager: IterableAuthManager = new IterableAuthManager(); /** * Initializes the Iterable React Native SDK in your app's Javascript or Typescript code. @@ -128,16 +115,11 @@ export class Iterable { config: IterableConfig = new IterableConfig() ): Promise { Iterable.savedConfig = config; - - Iterable.logger = new IterableLogger(Iterable.savedConfig); - - Iterable?.logger?.log('initialize: ' + apiKey); - - this.setupEventHandlers(); + this.setupIterable(config); const version = this.getVersionFromPackageJson(); - return RNIterableAPI.initializeWithApiKey(apiKey, config.toDict(), version); + return IterableApi.initializeWithApiKey(apiKey, { config, version }); } /** @@ -151,21 +133,31 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { - Iterable.savedConfig = config; + this.setupIterable(config); - Iterable.logger = new IterableLogger(Iterable.savedConfig); - - Iterable?.logger?.log('initialize2: ' + apiKey); - - this.setupEventHandlers(); const version = this.getVersionFromPackageJson(); - return RNIterableAPI.initialize2WithApiKey( - apiKey, - config.toDict(), + return IterableApi.initialize2WithApiKey(apiKey, { + config, version, - apiEndPoint - ); + apiEndPoint, + }); + } + + /** + * @internal + * Does basic setup of the Iterable SDK. + * @param config - The configuration object for the Iterable SDK + */ + private static setupIterable(config: IterableConfig = new IterableConfig()) { + if (config) { + Iterable.savedConfig = config; + + IterableLogger.setLoggingEnabled(config.logReactNativeSdkCalls ?? true); + IterableLogger.setLogLevel(config.logLevel); + } + + this.setupEventHandlers(); } /** @@ -218,9 +210,7 @@ export class Iterable { * ``` */ static setEmail(email: string | null, authToken?: string | null) { - Iterable?.logger?.log('setEmail: ' + email); - - RNIterableAPI.setEmail(email, authToken); + IterableApi.setEmail(email, authToken); } /** @@ -234,9 +224,7 @@ export class Iterable { * ``` */ static getEmail(): Promise { - Iterable?.logger?.log('getEmail'); - - return RNIterableAPI.getEmail(); + return IterableApi.getEmail(); } /** @@ -283,9 +271,7 @@ export class Iterable { * taken */ static setUserId(userId?: string | null, authToken?: string | null) { - Iterable?.logger?.log('setUserId: ' + userId); - - RNIterableAPI.setUserId(userId, authToken); + IterableApi.setUserId(userId, authToken); } /** @@ -299,9 +285,7 @@ export class Iterable { * ``` */ static getUserId(): Promise { - Iterable?.logger?.log('getUserId'); - - return RNIterableAPI.getUserId(); + return IterableApi.getUserId(); } /** @@ -313,9 +297,7 @@ export class Iterable { * ``` */ static disableDeviceForCurrentUser() { - Iterable?.logger?.log('disableDeviceForCurrentUser'); - - RNIterableAPI.disableDeviceForCurrentUser(); + IterableApi.disableDeviceForCurrentUser(); } /** @@ -330,9 +312,7 @@ export class Iterable { * ``` */ static getLastPushPayload(): Promise { - Iterable?.logger?.log('getLastPushPayload'); - - return RNIterableAPI.getLastPushPayload(); + return IterableApi.getLastPushPayload(); } /** @@ -358,10 +338,14 @@ export class Iterable { * ``` */ static getAttributionInfo(): Promise { - Iterable?.logger?.log('getAttributionInfo'); - - return RNIterableAPI.getAttributionInfo().then( - (dict: { campaignId: number; templateId: number; messageId: string } | null) => { + return IterableApi.getAttributionInfo().then( + ( + dict: { + campaignId: number; + templateId: number; + messageId: string; + } | null + ) => { if (dict) { return new IterableAttributionInfo( dict.campaignId as number, @@ -400,9 +384,7 @@ export class Iterable { * ``` */ static setAttributionInfo(attributionInfo?: IterableAttributionInfo) { - Iterable?.logger?.log('setAttributionInfo'); - - RNIterableAPI.setAttributionInfo(attributionInfo as unknown as { [key: string]: string | number | boolean; } | null); + IterableApi.setAttributionInfo(attributionInfo); } /** @@ -441,15 +423,13 @@ export class Iterable { appAlreadyRunning: boolean, dataFields?: unknown ) { - Iterable?.logger?.log('trackPushOpenWithCampaignId'); - - RNIterableAPI.trackPushOpenWithCampaignId( + IterableApi.trackPushOpenWithCampaignId({ campaignId, templateId, - messageId as string, + messageId, appAlreadyRunning, - dataFields as { [key: string]: string | number | boolean } | undefined - ); + dataFields, + }); } /** @@ -479,9 +459,7 @@ export class Iterable { * ``` */ static updateCart(items: IterableCommerceItem[]) { - Iterable?.logger?.log('updateCart'); - - RNIterableAPI.updateCart(items as unknown as { [key: string]: string | number | boolean }[]); + IterableApi.updateCart(items); } /** @@ -496,9 +474,7 @@ export class Iterable { */ static wakeApp() { if (Platform.OS === 'android') { - Iterable?.logger?.log('Attempting to wake the app'); - - RNIterableAPI.wakeApp(); + IterableApi.wakeApp(); } } @@ -531,9 +507,9 @@ export class Iterable { items: IterableCommerceItem[], dataFields?: unknown ) { - Iterable?.logger?.log('trackPurchase'); + IterableLogger?.log('trackPurchase'); - RNIterableAPI.trackPurchase(total, items as unknown as { [key: string]: string | number | boolean }[], dataFields as { [key: string]: string | number | boolean } | undefined); + IterableApi.trackPurchase({ total, items, dataFields }); } /** @@ -559,9 +535,13 @@ export class Iterable { message: IterableInAppMessage, location: IterableInAppLocation ) { - Iterable?.logger?.log('trackInAppOpen'); - - RNIterableAPI.trackInAppOpen(message.messageId, location); + if (!message?.messageId) { + IterableLogger?.log( + `Skipping trackInAppOpen because message ID is required, but received ${message}.` + ); + return; + } + IterableApi.trackInAppOpen({ message, location }); } /** @@ -590,9 +570,7 @@ export class Iterable { location: IterableInAppLocation, clickedUrl: string ) { - Iterable?.logger?.log('trackInAppClick'); - - RNIterableAPI.trackInAppClick(message.messageId, location, clickedUrl); + IterableApi.trackInAppClick({ message, location, clickedUrl }); } /** @@ -623,14 +601,7 @@ export class Iterable { source: IterableInAppCloseSource, clickedUrl?: string ) { - Iterable?.logger?.log('trackInAppClose'); - - RNIterableAPI.trackInAppClose( - message.messageId, - location, - source, - clickedUrl - ); + IterableApi.trackInAppClose({ message, location, source, clickedUrl }); } /** @@ -674,9 +645,7 @@ export class Iterable { location: IterableInAppLocation, source: IterableInAppDeleteSource ) { - Iterable?.logger?.log('inAppConsume'); - - RNIterableAPI.inAppConsume(message.messageId, location, source); + IterableApi.inAppConsume(message, location, source); } /** @@ -700,9 +669,7 @@ export class Iterable { * ``` */ static trackEvent(name: string, dataFields?: unknown) { - Iterable?.logger?.log('trackEvent'); - - RNIterableAPI.trackEvent(name, dataFields as { [key: string]: string | number | boolean } | undefined); + IterableApi.trackEvent({ name, dataFields }); } /** @@ -748,9 +715,7 @@ export class Iterable { dataFields: unknown | undefined, mergeNestedObjects: boolean ) { - Iterable?.logger?.log('updateUser'); - - RNIterableAPI.updateUser(dataFields as { [key: string]: string | number | boolean }, mergeNestedObjects); + IterableApi.updateUser(dataFields, mergeNestedObjects); } /** @@ -771,9 +736,7 @@ export class Iterable { * ``` */ static updateEmail(email: string, authToken?: string) { - Iterable?.logger?.log('updateEmail'); - - RNIterableAPI.updateEmail(email, authToken); + IterableApi.updateEmail(email, authToken); } /** @@ -855,9 +818,7 @@ export class Iterable { */ /* eslint-enable tsdoc/syntax */ static handleAppLink(link: string): Promise { - Iterable?.logger?.log('handleAppLink'); - - return RNIterableAPI.handleAppLink(link); + return IterableApi.handleAppLink(link); } /** @@ -902,16 +863,14 @@ export class Iterable { campaignId: number, templateId: number ) { - Iterable?.logger?.log('updateSubscriptions'); - - RNIterableAPI.updateSubscriptions( + IterableApi.updateSubscriptions({ emailListIds, unsubscribedChannelIds, unsubscribedMessageTypeIds, subscribedMessageTypeIds, campaignId, - templateId - ); + templateId, + }); } /** @@ -935,7 +894,7 @@ export class Iterable { * @internal */ private static setupEventHandlers() { - //Remove all listeners to avoid duplicate listeners + // Remove all listeners to avoid duplicate listeners RNEventEmitter.removeAllListeners(IterableEventName.handleUrlCalled); RNEventEmitter.removeAllListeners(IterableEventName.handleInAppCalled); RNEventEmitter.removeAllListeners( @@ -978,7 +937,7 @@ export class Iterable { const message = IterableInAppMessage.fromDict(messageDict); // MOB-10423: Check if we can use chain operator (?.) here instead const result = Iterable.savedConfig.inAppHandler!(message); - RNIterableAPI.setInAppShowResponse(result); + IterableApi.setInAppShowResponse(result); } ); } @@ -987,18 +946,18 @@ export class Iterable { let authResponseCallback: IterableAuthResponseResult; RNEventEmitter.addListener(IterableEventName.handleAuthCalled, () => { // MOB-10423: Check if we can use chain operator (?.) here instead - + // Asks frontend of the client/app to pass authToken Iterable.savedConfig.authHandler!() .then((promiseResult) => { // Promise result can be either just String OR of type AuthResponse. // If type AuthReponse, authToken will be parsed looking for `authToken` within promised object. Two additional listeners will be registered for success and failure callbacks sent by native bridge layer. // Else it will be looked for as a String. if (typeof promiseResult === typeof new IterableAuthResponse()) { - RNIterableAPI.passAlongAuthToken( + Iterable.authManager.passAlongAuthToken( (promiseResult as IterableAuthResponse).authToken ); - const timeoutId = setTimeout(() => { + setTimeout(() => { if ( authResponseCallback === IterableAuthResponseResult.SUCCESS ) { @@ -1008,27 +967,25 @@ export class Iterable { } else if ( authResponseCallback === IterableAuthResponseResult.FAILURE ) { + // We are currently only reporting JWT related errors. In + // the future, we should handle other types of errors as well. if ((promiseResult as IterableAuthResponse).failureCallback) { (promiseResult as IterableAuthResponse).failureCallback?.(); } } else { - Iterable?.logger?.log( - 'No callback received from native layer' - ); + IterableLogger?.log('No callback received from native layer'); } }, 1000); - // Use unref() to prevent the timeout from keeping the process alive - timeoutId.unref(); - } else if (typeof promiseResult === typeof '') { + } else if (typeof promiseResult === 'string') { //If promise only returns string - RNIterableAPI.passAlongAuthToken(promiseResult as string); + Iterable.authManager.passAlongAuthToken(promiseResult as string); } else { - Iterable?.logger?.log( + IterableLogger?.log( 'Unexpected promise returned. Auth token expects promise of String or AuthResponse type.' ); } }) - .catch((e) => Iterable?.logger?.log(e)); + .catch((e) => IterableLogger?.log(e)); }); RNEventEmitter.addListener( @@ -1039,8 +996,14 @@ export class Iterable { ); RNEventEmitter.addListener( IterableEventName.handleAuthFailureCalled, - () => { + (authFailureResponse: IterableAuthFailure) => { + // Mark the flag for above listener to indicate something failed. + // `catch(err)` will only indicate failure on high level. No actions + // should be taken inside `catch(err)`. authResponseCallback = IterableAuthResponseResult.FAILURE; + + // Call the actual JWT error with `authFailure` object. + Iterable.savedConfig?.onJWTError?.(authFailureResponse); } ); } @@ -1056,7 +1019,7 @@ export class Iterable { } }) .catch((reason) => { - Iterable?.logger?.log('could not open url: ' + reason); + IterableLogger?.log('could not open url: ' + reason); }); } } diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts new file mode 100644 index 000000000..ee41c3784 --- /dev/null +++ b/src/core/classes/IterableApi.test.ts @@ -0,0 +1,1132 @@ +import { Platform } from 'react-native'; + +import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; +import { IterableApi } from './IterableApi'; +import { IterableConfig } from './IterableConfig'; +import { IterableAttributionInfo } from './IterableAttributionInfo'; +import { IterableCommerceItem } from './IterableCommerceItem'; +import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; +import { IterableInAppTrigger } from '../../inApp/classes/IterableInAppTrigger'; +import { IterableInAppTriggerType } from '../../inApp/enums/IterableInAppTriggerType'; +import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; +import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; +import { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; +import { IterableInAppShowResponse } from '../../inApp/enums/IterableInAppShowResponse'; +import { type IterableInboxImpressionRowInfo } from '../../inbox/types/IterableInboxImpressionRowInfo'; + +// Mock the RNIterableAPI module +jest.mock('../../api', () => ({ + __esModule: true, + default: MockRNIterableAPI, +})); + +describe('IterableApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ====================================================== // + // ===================== INITIALIZE ===================== // + // ====================================================== // + + describe('initializeWithApiKey', () => { + it('should call RNIterableAPI.initializeWithApiKey with correct parameters', async () => { + // GIVEN an API key, config, and version + const apiKey = 'test-api-key'; + const config = new IterableConfig(); + const version = '1.0.0'; + + // WHEN initializeWithApiKey is called + const result = await IterableApi.initializeWithApiKey(apiKey, { + config, + version, + }); + + // THEN RNIterableAPI.initializeWithApiKey is called with correct parameters + expect(MockRNIterableAPI.initializeWithApiKey).toBeCalledWith( + apiKey, + config.toDict(), + version + ); + expect(result).toBe(true); + }); + + it('should use default config when not provided', async () => { + // GIVEN an API key and version + const apiKey = 'test-api-key'; + const version = '1.0.0'; + + // WHEN initializeWithApiKey is called without config + const result = await IterableApi.initializeWithApiKey(apiKey, { + config: new IterableConfig(), + version, + }); + + // THEN RNIterableAPI.initializeWithApiKey is called with default config + expect(MockRNIterableAPI.initializeWithApiKey).toBeCalledWith( + apiKey, + expect.any(Object), + version + ); + expect(result).toBe(true); + }); + }); + + describe('initialize2WithApiKey', () => { + it('should call RNIterableAPI.initialize2WithApiKey with correct parameters', async () => { + // GIVEN an API key, config, version, and endpoint + const apiKey = 'test-api-key'; + const config = new IterableConfig(); + const version = '1.0.0'; + const apiEndPoint = 'https://api.staging.iterable.com'; + + // WHEN initialize2WithApiKey is called + const result = await IterableApi.initialize2WithApiKey(apiKey, { + config, + version, + apiEndPoint, + }); + + // THEN RNIterableAPI.initialize2WithApiKey is called with correct parameters + expect(MockRNIterableAPI.initialize2WithApiKey).toBeCalledWith( + apiKey, + config.toDict(), + version, + apiEndPoint + ); + expect(result).toBe(true); + }); + + it('should use default config when not provided', async () => { + // GIVEN an API key, version, and endpoint + const apiKey = 'test-api-key'; + const version = '1.0.0'; + const apiEndPoint = 'https://api.staging.iterable.com'; + + // WHEN initialize2WithApiKey is called without config + const result = await IterableApi.initialize2WithApiKey(apiKey, { + version, + apiEndPoint, + config: new IterableConfig(), + }); + + // THEN RNIterableAPI.initialize2WithApiKey is called with default config + expect(MockRNIterableAPI.initialize2WithApiKey).toBeCalledWith( + apiKey, + expect.any(Object), + version, + apiEndPoint + ); + expect(result).toBe(true); + }); + }); + + // ====================================================== // + // ===================== USER MANAGEMENT ================ // + // ====================================================== // + + describe('setEmail', () => { + it('should call RNIterableAPI.setEmail with email only', () => { + // GIVEN an email + const email = 'user@example.com'; + + // WHEN setEmail is called + IterableApi.setEmail(email); + + // THEN RNIterableAPI.setEmail is called with email + expect(MockRNIterableAPI.setEmail).toBeCalledWith(email, undefined); + }); + + it('should call RNIterableAPI.setEmail with email and auth token', () => { + // GIVEN an email and auth token + const email = 'user@example.com'; + const authToken = 'jwt-token'; + + // WHEN setEmail is called + IterableApi.setEmail(email, authToken); + + // THEN RNIterableAPI.setEmail is called with email and auth token + expect(MockRNIterableAPI.setEmail).toBeCalledWith(email, authToken); + }); + + it('should call RNIterableAPI.setEmail with null email', () => { + // GIVEN null email + const email = null; + + // WHEN setEmail is called + IterableApi.setEmail(email); + + // THEN RNIterableAPI.setEmail is called with null email + expect(MockRNIterableAPI.setEmail).toBeCalledWith(null, undefined); + }); + }); + + describe('getEmail', () => { + it('should return the email from RNIterableAPI', async () => { + // GIVEN a mock email + const expectedEmail = 'user@example.com'; + MockRNIterableAPI.email = expectedEmail; + + // WHEN getEmail is called + const result = await IterableApi.getEmail(); + + // THEN the email is returned + expect(result).toBe(expectedEmail); + }); + }); + + describe('setUserId', () => { + it('should call RNIterableAPI.setUserId with userId only', () => { + // GIVEN a userId + const userId = 'user123'; + + // WHEN setUserId is called + IterableApi.setUserId(userId); + + // THEN RNIterableAPI.setUserId is called with userId + expect(MockRNIterableAPI.setUserId).toBeCalledWith(userId, undefined); + }); + + it('should call RNIterableAPI.setUserId with userId and auth token', () => { + // GIVEN a userId and auth token + const userId = 'user123'; + const authToken = 'jwt-token'; + + // WHEN setUserId is called + IterableApi.setUserId(userId, authToken); + + // THEN RNIterableAPI.setUserId is called with userId and auth token + expect(MockRNIterableAPI.setUserId).toBeCalledWith(userId, authToken); + }); + + it('should call RNIterableAPI.setUserId with null userId', () => { + // GIVEN null userId + const userId = null; + + // WHEN setUserId is called + IterableApi.setUserId(userId); + + // THEN RNIterableAPI.setUserId is called with null userId + expect(MockRNIterableAPI.setUserId).toBeCalledWith(null, undefined); + }); + + it('should call RNIterableAPI.setUserId with undefined userId', () => { + // GIVEN undefined userId + const userId = undefined; + + // WHEN setUserId is called + IterableApi.setUserId(userId); + + // THEN RNIterableAPI.setUserId is called with undefined userId + expect(MockRNIterableAPI.setUserId).toBeCalledWith(undefined, undefined); + }); + }); + + describe('getUserId', () => { + it('should return the userId from RNIterableAPI', async () => { + // GIVEN a mock userId + const expectedUserId = 'user123'; + MockRNIterableAPI.userId = expectedUserId; + + // WHEN getUserId is called + const result = await IterableApi.getUserId(); + + // THEN the userId is returned + expect(result).toBe(expectedUserId); + }); + }); + + describe('disableDeviceForCurrentUser', () => { + it('should call RNIterableAPI.disableDeviceForCurrentUser', () => { + // GIVEN no parameters + // WHEN disableDeviceForCurrentUser is called + IterableApi.disableDeviceForCurrentUser(); + + // THEN RNIterableAPI.disableDeviceForCurrentUser is called + expect(MockRNIterableAPI.disableDeviceForCurrentUser).toBeCalled(); + }); + }); + + describe('updateUser', () => { + it('should call RNIterableAPI.updateUser with data fields and merge flag', () => { + // GIVEN data fields and merge flag + const dataFields = { name: 'John', age: 30 }; + const mergeNestedObjects = true; + + // WHEN updateUser is called + IterableApi.updateUser(dataFields, mergeNestedObjects); + + // THEN RNIterableAPI.updateUser is called with correct parameters + expect(MockRNIterableAPI.updateUser).toBeCalledWith( + dataFields, + mergeNestedObjects + ); + }); + + it('should call RNIterableAPI.updateUser with mergeNestedObjects false', () => { + // GIVEN data fields and merge flag set to false + const dataFields = { name: 'Jane' }; + const mergeNestedObjects = false; + + // WHEN updateUser is called + IterableApi.updateUser(dataFields, mergeNestedObjects); + + // THEN RNIterableAPI.updateUser is called with correct parameters + expect(MockRNIterableAPI.updateUser).toBeCalledWith( + dataFields, + mergeNestedObjects + ); + }); + }); + + describe('updateEmail', () => { + it('should call RNIterableAPI.updateEmail with email only', () => { + // GIVEN a new email + const email = 'newuser@example.com'; + + // WHEN updateEmail is called + IterableApi.updateEmail(email); + + // THEN RNIterableAPI.updateEmail is called with email + expect(MockRNIterableAPI.updateEmail).toBeCalledWith(email, undefined); + }); + + it('should call RNIterableAPI.updateEmail with email and auth token', () => { + // GIVEN a new email and auth token + const email = 'newuser@example.com'; + const authToken = 'new-jwt-token'; + + // WHEN updateEmail is called + IterableApi.updateEmail(email, authToken); + + // THEN RNIterableAPI.updateEmail is called with email and auth token + expect(MockRNIterableAPI.updateEmail).toBeCalledWith(email, authToken); + }); + + it('should call RNIterableAPI.updateEmail with null auth token', () => { + // GIVEN a new email and null auth token + const email = 'newuser@example.com'; + const authToken = null; + + // WHEN updateEmail is called + IterableApi.updateEmail(email, authToken); + + // THEN RNIterableAPI.updateEmail is called with email and null auth token + expect(MockRNIterableAPI.updateEmail).toBeCalledWith(email, null); + }); + }); + + // ====================================================== // + // ===================== TRACKING ====================== // + // ====================================================== // + + describe('trackPushOpenWithCampaignId', () => { + it('should call RNIterableAPI.trackPushOpenWithCampaignId with all parameters', () => { + // GIVEN push open parameters + const params = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + appAlreadyRunning: false, + dataFields: { source: 'push' }, + }; + + // WHEN trackPushOpenWithCampaignId is called + IterableApi.trackPushOpenWithCampaignId(params); + + // THEN RNIterableAPI.trackPushOpenWithCampaignId is called with correct parameters + expect(MockRNIterableAPI.trackPushOpenWithCampaignId).toBeCalledWith( + params.campaignId, + params.templateId, + params.messageId, + params.appAlreadyRunning, + params.dataFields + ); + }); + + it('should call RNIterableAPI.trackPushOpenWithCampaignId without dataFields', () => { + // GIVEN push open parameters without dataFields + const params = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + appAlreadyRunning: true, + }; + + // WHEN trackPushOpenWithCampaignId is called + IterableApi.trackPushOpenWithCampaignId(params); + + // THEN RNIterableAPI.trackPushOpenWithCampaignId is called with correct parameters + expect(MockRNIterableAPI.trackPushOpenWithCampaignId).toBeCalledWith( + params.campaignId, + params.templateId, + params.messageId, + params.appAlreadyRunning, + undefined + ); + }); + + it('should call RNIterableAPI.trackPushOpenWithCampaignId with null messageId', () => { + // GIVEN push open parameters with null messageId + const params = { + campaignId: 123, + templateId: 456, + messageId: null, + appAlreadyRunning: false, + }; + + // WHEN trackPushOpenWithCampaignId is called + IterableApi.trackPushOpenWithCampaignId(params); + + // THEN RNIterableAPI.trackPushOpenWithCampaignId is called with correct parameters + expect(MockRNIterableAPI.trackPushOpenWithCampaignId).toBeCalledWith( + params.campaignId, + params.templateId, + null, + params.appAlreadyRunning, + undefined + ); + }); + }); + + describe('trackPurchase', () => { + it('should call RNIterableAPI.trackPurchase with all parameters', () => { + // GIVEN purchase parameters + const total = 99.99; + const items = [ + new IterableCommerceItem('item1', 'Product 1', 49.99, 1), + new IterableCommerceItem('item2', 'Product 2', 49.99, 1), + ]; + const dataFields = { currency: 'USD', discount: 10 }; + + // WHEN trackPurchase is called + IterableApi.trackPurchase({ total, items, dataFields }); + + // THEN RNIterableAPI.trackPurchase is called with correct parameters + expect(MockRNIterableAPI.trackPurchase).toBeCalledWith( + total, + items, + dataFields + ); + }); + + it('should call RNIterableAPI.trackPurchase without dataFields', () => { + // GIVEN purchase parameters without dataFields + const total = 50.0; + const items = [new IterableCommerceItem('item1', 'Product 1', 50.0, 1)]; + + // WHEN trackPurchase is called + IterableApi.trackPurchase({ total, items }); + + // THEN RNIterableAPI.trackPurchase is called with correct parameters + expect(MockRNIterableAPI.trackPurchase).toBeCalledWith( + total, + items, + undefined + ); + }); + }); + + describe('trackInAppOpen', () => { + it('should call RNIterableAPI.trackInAppOpen with message and location', () => { + // GIVEN an in-app message and location + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + + // WHEN trackInAppOpen is called + IterableApi.trackInAppOpen({ message, location }); + + // THEN RNIterableAPI.trackInAppOpen is called with correct parameters + expect(MockRNIterableAPI.trackInAppOpen).toBeCalledWith( + message.messageId, + location + ); + }); + }); + + describe('trackInAppClick', () => { + it('should call RNIterableAPI.trackInAppClick with message, location, and clickedUrl', () => { + // GIVEN an in-app message, location, and clicked URL + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const clickedUrl = 'https://example.com'; + + // WHEN trackInAppClick is called + IterableApi.trackInAppClick({ message, location, clickedUrl }); + + // THEN RNIterableAPI.trackInAppClick is called with correct parameters + expect(MockRNIterableAPI.trackInAppClick).toBeCalledWith( + message.messageId, + location, + clickedUrl + ); + }); + }); + + describe('trackInAppClose', () => { + it('should call RNIterableAPI.trackInAppClose with message, location, and source', () => { + // GIVEN an in-app message, location, and source + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const source = IterableInAppCloseSource.back; + + // WHEN trackInAppClose is called + IterableApi.trackInAppClose({ message, location, source }); + + // THEN RNIterableAPI.trackInAppClose is called with correct parameters + expect(MockRNIterableAPI.trackInAppClose).toBeCalledWith( + message.messageId, + location, + source, + undefined + ); + }); + + it('should call RNIterableAPI.trackInAppClose with clickedUrl when provided', () => { + // GIVEN an in-app message, location, source, and clicked URL + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const source = IterableInAppCloseSource.link; + const clickedUrl = 'https://example.com'; + + // WHEN trackInAppClose is called + IterableApi.trackInAppClose({ message, location, source, clickedUrl }); + + // THEN RNIterableAPI.trackInAppClose is called with correct parameters + expect(MockRNIterableAPI.trackInAppClose).toBeCalledWith( + message.messageId, + location, + source, + clickedUrl + ); + }); + }); + + describe('trackEvent', () => { + it('should call RNIterableAPI.trackEvent with name and dataFields', () => { + // GIVEN event name and data fields + const name = 'customEvent'; + const dataFields = { category: 'user_action', value: 100 }; + + // WHEN trackEvent is called + IterableApi.trackEvent({ name, dataFields }); + + // THEN RNIterableAPI.trackEvent is called with correct parameters + expect(MockRNIterableAPI.trackEvent).toBeCalledWith(name, dataFields); + }); + + it('should call RNIterableAPI.trackEvent with name only', () => { + // GIVEN event name only + const name = 'simpleEvent'; + + // WHEN trackEvent is called + IterableApi.trackEvent({ name }); + + // THEN RNIterableAPI.trackEvent is called with correct parameters + expect(MockRNIterableAPI.trackEvent).toBeCalledWith(name, undefined); + }); + }); + + // ====================================================== // + // ======================= AUTH ======================= // + // ====================================================== // + + describe('pauseAuthRetries', () => { + it('should call RNIterableAPI.pauseAuthRetries with true', () => { + // GIVEN pauseRetry is true + const pauseRetry = true; + + // WHEN pauseAuthRetries is called + IterableApi.pauseAuthRetries(pauseRetry); + + // THEN RNIterableAPI.pauseAuthRetries is called with true + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(true); + }); + + it('should call RNIterableAPI.pauseAuthRetries with false', () => { + // GIVEN pauseRetry is false + const pauseRetry = false; + + // WHEN pauseAuthRetries is called + IterableApi.pauseAuthRetries(pauseRetry); + + // THEN RNIterableAPI.pauseAuthRetries is called with false + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(false); + }); + }); + + describe('passAlongAuthToken', () => { + it('should call RNIterableAPI.passAlongAuthToken with valid token', () => { + // GIVEN a valid auth token + const authToken = 'valid-jwt-token'; + + // WHEN passAlongAuthToken is called + IterableApi.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with the token + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(authToken); + }); + + it('should call RNIterableAPI.passAlongAuthToken with null token', () => { + // GIVEN a null auth token + const authToken = null; + + // WHEN passAlongAuthToken is called + IterableApi.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with null + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(null); + }); + + it('should call RNIterableAPI.passAlongAuthToken with undefined token', () => { + // GIVEN an undefined auth token + const authToken = undefined; + + // WHEN passAlongAuthToken is called + IterableApi.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with undefined + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(undefined); + }); + }); + + // ====================================================== // + // ======================= IN-APP ======================= // + // ====================================================== // + + describe('inAppConsume', () => { + it('should call RNIterableAPI.inAppConsume with message, location, and source', () => { + // GIVEN an in-app message, location, and delete source + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const source = IterableInAppDeleteSource.deleteButton; + + // WHEN inAppConsume is called + IterableApi.inAppConsume(message, location, source); + + // THEN RNIterableAPI.inAppConsume is called with correct parameters + expect(MockRNIterableAPI.inAppConsume).toBeCalledWith( + message.messageId, + location, + source + ); + }); + }); + + describe('getInAppMessages', () => { + it('should return in-app messages from RNIterableAPI', async () => { + // GIVEN mock in-app messages + const mockMessages = [ + new IterableInAppMessage( + 'msg1', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ), + new IterableInAppMessage( + 'msg2', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.event), + new Date(), + new Date(), + true, + undefined, + undefined, + false, + 0 + ), + ]; + MockRNIterableAPI.messages = mockMessages; + + // WHEN getInAppMessages is called + const result = await IterableApi.getInAppMessages(); + + // THEN the messages are returned + expect(result).toBe(mockMessages); + }); + }); + + describe('getInboxMessages', () => { + it('should return inbox messages from RNIterableAPI', async () => { + // GIVEN mock inbox messages + const mockMessages = [ + new IterableInAppMessage( + 'msg1', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + true, // saveToInbox + undefined, + undefined, + false, + 0 + ), + ]; + MockRNIterableAPI.messages = mockMessages; + + // WHEN getInboxMessages is called + const result = await IterableApi.getInboxMessages(); + + // THEN the messages are returned + expect(result).toBe(mockMessages); + }); + }); + + describe('showMessage', () => { + it('should call RNIterableAPI.showMessage with messageId and consume flag', async () => { + // GIVEN a message ID and consume flag + const messageId = 'msg123'; + const consume = true; + const expectedUrl = 'https://example.com'; + MockRNIterableAPI.clickedUrl = expectedUrl; + + // WHEN showMessage is called + const result = await IterableApi.showMessage(messageId, consume); + + // THEN RNIterableAPI.showMessage is called with correct parameters + expect(MockRNIterableAPI.showMessage).toBeCalledWith(messageId, consume); + expect(result).toBe(expectedUrl); + }); + + it('should call RNIterableAPI.showMessage with consume set to false', async () => { + // GIVEN a message ID and consume flag set to false + const messageId = 'msg123'; + const consume = false; + + // WHEN showMessage is called + await IterableApi.showMessage(messageId, consume); + + // THEN RNIterableAPI.showMessage is called with consume set to false + expect(MockRNIterableAPI.showMessage).toBeCalledWith(messageId, false); + }); + }); + + describe('removeMessage', () => { + it('should call RNIterableAPI.removeMessage with messageId, location, and source', () => { + // GIVEN a message ID, location, and source + const messageId = 'msg123'; + const location = 1; // IterableInAppLocation.inApp + const source = 2; // IterableInAppDeleteSource.deleteButton + + // WHEN removeMessage is called + IterableApi.removeMessage(messageId, location, source); + + // THEN RNIterableAPI.removeMessage is called with correct parameters + expect(MockRNIterableAPI.removeMessage).toBeCalledWith( + messageId, + location, + source + ); + }); + }); + + describe('setReadForMessage', () => { + it('should call RNIterableAPI.setReadForMessage with messageId and read status', () => { + // GIVEN a message ID and read status + const messageId = 'msg123'; + const read = true; + + // WHEN setReadForMessage is called + IterableApi.setReadForMessage(messageId, read); + + // THEN RNIterableAPI.setReadForMessage is called with correct parameters + expect(MockRNIterableAPI.setReadForMessage).toBeCalledWith( + messageId, + read + ); + }); + + it('should call RNIterableAPI.setReadForMessage with read set to false', () => { + // GIVEN a message ID and read status set to false + const messageId = 'msg123'; + const read = false; + + // WHEN setReadForMessage is called + IterableApi.setReadForMessage(messageId, read); + + // THEN RNIterableAPI.setReadForMessage is called with read set to false + expect(MockRNIterableAPI.setReadForMessage).toBeCalledWith( + messageId, + false + ); + }); + }); + + describe('setAutoDisplayPaused', () => { + it('should call RNIterableAPI.setAutoDisplayPaused with true', () => { + // GIVEN autoDisplayPaused is true + const autoDisplayPaused = true; + + // WHEN setAutoDisplayPaused is called + IterableApi.setAutoDisplayPaused(autoDisplayPaused); + + // THEN RNIterableAPI.setAutoDisplayPaused is called with true + expect(MockRNIterableAPI.setAutoDisplayPaused).toBeCalledWith(true); + }); + + it('should call RNIterableAPI.setAutoDisplayPaused with false', () => { + // GIVEN autoDisplayPaused is false + const autoDisplayPaused = false; + + // WHEN setAutoDisplayPaused is called + IterableApi.setAutoDisplayPaused(autoDisplayPaused); + + // THEN RNIterableAPI.setAutoDisplayPaused is called with false + expect(MockRNIterableAPI.setAutoDisplayPaused).toBeCalledWith(false); + }); + }); + + describe('getHtmlInAppContentForMessage', () => { + it('should call RNIterableAPI.getHtmlInAppContentForMessage with messageId', async () => { + // GIVEN a message ID + const messageId = 'msg123'; + const mockContent = { html: '
Test content
' }; + MockRNIterableAPI.getHtmlInAppContentForMessage = jest + .fn() + .mockResolvedValue(mockContent); + + // WHEN getHtmlInAppContentForMessage is called + const result = await IterableApi.getHtmlInAppContentForMessage(messageId); + + // THEN RNIterableAPI.getHtmlInAppContentForMessage is called with messageId + expect(MockRNIterableAPI.getHtmlInAppContentForMessage).toBeCalledWith( + messageId + ); + expect(result).toBe(mockContent); + }); + }); + + describe('setInAppShowResponse', () => { + it('should call RNIterableAPI.setInAppShowResponse with response', () => { + // GIVEN an in-app show response + const response = IterableInAppShowResponse.show; + + // WHEN setInAppShowResponse is called + IterableApi.setInAppShowResponse(response); + + // THEN RNIterableAPI.setInAppShowResponse is called with response + expect(MockRNIterableAPI.setInAppShowResponse).toBeCalledWith(response); + }); + }); + + describe('startSession', () => { + it('should call RNIterableAPI.startSession with visible rows', () => { + // GIVEN visible rows + const visibleRows: IterableInboxImpressionRowInfo[] = [ + { messageId: 'msg1', silentInbox: true }, + { messageId: 'msg2', silentInbox: false }, + ]; + + // WHEN startSession is called + IterableApi.startSession(visibleRows); + + // THEN RNIterableAPI.startSession is called with visible rows + expect(MockRNIterableAPI.startSession).toBeCalledWith(visibleRows); + }); + }); + + describe('endSession', () => { + it('should call RNIterableAPI.endSession', () => { + // GIVEN no parameters + // WHEN endSession is called + IterableApi.endSession(); + + // THEN RNIterableAPI.endSession is called + expect(MockRNIterableAPI.endSession).toBeCalled(); + }); + }); + + describe('updateVisibleRows', () => { + it('should call RNIterableAPI.updateVisibleRows with visible rows', () => { + // GIVEN visible rows + const visibleRows: IterableInboxImpressionRowInfo[] = [ + { messageId: 'msg1', silentInbox: true }, + ]; + + // WHEN updateVisibleRows is called + IterableApi.updateVisibleRows(visibleRows); + + // THEN RNIterableAPI.updateVisibleRows is called with visible rows + expect(MockRNIterableAPI.updateVisibleRows).toBeCalledWith(visibleRows); + }); + + it('should call RNIterableAPI.updateVisibleRows with empty array when no rows provided', () => { + // GIVEN no visible rows + // WHEN updateVisibleRows is called without parameters + IterableApi.updateVisibleRows(); + + // THEN RNIterableAPI.updateVisibleRows is called with empty array + expect(MockRNIterableAPI.updateVisibleRows).toBeCalledWith([]); + }); + }); + + // ====================================================== // + // ======================= MOSC ======================= // + // ====================================================== // + + describe('updateCart', () => { + it('should call RNIterableAPI.updateCart with items', () => { + // GIVEN cart items + const items = [ + new IterableCommerceItem('item1', 'Product 1', 25.99, 2), + new IterableCommerceItem('item2', 'Product 2', 15.99, 1), + ]; + + // WHEN updateCart is called + IterableApi.updateCart(items); + + // THEN RNIterableAPI.updateCart is called with items + expect(MockRNIterableAPI.updateCart).toBeCalledWith(items); + }); + }); + + describe('wakeApp', () => { + it('should call RNIterableAPI.wakeApp on Android', () => { + // GIVEN Android platform + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // WHEN wakeApp is called + IterableApi.wakeApp(); + + // THEN RNIterableAPI.wakeApp is called + expect(MockRNIterableAPI.wakeApp).toBeCalled(); + + // Restore original platform + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + + it('should not call RNIterableAPI.wakeApp on iOS', () => { + // GIVEN iOS platform + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + + // WHEN wakeApp is called + IterableApi.wakeApp(); + + // THEN RNIterableAPI.wakeApp is not called + expect(MockRNIterableAPI.wakeApp).not.toBeCalled(); + + // Restore original platform + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + }); + + describe('handleAppLink', () => { + it('should call RNIterableAPI.handleAppLink with link', () => { + // GIVEN a link + const link = 'https://example.com/deep-link'; + + // WHEN handleAppLink is called + IterableApi.handleAppLink(link); + + // THEN RNIterableAPI.handleAppLink is called with link + expect(MockRNIterableAPI.handleAppLink).toBeCalledWith(link); + }); + }); + + describe('updateSubscriptions', () => { + it('should call RNIterableAPI.updateSubscriptions with all parameters', () => { + // GIVEN subscription parameters + const params = { + emailListIds: [1, 2, 3], + unsubscribedChannelIds: [4, 5], + unsubscribedMessageTypeIds: [6, 7, 8], + subscribedMessageTypeIds: [9, 10], + campaignId: 11, + templateId: 12, + }; + + // WHEN updateSubscriptions is called + IterableApi.updateSubscriptions(params); + + // THEN RNIterableAPI.updateSubscriptions is called with correct parameters + expect(MockRNIterableAPI.updateSubscriptions).toBeCalledWith( + params.emailListIds, + params.unsubscribedChannelIds, + params.unsubscribedMessageTypeIds, + params.subscribedMessageTypeIds, + params.campaignId, + params.templateId + ); + }); + + it('should call RNIterableAPI.updateSubscriptions with null arrays', () => { + // GIVEN subscription parameters with null arrays + const params = { + emailListIds: null, + unsubscribedChannelIds: null, + unsubscribedMessageTypeIds: null, + subscribedMessageTypeIds: null, + campaignId: 11, + templateId: 12, + }; + + // WHEN updateSubscriptions is called + IterableApi.updateSubscriptions(params); + + // THEN RNIterableAPI.updateSubscriptions is called with null arrays + expect(MockRNIterableAPI.updateSubscriptions).toBeCalledWith( + null, + null, + null, + null, + params.campaignId, + params.templateId + ); + }); + }); + + describe('getLastPushPayload', () => { + it('should return the last push payload from RNIterableAPI', async () => { + // GIVEN a mock push payload + const mockPayload = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + customData: { key: 'value' }, + }; + MockRNIterableAPI.lastPushPayload = mockPayload; + + // WHEN getLastPushPayload is called + const result = await IterableApi.getLastPushPayload(); + + // THEN the push payload is returned + expect(result).toBe(mockPayload); + }); + }); + + describe('getAttributionInfo', () => { + it('should return IterableAttributionInfo when attribution info exists', async () => { + // GIVEN mock attribution info + const mockAttributionDict = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + }; + MockRNIterableAPI.getAttributionInfo = jest + .fn() + .mockResolvedValue(mockAttributionDict); + + // WHEN getAttributionInfo is called + const result = await IterableApi.getAttributionInfo(); + + // THEN IterableAttributionInfo is returned + expect(result).toBeInstanceOf(IterableAttributionInfo); + expect(result?.campaignId).toBe(123); + expect(result?.templateId).toBe(456); + expect(result?.messageId).toBe('msg123'); + }); + + it('should return undefined when attribution info is null', async () => { + // GIVEN null attribution info + MockRNIterableAPI.getAttributionInfo = jest.fn().mockResolvedValue(null); + + // WHEN getAttributionInfo is called + const result = await IterableApi.getAttributionInfo(); + + // THEN undefined is returned + expect(result).toBeUndefined(); + }); + }); + + describe('setAttributionInfo', () => { + it('should call RNIterableAPI.setAttributionInfo with attribution info', () => { + // GIVEN attribution info + const attributionInfo = new IterableAttributionInfo(123, 456, 'msg123'); + + // WHEN setAttributionInfo is called + IterableApi.setAttributionInfo(attributionInfo); + + // THEN RNIterableAPI.setAttributionInfo is called with attribution info + expect(MockRNIterableAPI.setAttributionInfo).toBeCalledWith( + attributionInfo + ); + }); + + it('should call RNIterableAPI.setAttributionInfo with undefined', () => { + // GIVEN undefined attribution info + const attributionInfo = undefined; + + // WHEN setAttributionInfo is called + IterableApi.setAttributionInfo(attributionInfo); + + // THEN RNIterableAPI.setAttributionInfo is called with undefined + expect(MockRNIterableAPI.setAttributionInfo).toBeCalledWith(undefined); + }); + }); +}); diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts new file mode 100644 index 000000000..fe2b446a3 --- /dev/null +++ b/src/core/classes/IterableApi.ts @@ -0,0 +1,634 @@ +import { Platform } from 'react-native'; + +import RNIterableAPI from '../../api'; +import type { IterableHtmlInAppContent } from '../../inApp/classes/IterableHtmlInAppContent'; +import type { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; +import type { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; +import type { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; +import type { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; +import type { IterableInAppShowResponse } from '../../inApp/enums/IterableInAppShowResponse'; +import type { IterableInboxImpressionRowInfo } from '../../inbox/types/IterableInboxImpressionRowInfo'; +import { IterableAttributionInfo } from './IterableAttributionInfo'; +import type { IterableCommerceItem } from './IterableCommerceItem'; +import { IterableConfig } from './IterableConfig'; +import { IterableLogger } from './IterableLogger'; + +/** + * Contains functions that directly interact with the native layer. + */ +export class IterableApi { + // ====================================================== // + // ===================== INITIALIZE ===================== // + // ====================================================== // + + /** + * Initializes the Iterable React Native SDK in your app's Javascript or Typescript code. + * + * @param apiKey - The [*mobile* API + * key](https://support.iterable.com/hc/en-us/articles/360043464871-API-Keys) + * for your application + * @param config - Configuration object for the SDK + * @param version - Version of the SDK, derived from the package.json file + */ + static initializeWithApiKey( + apiKey: string, + { + config = new IterableConfig(), + version, + }: { + config: IterableConfig; + version: string; + } + ): Promise { + IterableLogger.log('initializeWithApiKey: ', apiKey); + return RNIterableAPI.initializeWithApiKey(apiKey, config.toDict(), version); + } + + /** + * DO NOT CALL THIS METHOD. + * This method is used internally to connect to staging environment. + * + * @internal + */ + static initialize2WithApiKey( + apiKey: string, + { + config = new IterableConfig(), + version, + apiEndPoint, + }: { + config: IterableConfig; + version: string; + apiEndPoint: string; + } + ): Promise { + IterableLogger.log('initialize2WithApiKey: ', apiKey); + return RNIterableAPI.initialize2WithApiKey( + apiKey, + config.toDict(), + version, + apiEndPoint + ); + } + + // ---- End INITIALIZE ---- // + + // ====================================================== // + // ===================== USER MANAGEMENT ================ // + // ====================================================== // + + /** + * Associate the current user with the passed in email parameter. + * + * @param email - Email address to associate with + * the current user + * @param authToken - Valid, pre-fetched JWT the SDK + * can use to authenticate API requests, optional - If null/undefined, no JWT + * related action will be taken + */ + static setEmail(email: string | null, authToken?: string | null) { + IterableLogger.log('setEmail: ', email); + return RNIterableAPI.setEmail(email, authToken); + } + + /** + * Get the email associated with the current user. + * + * @returns The email associated with the current user + */ + static getEmail() { + IterableLogger.log('getEmail'); + return RNIterableAPI.getEmail(); + } + + /** + * Associate the current user with the passed in `userId` parameter. + * + * WARNING: specify a user by calling `Iterable.setEmail` or + * `Iterable.setUserId`, but **NOT** both. + * + * @param userId - User ID to associate with the current user + * @param authToken - Valid, pre-fetched JWT the SDK + * can use to authenticate API requests, optional - If null/undefined, no JWT + * related action will be taken + */ + static setUserId( + userId: string | null | undefined, + authToken?: string | null + ) { + IterableLogger.log('setUserId: ', userId); + return RNIterableAPI.setUserId(userId, authToken); + } + + /** + * Get the `userId` associated with the current user. + */ + static getUserId() { + IterableLogger.log('getUserId'); + return RNIterableAPI.getUserId(); + } + + /** + * Disable the device for the current user. + */ + static disableDeviceForCurrentUser() { + IterableLogger.log('disableDeviceForCurrentUser'); + return RNIterableAPI.disableDeviceForCurrentUser(); + } + + /** + * Save data to the current user's Iterable profile. + * + * @param dataFields - The data fields to update + * @param mergeNestedObjects - Whether to merge nested objects + */ + static updateUser(dataFields: unknown, mergeNestedObjects: boolean) { + IterableLogger.log('updateUser: ', dataFields, mergeNestedObjects); + return RNIterableAPI.updateUser(dataFields, mergeNestedObjects); + } + + /** + * Change the value of the email field on the current user's Iterable profile. + * + * @param email - The new email to set + * @param authToken - The new auth token (JWT) to set with the new email, optional - If null/undefined, no JWT-related action will be taken + */ + static updateEmail(email: string, authToken?: string | null) { + IterableLogger.log('updateEmail: ', email, authToken); + return RNIterableAPI.updateEmail(email, authToken); + } + + // ---- End USER MANAGEMENT ---- // + + // ====================================================== // + // ===================== TRACKING ====================== // + // ====================================================== // + + /** + * Create a `pushOpen` event on the current user's Iterable profile, populating + * it with data provided to the method call. + * + * @param campaignId - The campaign ID + * @param templateId - The template ID + * @param messageId - The message ID + * @param appAlreadyRunning - Whether the app is already running + * @param dataFields - The data fields to track + */ + static trackPushOpenWithCampaignId({ + campaignId, + templateId, + messageId, + appAlreadyRunning, + dataFields, + }: { + campaignId: number; + templateId: number; + messageId: string | null | undefined; + appAlreadyRunning: boolean; + dataFields?: unknown; + }) { + IterableLogger.log( + 'trackPushOpenWithCampaignId: ', + campaignId, + templateId, + messageId, + appAlreadyRunning, + dataFields + ); + return RNIterableAPI.trackPushOpenWithCampaignId( + campaignId, + templateId, + messageId, + appAlreadyRunning, + dataFields + ); + } + + /** + * Create a `purchase` event on the current user's Iterable profile, populating + * it with data provided to the method call. + * + * @param total - The total cost of the purchase + * @param items - The items included in the purchase + * @param dataFields - The data fields to track + */ + static trackPurchase({ + total, + items, + dataFields, + }: { + total: number; + items: IterableCommerceItem[]; + dataFields?: unknown; + }) { + IterableLogger.log('trackPurchase: ', total, items, dataFields); + return RNIterableAPI.trackPurchase(total, items, dataFields); + } + + /** + * Create an `inAppOpen` event for the specified message on the current user's profile + * for manual tracking purposes. Iterable's SDK automatically tracks in-app message opens when you use the + * SDK's default rendering. + * + * @param message - The in-app message (an {@link IterableInAppMessage} object) + * @param location - The location of the in-app message (an IterableInAppLocation enum) + */ + static trackInAppOpen({ + message, + location, + }: { + message: IterableInAppMessage; + location: IterableInAppLocation; + }) { + IterableLogger.log('trackInAppOpen: ', message, location); + return RNIterableAPI.trackInAppOpen(message.messageId, location); + } + + /** + * Create an `inAppClick` event for the specified message on the current user's profile + * for manual tracking purposes. Iterable's SDK automatically tracks in-app message clicks when you use the + * SDK's default rendering. Click events refer to click events within the in-app message to distinguish + * from `inAppOpen` events. + * + * @param message - The in-app message. + * @param location - The location of the in-app message. + * @param clickedUrl - The URL clicked by the user. + */ + static trackInAppClick({ + message, + location, + clickedUrl, + }: { + message: IterableInAppMessage; + location: IterableInAppLocation; + clickedUrl: string; + }) { + IterableLogger.log('trackInAppClick: ', message, location, clickedUrl); + return RNIterableAPI.trackInAppClick( + message.messageId, + location, + clickedUrl + ); + } + + /** + * Create an `inAppClose` event for the specified message on the current user's profile + * for manual tracking purposes. Iterable's SDK automatically tracks in-app message close events when you use the + * SDK's default rendering. + * + * @param message - The in-app message. + * @param location - The location of the in-app message. + * @param source - The way the in-app was closed. + * @param clickedUrl - The URL clicked by the user. + */ + static trackInAppClose({ + message, + location, + source, + clickedUrl, + }: { + message: IterableInAppMessage; + location: IterableInAppLocation; + source: IterableInAppCloseSource; + clickedUrl?: string; + }) { + IterableLogger.log( + 'trackInAppClose: ', + message, + location, + source, + clickedUrl + ); + return RNIterableAPI.trackInAppClose( + message.messageId, + location, + source, + clickedUrl + ); + } + + /** + * Create a custom event on the current user's Iterable profile, populating + * it with data provided to the method call. + * + * @param name - The name of the event + * @param dataFields - The data fields to track + */ + static trackEvent({ + name, + dataFields, + }: { + name: string; + dataFields?: unknown; + }) { + IterableLogger.log('trackEvent: ', name, dataFields); + return RNIterableAPI.trackEvent(name, dataFields); + } + + // ---- End TRACKING ---- // + + // ====================================================== // + // ======================= AUTH ======================= // + // ====================================================== // + + /** + * Pause or resume the automatic retrying of authentication requests. + * + * @param pauseRetry - Whether to pause or resume the automatic retrying of authentication requests + */ + static pauseAuthRetries(pauseRetry: boolean) { + IterableLogger.log('pauseAuthRetries: ', pauseRetry); + return RNIterableAPI.pauseAuthRetries(pauseRetry); + } + + /** + * Pass along an auth token to the SDK. + * + * @param authToken - The auth token to pass along + */ + static passAlongAuthToken(authToken: string | null | undefined) { + IterableLogger.log('passAlongAuthToken: ', authToken); + return RNIterableAPI.passAlongAuthToken(authToken); + } + + // ---- End AUTH ---- // + + // ====================================================== // + // ======================= IN-APP ======================= // + // ====================================================== // + + /** + * Remove the specified message from the current user's message queue. + * + * @param message - The in-app message. + * @param location - The location of the in-app message. + * @param source - The way the in-app was consumed. + */ + static inAppConsume( + message: IterableInAppMessage, + location: IterableInAppLocation, + source: IterableInAppDeleteSource + ) { + IterableLogger.log('inAppConsume: ', message, location, source); + return RNIterableAPI.inAppConsume(message.messageId, location, source); + } + + /** + * Retrieve the current user's list of in-app messages stored in the local queue. + * + * @returns A Promise that resolves to an array of in-app messages. + */ + static getInAppMessages(): Promise { + IterableLogger.log('getInAppMessages'); + return RNIterableAPI.getInAppMessages() as unknown as Promise< + IterableInAppMessage[] + >; + } + + /** + * Retrieve the current user's list of in-app messages designated for the + * mobile inbox and stored in the local queue. + * + * @returns A Promise that resolves to an array of messages marked as `saveToInbox`. + */ + static getInboxMessages(): Promise { + IterableLogger.log('getInboxMessages'); + return RNIterableAPI.getInboxMessages() as unknown as Promise< + IterableInAppMessage[] + >; + } + + /** + * Renders an in-app message and consumes it from the user's message queue if necessary. + * + * If you skip showing an in-app message when it arrives, you can show it at + * another time by calling this method. + * + * @param messageId - The message to show (an {@link IterableInAppMessage} object) + * @param consume - Whether or not the message should be consumed from the user's message queue after being shown. This should be defaulted to true. + */ + static showMessage( + messageId: string, + consume: boolean + ): Promise { + IterableLogger.log('showMessage: ', messageId, consume); + return RNIterableAPI.showMessage(messageId, consume); + } + + /** + * Remove the specified message from the current user's message queue. + * + * @param messageId - The message to remove. + * @param location - The location of the message. + * @param source - The way the message was removed. + */ + static removeMessage( + messageId: string, + location: number, + source: number + ): void { + IterableLogger.log('removeMessage: ', messageId, location, source); + return RNIterableAPI.removeMessage(messageId, location, source); + } + + /** + * Set the read status of the specified message. + * + * @param messageId - The message to set the read status of. + * @param read - Whether the message is read. + */ + static setReadForMessage(messageId: string, read: boolean): void { + IterableLogger.log('setReadForMessage: ', messageId, read); + return RNIterableAPI.setReadForMessage(messageId, read); + } + + /** + * Pause or unpause the automatic display of incoming in-app messages + * + * @param autoDisplayPaused - Whether to pause or unpause the automatic display of incoming in-app messages + */ + static setAutoDisplayPaused(autoDisplayPaused: boolean): void { + IterableLogger.log('setAutoDisplayPaused: ', autoDisplayPaused); + return RNIterableAPI.setAutoDisplayPaused(autoDisplayPaused); + } + + /** + * Retrieve HTML in-app content for a specified in-app message. + * + * @param messageId - The message from which to get HTML content. + * + * @returns A Promise that resolves to an {@link IterableHtmlInAppContent} object. + */ + static getHtmlInAppContentForMessage( + messageId: string + ): Promise { + IterableLogger.log('getHtmlInAppContentForMessage: ', messageId); + return RNIterableAPI.getHtmlInAppContentForMessage(messageId); + } + + /** + * Set the response to an in-app message. + * + * @param inAppShowResponse - The response to an in-app message. + */ + static setInAppShowResponse(inAppShowResponse: IterableInAppShowResponse) { + IterableLogger.log('setInAppShowResponse: ', inAppShowResponse); + return RNIterableAPI.setInAppShowResponse(inAppShowResponse); + } + + /** + * Start a session. + * + * @param visibleRows - The visible rows. + */ + static startSession(visibleRows: IterableInboxImpressionRowInfo[]) { + IterableLogger.log('startSession: ', visibleRows); + return RNIterableAPI.startSession(visibleRows); + } + + /** + * End a session. + */ + static endSession() { + IterableLogger.log('endSession'); + return RNIterableAPI.endSession(); + } + + /** + * Update the visible rows. + * + * @param visibleRows - The visible rows. + */ + static updateVisibleRows(visibleRows: IterableInboxImpressionRowInfo[] = []) { + IterableLogger.log('updateVisibleRows: ', visibleRows); + return RNIterableAPI.updateVisibleRows(visibleRows); + } + + // ---- End IN-APP ---- // + + // ====================================================== // + // ======================= MOSC ======================= // + // ====================================================== // + + /** + * Update the cart. + * + * @param items - The items. + */ + static updateCart(items: IterableCommerceItem[]) { + IterableLogger.log('updateCart: ', items); + return RNIterableAPI.updateCart(items); + } + + /** + * Wake the app. + * ANDROID ONLY + */ + static wakeApp() { + if (Platform.OS === 'android') { + IterableLogger.log('wakeApp'); + return RNIterableAPI.wakeApp(); + } + } + + /** + * Handle an app link -- this is used to handle deep links. + * + * @param link - The link. + */ + static handleAppLink(link: string) { + IterableLogger.log('handleAppLink: ', link); + return RNIterableAPI.handleAppLink(link); + } + + /** + * Update the subscriptions. + * + * @param emailListIds - The email list IDs. + * @param unsubscribedChannelIds - The unsubscribed channel IDs. + * @param unsubscribedMessageTypeIds - The unsubscribed message type IDs. + * @param subscribedMessageTypeIds - The subscribed message type IDs. + * @param campaignId - The campaign ID. + * @param templateId - The template ID. + */ + static updateSubscriptions({ + emailListIds, + unsubscribedChannelIds, + unsubscribedMessageTypeIds, + subscribedMessageTypeIds, + campaignId, + templateId, + }: { + emailListIds: number[] | null; + unsubscribedChannelIds: number[] | null; + unsubscribedMessageTypeIds: number[] | null; + subscribedMessageTypeIds: number[] | null; + campaignId: number; + templateId: number; + }) { + IterableLogger.log( + 'updateSubscriptions: ', + emailListIds, + unsubscribedChannelIds, + unsubscribedMessageTypeIds, + subscribedMessageTypeIds, + campaignId, + templateId + ); + return RNIterableAPI.updateSubscriptions( + emailListIds, + unsubscribedChannelIds, + unsubscribedMessageTypeIds, + subscribedMessageTypeIds, + campaignId, + templateId + ); + } + + /** + * Get the last push payload. + */ + static getLastPushPayload() { + IterableLogger.log('getLastPushPayload'); + return RNIterableAPI.getLastPushPayload(); + } + + /** + * Get the attribution info. + */ + static getAttributionInfo() { + IterableLogger.log('getAttributionInfo'); + // FIXME: What if this errors? + return RNIterableAPI.getAttributionInfo().then( + ( + dict: { + campaignId: number; + templateId: number; + messageId: string; + } | null + ) => { + if (dict) { + return new IterableAttributionInfo( + dict.campaignId as number, + dict.templateId as number, + dict.messageId as string + ); + } else { + return undefined; + } + } + ); + } + + /** + * Set the attribution info. + * + * @param attributionInfo - The attribution info. + */ + static setAttributionInfo(attributionInfo?: IterableAttributionInfo) { + IterableLogger.log('setAttributionInfo: ', attributionInfo); + return RNIterableAPI.setAttributionInfo(attributionInfo); + } + + // ---- End MOSC ---- // +} diff --git a/src/core/classes/IterableAuthManager.ts b/src/core/classes/IterableAuthManager.ts new file mode 100644 index 000000000..44ece1af0 --- /dev/null +++ b/src/core/classes/IterableAuthManager.ts @@ -0,0 +1,44 @@ +import { IterableAuthResponse } from './IterableAuthResponse'; +import { IterableApi } from './IterableApi'; + +/** + * Manages the authentication for the Iterable SDK. + * + * @example + * ```typescript + * const authManager = new IterableAuthManager(); + * ``` + */ +export class IterableAuthManager { + /** + * Pause the authentication retry mechanism. + * + * @param pauseRetry - Whether to pause the authentication retry mechanism + * + * @example + * ```typescript + * const authManager = new IterableAuthManager(); + * authManager.pauseAuthRetries(true); + * ``` + */ + pauseAuthRetries(pauseRetry: boolean) { + return IterableApi.pauseAuthRetries(pauseRetry); + } + + /** + * Pass along an auth token to the SDK. + * + * @param authToken - The auth token to pass along + * + * @example + * ```typescript + * const authManager = new IterableAuthManager(); + * authManager.passAlongAuthToken(MY_AUTH_TOKEN); + * ``` + */ + passAlongAuthToken( + authToken: string | null | undefined + ): Promise { + return IterableApi.passAlongAuthToken(authToken); + } +} diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 1c0550b0c..173b57ab3 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -1,10 +1,10 @@ import { type IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; -import { IterableInAppShowResponse } from '../../inApp/enums'; -import { - IterableDataRegion, - IterableLogLevel, - IterablePushPlatform, -} from '../enums'; +import { IterableInAppShowResponse } from '../../inApp/enums/IterableInAppShowResponse'; +import { IterableDataRegion } from '../enums/IterableDataRegion'; +import { IterableLogLevel } from '../enums/IterableLogLevel'; +import { IterablePushPlatform } from '../enums/IterablePushPlatform'; +import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableRetryPolicy } from '../types/IterableRetryPolicy'; import { IterableAction } from './IterableAction'; import type { IterableActionContext } from './IterableActionContext'; import type { IterableAuthResponse } from './IterableAuthResponse'; @@ -206,12 +206,37 @@ export class IterableConfig { */ authHandler?: () => Promise; + /** + * A callback function that is called when the SDK encounters an error while + * validing the JWT. + * + * The retry for JWT should be automatically handled by the native SDK, so + * this is just for logging/transparency purposes. + * + * @param authFailure - The details of the auth failure. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.onJWTError = (authFailure) => { + * console.error('Error fetching JWT:', authFailure); + * }; + * ``` + */ + onJWTError?: (authFailure: IterableAuthFailure) => void; + /** * Set the verbosity of Android and iOS project's log system. * * By default, you will be able to see info level logs printed in IDE when running the app. */ - logLevel: IterableLogLevel = IterableLogLevel.info; + logLevel: IterableLogLevel = IterableLogLevel.debug; + + /** + * Configuration for JWT refresh retry behavior. + * If not specified, the SDK will use default retry behavior. + */ + retryPolicy?: IterableRetryPolicy; /** * Set whether the React Native SDK should print function calls to console. @@ -342,6 +367,7 @@ export class IterableConfig { dataRegion: this.dataRegion, pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, + retryPolicy: this.retryPolicy, }; } } diff --git a/src/core/classes/IterableLogger.test.ts b/src/core/classes/IterableLogger.test.ts new file mode 100644 index 000000000..8caacdf86 --- /dev/null +++ b/src/core/classes/IterableLogger.test.ts @@ -0,0 +1,398 @@ +import { IterableLogLevel } from '../enums/IterableLogLevel'; +import { IterableLogger, DEFAULT_LOG_LEVEL, DEFAULT_LOGGING_ENABLED } from './IterableLogger'; + +// Mock console.log to capture log output +const mockConsoleLog = jest.fn(); +const originalConsoleLog = console.log; + +describe('IterableLogger', () => { + beforeEach(() => { + // Reset to default values before each test + IterableLogger.loggingEnabled = DEFAULT_LOGGING_ENABLED; + IterableLogger.logLevel = DEFAULT_LOG_LEVEL; + + // Mock console.log + console.log = mockConsoleLog; + mockConsoleLog.mockClear(); + }); + + afterEach(() => { + // Restore original console.log + console.log = originalConsoleLog; + }); + + describe('Static Properties', () => { + test('should have default logging enabled', () => { + expect(IterableLogger.loggingEnabled).toBe(true); + }); + + test('should have default log level as debug', () => { + expect(IterableLogger.logLevel).toBe(IterableLogLevel.debug); + }); + + test('should allow setting loggingEnabled directly', () => { + IterableLogger.loggingEnabled = false; + expect(IterableLogger.loggingEnabled).toBe(false); + }); + + test('should allow setting logLevel directly', () => { + IterableLogger.logLevel = IterableLogLevel.error; + expect(IterableLogger.logLevel).toBe(IterableLogLevel.error); + }); + }); + + describe('setLoggingEnabled', () => { + test('should set logging enabled to true when passed true', () => { + IterableLogger.setLoggingEnabled(true); + expect(IterableLogger.loggingEnabled).toBe(true); + }); + + test('should set logging enabled to false when passed false', () => { + IterableLogger.setLoggingEnabled(false); + expect(IterableLogger.loggingEnabled).toBe(false); + }); + + test('should default to true when passed non-boolean value', () => { + IterableLogger.setLoggingEnabled(undefined); + expect(IterableLogger.loggingEnabled).toBe(true); + }); + + test('should default to true when passed null', () => { + // @ts-expect-error - null is not a valid value for loggingEnabled + IterableLogger.setLoggingEnabled(null); + expect(IterableLogger.loggingEnabled).toBe(true); + }); + + test('should default to true when passed string', () => { + // @ts-expect-error - string is not a valid value for loggingEnabled + IterableLogger.setLoggingEnabled('true'); + expect(IterableLogger.loggingEnabled).toBe(true); + }); + }); + + describe('setLogLevel', () => { + test('should set log level to error when passed error', () => { + IterableLogger.setLogLevel(IterableLogLevel.error); + expect(IterableLogger.logLevel).toBe(IterableLogLevel.error); + }); + + test('should set log level to debug when passed debug', () => { + IterableLogger.setLogLevel(IterableLogLevel.debug); + expect(IterableLogger.logLevel).toBe(IterableLogLevel.debug); + }); + + test('should set log level to info when passed info', () => { + IterableLogger.setLogLevel(IterableLogLevel.info); + expect(IterableLogger.logLevel).toBe(IterableLogLevel.info); + }); + + test('should default to debug when passed undefined', () => { + IterableLogger.setLogLevel(undefined); + expect(IterableLogger.logLevel).toBe(IterableLogLevel.debug); + }); + }); + + describe('log method', () => { + test('should log message when logging is enabled', () => { + IterableLogger.log('Test message'); + expect(mockConsoleLog).toHaveBeenCalledWith('Test message'); + }); + + test('should log message with optional parameters when logging is enabled', () => { + IterableLogger.log('Test message', 'param1', 'param2'); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Test message', + 'param1', + 'param2' + ); + }); + + test('should not log when logging is disabled', () => { + IterableLogger.loggingEnabled = false; + IterableLogger.log('Test message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + test('should log undefined message when no message provided', () => { + IterableLogger.log(); + expect(mockConsoleLog).toHaveBeenCalledWith(undefined); + }); + + test('should log object when object is passed', () => { + const testObj = { key: 'value' }; + IterableLogger.log(testObj); + expect(mockConsoleLog).toHaveBeenCalledWith(testObj); + }); + }); + + describe('error method', () => { + test('should log error message when logging is enabled and log level is error', () => { + IterableLogger.logLevel = IterableLogLevel.error; + IterableLogger.error('Error message'); + expect(mockConsoleLog).toHaveBeenCalledWith('ERROR:', 'Error message'); + }); + + test('should log error message with optional parameters', () => { + IterableLogger.logLevel = IterableLogLevel.error; + IterableLogger.error('Error message', 'param1', 'param2'); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'ERROR:', + 'Error message', + 'param1', + 'param2' + ); + }); + + test('should not log when logging is disabled', () => { + IterableLogger.loggingEnabled = false; + IterableLogger.logLevel = IterableLogLevel.error; + IterableLogger.error('Error message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + test('should not log when log level is not error', () => { + IterableLogger.logLevel = IterableLogLevel.debug; + IterableLogger.error('Error message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + test('should not log when log level is info', () => { + IterableLogger.logLevel = IterableLogLevel.info; + IterableLogger.error('Error message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + }); + + describe('debug method', () => { + test('should log debug message when logging is enabled and log level is debug', () => { + IterableLogger.logLevel = IterableLogLevel.debug; + IterableLogger.debug('Debug message'); + expect(mockConsoleLog).toHaveBeenCalledWith('DEBUG:', 'Debug message'); + }); + + test('should log debug message when logging is enabled and log level is error', () => { + IterableLogger.logLevel = IterableLogLevel.error; + IterableLogger.debug('Debug message'); + expect(mockConsoleLog).toHaveBeenCalledWith('DEBUG:', 'Debug message'); + }); + + test('should log debug message with optional parameters', () => { + IterableLogger.logLevel = IterableLogLevel.debug; + IterableLogger.debug('Debug message', 'param1', 'param2'); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'DEBUG:', + 'Debug message', + 'param1', + 'param2' + ); + }); + + test('should not log when logging is disabled', () => { + IterableLogger.loggingEnabled = false; + IterableLogger.logLevel = IterableLogLevel.debug; + IterableLogger.debug('Debug message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + test('should not log when log level is info', () => { + IterableLogger.logLevel = IterableLogLevel.info; + IterableLogger.debug('Debug message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + }); + + describe('info method', () => { + test('should log info message when logging is enabled and log level is info', () => { + IterableLogger.logLevel = IterableLogLevel.info; + IterableLogger.info('Info message'); + expect(mockConsoleLog).toHaveBeenCalledWith('INFO:', 'Info message'); + }); + + test('should log info message when logging is enabled and log level is debug', () => { + IterableLogger.logLevel = IterableLogLevel.debug; + IterableLogger.info('Info message'); + expect(mockConsoleLog).toHaveBeenCalledWith('INFO:', 'Info message'); + }); + + test('should log info message when logging is enabled and log level is error', () => { + IterableLogger.logLevel = IterableLogLevel.error; + IterableLogger.info('Info message'); + expect(mockConsoleLog).toHaveBeenCalledWith('INFO:', 'Info message'); + }); + + test('should log info message with optional parameters', () => { + IterableLogger.logLevel = IterableLogLevel.info; + IterableLogger.info('Info message', 'param1', 'param2'); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'INFO:', + 'Info message', + 'param1', + 'param2' + ); + }); + + test('should not log when logging is disabled', () => { + IterableLogger.loggingEnabled = false; + IterableLogger.logLevel = IterableLogLevel.info; + IterableLogger.info('Info message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + }); + + describe('Log Level Hierarchy', () => { + test('should respect log level hierarchy for error level', () => { + IterableLogger.logLevel = IterableLogLevel.error; + + IterableLogger.error('Error message'); + IterableLogger.debug('Debug message'); + IterableLogger.info('Info message'); + + // When logLevel is error (3), all messages should log + // Note: There's a bug in the error method - it only logs when logLevel is exactly error + // It should log when logLevel is error OR higher (debug, info) + expect(mockConsoleLog).toHaveBeenCalledTimes(3); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'ERROR:', + 'Error message' + ); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 2, + 'DEBUG:', + 'Debug message' + ); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 3, + 'INFO:', + 'Info message' + ); + }); + + test('should respect log level hierarchy for debug level', () => { + IterableLogger.logLevel = IterableLogLevel.debug; + + IterableLogger.error('Error message'); + IterableLogger.debug('Debug message'); + IterableLogger.info('Info message'); + + // When logLevel is debug (1), debug and info should log + // Note: There's a bug in the error method - it doesn't log when logLevel is debug + // It should log when logLevel is debug OR higher (info) + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'DEBUG:', + 'Debug message' + ); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 2, + 'INFO:', + 'Info message' + ); + }); + + test('should respect log level hierarchy for info level', () => { + IterableLogger.logLevel = IterableLogLevel.info; + + IterableLogger.error('Error message'); + IterableLogger.debug('Debug message'); + IterableLogger.info('Info message'); + + // When logLevel is info (2), only info should log + // Note: There's a bug in the error method - it doesn't log when logLevel is info + // It should log when logLevel is info (highest level) + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'INFO:', + 'Info message' + ); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty string messages', () => { + IterableLogger.log(''); + expect(mockConsoleLog).toHaveBeenCalledWith(''); + }); + + test('should handle null messages', () => { + IterableLogger.log(null); + expect(mockConsoleLog).toHaveBeenCalledWith(null); + }); + + test('should handle zero as message', () => { + IterableLogger.log(0); + expect(mockConsoleLog).toHaveBeenCalledWith(0); + }); + + test('should handle false as message', () => { + IterableLogger.log(false); + expect(mockConsoleLog).toHaveBeenCalledWith(false); + }); + + test('should handle complex objects as messages', () => { + const complexObj = { + nested: { value: 'test' }, + array: [1, 2, 3], + func: () => 'test', + }; + IterableLogger.log(complexObj); + expect(mockConsoleLog).toHaveBeenCalledWith(complexObj); + }); + + test('should handle multiple optional parameters of different types', () => { + IterableLogger.log('Message', 123, true, { key: 'value' }, [1, 2, 3]); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Message', + 123, + true, + { key: 'value' }, + [1, 2, 3] + ); + }); + }); + + describe('Integration Tests', () => { + test('should work with real-world usage patterns', () => { + // Simulate typical usage + IterableLogger.setLoggingEnabled(true); + IterableLogger.setLogLevel(IterableLogLevel.info); + + IterableLogger.info('SDK initialized'); + IterableLogger.debug('Debug info', { userId: '123' }); + IterableLogger.error('API error', { status: 500 }); + + // Note: Due to bug in error method, only info logs when logLevel is info + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'INFO:', + 'SDK initialized' + ); + }); + + test('should handle rapid state changes', () => { + // Test rapid state changes + IterableLogger.setLoggingEnabled(false); + IterableLogger.log('Should not appear'); + + IterableLogger.setLoggingEnabled(true); + IterableLogger.setLogLevel(IterableLogLevel.error); + IterableLogger.info('Should appear'); // info logs when logLevel is error + IterableLogger.error('Should appear'); + + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'INFO:', + 'Should appear' + ); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 2, + 'ERROR:', + 'Should appear' + ); + }); + }); +}); diff --git a/src/core/classes/IterableLogger.ts b/src/core/classes/IterableLogger.ts index 3d9854888..6ce8d0d7c 100644 --- a/src/core/classes/IterableLogger.ts +++ b/src/core/classes/IterableLogger.ts @@ -1,51 +1,145 @@ -import { IterableConfig } from './IterableConfig'; +import { IterableLogLevel } from '../enums/IterableLogLevel'; + +export const DEFAULT_LOG_LEVEL = IterableLogLevel.debug; +export const DEFAULT_LOGGING_ENABLED = true; /** * A logger class for the Iterable SDK. * - * This class is responsible for logging messages based on the configuration provided. + * This class is responsible for logging messages based on the configuration + * provided, is useful in unit testing or debug environments. * * @remarks * The logging behavior is controlled by the `logReactNativeSdkCalls` property * in {@link IterableConfig}. - * If this property is not set, logging defaults to `true`, which is useful in unit testing or debug environments. + * + * If this property is not set, logging defaults to `true`, which is useful in + * unit testing or debug environments. * * @example * ```typescript - * const config = new IterableConfig(); - * config.logReactNativeSdkCalls = true; - * const logger = new IterableLogger(config); - * logger.log('This is a log message.'); + * IterableLogger.logLevel = IterableLogLevel.debug; + * IterableLogger.loggingEnabled = true; + * + * // This log will show in the developer console + * IterableLogger.log('I will be shown.'); + * + * Iterable.loggingEnabled = false; + * + * // This log will show in the developer console + * IterableLogger.log('I will NOT be shown.'); + * * ``` */ export class IterableLogger { /** - * The configuration settings for the Iterable SDK. - * This property is read-only and is initialized with an instance of `IterableConfig`. + * Whether logs should show in the developer console. */ - readonly config: IterableConfig; + static loggingEnabled = DEFAULT_LOGGING_ENABLED; /** - * Creates an instance of IterableLogger. + * The level of logging. * - * @param config - The configuration object for IterableLogger. + * This controls which logs will show when using the {@link IterableLogger.error}, {@link IterableLogger.debug}, and {@link IterableLogger.info} methods. */ - constructor(config: IterableConfig) { - this.config = config; + static logLevel = DEFAULT_LOG_LEVEL; + + /** + * Sets whether logs should show in the developer console. + * + * @param loggingEnabled - Whether logs should show in the developer console. + */ + static setLoggingEnabled(loggingEnabled?: boolean) { + IterableLogger.loggingEnabled = + typeof loggingEnabled === 'boolean' + ? loggingEnabled + : DEFAULT_LOGGING_ENABLED; + } + + /** + * Sets the level of logging to show in the developer console. + * + * @param logLevel - The level of logging to show in the developer console. + */ + static setLogLevel(logLevel?: IterableLogLevel) { + IterableLogger.logLevel = + typeof logLevel === 'undefined' ? DEFAULT_LOG_LEVEL : logLevel; } /** * Logs a message to the console if logging is enabled. * * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.log('I will show if logging is enabled'); + * ``` + */ + static log(message?: unknown, ...optionalParams: unknown[]) { + if (!IterableLogger.loggingEnabled) return; + + console.log(message, ...optionalParams); + } + + /** + * Logs a message to the console if the log level is {@link IterableLogLevel.error}. + * + * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.error('I will only show if the log level is error and logging is enabled'); + * ``` + */ + static error(message?: unknown, ...optionalParams: unknown[]) { + if (!IterableLogger.loggingEnabled) return; + if (IterableLogger.logLevel !== IterableLogLevel.error) return; + + console.log(`ERROR:`, message, ...optionalParams); + } + + /** + * Logs a message to the console if the log level is + * {@link IterableLogLevel.debug} or {@link IterableLogLevel.error}. + * + * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.debug('I will show if the log level is debug and logging is enabled'); + * IterableLogger.debug('I will also show if the log level is error and logging is enabled'); + * ``` + */ + static debug(message?: unknown, ...optionalParams: unknown[]) { + if (!IterableLogger.loggingEnabled) return; + + const shouldLog = [IterableLogLevel.error, IterableLogLevel.debug].includes( + IterableLogger.logLevel + ); + + if (!shouldLog) return; + + console.log(`DEBUG:`, message, ...optionalParams); + } + + /** + * Logs a message to the console if the log level is + * {@link IterableLogLevel.info}, {@link IterableLogLevel.debug} or + * {@link IterableLogLevel.error}. + * + * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.info('I will show if the log level is info and logging is enabled'); + * IterableLogger.info('I will also show if the log level is debug and logging is enabled'); + * IterableLogger.info('I will also show if the log level is error and logging is enabled'); + * ``` */ - log(message: string) { - // default to `true` in the case of unit testing where `Iterable` is not initialized - // which is most likely in a debug environment anyways - const loggingEnabled = this.config.logReactNativeSdkCalls ?? true; + static info(message?: unknown, ...optionalParams: unknown[]) { + if (!IterableLogger.loggingEnabled) return; - if (loggingEnabled) { - console.log(message); - } + console.log(`INFO:`, message, ...optionalParams); } } diff --git a/src/core/classes/index.ts b/src/core/classes/index.ts index 60c26047c..c201f3d7b 100644 --- a/src/core/classes/index.ts +++ b/src/core/classes/index.ts @@ -1,7 +1,9 @@ export * from './Iterable'; export * from './IterableAction'; export * from './IterableActionContext'; +export * from './IterableApi'; export * from './IterableAttributionInfo'; +export * from './IterableAuthManager'; export * from './IterableAuthResponse'; export * from './IterableCommerceItem'; export * from './IterableConfig'; diff --git a/src/core/enums/IterableAuthFailureReason.ts b/src/core/enums/IterableAuthFailureReason.ts new file mode 100644 index 000000000..51c610c4f --- /dev/null +++ b/src/core/enums/IterableAuthFailureReason.ts @@ -0,0 +1,43 @@ +/** + * The reason for the failure of an authentication attempt. + * + * This is generally related to JWT token validation. + * + * FIXME: Android returns the string (EG: `'AUTH_TOKEN_EXPIRATION_INVALID'`), + * but iOS returns the enum value (EG: `0`). These should be standardized so + * that they both return the same type on either platform. + */ +export enum IterableAuthFailureReason { + /** + * An auth token's expiration must be less than one year from its issued-at + * time. + */ + AUTH_TOKEN_EXPIRATION_INVALID, + /** The token has expired. */ + AUTH_TOKEN_EXPIRED, + /** Token has an invalid format (failed a regular expression check). */ + AUTH_TOKEN_FORMAT_INVALID, + /** `onAuthTokenRequested` threw an exception. */ + AUTH_TOKEN_GENERATION_ERROR, + /** Any other error not captured by another constant. */ + AUTH_TOKEN_GENERIC_ERROR, + /** Iterable has invalidated this token and it cannot be used. */ + AUTH_TOKEN_INVALIDATED, + /** The request to Iterable's API did not include a JWT authorization header. */ + AUTH_TOKEN_MISSING, + /** `onAuthTokenRequested` returned a null JWT token. */ + AUTH_TOKEN_NULL, + /** + * Iterable could not decode the token's payload (`iat`, `exp`, `email`, + * or `userId`). + */ + AUTH_TOKEN_PAYLOAD_INVALID, + /** Iterable could not validate the token's authenticity. */ + AUTH_TOKEN_SIGNATURE_INVALID, + /** + * The token doesn't include an `email` or a `userId`. Or, one of these + * values is included, but it references a user that isn't in the Iterable + * project. + */ + AUTH_TOKEN_USER_KEY_INVALID, +} diff --git a/src/core/enums/IterableLogLevel.ts b/src/core/enums/IterableLogLevel.ts index 04c13ec7b..abb33577d 100644 --- a/src/core/enums/IterableLogLevel.ts +++ b/src/core/enums/IterableLogLevel.ts @@ -1,14 +1,23 @@ /** - * Enum representing the level of logs will Android and iOS projects be using. + * Level of logs for iOS, Android and React Native. + * + * These levels will control when logs are shown. * * @see [Android Log Levels](https://source.android.com/docs/core/tests/debug/understanding-logging) * @see [iOS Log Levels](https://apple.github.io/swift-log/docs/current/Logging/Structs/Logger/Level.html#/s:7Logging6LoggerV5LevelO4infoyA2EmF) */ export enum IterableLogLevel { - /** Appropriate for messages that contain information normally of use only when debugging a program. */ + /** Show logs only for errors. */ + error = 3, + /** + * Show logs for messages that contain information normally of use only when debugging a program. + * Also includes {@link IterableLogLevel.error} messages. + */ debug = 1, - /** Appropriate for informational messages. */ + /** + * Show logs which include general information about app flow — e.g., lifecycle events + * or major state changes. This is the most verbose logging level. + * Also includes {@link IterableLogLevel.error} and {@link IterableLogLevel.debug} messages. + */ info = 2, - /** Appropriate for error conditions. */ - error = 3, } diff --git a/src/core/enums/IterableRetryBackoff.ts b/src/core/enums/IterableRetryBackoff.ts new file mode 100644 index 000000000..526b58eaf --- /dev/null +++ b/src/core/enums/IterableRetryBackoff.ts @@ -0,0 +1,17 @@ +/* eslint-disable tsdoc/syntax */ + +/** + * The type of backoff to use when retrying a request. + */ +export enum IterableRetryBackoff { + /** + * Linear backoff (each retry will wait for a fixed interval) + * TODO: check with @Ayyanchira if this is correct + */ + LINEAR = 'LINEAR', + /** + * Exponential backoff (each retry will wait for an interval that increases exponentially) + * TODO: check with @Ayyanchira if this is correct + */ + EXPONENTIAL = 'EXPONENTIAL', +} diff --git a/src/core/enums/index.ts b/src/core/enums/index.ts index e95b5350c..52f4eb20d 100644 --- a/src/core/enums/index.ts +++ b/src/core/enums/index.ts @@ -1,6 +1,8 @@ export * from './IterableActionSource'; +export * from './IterableAuthFailureReason'; export * from './IterableAuthResponseResult'; export * from './IterableDataRegion'; export * from './IterableEventName'; export * from './IterableLogLevel'; export * from './IterablePushPlatform'; +export * from './IterableRetryBackoff'; diff --git a/src/core/types/IterableAuthFailure.ts b/src/core/types/IterableAuthFailure.ts new file mode 100644 index 000000000..0f2d1cf5e --- /dev/null +++ b/src/core/types/IterableAuthFailure.ts @@ -0,0 +1,18 @@ +import type { IterableAuthFailureReason } from "../enums/IterableAuthFailureReason"; + +/** + * The details of an auth failure. + */ +export interface IterableAuthFailure { + /** `userId` or `email` of the signed-in user */ + userKey: string; + + /** The `authToken` which caused the failure */ + failedAuthToken: string; + + /** The timestamp of the failed request */ + failedRequestTime: number; + + /** Indicates a reason for failure */ + failureReason: IterableAuthFailureReason; +} diff --git a/src/core/types/IterableRetryPolicy.ts b/src/core/types/IterableRetryPolicy.ts new file mode 100644 index 000000000..318b705a0 --- /dev/null +++ b/src/core/types/IterableRetryPolicy.ts @@ -0,0 +1,16 @@ +import type { IterableRetryBackoff } from "../enums/IterableRetryBackoff"; + +/** + * The policy for retrying an authentication attempt. + */ +export interface IterableRetryPolicy { + /** Number of consecutive JWT refresh retries the SDK should attempt */ + maxRetry: number; + /** + * Duration between JWT refresh retries in seconds + * (starting point for retry backoff) + */ + retryInterval: number; + /** The backoff pattern to apply between retry attempts. */ + retryBackoff: IterableRetryBackoff; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index f5d846482..7659a76e4 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -1 +1,3 @@ +export * from './IterableAuthFailure'; export * from './IterableEdgeInsetDetails'; +export * from './IterableRetryPolicy'; diff --git a/src/inApp/classes/IterableInAppManager.ts b/src/inApp/classes/IterableInAppManager.ts index f376b35bc..ae34f80d1 100644 --- a/src/inApp/classes/IterableInAppManager.ts +++ b/src/inApp/classes/IterableInAppManager.ts @@ -1,4 +1,4 @@ -import { RNIterableAPI } from '../../api'; +import { IterableApi } from '../../core/classes/IterableApi'; import type { IterableInAppDeleteSource, IterableInAppLocation, @@ -13,6 +13,20 @@ import { IterableInAppMessage } from './IterableInAppMessage'; * displaying messages, removing messages, setting read status, and more. * * The `inAppManager` property of an `Iterable` instance is set to an instance of this class. + * + * @example + * ```typescript + * const inAppManager = new IterableInAppManager(); + * + * inAppManager.getMessages().then(messages => { + * console.log('Messages:', messages); + * }); + * + * // You can also access an instance on `Iterable.inAppManager` + * Iterable.inAppManager.getMessages().then(messages => { + * console.log('Messages:', messages); + * }); + * ``` */ export class IterableInAppManager { /** @@ -32,9 +46,7 @@ export class IterableInAppManager { * @returns A Promise that resolves to an array of in-app messages. */ getMessages(): Promise { - // Iterable?.logger?.log('InAppManager.getMessages'); - - return RNIterableAPI.getInAppMessages() as unknown as Promise< + return IterableApi.getInAppMessages() as unknown as Promise< IterableInAppMessage[] >; } @@ -57,9 +69,7 @@ export class IterableInAppManager { * @returns A Promise that resolves to an array of messages marked as `saveToInbox`. */ getInboxMessages(): Promise { - // Iterable?.logger?.log('InAppManager.getInboxMessages'); - - return RNIterableAPI.getInboxMessages() as unknown as Promise< + return IterableApi.getInboxMessages() as unknown as Promise< IterableInAppMessage[] >; } @@ -86,9 +96,7 @@ export class IterableInAppManager { message: IterableInAppMessage, consume: boolean ): Promise { - // Iterable?.logger?.log('InAppManager.show'); - - return RNIterableAPI.showMessage(message.messageId, consume); + return IterableApi.showMessage(message.messageId, consume); } /** @@ -114,9 +122,7 @@ export class IterableInAppManager { location: IterableInAppLocation, source: IterableInAppDeleteSource ): void { - // Iterable?.logger?.log('InAppManager.remove'); - - return RNIterableAPI.removeMessage(message.messageId, location, source); + return IterableApi.removeMessage(message.messageId, location, source); } /** @@ -131,9 +137,7 @@ export class IterableInAppManager { * ``` */ setReadForMessage(message: IterableInAppMessage, read: boolean) { - // Iterable?.logger?.log('InAppManager.setRead'); - - RNIterableAPI.setReadForMessage(message.messageId, read); + return IterableApi.setReadForMessage(message.messageId, read); } /** @@ -151,11 +155,7 @@ export class IterableInAppManager { getHtmlContentForMessage( message: IterableInAppMessage ): Promise { - // Iterable?.logger?.log('InAppManager.getHtmlContentForMessage'); - - return RNIterableAPI.getHtmlInAppContentForMessage( - message.messageId - ) as unknown as Promise; + return IterableApi.getHtmlInAppContentForMessage(message.messageId); } /** @@ -173,8 +173,6 @@ export class IterableInAppManager { * ``` */ setAutoDisplayPaused(paused: boolean) { - // Iterable?.logger?.log('InAppManager.setAutoDisplayPaused'); - - RNIterableAPI.setAutoDisplayPaused(paused); + return IterableApi.setAutoDisplayPaused(paused); } } diff --git a/src/inApp/classes/IterableInAppMessage.ts b/src/inApp/classes/IterableInAppMessage.ts index 8a5b816bf..c77b08e63 100644 --- a/src/inApp/classes/IterableInAppMessage.ts +++ b/src/inApp/classes/IterableInAppMessage.ts @@ -135,19 +135,19 @@ export class IterableInAppMessage { * @returns A new instance of `IterableInAppMessage` populated with data from the `viewToken`. */ static fromViewToken(viewToken: ViewToken) { - const inAppMessage = viewToken.item.inAppMessage as IterableInAppMessage; + const inAppMessage = viewToken?.item?.inAppMessage as IterableInAppMessage; return new IterableInAppMessage( - inAppMessage.messageId, - inAppMessage.campaignId, - inAppMessage.trigger, - inAppMessage.createdAt, - inAppMessage.expiresAt, - inAppMessage.saveToInbox, - inAppMessage.inboxMetadata, - inAppMessage.customPayload, - inAppMessage.read, - inAppMessage.priorityLevel + inAppMessage?.messageId, + inAppMessage?.campaignId, + inAppMessage?.trigger, + inAppMessage?.createdAt, + inAppMessage?.expiresAt, + inAppMessage?.saveToInbox, + inAppMessage?.inboxMetadata, + inAppMessage?.customPayload, + inAppMessage?.read, + inAppMessage?.priorityLevel ); } diff --git a/src/inbox/classes/IterableInboxDataModel.ts b/src/inbox/classes/IterableInboxDataModel.ts index 311f5cc7c..4b81d5b22 100644 --- a/src/inbox/classes/IterableInboxDataModel.ts +++ b/src/inbox/classes/IterableInboxDataModel.ts @@ -1,5 +1,4 @@ -import { RNIterableAPI } from '../../api'; -import { Iterable } from '../../core/classes/Iterable'; +import { IterableApi } from '../../core/classes/IterableApi'; import { IterableHtmlInAppContent, IterableInAppDeleteSource, @@ -94,11 +93,7 @@ export class IterableInboxDataModel { * @returns A promise that resolves to the HTML content of the specified message. */ getHtmlContentForMessageId(id: string): Promise { - Iterable?.logger?.log( - 'IterableInboxDataModel.getHtmlContentForItem messageId: ' + id - ); - - return RNIterableAPI.getHtmlInAppContentForMessage(id).then( + return IterableApi.getHtmlInAppContentForMessage(id).then( (content: IterableHtmlInAppContentRaw) => { return IterableHtmlInAppContent.fromDict(content); } @@ -111,9 +106,7 @@ export class IterableInboxDataModel { * @param id - The unique identifier of the message to be marked as read. */ setMessageAsRead(id: string) { - Iterable?.logger?.log('IterableInboxDataModel.setMessageAsRead'); - - RNIterableAPI.setReadForMessage(id, true); + return IterableApi.setReadForMessage(id, true); } /** @@ -123,9 +116,11 @@ export class IterableInboxDataModel { * @param deleteSource - The source from which the delete action is initiated. */ deleteItemById(id: string, deleteSource: IterableInAppDeleteSource) { - Iterable?.logger?.log('IterableInboxDataModel.deleteItemById'); - - RNIterableAPI.removeMessage(id, IterableInAppLocation.inbox, deleteSource); + return IterableApi.removeMessage( + id, + IterableInAppLocation.inbox, + deleteSource + ); } /** @@ -135,7 +130,7 @@ export class IterableInboxDataModel { * If the fetch operation fails, the promise resolves to an empty array. */ async refresh(): Promise { - return RNIterableAPI.getInboxMessages().then( + return IterableApi.getInboxMessages().then( (messages: IterableInAppMessage[]) => { return this.processMessages(messages); }, @@ -151,7 +146,7 @@ export class IterableInboxDataModel { * @param visibleRows - An array of `IterableInboxImpressionRowInfo` objects representing the rows that are currently visible. */ startSession(visibleRows: IterableInboxImpressionRowInfo[] = []) { - RNIterableAPI.startSession(visibleRows as unknown as { [key: string]: string | number | boolean }[]); + return IterableApi.startSession(visibleRows); } /** @@ -162,7 +157,7 @@ export class IterableInboxDataModel { */ async endSession(visibleRows: IterableInboxImpressionRowInfo[] = []) { await this.updateVisibleRows(visibleRows); - RNIterableAPI.endSession(); + return IterableApi.endSession(); } /** @@ -178,7 +173,7 @@ export class IterableInboxDataModel { * Defaults to an empty array if not provided. */ updateVisibleRows(visibleRows: IterableInboxImpressionRowInfo[] = []) { - RNIterableAPI.updateVisibleRows(visibleRows as unknown as { [key: string]: string | number | boolean }[]); + return IterableApi.updateVisibleRows(visibleRows); } /** diff --git a/src/inbox/components/IterableInboxMessageList.tsx b/src/inbox/components/IterableInboxMessageList.tsx index 95d6707c5..b74cc7097 100644 --- a/src/inbox/components/IterableInboxMessageList.tsx +++ b/src/inbox/components/IterableInboxMessageList.tsx @@ -95,16 +95,30 @@ export const IterableInboxMessageList = ({ function getRowInfosFromViewTokens( viewTokens: Array ): Array { - return viewTokens.map(function (viewToken) { - const inAppMessage = IterableInAppMessage.fromViewToken(viewToken); + return viewTokens + .filter((viewToken) => { + // Filter out viewTokens that don't have valid items or inAppMessage + return viewToken?.item?.inAppMessage?.messageId; + }) + .map(function (viewToken) { + try { + const inAppMessage = IterableInAppMessage.fromViewToken(viewToken); - const impression = { - messageId: inAppMessage.messageId, - silentInbox: inAppMessage.isSilentInbox(), - } as IterableInboxImpressionRowInfo; + const impression = { + messageId: inAppMessage?.messageId, + silentInbox: inAppMessage?.isSilentInbox(), + } as IterableInboxImpressionRowInfo; - return impression; - }); + return impression; + } catch (error) { + // Log the error and return null to be filtered out + console.warn('Failed to create impression from ViewToken:', error); + return null; + } + }) + .filter( + (impression) => impression !== null + ) as Array; } const inboxSessionViewabilityConfig: ViewabilityConfig = { diff --git a/src/index.tsx b/src/index.tsx index 885cd74bd..240ac51f5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,17 +14,24 @@ export { } from './core/classes'; export { IterableActionSource, + IterableAuthFailureReason, + IterableAuthResponseResult, IterableDataRegion, IterableEventName, IterableLogLevel, IterablePushPlatform, + IterableRetryBackoff, } from './core/enums'; export { useAppStateListener, useDeviceOrientation, type IterableDeviceOrientation, } from './core/hooks'; -export { type IterableEdgeInsetDetails } from './core/types'; +export type { + IterableAuthFailure, + IterableEdgeInsetDetails, + IterableRetryPolicy, +} from './core/types'; export { IterableHtmlInAppContent, IterableInAppCloseSource, diff --git a/src/itblBuildInfo.ts b/src/itblBuildInfo.ts index 097bd43d2..9ab008e03 100644 --- a/src/itblBuildInfo.ts +++ b/src/itblBuildInfo.ts @@ -3,5 +3,5 @@ * It contains the version of the package */ export const buildInfo = { - version: '2.1.0-beta.1', + version: '2.2.0-alpha.0', }; diff --git a/yarn.lock b/yarn.lock index 418409009..348741043 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1994,7 +1994,7 @@ __metadata: commitlint: ^19.6.1 del-cli: ^5.1.0 eslint: ^8.51.0 - eslint-config-prettier: ^9.0.0 + eslint-config-prettier: ^10.1.8 eslint-plugin-jest: ^28.9.0 eslint-plugin-prettier: ^5.0.1 eslint-plugin-tsdoc: ^0.3.0 @@ -6215,25 +6215,25 @@ __metadata: languageName: node linkType: hard -"eslint-config-prettier@npm:^8.5.0": - version: 8.10.2 - resolution: "eslint-config-prettier@npm:8.10.2" +"eslint-config-prettier@npm:^10.1.8": + version: 10.1.8 + resolution: "eslint-config-prettier@npm:10.1.8" peerDependencies: eslint: ">=7.0.0" bin: eslint-config-prettier: bin/cli.js - checksum: a92b7e8a996e65adf79de1579524235687e9d3552d088cfab4f170da60d23762addb4276169c8ca3a9551329dda8408c59f7e414101b238a6385379ac1bc3b16 + checksum: 9140e19f78f0dbc888b160bb72b85f8043bada7b12a548faa56cea0ba74f8ef16653250ffd014d85d9a376a88c4941c96a3cdc9d39a07eb3def6967166635bd8 languageName: node linkType: hard -"eslint-config-prettier@npm:^9.0.0": - version: 9.1.2 - resolution: "eslint-config-prettier@npm:9.1.2" +"eslint-config-prettier@npm:^8.5.0": + version: 8.10.2 + resolution: "eslint-config-prettier@npm:8.10.2" peerDependencies: eslint: ">=7.0.0" bin: eslint-config-prettier: bin/cli.js - checksum: e786b767331094fd024cb1b0899964a9da0602eaf4ebd617d6d9794752ccd04dbe997e3c14c17f256c97af20bee1c83c9273f69b74cb2081b6f514580d62408f + checksum: a92b7e8a996e65adf79de1579524235687e9d3552d088cfab4f170da60d23762addb4276169c8ca3a9551329dda8408c59f7e414101b238a6385379ac1bc3b16 languageName: node linkType: hard