diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index af2288d09..5343bd6c9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -59,7 +59,8 @@ jobs: total_tests: 0, passed_tests: 0, failed_tests: 0, - success_rate: 0 + success_rate: 0, + skipped_tests: 0 }; try { @@ -77,17 +78,93 @@ jobs: } catch (error) { core.warning(`Error reading test summary: ${error.message}`); } - - - // Extract just the main content from the HTML - removing the HTML tags + + // Clean and optimize HTML for GitHub Check Run API function stripHtml(html) { - // Simple regex to extract text content from HTML + if (!html) return ''; + return html - .replace(/

[\s\S]*?<\/h2>/gi, '') + // Remove problematic elements + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + // Clean up complex attributes but keep basic structure + .replace(/\s*(class|id|dir|data-[^=]*|role|aria-[^=]*|tabindex)="[^"]*"/gi, '') + .replace(/\s*(markdown-accessiblity-table|data-catalyst)="[^"]*"/gi, '') + // Remove GitHub-specific elements that don't render in Check Runs + .replace(/]*>.*?<\/g-emoji>/gi, '⚠️') + .replace(//gi, '') + .replace(/
/gi, '') + // Clean up excessive whitespace + .replace(/\s+/g, ' ') + .replace(/>\s+<') .trim(); } - // Create the check with test results as summary and coverage as details + // Function to safely truncate content to fit byte limit + function truncateToByteLimit(text, maxBytes) { + if (!text) return ''; + + // Convert to bytes to check actual size + const encoder = new TextEncoder(); + let bytes = encoder.encode(text); + + if (bytes.length <= maxBytes) { + return text; + } + + // Binary search to find the maximum length that fits + let left = 0; + let right = text.length; + let result = ''; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const substring = text.substring(0, mid); + const substringBytes = encoder.encode(substring); + + if (substringBytes.length <= maxBytes) { + result = substring; + left = mid + 1; + } else { + right = mid - 1; + } + } + + // Add truncation indicator if content was cut off + if (result.length < text.length) { + const truncateMsg = '\n\n... (truncated due to size limits)'; + const truncateMsgBytes = encoder.encode(truncateMsg); + + if (encoder.encode(result).length + truncateMsgBytes.length <= maxBytes) { + result += truncateMsg; + } + } + + return result; + } + + // Extract and clean content + const cleanTestReport = stripHtml(testReport); + const cleanCoverageReport = stripHtml(coverageReport); + + // Create concise summary focusing on key information + const summaryContent = `Test Results Summary: + • Total Tests: ${testStats.total_tests} + • Passed: ${testStats.passed_tests} + • Failed: ${testStats.failed_tests} + • Skipped: ${testStats.skipped_tests || 0} + • Success Rate: ${(testStats.success_rate || 0).toFixed(1)}% + + ${cleanTestReport}`; + + // Ensure summary fits within GitHub's 65535 byte limit (leaving some buffer) + const truncatedSummary = truncateToByteLimit(summaryContent, 65000); + + // Ensure coverage report fits within the text field limit + const truncatedCoverage = truncateToByteLimit(cleanCoverageReport, 65000); + + // Create the check with test results await github.rest.checks.create({ owner: context.repo.owner, repo: context.repo.repo, @@ -96,9 +173,9 @@ jobs: status: 'completed', conclusion: testStats.failed_tests > 0 ? 'failure' : 'success', output: { - title: `Tests: ${testStats.passed_tests}/${testStats.passed_tests + testStats.failed_tests} passed (${(testStats.success_rate).toFixed(1)}%) Skipped: ${testStats.skipped_tests}`, - summary: stripHtml(testReport.substring(0, 65000)), - text: stripHtml(coverageReport.substring(0, 65000)) + title: `Tests: ${testStats.passed_tests}/${testStats.passed_tests + testStats.failed_tests} passed (${(testStats.success_rate || 0).toFixed(1)}%) Skipped: ${testStats.skipped_tests || 0}`, + summary: truncatedSummary, + text: truncatedCoverage } }); diff --git a/.gitignore b/.gitignore index 4d9718613..7357e9cba 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ xcuserdata .swiftpm/ +# Claude AI agent settings +.claude/ + *~ Podfile.lock Pods/ diff --git a/AGENT_README.md b/AGENT_README.md new file mode 100644 index 000000000..f57ecee1b --- /dev/null +++ b/AGENT_README.md @@ -0,0 +1,166 @@ +# AGENT README - Iterable Swift SDK + +## Project Overview +This is the **Iterable Swift SDK** for iOS/macOS integration. The SDK provides: +- Push notification handling +- In-app messaging +- Event tracking +- User management +- Unknown user tracking + +## Key Architecture +- **Core SDK**: `swift-sdk/` - Main SDK implementation +- **Sample Apps**: `sample-apps/` - Example integrations +- **Tests**: `tests/` - Unit tests, UI tests, and integration tests +- **Notification Extension**: `notification-extension/` - Rich push support + +## Development Workflow + +### 🔨 Building the SDK +```bash +./agent_build.sh +``` +- Validates compilation on iOS Simulator +- Shows build errors with context +- Requires macOS with Xcode + +### Listing All Available Tests + +# List all available test suites +```bash +./agent_test.sh --list +``` + +### 🧪 Running Tests +```bash +# Run all tests +./agent_test.sh + +# Run specific test suite +./agent_test.sh IterableApiCriteriaFetchTests + +# Run specific unit test (dot notation - recommended) +./agent_test.sh "IterableApiCriteriaFetchTests.testForegroundCriteriaFetchWhenConditionsMet" + +# Run any specific test with path +./agent_test.sh "unit-tests/IterableApiCriteriaFetchTests/testForegroundCriteriaFetchWhenConditionsMet" +``` +- Executes on iOS Simulator with accurate pass/fail reporting +- Returns exit code 0 for success, 1 for failures +- Shows detailed test counts and failure information +- `--list` shows all test suites with test counts +- Requires password for xcpretty installation (first run) + +## Project Structure +``` +swift-sdk/ +├── swift-sdk/ # Main SDK source +│ ├── Core/ # Public APIs and models +│ ├── Internal/ # Internal implementation +│ ├── SDK/ # Main SDK entry points +│ └── ui-components/ # SwiftUI/UIKit components +├── tests/ # Test suites +│ ├── unit-tests/ # Unit tests +│ ├── ui-tests/ # UI automation tests +│ └── endpoint-tests/ # API endpoint tests +├── sample-apps/ # Example applications +└── notification-extension/ # Push notification extension +``` + +## Key Classes +- **IterableAPI**: Main SDK interface +- **IterableConfig**: Configuration management +- **InternalIterableAPI**: Core implementation +- **UnknownUserManager**: Unknown user tracking +- **LocalStorage**: Data persistence + +## Common Tasks + +### Adding New Features +1. Build first: `./agent_build.sh` +2. Implement in `swift-sdk/Internal/` or `swift-sdk/SDK/` +3. Add tests in `tests/unit-tests/` +4. Verify: `./agent_test.sh` (all tests) or `./agent_test.sh YourTestSuite` (specific suite) + +### Debugging Build Issues +- Build script shows compilation errors with file paths +- Check Xcode project references in `swift-sdk.xcodeproj/project.pbxproj` +- Verify file renames are reflected in project file + +### Test Failures +- Test script shows specific failures with line numbers and detailed error messages +- Run failing tests individually: `./agent_test.sh "TestSuite.testMethod"` +- Mock classes available in `tests/common/` +- Update parameter names when refactoring APIs + +## Requirements +- **macOS**: Required for Xcode builds +- **Xcode**: Latest stable version +- **Ruby**: For xcpretty (auto-installed) +- **iOS Simulator**: For testing + +## Quick Start for AI Agents +1. Run `./agent_build.sh` to verify project builds +2. Run `./agent_test.sh` to check test health (or `./agent_test.sh TestSuite` for specific suite) +3. Make changes to source files +4. Re-run both scripts to validate +5. Debug failing tests: `./agent_test.sh "TestSuite.testMethod"` +6. Commit when both pass ✅ + +## Test Filtering Examples +```bash +# Debug specific failing tests +./agent_test.sh "IterableApiCriteriaFetchTests.testForegroundCriteriaFetchWhenConditionsMet" + +# Run a problematic test suite +./agent_test.sh ValidateCustomEventUserUpdateAPITest + +# Check auth-related tests +./agent_test.sh AuthTests +``` + +## AI Agent Memory System + +### 🧠 Update Instructions for AI Agents +**IMPORTANT**: When you discover something useful while working on this codebase, update this README to help future AI agents. Add learnings to the sections below. + +### 📍 Code Location Map +- **Auth Logic**: `swift-sdk/Internal/AuthManager.swift` (main auth manager), `swift-sdk/Internal/Auth.swift` (auth models) +- **API Calls**: `swift-sdk/Internal/api-client/ApiClient.swift` (main client), `swift-sdk/Internal/Network/NetworkHelper.swift` (networking) +- **Models**: `swift-sdk/Core/Models/` (all data structures - CommerceItem, IterableInAppMessage, etc.) +- **Main Entry**: `swift-sdk/SDK/IterableAPI.swift` (public API), `swift-sdk/Internal/InternalIterableAPI.swift` (core implementation) +- **Request Handling**: `swift-sdk/Internal/api-client/Request/` (online/offline processors) + +### 🛠️ Common Task Recipes + +**Add New API Endpoint:** +1. Add path constant to `swift-sdk/Core/Constants.swift` in `Const.Path` +2. Add method to `ApiClientProtocol.swift` and implement in `ApiClient.swift` +3. Create request in `swift-sdk/Internal/api-client/Request/RequestCreator.swift` +4. Add to `RequestHandlerProtocol.swift` and `RequestHandler.swift` + +**Modify Auth Logic:** +- Main logic: `swift-sdk/Internal/AuthManager.swift` +- Token storage: `swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift` +- Auth failures: Handle in `RequestProcessorUtil.swift` + +**Add New Model:** +- Create in `swift-sdk/Core/Models/YourModel.swift` +- Make it `@objcMembers public class` for Objective-C compatibility +- Implement `Codable` if it needs JSON serialization + +### 🐛 Common Failure Solutions + +**"Test X failed"** → Check test file in `tests/unit-tests/` - often parameter name mismatches after refactoring + +**"Build failed: file not found"** → Update `swift-sdk.xcodeproj/project.pbxproj` to include new/renamed files + +**"Auth token issues"** → Check `AuthManager.swift` and ensure JWT format is correct in tests + +**"Network request fails"** → Check endpoint in `Constants.swift` and request creation in `RequestCreator.swift` + +## Notes +- Always test builds after refactoring +- Parameter name changes require test file updates +- Project file (`*.pbxproj`) may need manual updates for file renames +- Sample apps demonstrate SDK usage patterns diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d63ee67..17ac6d812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). + +## [6.6.0-beta3] + +- This release includes fixes for the Unknown user activation private beta: + - Criteria is now fetched on foregrounding the app by default. This feature can be turned off setting enableForegroundCriteriaFetch flag to false. + - Unknown user ids are only generated once when multiple track calls are made. +- Unknown user activation is currently in private beta. If you'd like to learn more about it or discuss using it, talk to your Iterable customer success manager (who can also provide detailed documentation). + +## [6.6.0-beta2] + +- This release fixes beta1 release which was released from the wrong branch. + +## [6.6.0-beta1] + +- This release includes initial support for Unknown user activation, a feature that allows marketers to convert valuable visitors into customers. With this feature, the SDK can: + - Fetch unknown user profile creation criteria from your Iterable project, and then automatically create Iterable user profiles for Unknown users who meet these criteria. + - Save information about a user's previous interactions with your application to their unknown user profile, after it's created. + - Display personalized messages for Unknown users (in-app, push, and embedded messages). + - Merge unknown user profiles into an existing, known user profiles (when needed). +- Unknown user activation is currently in private beta. If you'd like to learn more about it or discuss using it, talk to your Iterable customer success manager (who can also provide detailed documentation). + ## [Unreleased] ## [6.5.13] diff --git a/CLAUDE.md b/CLAUDE.md index bf3f036cf..954a9d56a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,6 @@ 🤖 **AI Agent Instructions** -Please read the `agent/AGENT_README.md` file for comprehensive project information, development workflow, and testing procedures. +Please read the `AGENT_README.md` file for comprehensive project information, development workflow, and testing procedures. All the information you need to work on this Iterable Swift SDK project is documented there. \ No newline at end of file diff --git a/agent_build.sh b/agent_build.sh new file mode 100755 index 000000000..9eaf90e7f --- /dev/null +++ b/agent_build.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# This script is to be used by LLMs and AI agents to build the Iterable Swift SDK on macOS. +# It uses xcpretty to format the build output and only shows errors. +# It also checks if the build is successful and exits with the correct status. + +# Check if running on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "❌ This script requires macOS to run Xcode builds" + exit 1 +fi + +# Make sure xcpretty is installed +if ! command -v xcpretty &> /dev/null; then + echo "xcpretty not found, installing via gem..." + sudo gem install xcpretty +fi + +echo "Building Iterable Swift SDK..." + +# Create a temporary file for the build output +TEMP_OUTPUT=$(mktemp) + +# Run the build and capture all output +xcodebuild \ + -project swift-sdk.xcodeproj \ + -scheme "swift-sdk" \ + -configuration Debug \ + -sdk iphonesimulator \ + build > $TEMP_OUTPUT 2>&1 + +# Check the exit status +BUILD_STATUS=$? + +# Show errors and warnings if build failed +if [ $BUILD_STATUS -eq 0 ]; then + echo "✅ Iterable SDK build succeeded!" +else + echo "❌ Iterable SDK build failed with status $BUILD_STATUS" + echo "" + echo "🔍 Build errors:" + grep -E 'error:|fatal:' $TEMP_OUTPUT | head -10 + echo "" + echo "⚠️ Build warnings:" + grep -E 'warning:' $TEMP_OUTPUT | head -5 +fi + +# Remove the temporary file +rm $TEMP_OUTPUT + +exit $BUILD_STATUS \ No newline at end of file diff --git a/agent_test.sh b/agent_test.sh new file mode 100755 index 000000000..10151054a --- /dev/null +++ b/agent_test.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# Check if running on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "❌ This script requires macOS to run Xcode tests" + exit 1 +fi + +# Parse command line arguments +FILTER="" +LIST_TESTS=false + +if [[ $# -eq 1 ]]; then + if [[ "$1" == "--list" ]]; then + LIST_TESTS=true + else + FILTER="$1" + echo "🎯 Running tests with filter: $FILTER" + fi +elif [[ $# -gt 1 ]]; then + echo "❌ Usage: $0 [filter|--list]" + echo " filter: Test suite name (e.g., 'IterableApiCriteriaFetchTests')" + echo " or specific test (e.g., 'IterableApiCriteriaFetchTests.testForegroundCriteriaFetchWhenConditionsMet')" + echo " or full path (e.g., 'unit-tests/IterableApiCriteriaFetchTests/testForegroundCriteriaFetchWhenConditionsMet')" + echo " --list: List all available test suites and tests" + exit 1 +fi + +# Handle test listing +if [[ "$LIST_TESTS" == true ]]; then + echo "📋 Listing available test suites..." + + # Use grep to extract test class names from source files + echo "📦 Available Test Suites:" + find tests/unit-tests -name "*.swift" -exec basename {} .swift \; | sort | while read test_file; do + # Count test methods in each file + test_count=$(grep -c "func test" "tests/unit-tests/$test_file.swift" 2>/dev/null || echo "0") + echo " • $test_file ($test_count tests)" + done + + echo "" + echo "🔍 Example Usage:" + echo " ./agent_test.sh AuthTests" + echo " ./agent_test.sh \"AuthTests.testAsyncAuthTokenRetrieval\"" + echo "" + echo "💡 To see specific test methods in a suite, check the source file:" + echo " grep 'func test' tests/unit-tests/AuthTests.swift" + + exit 0 +fi + +# Make sure xcpretty is installed +if ! command -v xcpretty &> /dev/null; then + echo "xcpretty not found, installing via gem..." + sudo gem install xcpretty +fi + +echo "Running Iterable Swift SDK unit tests..." + +# Create a temporary file for the test output +TEMP_OUTPUT=$(mktemp) + +# Build the xcodebuild command +XCODEBUILD_CMD="xcodebuild test \ + -project swift-sdk.xcodeproj \ + -scheme swift-sdk \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + -enableCodeCoverage YES \ + -skipPackagePluginValidation \ + CODE_SIGNING_REQUIRED=NO" + +# Add filter if specified +if [[ -n "$FILTER" ]]; then + # If filter contains a slash, use it as-is (already in unit-tests/TestSuite/testMethod format) + if [[ "$FILTER" == *"/"* ]]; then + XCODEBUILD_CMD="$XCODEBUILD_CMD -only-testing:$FILTER" + # If filter contains a dot, convert TestSuite.testMethod to unit-tests/TestSuite/testMethod + elif [[ "$FILTER" == *"."* ]]; then + TEST_SUITE=$(echo "$FILTER" | cut -d'.' -f1) + TEST_METHOD=$(echo "$FILTER" | cut -d'.' -f2) + XCODEBUILD_CMD="$XCODEBUILD_CMD -only-testing:unit-tests/$TEST_SUITE/$TEST_METHOD" + # Otherwise, assume it's just a test suite name and add the target + else + XCODEBUILD_CMD="$XCODEBUILD_CMD -only-testing:unit-tests/$FILTER" + fi +fi + +# Run the tests with xcpretty for clean output (incremental - skips rebuild if possible) +eval $XCODEBUILD_CMD 2>&1 | tee $TEMP_OUTPUT | xcpretty + +# Check the exit status +TEST_STATUS=$? + +# Parse the "Executed X test(s), with Y failure(s)" line +EXECUTED_LINE=$(grep "Executed.*test.*with.*failure" $TEMP_OUTPUT | tail -1) +if [[ -n "$EXECUTED_LINE" ]]; then + TOTAL_TESTS=$(echo "$EXECUTED_LINE" | sed -n 's/.*Executed \([0-9][0-9]*\) test.*/\1/p') + FAILED_TESTS=$(echo "$EXECUTED_LINE" | sed -n 's/.*with \([0-9][0-9]*\) failure.*/\1/p') + + # Ensure we have valid numbers + if [[ -z "$TOTAL_TESTS" ]]; then TOTAL_TESTS=0; fi + if [[ -z "$FAILED_TESTS" ]]; then FAILED_TESTS=0; fi + + PASSED_TESTS=$(($TOTAL_TESTS - $FAILED_TESTS)) +else + TOTAL_TESTS=0 + FAILED_TESTS=0 + PASSED_TESTS=0 +fi + +# Show test results +if [ "$FAILED_TESTS" -eq 0 ] && [ "$TOTAL_TESTS" -gt 0 ]; then + echo "✅ All tests passed! ($TOTAL_TESTS tests)" + FINAL_STATUS=0 +elif [ "$FAILED_TESTS" -gt 0 ]; then + echo "❌ Tests failed: $FAILED_TESTS failed, $PASSED_TESTS passed ($TOTAL_TESTS total)" + FINAL_STATUS=1 +else + echo "⚠️ No test results found" + FINAL_STATUS=$TEST_STATUS +fi + +# Remove the temporary file +rm $TEMP_OUTPUT + +exit $FINAL_STATUS \ No newline at end of file diff --git a/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj b/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj index 774114af1..0c443bdb1 100644 --- a/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj +++ b/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 37088F332B3C38250000B218 /* IterableAppExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 37088F322B3C38250000B218 /* IterableAppExtensions */; }; + 37088F352B3C38250000B218 /* IterableSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 37088F342B3C38250000B218 /* IterableSDK */; }; 551A5FF1251AB1950004C9A0 /* IterableSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 551A5FF0251AB1950004C9A0 /* IterableSDK */; }; 551A5FF3251AB19B0004C9A0 /* IterableAppExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 551A5FF2251AB19B0004C9A0 /* IterableAppExtensions */; }; AC1BDF5820E304BC000010CA /* CoffeeListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5ECD9E20E304000081E1DA /* CoffeeListTableViewController.swift */; }; @@ -79,6 +81,7 @@ buildActionMask = 2147483647; files = ( 551A5FF1251AB1950004C9A0 /* IterableSDK in Frameworks */, + 37088F352B3C38250000B218 /* IterableSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -86,6 +89,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37088F332B3C38250000B218 /* IterableAppExtensions in Frameworks */, 551A5FF3251AB19B0004C9A0 /* IterableAppExtensions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -219,6 +223,7 @@ name = "swift-sample-app"; packageProductDependencies = ( 551A5FF0251AB1950004C9A0 /* IterableSDK */, + 37088F342B3C38250000B218 /* IterableSDK */, ); productName = "swift-sample-app"; productReference = ACA3A13520E2F6AF00FEF74F /* swift-sample-app.app */; @@ -239,6 +244,7 @@ name = "swift-sample-app-notification-extension"; packageProductDependencies = ( 551A5FF2251AB19B0004C9A0 /* IterableAppExtensions */, + 37088F322B3C38250000B218 /* IterableAppExtensions */, ); productName = "swift-sample-app-notification-extension"; productReference = ACA3A14E20E2F83D00FEF74F /* swift-sample-app-notification-extension.appex */; @@ -281,6 +287,8 @@ Base, ); mainGroup = ACA3A12C20E2F6AF00FEF74F; + packageReferences = ( + ); productRefGroup = ACA3A13620E2F6AF00FEF74F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -688,6 +696,14 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 37088F322B3C38250000B218 /* IterableAppExtensions */ = { + isa = XCSwiftPackageProductDependency; + productName = IterableAppExtensions; + }; + 37088F342B3C38250000B218 /* IterableSDK */ = { + isa = XCSwiftPackageProductDependency; + productName = IterableSDK; + }; 551A5FF0251AB1950004C9A0 /* IterableSDK */ = { isa = XCSwiftPackageProductDependency; productName = IterableSDK; diff --git a/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift b/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift index b0c80f51a..7c07c7953 100644 --- a/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift +++ b/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift @@ -12,12 +12,28 @@ import UserNotifications import IterableSDK @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate, IterableAuthDelegate { + func onAuthTokenRequested(completion: @escaping IterableSDK.AuthTokenRetrievalHandler) { + // ITBL: Set your actual secret. + let jwt = IterableTokenGenerator.generateJwtForUserId( + secret: "", + iat: Int(Date().timeIntervalSince1970), + exp: Int(Date().timeIntervalSince1970) + (24*60), + userId: IterableAPI.userId ?? "") + print(jwt) + completion(jwt) + } + + + func onAuthFailure(_ authFailure: IterableSDK.AuthFailure) { + + } + var window: UIWindow? // ITBL: Set your actual api key here. let iterableApiKey = "" - + func application(_: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // ITBL: Setup Notification setupNotifications() @@ -27,7 +43,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { config.customActionDelegate = self config.urlDelegate = self config.inAppDisplayInterval = 1 - + config.unknownUserHandler = self + config.enableUnknownUserActivation = true + config.authDelegate = self IterableAPI.initialize(apiKey: iterableApiKey, launchOptions: launchOptions, config: config) @@ -157,6 +175,12 @@ extension AppDelegate: IterableURLDelegate { } } +extension AppDelegate: IterableUnknownUserHandler { + func onUnknownUserCreated(userId: String) { + print("UserId Created from unknown user session: \(userId)") + } +} + // MARK: IterableCustomActionDelegate extension AppDelegate: IterableCustomActionDelegate { @@ -171,3 +195,4 @@ extension AppDelegate: IterableCustomActionDelegate { return false } } + diff --git a/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard b/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard index c122a01bc..e1cd1ef20 100644 --- a/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard +++ b/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -19,7 +19,7 @@ - + @@ -35,6 +35,14 @@ + + + + + + + + diff --git a/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift b/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift index 50eb7e2d1..ba923a34e 100644 --- a/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift +++ b/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift @@ -60,22 +60,42 @@ class CoffeeListTableViewController: UITableViewController { } // MARK: - TableViewDataSourceDelegate Functions - - override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - filtering ? filteredCoffees.count : coffees.count + override func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == 0 { + return 1 + } else { + return filtering ? filteredCoffees.count : coffees.count + } + } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "coffeeCell", for: indexPath) - - let coffeeList = filtering ? filteredCoffees : coffees - let coffee = coffeeList[indexPath.row] - cell.textLabel?.text = coffee.name - cell.imageView?.image = coffee.image - - return cell + if indexPath.section == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: "visitorUsageTrackedCell", for: indexPath) + cell.textLabel?.text = IterableAPI.getVisitorUsageTracked() ? "Tap to disable Visitor Usage Track" : "Tap to enable Visitor Usage Track" + cell.textLabel?.numberOfLines = 0 + cell.accessoryType = IterableAPI.getVisitorUsageTracked() ? .checkmark : .none + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: "coffeeCell", for: indexPath) + let coffeeList = filtering ? filteredCoffees : coffees + let coffee = coffeeList[indexPath.row] + cell.textLabel?.text = coffee.name + cell.imageView?.image = coffee.image + return cell + } } - + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if indexPath.section == 0 { + let permissionToTrack = IterableAPI.getVisitorUsageTracked() + IterableAPI.setVisitorUsageTracked(isVisitorUsageTracked: !permissionToTrack) + self.tableView.reloadData() + } + } + // MARK: Tap Handlers @IBAction func loginOutBarButtonTapped(_: UIBarButtonItem) { @@ -93,7 +113,7 @@ class CoffeeListTableViewController: UITableViewController { // MARK: - Navigation override func prepare(for segue: UIStoryboardSegue, sender _: Any?) { - guard let indexPath = tableView.indexPathForSelectedRow else { + guard let indexPath = tableView.indexPathForSelectedRow, indexPath.section == 1 else { return } diff --git a/swift-sdk.xcodeproj/project.pbxproj b/swift-sdk.xcodeproj/project.pbxproj index bef7e7c8a..1ee74dfd2 100644 --- a/swift-sdk.xcodeproj/project.pbxproj +++ b/swift-sdk.xcodeproj/project.pbxproj @@ -13,6 +13,20 @@ 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CB31B4210960C4004ACDEC /* TestUtils.swift */; }; 092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 092D01932D3038F600E3066A /* NotificationObserverTests.swift */; }; 09876F3D2DF1D0290051F047 /* RedirectNetworkSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09876F3C2DF1D0290051F047 /* RedirectNetworkSessionTests.swift */; }; + 09CAA47B2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */; }; + 09E8F2F92E29008200E92ABB /* ConsentTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E8F2F82E29008200E92ABB /* ConsentTrackingTests.swift */; }; + 1802C00F2CA2C99E009DEA2B /* CombinationComplexCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */; }; + 181063DB2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */; }; + 181063DD2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */; }; + 181063DF2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */; }; + 182A2A152C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182A2A142C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift */; }; + 1881A21B2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1881A21A2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift */; }; + 18A3520A2C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A352092C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift */; }; + 18A3520C2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A3520B2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift */; }; + 18BB8B7A2C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18BB8B792C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift */; }; + 18E23AE02C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */; }; + 18E5B5D12CC77BCE00A558EC /* IterableTokenGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */; }; + 18E5B5D32CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */; }; 1CBFFE1A2A97AEEF00ED57EE /* EmbeddedManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */; }; 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */; }; 1CBFFE1C2A97AEEF00ED57EE /* EmbeddedSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */; }; @@ -163,6 +177,7 @@ 5B5AA716284F1A6D0093FED4 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5AA710284F1A6D0093FED4 /* MockNetworkSession.swift */; }; 5B5AA717284F1A6D0093FED4 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5AA710284F1A6D0093FED4 /* MockNetworkSession.swift */; }; 5B6C3C1127CE871F00B9A753 /* NavInboxSessionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6C3C1027CE871F00B9A753 /* NavInboxSessionUITests.swift */; }; + 5B88BC482805D09D004016E5 /* (null) in Sources */ = {isa = PBXBuildFile; }; 8A272FD02DD3775800634559 /* IterableDataRegionObjCTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 8A272FCF2DD3775800634559 /* IterableDataRegionObjCTests.m */; }; 8AAA8BA92D07310600DF8220 /* IterableSDK.h in Headers */ = {isa = PBXBuildFile; fileRef = 8AAA8B6C2D07310600DF8220 /* IterableSDK.h */; }; 8AAA8BAB2D07310600DF8220 /* IterableAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8B1F2D07310600DF8220 /* IterableAction.swift */; }; @@ -286,6 +301,7 @@ 8AAA8CDF2D074C2000DF8220 /* APNSTypeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8C532D074C2000DF8220 /* APNSTypeChecker.swift */; }; 8AAA8CE02D074C2000DF8220 /* Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8C552D074C2000DF8220 /* Auth.swift */; }; 8AB8D7D22D3805A900DECFE5 /* IterableAPIMobileFrameworkDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB8D7D12D3805A900DECFE5 /* IterableAPIMobileFrameworkDetector.swift */; }; + 9F0616412C9CA9D400FE2E6A /* IterableIdentityResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0616402C9CA9D200FE2E6A /* IterableIdentityResolution.swift */; }; 9FF05EAC2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */; }; 9FF05EAD2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */; }; 9FF05EAE2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */; }; @@ -410,9 +426,24 @@ ACFF42B02465B4AE00FDF10D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFF42AF2465B4AE00FDF10D /* AppDelegate.swift */; }; BA2BB8192BADD5A500EA0229 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BA2BB8182BADD5A500EA0229 /* PrivacyInfo.xcprivacy */; }; BA2BB81A2BADD5A500EA0229 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BA2BB8182BADD5A500EA0229 /* PrivacyInfo.xcprivacy */; }; + DF7302152C2C176E0002633A /* UnknownUserComplexCriteriaMatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF7302142C2C176E0002633A /* UnknownUserComplexCriteriaMatchTests.swift */; }; + DF97D12B2C2D4A060034D38C /* UnknownUserCriteriaIsSetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF97D12A2C2D4A060034D38C /* UnknownUserCriteriaIsSetTests.swift */; }; + DFFD62392C3681B900010883 /* UserMergeScenariosTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFD62382C3681B900010883 /* UserMergeScenariosTests.swift */; }; + E9EA7C9F2C1EDE5800A9D6FB /* UnknownUserManager+Functions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9B2C1EDE5800A9D6FB /* UnknownUserManager+Functions.swift */; }; + E9EA7CA02C1EDE5800A9D6FB /* UnknownUserMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9C2C1EDE5800A9D6FB /* UnknownUserMerge.swift */; }; + E9EA7CA12C1EDE5800A9D6FB /* UnknownUserManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9D2C1EDE5800A9D6FB /* UnknownUserManagerProtocol.swift */; }; + E9EA7CA22C1EDE5800A9D6FB /* UnknownUserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9E2C1EDE5800A9D6FB /* UnknownUserManager.swift */; }; + E9EA7CA82C1EE3BA00A9D6FB /* UnknownUserCriteriaMatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7CA62C1EE3BA00A9D6FB /* UnknownUserCriteriaMatchTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 373268002B4D51B200CC82C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AC2263D620CF49B8009800EB /* Project object */; + proxyType = 1; + remoteGlobalIDString = AC2263DE20CF49B8009800EB; + remoteInfo = "swift-sdk"; + }; 5B38881D27FAE6DB00482BE7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AC2263D620CF49B8009800EB /* Project object */; @@ -549,6 +580,20 @@ 00CB31B4210960C4004ACDEC /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 092D01932D3038F600E3066A /* NotificationObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationObserverTests.swift; sourceTree = ""; }; 09876F3C2DF1D0290051F047 /* RedirectNetworkSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedirectNetworkSessionTests.swift; sourceTree = ""; }; + 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableApiCriteriaFetchTests.swift; sourceTree = ""; }; + 09E8F2F82E29008200E92ABB /* ConsentTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentTrackingTests.swift; sourceTree = ""; }; + 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinationComplexCriteria.swift; sourceTree = ""; }; + 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventUserUpdateTestCaseTests.swift; sourceTree = ""; }; + 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateCustomEventUserUpdateAPITest.swift; sourceTree = ""; }; + 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateStoredEventCheckUnknownToKnownUserTest.swift; sourceTree = ""; }; + 182A2A142C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypeComparatorSearchQueryCriteria.swift; sourceTree = ""; }; + 1881A21A2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparatorDataTypeWithArrayInput.swift; sourceTree = ""; }; + 18A352092C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NestedFieldSupportForArrayData.swift; sourceTree = ""; }; + 18A3520B2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsOneOfInNotOneOfCriteareaTest.swift; sourceTree = ""; }; + 18BB8B792C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparatorTypeDoesNotEqualMatchTest.swift; sourceTree = ""; }; + 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CombinationLogicEventTypeCriteria.swift; sourceTree = ""; }; + 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableTokenGenerator.swift; sourceTree = ""; }; + 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateTokenForDestinationUserTest.swift; sourceTree = ""; }; 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedManagerTests.swift; sourceTree = ""; }; 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedMessagingProcessorTests.swift; sourceTree = ""; }; 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedSessionManagerTests.swift; sourceTree = ""; }; @@ -717,6 +762,7 @@ 8AB8D7D12D3805A900DECFE5 /* IterableAPIMobileFrameworkDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableAPIMobileFrameworkDetector.swift; sourceTree = ""; }; 8AC534392D760B6C00F84F44 /* swift-sdk.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "swift-sdk.xctestplan"; sourceTree = ""; }; 8AC5343A2D760BD400F84F44 /* endpoint-tests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "endpoint-tests.xctestplan"; sourceTree = ""; }; + 9F0616402C9CA9D200FE2E6A /* IterableIdentityResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableIdentityResolution.swift; sourceTree = ""; }; 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthManager.swift; sourceTree = ""; }; AC02CAA5234E50B5006617E0 /* RegistrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationTests.swift; sourceTree = ""; }; AC05644A26387B54001FB810 /* MockPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPersistence.swift; sourceTree = ""; }; @@ -823,9 +869,24 @@ ACFF42AE24656ECF00FDF10D /* ui-tests-app.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ui-tests-app.entitlements"; sourceTree = ""; }; ACFF42AF2465B4AE00FDF10D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BA2BB8182BADD5A500EA0229 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + DF7302142C2C176E0002633A /* UnknownUserComplexCriteriaMatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownUserComplexCriteriaMatchTests.swift; sourceTree = ""; }; + DF97D12A2C2D4A060034D38C /* UnknownUserCriteriaIsSetTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnknownUserCriteriaIsSetTests.swift; sourceTree = ""; }; + DFFD62382C3681B900010883 /* UserMergeScenariosTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMergeScenariosTests.swift; sourceTree = ""; }; + E9EA7C9B2C1EDE5800A9D6FB /* UnknownUserManager+Functions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UnknownUserManager+Functions.swift"; sourceTree = ""; }; + E9EA7C9C2C1EDE5800A9D6FB /* UnknownUserMerge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnknownUserMerge.swift; sourceTree = ""; }; + E9EA7C9D2C1EDE5800A9D6FB /* UnknownUserManagerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnknownUserManagerProtocol.swift; sourceTree = ""; }; + E9EA7C9E2C1EDE5800A9D6FB /* UnknownUserManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnknownUserManager.swift; sourceTree = ""; }; + E9EA7CA62C1EE3BA00A9D6FB /* UnknownUserCriteriaMatchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnknownUserCriteriaMatchTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 373267F82B4D51B200CC82C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DB20CF49B8009800EB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -924,6 +985,13 @@ name = "Test Files"; sourceTree = ""; }; + 095D04D92D394DA100B23572 /* Recovered References */ = { + isa = PBXGroup; + children = ( + ); + name = "Recovered References"; + sourceTree = ""; + }; 1CBFFE152A97AEDC00ED57EE /* embedded-messaging-tests */ = { isa = PBXGroup; children = ( @@ -1233,6 +1301,7 @@ AC90C4C520D8632E00EECA5D /* notification-extension */, ACFCA72920EB02DB00BFB277 /* tests */, 5550F22324217CFC0014456A /* misc */, + 095D04D92D394DA100B23572 /* Recovered References */, ); sourceTree = ""; }; @@ -1258,6 +1327,8 @@ AC2263E120CF49B8009800EB /* swift-sdk */ = { isa = PBXGroup; children = ( + AC72A0BB20CF4C8C004D7997 /* Internal */, + 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */, 8AAA8B6C2D07310600DF8220 /* IterableSDK.h */, 8AAA8B312D07310600DF8220 /* core */, 8AAA8C852D074C2000DF8220 /* internal */, @@ -1366,10 +1437,44 @@ name = "local-storage-tests"; sourceTree = ""; }; + AC5E888724E1B7AD00752321 /* Request Processing */ = { + isa = PBXGroup; + children = ( + ); + name = "Request Processing"; + sourceTree = ""; + }; + AC72A0AC20CF4C08004D7997 /* Util */ = { + isa = PBXGroup; + children = ( + ); + name = Util; + sourceTree = ""; + }; + AC72A0BB20CF4C8C004D7997 /* Internal */ = { + isa = PBXGroup; + children = ( + E9EA7C9A2C1EDE4400A9D6FB /* UnknownUserTracking */, + AC7A525F227BB9B80064D67E /* Initialization */, + AC5E888724E1B7AD00752321 /* Request Processing */, + AC72A0AC20CF4C08004D7997 /* Util */, + ); + path = Internal; + sourceTree = ""; + }; + AC7A525F227BB9B80064D67E /* Initialization */ = { + isa = PBXGroup; + children = ( + ); + name = Initialization; + sourceTree = ""; + }; AC7B142C20D02CE200877BFE /* unit-tests */ = { isa = PBXGroup; children = ( + 09E8F2F82E29008200E92ABB /* ConsentTrackingTests.swift */, 8A272FCF2DD3775800634559 /* IterableDataRegionObjCTests.m */, + E9EA7CA52C1EE39A00A9D6FB /* unknown-user-tracking-tests */, 1CBFFE152A97AEDC00ED57EE /* embedded-messaging-tests */, 552A0AAA280E24E400A80963 /* api-tests */, AC3A3029262EE04400425435 /* deep-linking-tests */, @@ -1602,6 +1707,41 @@ path = ../..; sourceTree = ""; }; + E9EA7C9A2C1EDE4400A9D6FB /* UnknownUserTracking */ = { + isa = PBXGroup; + children = ( + 9F0616402C9CA9D200FE2E6A /* IterableIdentityResolution.swift */, + E9EA7C9E2C1EDE5800A9D6FB /* UnknownUserManager.swift */, + E9EA7C9B2C1EDE5800A9D6FB /* UnknownUserManager+Functions.swift */, + E9EA7C9D2C1EDE5800A9D6FB /* UnknownUserManagerProtocol.swift */, + E9EA7C9C2C1EDE5800A9D6FB /* UnknownUserMerge.swift */, + ); + name = UnknownUserTracking; + sourceTree = ""; + }; + E9EA7CA52C1EE39A00A9D6FB /* unknown-user-tracking-tests */ = { + isa = PBXGroup; + children = ( + 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */, + E9EA7CA62C1EE3BA00A9D6FB /* UnknownUserCriteriaMatchTests.swift */, + DF97D12A2C2D4A060034D38C /* UnknownUserCriteriaIsSetTests.swift */, + DF7302142C2C176E0002633A /* UnknownUserComplexCriteriaMatchTests.swift */, + DFFD62382C3681B900010883 /* UserMergeScenariosTests.swift */, + 182A2A142C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift */, + 18BB8B792C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift */, + 1881A21A2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift */, + 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */, + 18A352092C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift */, + 18A3520B2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift */, + 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */, + 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */, + 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */, + 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */, + 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */, + ); + name = "unknown-user-tracking-tests"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1624,6 +1764,23 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ + 373267FA2B4D51B200CC82C9 /* UnknownUserMerge */ = { + isa = PBXNativeTarget; + buildConfigurationList = 373268042B4D51B200CC82C9 /* Build configuration list for PBXNativeTarget "UnknownUserMerge" */; + buildPhases = ( + 373267F72B4D51B200CC82C9 /* Sources */, + 373267F82B4D51B200CC82C9 /* Frameworks */, + 373267F92B4D51B200CC82C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 373268012B4D51B200CC82C9 /* PBXTargetDependency */, + ); + name = UnknownUserMerge; + productName = UnknownUserMerge; + productType = "com.apple.product-type.bundle.unit-test"; + }; AC2263DE20CF49B8009800EB /* swift-sdk */ = { isa = PBXNativeTarget; buildConfigurationList = AC2263F320CF49B8009800EB /* Build configuration list for PBXNativeTarget "swift-sdk" */; @@ -1843,6 +2000,9 @@ LastUpgradeCheck = 1420; ORGANIZATIONNAME = Iterable; TargetAttributes = { + 373267FA2B4D51B200CC82C9 = { + CreatedOnToolsVersion = 15.1; + }; AC2263DE20CF49B8009800EB = { CreatedOnToolsVersion = 9.4; }; @@ -1920,11 +2080,19 @@ ACDA976B23159C39004C412E /* inbox-ui-tests */, AC28480624AA44C600C1FC7F /* endpoint-tests */, ACFD5AB724C8200C008E497A /* offline-events-tests */, + 373267FA2B4D51B200CC82C9 /* UnknownUserMerge */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 373267F92B4D51B200CC82C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DD20CF49B8009800EB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2027,6 +2195,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 373267F72B4D51B200CC82C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DA20CF49B8009800EB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2151,6 +2326,13 @@ 8AAA8C1B2D07310600DF8220 /* IterableConfig.swift in Sources */, 8AAA8C1C2D07310600DF8220 /* IterableInboxCell.swift in Sources */, AC50865424C60172001DC132 /* IterableDataModel.xcdatamodeld in Sources */, + 5B88BC482805D09D004016E5 /* (null) in Sources */, + 9F0616412C9CA9D400FE2E6A /* IterableIdentityResolution.swift in Sources */, + 18E5B5D12CC77BCE00A558EC /* IterableTokenGenerator.swift in Sources */, + E9EA7C9F2C1EDE5800A9D6FB /* UnknownUserManager+Functions.swift in Sources */, + E9EA7CA22C1EDE5800A9D6FB /* UnknownUserManager.swift in Sources */, + E9EA7CA02C1EDE5800A9D6FB /* UnknownUserMerge.swift in Sources */, + E9EA7CA12C1EDE5800A9D6FB /* UnknownUserManagerProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2198,23 +2380,31 @@ 55AEA95925F05B7D00B38CED /* InAppMessageProcessorTests.swift in Sources */, ACC362B824D17005002C67BA /* IterableRequestTests.swift in Sources */, AC2C668720D3435700D46CC9 /* ActionRunnerTests.swift in Sources */, + 181063DB2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift in Sources */, 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */, AC89661E2124FBCE0051A6CD /* AutoRegistrationTests.swift in Sources */, 9FF05EAF2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */, ACA8D1A921965B7D001B1332 /* InAppTests.swift in Sources */, 5588DFB928C045E3000697D7 /* MockInAppDelegate.swift in Sources */, 5588DFD128C0465E000697D7 /* MockAPNSTypeChecker.swift in Sources */, + DF7302152C2C176E0002633A /* UnknownUserComplexCriteriaMatchTests.swift in Sources */, + 18BB8B7A2C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift in Sources */, 00B6FACC210E8484007535CF /* APNSTypeCheckerTests.swift in Sources */, + 09CAA47B2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift in Sources */, AC8F35A2239806B500302994 /* InboxViewControllerViewModelTests.swift in Sources */, 092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */, AC995F9A2166EEB50099A184 /* CommonMocks.swift in Sources */, + E9EA7CA82C1EE3BA00A9D6FB /* UnknownUserCriteriaMatchTests.swift in Sources */, 5588DFE128C046B7000697D7 /* MockLocalStorage.swift in Sources */, + 18A3520A2C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift in Sources */, 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */, 5588DF8128C04494000697D7 /* MockUrlDelegate.swift in Sources */, 5585DF8F22A73390000A32B9 /* IterableInboxViewControllerTests.swift in Sources */, + 18A3520C2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift in Sources */, 55B9F15124B3D33700E8198A /* AuthTests.swift in Sources */, 55B06F3829D5102800C3B1BC /* BlankApiClient.swift in Sources */, 5588DFE928C046D7000697D7 /* MockInboxState.swift in Sources */, + 181063DF2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift in Sources */, ACED4C01213F50B30055A497 /* LoggingTests.swift in Sources */, AC52C5B8272A8B32000DCDCF /* KeychainWrapperTests.swift in Sources */, ACC3FD9E2536D7A30004A2E0 /* InAppFilePersistenceTests.swift in Sources */, @@ -2223,10 +2413,14 @@ 5588DFA928C045AE000697D7 /* MockInAppFetcher.swift in Sources */, 55CC257B2462064F00A77FD5 /* InAppPresenterTests.swift in Sources */, AC4BA00224163D8F007359F1 /* IterableHtmlMessageViewControllerTests.swift in Sources */, + 18E5B5D32CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift in Sources */, 55B37FC822975A840042F13A /* InboxMessageViewModelTests.swift in Sources */, + 182A2A152C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift in Sources */, 55E6F462238E066400808BCE /* DeepLinkTests.swift in Sources */, + 18E23AE02C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift in Sources */, 55B37FC1229620D20042F13A /* CommerceItemTests.swift in Sources */, 5588DFC128C0460E000697D7 /* MockNotificationCenter.swift in Sources */, + DFFD62392C3681B900010883 /* UserMergeScenariosTests.swift in Sources */, 5588DFC928C04642000697D7 /* MockInAppPersister.swift in Sources */, AC776DA4211A17C700C27C27 /* IterableRequestUtilTests.swift in Sources */, ACAA816E231163660035C743 /* RequestCreatorTests.swift in Sources */, @@ -2238,8 +2432,10 @@ 5531CDAE22A9C992000D05E2 /* ClassExtensionsTests.swift in Sources */, AC995F9E2167E9FD0099A184 /* CommonExtensions.swift in Sources */, 5536781F2576FF9000DB3652 /* IterableUtilTests.swift in Sources */, + 09E8F2F92E29008200E92ABB /* ConsentTrackingTests.swift in Sources */, AC2C668020D31B1F00D46CC9 /* NotificationResponseTests.swift in Sources */, 55E02D39253F8D86009DB8BC /* WebViewProtocolTests.swift in Sources */, + 1881A21B2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift in Sources */, AC750A4A234CD67900561902 /* InAppHelperTests.swift in Sources */, AC87172621A4E47E00FEA369 /* TestInAppPayloadGenerator.swift in Sources */, AC1670CD2230A91C00989F8E /* InboxTests.swift in Sources */, @@ -2249,16 +2445,19 @@ AC64626B2140AACF0046E1BD /* IterableAPIResponseTests.swift in Sources */, 1CBFFE1D2A97AEEF00ED57EE /* EmbeddedMessagingSerializationTests.swift in Sources */, 5588DFB128C045C9000697D7 /* MockInAppDisplayer.swift in Sources */, + DF97D12B2C2D4A060034D38C /* UnknownUserCriteriaIsSetTests.swift in Sources */, 55B37FC6229752DD0042F13A /* OrderedDictionaryTests.swift in Sources */, 1CBFFE1C2A97AEEF00ED57EE /* EmbeddedSessionManagerTests.swift in Sources */, 5588DFD928C04683000697D7 /* MockWebView.swift in Sources */, 55B5498423973B5C00243E87 /* InboxSessionManagerTests.swift in Sources */, ACB37AB0240268A60093A8EA /* SampleInboxViewDelegateImplementations.swift in Sources */, 5588DF7928C04463000697D7 /* MockNotificationResponse.swift in Sources */, + 181063DD2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift in Sources */, AC3A2FF0262EDD4C00425435 /* InAppPriorityTests.swift in Sources */, ACD6116E21080564003E7F6B /* IterableAPITests.swift in Sources */, 5588DF8928C044BE000697D7 /* MockCustomActionDelegate.swift in Sources */, AC02CAA6234E50B5006617E0 /* RegistrationTests.swift in Sources */, + 1802C00F2CA2C99E009DEA2B /* CombinationComplexCriteria.swift in Sources */, 5588DFA128C04570000697D7 /* MockApplicationStateProvider.swift in Sources */, 5588DFF128C046FF000697D7 /* MockMessageViewControllerEventTracker.swift in Sources */, ACEDF41F2183C436000B9BFE /* PendingTests.swift in Sources */, @@ -2476,6 +2675,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 373268012B4D51B200CC82C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = AC2263DE20CF49B8009800EB /* swift-sdk */; + targetProxy = 373268002B4D51B200CC82C9 /* PBXContainerItemProxy */; + }; 5B38881E27FAE6DB00482BE7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = ACF560D220E443BF000AAC23 /* host-app */; @@ -2595,6 +2800,58 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 373268022B4D51B200CC82C9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = UnknownUserMergeTests.UnknownUserMerge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 373268032B4D51B200CC82C9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = UnknownUserMergeTests.UnknownUserMerge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; AC2263F120CF49B8009800EB /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3239,6 +3496,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 373268042B4D51B200CC82C9 /* Build configuration list for PBXNativeTarget "UnknownUserMerge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 373268022B4D51B200CC82C9 /* Debug */, + 373268032B4D51B200CC82C9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AC2263D920CF49B8009800EB /* Build configuration list for PBXProject "swift-sdk" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme b/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme index 9ec9d5991..24eb9a269 100644 --- a/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme +++ b/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme @@ -80,8 +80,8 @@ diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index 7b2797ba2..ab39eef4f 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -10,12 +10,23 @@ enum Endpoint { static let api = Endpoint.apiHostName + Const.apiPath } +enum EventType { + static let customEvent = "customEvent" + static let purchase = "purchase" + static let updateUser = "user" + static let updateCart = "updateCart" + static let unknownSession = "unknownSession" + static let tokenRegistration = "tokenRegistration" + static let trackEvent = "trackEvent" +} + enum Const { static let apiPath = "/api/" static let deepLinkRegex = "/a/[a-zA-Z0-9]+" static let href = "href" static let exponentialFactor = 2.0 + static let criteriaFetchingCooldown = 120000.0 // 120 seconds = 120,000 milliseconds enum Http { static let GET = "GET" @@ -40,6 +51,10 @@ enum Const { static let updateEmail = "users/updateEmail" static let updateSubscriptions = "users/updateSubscriptions" static let getRemoteConfiguration = "mobile/getRemoteConfiguration" + static let mergeUser = "users/merge"; + static let getCriteria = "unknownuser/list"; + static let trackUnknownUserSession = "unknownuser/events/session"; + static let trackConsent = "unknownuser/consent"; static let getEmbeddedMessages = "embedded-messaging/messages" static let embeddedMessageReceived = "embedded-messaging/events/received" static let embeddedMessageClick = "embedded-messaging/events/click" @@ -57,6 +72,14 @@ enum Const { static let deviceId = "itbl_device_id" static let sdkVersion = "itbl_sdk_version" static let offlineMode = "itbl_offline_mode" + static let unknownUserEvents = "itbl_unknown_user_events" + static let unknownUserUpdate = "itbl_unknown_user_update" + static let criteriaData = "itbl_criteria_data" + static let unknownUserSessions = "itbl_unknown_user_sessions" + static let matchedCriteria = "itbl_matched_criteria" + static let eventList = "itbl_event_list" + static let visitorUsageTracked = "itbl_visitor_usage_tracked" + static let visitorConsentTimestamp = "itbl_visitor_consent_timestamp" static let isNotificationsEnabled = "itbl_isNotificationsEnabled" static let hasStoredNotificationSetting = "itbl_hasStoredNotificationSetting" @@ -69,6 +92,7 @@ enum Const { enum Key { static let email = "itbl_email" static let userId = "itbl_userid" + static let userIdUnknownUser = "itbl_userid_unknown_user" static let authToken = "itbl_auth_token" } } @@ -117,6 +141,11 @@ enum JsonKey { static let subscribedMessageTypeIds = "subscribedMessageTypeIds" static let preferUserId = "preferUserId" + static let sourceEmail = "sourceEmail" + static let sourceUserId = "sourceUserId" + static let destinationEmail = "destinationEmail" + static let destinationUserId = "destinationUserId" + static let mergeNestedObjects = "mergeNestedObjects" static let inboxMetadata = "inboxMetadata" @@ -173,6 +202,7 @@ enum JsonKey { static let actionIdentifier = "actionIdentifier" static let userText = "userText" static let appAlreadyRunning = "appAlreadyRunning" + static let unknownSessionContext = "unknownSessionContext" static let html = "html" @@ -182,11 +212,66 @@ enum JsonKey { static let contentType = "Content-Type" + // AUT + static let createNewFields = "createNewFields" + static let eventType = "dataType" + static let eventTimeStamp = "eventTimeStamp" + static let criteriaSets = "criteriaSets" + static let matchedCriteriaId = "matchedCriteriaId" + static let mobilePushOptIn = "mobilePushOptIn" + + enum CriteriaItem { + static let searchQuery = "searchQuery" + static let criteriaId = "criteriaId" + static let searchQueries = "searchQueries" + static let combinator = "combinator" + static let searchCombo = "searchCombo" + static let field = "field" + static let comparatorType = "comparatorType" + static let fieldType = "fieldType" + static let value = "value" + static let values = "values" + static let minMatch = "minMatch" + + enum Combinator { + static let and = "And" + static let or = "Or" + static let not = "Not" + } + + enum CartEventItemsPrefix { + static let updateCartItemPrefix = "updateCart.updatedShoppingCartItems" + static let purchaseItemPrefix = "shoppingCartItems" + } + + enum CartEventPrefix { + static let updateCartItemPrefix = CartEventItemsPrefix.updateCartItemPrefix + "." + static let purchaseItemPrefix = CartEventItemsPrefix.purchaseItemPrefix + "." + } + + enum Comparator { + static let Equals = "Equals" + static let DoesNotEquals = "DoesNotEqual" + static let IsSet = "IsSet" + static let GreaterThan = "GreaterThan" + static let LessThan = "LessThan" + static let GreaterThanOrEqualTo = "GreaterThanOrEqualTo" + static let LessThanOrEqualTo = "LessThanOrEqualTo" + static let Contains = "Contains" + static let StartsWith = "StartsWith" + static let MatchesRegex = "MatchesRegex" + } + } + static let mobileFrameworkInfo = "mobileFrameworkInfo" static let frameworkType = "frameworkType" -// embedded +// Consent tracking + static let consentTimestamp = "consentTimestamp" + static let isUserKnown = "isUserKnown" + + // Embedded Messages static let embeddedSessionId = "session" static let placementId = "placementId" static let embeddedSessionStart = "embeddedSessionStart" @@ -384,6 +469,12 @@ extension Array: JsonValueRepresentable where Element: JsonValueRepresentable { } } +extension Int64: JsonValueRepresentable { + public var jsonValue: Any { + self + } +} + enum MobileDeviceType: String, Codable { case iOS case Android @@ -421,6 +512,12 @@ public enum IterableCustomActionName: String, CaseIterable { case delete } +public enum MergeResult: String { + case mergenotrequired + case mergesuccessful + case mergefailed +} + public typealias ITEActionBlock = (String?) -> Void public typealias ITBURLCallback = (URL?) -> Void public typealias OnSuccessHandler = (_ data: [AnyHashable: Any]?) -> Void @@ -428,3 +525,4 @@ public typealias OnFailureHandler = (_ reason: String?, _ data: Data?) -> Void public typealias UrlHandler = (URL) -> Bool public typealias CustomActionHandler = (String) -> Bool public typealias AuthTokenRetrievalHandler = (String?) -> Void +public typealias MergeActionHandler = (MergeResult, String?) -> Void diff --git a/swift-sdk/Internal/Auth.swift b/swift-sdk/Internal/Auth.swift index 77c34f4c3..8e3e9d5e4 100644 --- a/swift-sdk/Internal/Auth.swift +++ b/swift-sdk/Internal/Auth.swift @@ -12,12 +12,15 @@ struct Auth { let userId: String? let email: String? let authToken: String? + let userIdUnknownUser: String? var emailOrUserId: EmailOrUserId { if let email = email { return .email(email) } else if let userId = userId { return .userId(userId) + } else if let userIdUnknownUser = userIdUnknownUser { + return .userIdUnknownUser(userIdUnknownUser) } else { return .none } @@ -26,6 +29,7 @@ struct Auth { enum EmailOrUserId { case email(String) case userId(String) + case userIdUnknownUser(String) case none } } diff --git a/swift-sdk/Internal/AuthManager.swift b/swift-sdk/Internal/AuthManager.swift index 58b5404c9..15e762889 100644 --- a/swift-sdk/Internal/AuthManager.swift +++ b/swift-sdk/Internal/AuthManager.swift @@ -89,6 +89,13 @@ class AuthManager: IterableAuthManagerProtocol { storeAuthToken() clearRefreshTimer() + + if localStorage.email != nil || localStorage.userId != nil || localStorage.userIdUnknownUser != nil { + localStorage.unknownUserEvents = nil + localStorage.unknownUserSessions = nil + localStorage.unknownUserUpdate = nil + } + isLastAuthTokenValid = false } @@ -157,11 +164,19 @@ class AuthManager: IterableAuthManagerProtocol { if retrievedAuthToken != nil { let isRefreshQueued = queueAuthTokenExpirationRefresh(retrievedAuthToken, onSuccess: onSuccess) if !isRefreshQueued { - onSuccess?(retrievedAuthToken) // Use retrievedAuthToken instead of authToken + onSuccess?(authToken) + authToken = retrievedAuthToken + storeAuthToken() + } else { + authToken = retrievedAuthToken + storeAuthToken() + onSuccess?(authToken) } } else { handleAuthFailure(failedAuthToken: nil, reason: .authTokenNull) scheduleAuthTokenRefreshTimer(interval: getNextRetryInterval(), successCallback: onSuccess) + authToken = retrievedAuthToken + storeAuthToken() } } diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index 9c6aca25f..d52f8c2a1 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -48,6 +48,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } + // MARK: - Pending Consent Tracking + + /// Holds consent data that should be sent once user creation is confirmed + private struct PendingConsentData { + let consentTimestamp: Int64 + let email: String? + let userId: String? + let isUserKnown: Bool + } + + private var pendingConsentData: PendingConsentData? + var deviceMetadata: DeviceMetadata { DeviceMetadata(deviceId: deviceId, platform: JsonValue.iOS, @@ -66,7 +78,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } var auth: Auth { - Auth(userId: userId, email: email, authToken: authManager.getAuthToken()) + Auth(userId: userId, email: email, authToken: authManager.getAuthToken(), userIdUnknownUser: localStorage.userIdUnknownUser) } var dependencyContainer: DependencyContainerProtocol @@ -82,6 +94,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { self.dependencyContainer.createAuthManager(config: self.config) }() + lazy var unknownUserManager: UnknownUserManagerProtocol = { + self.dependencyContainer.createUnknownUserManager(config: self.config) + }() + + lazy var unknownUserMerge: UnknownUserMergeProtocol = { + self.dependencyContainer.createUnknownUserMerge(apiClient: apiClient as! ApiClient, unknownUserManager: unknownUserManager, localStorage: localStorage) + }() + lazy var embeddedManager: IterableInternalEmbeddedManagerProtocol = { self.dependencyContainer.createEmbeddedManager(config: self.config, apiClient: self.apiClient) @@ -122,63 +142,213 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { _payloadData = data } - func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { - ITBInfo() + func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, identityResolution: IterableIdentityResolution? = nil) { - if _email == email && email != nil && authToken != nil { - checkAndUpdateAuthToken(authToken) + ITBInfo() + if self._email == email && email != nil { + self.checkAndUpdateAuthToken(authToken) return } - - if _email == email { + + if self._email == email { return } - logoutPreviousUser() - - _email = email - _userId = nil - _successCallback = successHandler - _failureCallback = failureHandler - - storeIdentifierData() + self.logoutPreviousUser() + + self._email = email + self._userId = nil + + self.onLogin(authToken) { [weak self] in + guard let config = self?.config else { + return + } + let merge = identityResolution?.mergeOnUnknownUserToKnown ?? config.identityResolution.mergeOnUnknownUserToKnown + let replay = identityResolution?.replayOnVisitorToKnown ?? config.identityResolution.replayOnVisitorToKnown + if config.enableUnknownUserActivation, let email = email { + // Prepare consent for replay scenario before merge + // Check if this is truly a replay scenario (no existing anonymous user before merge) + if let replay, replay, self?.localStorage.userIdUnknownUser == nil { + self?.prepareConsent(email: email, userId: nil) + } + + self?.attemptAndProcessMerge( + merge: merge ?? true, + replay: replay ?? true, + destinationUser: email, + isEmail: true, + failureHandler: failureHandler + ) + + // Clear unknown user ID after merge for email login + self?.localStorage.userIdUnknownUser = nil + } + } - onLogin(authToken) + + self._successCallback = successHandler + self._failureCallback = failureHandler + self.storeIdentifierData() } - func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { + func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, isUnknownUser: Bool = false, identityResolution: IterableIdentityResolution? = nil) { ITBInfo() - - if _userId == userId && userId != nil && authToken != nil { - checkAndUpdateAuthToken(authToken) + + if self._userId == userId && userId != nil { + self.checkAndUpdateAuthToken(authToken) return } - - if _userId == userId { + + if self._userId == userId { return } + + self.logoutPreviousUser() + + self._email = nil + self._userId = userId + self.onLogin(authToken) { [weak self] in + guard let config = self?.config else { + return + } + if config.enableUnknownUserActivation { + if let userId = userId, userId != (self?.localStorage.userIdUnknownUser ?? "") { + let merge = identityResolution?.mergeOnUnknownUserToKnown ?? config.identityResolution.mergeOnUnknownUserToKnown + let replay = identityResolution?.replayOnVisitorToKnown ?? config.identityResolution.replayOnVisitorToKnown + + // Prepare consent for replay scenario before merge + // Check if this is truly a replay scenario (no existing anonymous user before merge) + if let replay, replay, self?.localStorage.userIdUnknownUser == nil { + self?.prepareConsent(email: nil, userId: userId) + } + + self?.attemptAndProcessMerge( + merge: merge ?? true, + replay: replay ?? true, + destinationUser: userId, + isEmail: false, + failureHandler: failureHandler + ) + + // Clear unknown user ID after merge (unless this is an unknown user login) + if !isUnknownUser { + self?.localStorage.userIdUnknownUser = nil + } + } + } + } + + self._successCallback = successHandler + self._failureCallback = failureHandler + self.storeIdentifierData() + + } + + func logoutUser() { logoutPreviousUser() + } + + func attemptAndProcessMerge(merge: Bool, replay: Bool, destinationUser: String?, isEmail: Bool, failureHandler: OnFailureHandler? = nil) { + unknownUserMerge.tryMergeUser(destinationUser: destinationUser, isEmail: isEmail, merge: merge) { mergeResult, error in + + if mergeResult == MergeResult.mergenotrequired || mergeResult == MergeResult.mergesuccessful { + if (replay) { + self.unknownUserManager.syncEvents() + } + } else { + failureHandler?(error, nil) + } + self.unknownUserManager.clearVisitorEventsAndUserData() + } + } + + func setVisitorUsageTracked(isVisitorUsageTracked: Bool) { + ITBInfo("CONSENT CHANGED - local events cleared") + self.localStorage.visitorUsageTracked = isVisitorUsageTracked - _email = nil - _userId = userId - _successCallback = successHandler - _failureCallback = failureHandler + // Store consent timestamp when consent is given + if isVisitorUsageTracked { + self.localStorage.visitorConsentTimestamp = Int64(dateProvider.currentDate.timeIntervalSince1970 * 1000) + } else { + self.localStorage.visitorConsentTimestamp = nil + } - storeIdentifierData() + self.localStorage.unknownUserEvents = nil + self.localStorage.unknownUserSessions = nil + self.localStorage.unknownUserUpdate = nil + self.localStorage.userIdUnknownUser = nil - onLogin(authToken) + if isVisitorUsageTracked && config.enableUnknownUserActivation { + ITBInfo("CONSENT GIVEN and UNKNOWN USER TRACKING ENABLED - Criteria fetched") + self.unknownUserManager.getUnknownUserCriteria() + self.unknownUserManager.updateUnknownUserSession() + } } - - func logoutUser() { - logoutPreviousUser() + + func getVisitorUsageTracked() -> Bool { + return self.localStorage.visitorUsageTracked + } + + /// Prepares consent data to be sent when user registration is confirmed during "replay scenario". + /// + /// A "replay scenario" occurs when a user signs up or logs in but does not meet the criteria + /// for immediate consent tracking. This method stores consent data to be sent once user + /// registration is confirmed through the registration success callback. + /// + /// This method is typically called during user sign-up or sign-in processes to ensure that + /// consent data is properly recorded for compliance and analytics purposes. + private func prepareConsent(email: String?, userId: String?) { + guard let consentTimestamp = localStorage.visitorConsentTimestamp else { + return + } + + // Only prepare consent if we have previous anonymous tracking consent but no anonymous user ID + guard localStorage.userIdUnknownUser == nil && localStorage.visitorUsageTracked else { + return + } + + // Store the consent data to be sent when user registration is confirmed + pendingConsentData = PendingConsentData( + consentTimestamp: consentTimestamp, + email: email, + userId: userId, + isUserKnown: true + ) + + ITBInfo("Consent data prepared for replay scenario - will send after user registration is confirmed") } + /// Sends any pending consent data now that user creation is confirmed + private func sendPendingConsent() { + guard let consentData = pendingConsentData else { + ITBDebug("No pending consent to send") + return + } + + ITBDebug("Sending pending consent after user registration: email set=\(consentData.email != nil), userId set=\(consentData.userId != nil), timestamp=\(consentData.consentTimestamp)") + + apiClient.trackConsent( + consentTimestamp: consentData.consentTimestamp, + email: consentData.email, + userId: consentData.userId, + isUserKnown: consentData.isUserKnown + ).onSuccess { _ in + ITBInfo("Pending consent tracked successfully after user registration") + }.onError { error in + ITBError("Failed to track pending consent after user registration: \(error)") + } + + // Clear the pending consent data + pendingConsentData = nil + } + // MARK: - API Request Calls func register(token: String, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { + guard let appName = pushIntegrationName else { let errorMessage = "Not registering device token - appName must not be nil" ITBError(errorMessage) @@ -186,6 +356,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onFailure?(errorMessage, nil) return } + + if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { + if config.enableUnknownUserActivation { + unknownUserManager.trackUnknownUserTokenRegistration(token: token) + } + onFailure?("Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods", nil) + return + } hexToken = token @@ -202,10 +380,16 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { requestHandler.register(registerTokenInfo: registerTokenInfo, notificationStateProvider: notificationStateProvider, onSuccess: { (_ data: [AnyHashable: Any]?) in + // Send any pending consent now that user registration is confirmed + ITBDebug("Device registration succeeded; attempting to send pending consent if any") + self.sendPendingConsent() self._successCallback?(data) onSuccess?(data) }, onFailure: { (_ reason: String?, _ data: Data?) in + // Clear any pending consent on failure + ITBDebug("Device registration failed; clearing any pending consent") + self.pendingConsentData = nil self._failureCallback?(reason, data) onFailure?(reason, data) } @@ -252,7 +436,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { mergeNestedObjects: Bool, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.updateUser(dataFields, mergeNestedObjects: mergeNestedObjects, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { + if config.enableUnknownUserActivation { + ITBInfo("UUA ENABLED - unknown user update user") + unknownUserManager.trackUnknownUserUpdateUser(dataFields) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.updateUser(dataFields, mergeNestedObjects: mergeNestedObjects, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -277,7 +468,29 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { func updateCart(items: [CommerceItem], onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { + if config.enableUnknownUserActivation { + ITBInfo("UUA ENABLED - unknown user update cart") + unknownUserManager.trackUnknownUserUpdateCart(items: items) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) + } + + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + return requestHandler.updateCart(items: items, createdAt: createdAt, onSuccess: onSuccess, onFailure: onFailure) + } + + private func rejectWithInitializationError(onFailure: OnFailureHandler? = nil) -> Pending { + let result = Fulfill() + result.reject(with: SendRequestError()) + onFailure?("Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods", nil) + return result } @discardableResult @@ -288,7 +501,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { templateId: NSNumber? = nil, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.trackPurchase(total, + if !isEitherUserIdOrEmailSet() { + if config.enableUnknownUserActivation { + ITBInfo("UUA ENABLED - unknown user track purchase") + unknownUserManager.trackUnknownUserPurchaseEvent(total: total, items: items, dataFields: dataFields) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.trackPurchase(total, items: items, dataFields: dataFields, campaignId: campaignId, @@ -296,6 +516,21 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onSuccess: onSuccess, onFailure: onFailure) } + + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]? = nil, + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + return requestHandler.trackPurchase(total, + items: items, + dataFields: dataFields, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } @discardableResult @@ -340,7 +575,22 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { dataFields: [AnyHashable: Any]? = nil, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.track(event: eventName, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && localStorage.userIdUnknownUser == nil { + if config.enableUnknownUserActivation { + ITBInfo("UUA ENABLED - unknown user track custom event") + unknownUserManager.trackUnknownUserEvent(name: eventName, dataFields: dataFields) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.track(event: eventName, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) + } + + @discardableResult + func track(_ eventName: String, + withBody body: [AnyHashable: Any], + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + requestHandler.track(event: eventName, withBody: body, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -432,7 +682,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { source: InAppDeleteSource? = nil, inboxSessionId: String? = nil, onSuccess: OnSuccessHandler? = nil, - onFailure: OnFailureHandler? = nil) -> Pending { + onFailure: OnFailureHandler? = nil) -> Pending { requestHandler.inAppConsume(message: message, location: location, source: source, @@ -559,7 +809,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } - func isSDKInitialized() -> Bool { + + public func isSDKInitialized() -> Bool { let isInitialized = !apiKey.isEmpty && isEitherUserIdOrEmailSet() if !isInitialized { @@ -577,6 +828,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { IterableUtil.isNullOrEmpty(string: _email) && IterableUtil.isNullOrEmpty(string: _userId) } + public func isUnknownUserSet() -> Bool { + IterableUtil.isNotNullOrEmpty(string: localStorage.userIdUnknownUser) + } + private func logoutPreviousUser() { ITBInfo() @@ -604,43 +859,52 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { localStorage.userId = _userId } - private func onLogin(_ authToken: String? = nil) { + private func onLogin(_ authToken: String? = nil, onloginSuccess onloginSuccessCallBack: (()->())? = nil) { + guard isSDKInitialized() else { return } + ITBInfo() guard isSDKInitialized() else { return } self.authManager.pauseAuthRetries(false) - if let authToken = authToken { + if let authToken { self.authManager.setNewToken(authToken) - completeUserLogin() + completeUserLogin(onloginSuccessCallBack: onloginSuccessCallBack) } else if isEitherUserIdOrEmailSet() && config.authDelegate != nil { - requestNewAuthToken() + requestNewAuthToken(onloginSuccessCallBack: onloginSuccessCallBack) } else { - completeUserLogin() + completeUserLogin(onloginSuccessCallBack: onloginSuccessCallBack) } } - private func requestNewAuthToken() { + private func requestNewAuthToken(onloginSuccessCallBack: (()->())? = nil) { ITBInfo() authManager.requestNewAuthToken(hasFailedPriorAuth: false, onSuccess: { [weak self] token in if token != nil { - self?.completeUserLogin() + self?.completeUserLogin(onloginSuccessCallBack: onloginSuccessCallBack) } }, shouldIgnoreRetryPolicy: true) } - private func completeUserLogin() { - ITBInfo() + private func completeUserLogin(onloginSuccessCallBack: (()->())? = nil) { + ITBInfo() guard isSDKInitialized() else { return } if config.autoPushRegistration { notificationStateProvider.registerForRemoteNotifications() } else { - _successCallback?([:]) + // If auto push registration is disabled, send pending consent here + // since register() won't be called automatically + ITBDebug("Auto push registration disabled; attempting to send pending consent after login") + sendPendingConsent() + _successCallback?([:]) } _ = inAppManager.scheduleSync() + if onloginSuccessCallBack != nil { + onloginSuccessCallBack!() + } } private func retrieveIdentifierData() { @@ -662,7 +926,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } private func checkAndUpdateAuthToken(_ authToken: String? = nil) { - if config.authDelegate != nil && authToken != authManager.getAuthToken() { + if config.authDelegate != nil && authToken != authManager.getAuthToken() && authToken != nil { onLogin(authToken) } } @@ -737,6 +1001,11 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } @objc private func onAppDidBecomeActiveNotification(notification: Notification) { + handlePushNotificationState() + handleMatchingCriteriaState() + } + + private func handlePushNotificationState() { guard config.autoPushRegistration else { return } notificationStateProvider.isNotificationsEnabled { [weak self] systemEnabled in @@ -757,6 +1026,24 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } + private func handleMatchingCriteriaState() { + guard config.enableForegroundCriteriaFetch else { return } + + let currentTime = Date().timeIntervalSince1970 * 1000 // Convert to milliseconds + + // fetching unknown user criteria on foregrounding + if noUserLoggedIn() + && !isUnknownUserSet() + && config.enableUnknownUserActivation + && getVisitorUsageTracked() + && (currentTime - unknownUserManager.getLastCriteriaFetch() >= Const.criteriaFetchingCooldown) { + + unknownUserManager.updateLastCriteriaFetch(currentTime: currentTime) + unknownUserManager.getUnknownUserCriteria() + ITBInfo("Fetching unknown user criteria - Foreground") + } + } + private func handle(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { guard let launchOptions = launchOptions else { return @@ -825,6 +1112,17 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } + func getCriteriaData(completion: @escaping (Data) -> Void) { + apiClient.getCriteria().onSuccess { data in + do { + let jsonData = try JSONSerialization.data(withJSONObject: data, options: []) + completion(jsonData) + } catch { + print("Error converting dictionary to data: \(error)") + } + } + } + private func createDefaultMobileFrameworkInfo() -> IterableAPIMobileFrameworkInfo { let frameworkType = IterableAPIMobileFrameworkDetector.frameworkType() return IterableAPIMobileFrameworkInfo( diff --git a/swift-sdk/Internal/IterableIdentityResolution.swift b/swift-sdk/Internal/IterableIdentityResolution.swift new file mode 100644 index 000000000..8bfcc5f2a --- /dev/null +++ b/swift-sdk/Internal/IterableIdentityResolution.swift @@ -0,0 +1,23 @@ +// +// Untitled.swift +// swift-sdk +// +// Created by Evan Greer on 9/19/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import Foundation +@objc public class IterableIdentityResolution: NSObject { + + /// userId or email of the signed-in user + public var replayOnVisitorToKnown: Bool? + + /// the authToken which caused the failure + public let mergeOnUnknownUserToKnown: Bool? + + public init(replayOnVisitorToKnown: Bool?, + mergeOnUnknownUserToKnown: Bool?) { + self.replayOnVisitorToKnown = replayOnVisitorToKnown + self.mergeOnUnknownUserToKnown = mergeOnUnknownUserToKnown + } +} diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 5c11ec791..f51e16d9a 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -9,7 +9,7 @@ class IterableUserDefaults { init(userDefaults: UserDefaults = UserDefaults.standard) { self.userDefaults = userDefaults } - + // migrated to IterableKeychain var userId: String? { get { @@ -18,7 +18,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .userId) } } - + // migrated to IterableKeychain var email: String? { get { @@ -27,7 +27,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .email) } } - + // migrated to IterableKeychain var authToken: String? { get { @@ -36,7 +36,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .authToken) } } - + // deprecated, not in use anymore var ddlChecked: Bool { get { @@ -45,7 +45,7 @@ class IterableUserDefaults { save(bool: newValue, withKey: .ddlChecked) } } - + var deviceId: String? { get { string(withKey: .deviceId) @@ -53,7 +53,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .deviceId) } } - + var sdkVersion: String? { get { string(withKey: .sdkVersion) @@ -61,7 +61,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .sdkVersion) } } - + var offlineMode: Bool { get { bool(withKey: .offlineMode) @@ -69,6 +69,90 @@ class IterableUserDefaults { save(bool: newValue, withKey: .offlineMode) } } + + var visitorUsageTracked: Bool { + get { + return bool(withKey: .visitorUsageTracked) + } set { + save(bool: newValue, withKey: .visitorUsageTracked) + } + } + + var visitorConsentTimestamp: Int64? { + get { + return int64(withKey: .visitorConsentTimestamp) + } set { + save(int64: newValue, withKey: .visitorConsentTimestamp) + } + } + + var unknownUserEvents: [[AnyHashable: Any]]? { + get { + return eventData(withKey: .unknownUserEvents) + } set { + saveEventData(unknownUserEvents: newValue, withKey: .unknownUserEvents) + } + } + + var unknownUserUpdate: [AnyHashable: Any]? { + get { + return userUpdateData(withKey: .unknownUserUpdate) + } set { + saveUserUpdate(newValue, withKey: .unknownUserUpdate) + } + } + + var criteriaData: Data? { + get { + return getCriteriaData(withKey: .criteriaData) + } set { + saveCriteriaData(data: newValue, withKey: .criteriaData) + } + } + + var unknownUserSessions: IterableUnknownUserSessionsWrapper? { + get { + return unknownUserSessionsData(withKey: .unknownUserSessions) + } set { + saveUnknownUserSessionsData(data: newValue, withKey: .unknownUserSessions) + } + } + + var body = [AnyHashable: Any]() + + private func unknownUserSessionsData(withKey key: UserDefaultsKey) -> IterableUnknownUserSessionsWrapper? { + if let savedData = UserDefaults.standard.data(forKey: key.value) { + let decodedData = try? JSONDecoder().decode(IterableUnknownUserSessionsWrapper.self, from: savedData) + return decodedData + } + return nil + } + + private func saveUnknownUserSessionsData(data: IterableUnknownUserSessionsWrapper?, withKey key: UserDefaultsKey) { + if let encodedData = try? JSONEncoder().encode(data) { + userDefaults.set(encodedData, forKey: key.value) + } + } + + private func criteriaData(withKey key: UserDefaultsKey) -> [Criteria]? { + if let savedData = UserDefaults.standard.data(forKey: key.value) { + let decodedData = try? JSONDecoder().decode([Criteria].self, from: savedData) + return decodedData + } + return nil + } + + private func saveCriteriaData(data: Data?, withKey key: UserDefaultsKey) { + userDefaults.set(data, forKey: key.value) + } + + private func saveEventData(unknownUserEvents: [[AnyHashable: Any]]?, withKey key: UserDefaultsKey) { + userDefaults.set(unknownUserEvents, forKey: key.value) + } + + private func saveUserUpdate(_ update: [AnyHashable: Any]?, withKey key: UserDefaultsKey) { + userDefaults.set(update, forKey: key.value) + } var isNotificationsEnabled: Bool { get { @@ -120,6 +204,16 @@ class IterableUserDefaults { } } + private func dict(withKey key: UserDefaultsKey) throws -> [AnyHashable: Any]? { + guard let encodedEnvelope = userDefaults.value(forKey: key.value) as? Data else { + return nil + } + + let envelope = try JSONDecoder().decode(EnvelopeNoExpiration.self, from: encodedEnvelope) + let decoded = try JSONSerialization.jsonObject(with: envelope.payload, options: []) as? [AnyHashable: Any] + return decoded + } + private func codable(withKey key: UserDefaultsKey, currentDate: Date) throws -> T? { guard let encodedEnvelope = userDefaults.value(forKey: key.value) as? Data else { return nil @@ -144,6 +238,26 @@ class IterableUserDefaults { userDefaults.bool(forKey: key.value) } + private func eventData(withKey key: UserDefaultsKey) -> [[AnyHashable: Any]]? { + userDefaults.array(forKey: key.value) as? [[AnyHashable: Any]] + } + + private func userUpdateData(withKey key: UserDefaultsKey) -> [AnyHashable: Any]? { + userDefaults.object(forKey: key.value) as? [AnyHashable: Any] + } + + private func getCriteriaData(withKey key: UserDefaultsKey) -> Data? { + userDefaults.object(forKey: key.value) as? Data + } + + private func int64(withKey key: UserDefaultsKey) -> Int64? { + userDefaults.object(forKey: key.value) as? Int64 + } + + private func save(int64: Int64?, withKey key: UserDefaultsKey) { + userDefaults.set(int64, forKey: key.value) + } + private static func isExpired(expiration: Date?, currentDate: Date) -> Bool { if let expiration = expiration { if expiration.timeIntervalSinceReferenceDate > currentDate.timeIntervalSinceReferenceDate { @@ -198,6 +312,17 @@ class IterableUserDefaults { userDefaults.set(encodedEnvelope, forKey: key.value) } + private func save(data: Data?, withKey key: UserDefaultsKey) throws { + guard let data = data else { + userDefaults.removeObject(forKey: key.value) + return + } + + let envelope = EnvelopeNoExpiration(payload: data) + let encodedEnvelope = try JSONEncoder().encode(envelope) + userDefaults.set(encodedEnvelope, forKey: key.value) + } + private struct UserDefaultsKey { let value: String @@ -212,12 +337,22 @@ class IterableUserDefaults { static let deviceId = UserDefaultsKey(value: Const.UserDefault.deviceId) static let sdkVersion = UserDefaultsKey(value: Const.UserDefault.sdkVersion) static let offlineMode = UserDefaultsKey(value: Const.UserDefault.offlineMode) + static let unknownUserEvents = UserDefaultsKey(value: Const.UserDefault.unknownUserEvents) + static let unknownUserUpdate = UserDefaultsKey(value: Const.UserDefault.unknownUserUpdate) + static let criteriaData = UserDefaultsKey(value: Const.UserDefault.criteriaData) + static let unknownUserSessions = UserDefaultsKey(value: Const.UserDefault.unknownUserSessions) + static let visitorUsageTracked = UserDefaultsKey(value: Const.UserDefault.visitorUsageTracked) + static let visitorConsentTimestamp = UserDefaultsKey(value: Const.UserDefault.visitorConsentTimestamp) + static let isNotificationsEnabled = UserDefaultsKey(value: Const.UserDefault.isNotificationsEnabled) static let hasStoredNotificationSetting = UserDefaultsKey(value: Const.UserDefault.hasStoredNotificationSetting) } - private struct Envelope: Codable { let payload: Data let expiration: Date? } + + private struct EnvelopeNoExpiration: Codable { + let payload: Data + } } diff --git a/swift-sdk/Internal/Models.swift b/swift-sdk/Internal/Models.swift index 9e36ea3fa..fbfb1e179 100644 --- a/swift-sdk/Internal/Models.swift +++ b/swift-sdk/Internal/Models.swift @@ -8,3 +8,26 @@ import Foundation struct RemoteConfiguration: Codable, Equatable { let offlineMode: Bool } + +struct Criteria: Codable { + let criteriaId: String + let criteriaList: [CriteriaItem] +} + +struct CriteriaItem: Codable { + let criteriaType: String + let comparator: String? + let name: String? + let aggregateCount: Int? + let total: Int? +} + +struct IterableUnknownUserSessions: Codable { + var totalUnknownUserSessionCount: Int + var lastUnknownUserSession: Int + var firstUnknownUserSession: Int +} + +struct IterableUnknownUserSessionsWrapper: Codable { + var itbl_unknown_user_sessions: IterableUnknownUserSessions +} diff --git a/swift-sdk/Internal/RequestHandlerProtocol.swift b/swift-sdk/Internal/RequestHandlerProtocol.swift index a904bf3be..4c51e1b43 100644 --- a/swift-sdk/Internal/RequestHandlerProtocol.swift +++ b/swift-sdk/Internal/RequestHandlerProtocol.swift @@ -43,6 +43,12 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -52,6 +58,14 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -67,6 +81,12 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler?, diff --git a/swift-sdk/Internal/UnknownUserManager+Functions.swift b/swift-sdk/Internal/UnknownUserManager+Functions.swift new file mode 100644 index 000000000..dc3c0d443 --- /dev/null +++ b/swift-sdk/Internal/UnknownUserManager+Functions.swift @@ -0,0 +1,690 @@ +// +// File.swift +// +// +// Created by HARDIK MASHRU on 13/11/23. +// + +import Foundation + +// Convert commerce items to dictionaries +func convertCommerceItemsToDictionary(_ items: [CommerceItem]) -> [[AnyHashable:Any]] { + let dictionaries = items.map { item in + return item.toDictionary() + } + return dictionaries +} + +// Convert to commerce items from dictionaries +func convertCommerceItems(from dictionaries: [[AnyHashable: Any]]) -> [CommerceItem] { + return dictionaries.compactMap { dictionary in + let item = CommerceItem(id: dictionary[JsonKey.CommerceItem.id] as? String ?? "", name: dictionary[JsonKey.CommerceItem.name] as? String ?? "", price: dictionary[JsonKey.CommerceItem.price] as? NSNumber ?? 0, quantity: dictionary[JsonKey.CommerceItem.quantity] as? UInt ?? 0) + item.sku = dictionary[JsonKey.CommerceItem.sku] as? String + item.itemDescription = dictionary[JsonKey.CommerceItem.description] as? String + item.url = dictionary[JsonKey.CommerceItem.url] as? String + item.imageUrl = dictionary[JsonKey.CommerceItem.imageUrl] as? String + item.categories = dictionary[JsonKey.CommerceItem.categories] as? [String] + item.dataFields = dictionary[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] + + return item + } +} + +func convertToDictionary(data: Codable) -> [AnyHashable: Any] { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(data) + if let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [AnyHashable: Any] { + return dictionary + } + } catch { + print("Error converting to dictionary: \(error)") + } + return [:] +} + +// Converts UTC Datetime from current time +func getUTCDateTime() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + dateFormatter.timeZone = TimeZone(identifier: "UTC") + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + let utcDate = Date() + return dateFormatter.string(from: utcDate) +} + +struct CriteriaCompletionChecker { + init(unknownUserCriteria: Data, unknownUserEvents: [[AnyHashable: Any]]) { + self.unknownUserEvents = unknownUserEvents + self.unknownUserCriteria = unknownUserCriteria + } + + func getMatchedCriteria() -> String? { + var criteriaId: String? = nil + if let json = try? JSONSerialization.jsonObject(with: unknownUserCriteria, options: []) as? [String: Any] { + // Access the criteriaList + if let criteriaList = json[JsonKey.criteriaSets] as? [[String: Any]] { + // Iterate over the criteria + for criteria in criteriaList { + // Perform operations on each criteria + if let searchQuery = criteria[JsonKey.CriteriaItem.searchQuery] as? [String: Any], let currentCriteriaId = criteria[JsonKey.CriteriaItem.criteriaId] as? String { + // we will split purhase/updatecart event items as seperate events because we need to compare it against the single item in criteria json + var eventsToProcess = getEventsWithCartItems() + eventsToProcess.append(contentsOf: getNonCartEvents()) + let result = evaluateTree(node: searchQuery, localEventData: eventsToProcess) + if (result) { + criteriaId = currentCriteriaId + break + } + } + } + } + } + return criteriaId + } + + func getMappedKeys(event: [AnyHashable: Any]) -> [String] { + var itemKeys: [String] = [] + for (_ , value) in event { + if let arrayValue = value as? [[AnyHashable: Any]], arrayValue.count > 0 { // this is a special case of items array in purchase event + // If the value is an array, handle it + itemKeys.append(contentsOf: extractKeys(dict: arrayValue[0])) + } else { + itemKeys.append(contentsOf: extractKeys(dict: event)) + } + } + return itemKeys + } + + func getNonCartEvents() -> [[AnyHashable: Any]] { + let nonPurchaseEvents = unknownUserEvents.filter { dictionary in + if let dataType = dictionary[JsonKey.eventType] as? String { + return dataType != EventType.purchase && dataType != EventType.updateCart + } + return false + } + var processedEvents: [[AnyHashable: Any]] = [] + for eventItem in nonPurchaseEvents { + var updatedItem = eventItem + // handle dataFields if any + if let dataFields = eventItem[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] { + for (key, value) in dataFields { + if key is String { + updatedItem[key] = value + } + } + updatedItem.removeValue(forKey: JsonKey.CommerceItem.dataFields) + } + processedEvents.append(updatedItem) + } + return processedEvents + } + + private func processEvent(eventItem: [AnyHashable: Any], eventType: String, eventName: String, prefix: String) -> [AnyHashable: Any] { + var updatedItem = [AnyHashable: Any]() + if let items = eventItem[JsonKey.Commerce.items] as? [[AnyHashable: Any]] { + let updatedCartOrPurchaseItems = items.map { item -> [AnyHashable: Any] in + var updateCartOrPurchaseItem = [AnyHashable: Any]() + for (key, value) in item { + if let stringKey = key as? String { + updateCartOrPurchaseItem[prefix + stringKey] = value + } + } + return updateCartOrPurchaseItem + } + if eventName.isEmpty { + updatedItem[JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix] = updatedCartOrPurchaseItems + } else { + updatedItem[JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix] = updatedCartOrPurchaseItems + } + } + + + // handle dataFields if any + if let dataFields = eventItem[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] { + for (key, value) in dataFields { + if key is String { + updatedItem[key] = value + } + } + } + + for (key, value) in eventItem { + if (key as! String != JsonKey.Commerce.items && key as! String != JsonKey.CommerceItem.dataFields) { + if (key as! String == JsonKey.eventType) { + updatedItem[key] = EventType.customEvent; + } else { + updatedItem[key] = value + } + } + } + + updatedItem[JsonKey.eventType] = eventType + if !eventName.isEmpty { + updatedItem[JsonKey.eventName] = eventName + } + updatedItem.removeValue(forKey: JsonKey.Commerce.items) + return updatedItem; + } + + func getEventsWithCartItems() -> [[AnyHashable: Any]] { + let purchaseEvents = unknownUserEvents.filter { dictionary in + if let dataType = dictionary[JsonKey.eventType] as? String { + return dataType == EventType.purchase || dataType == EventType.updateCart + } + return false + } + + var processedEvents: [[AnyHashable: Any]] = [] + for var eventItem in purchaseEvents { + if let eventType = eventItem[JsonKey.eventType] as? String, eventType == EventType.purchase { + processedEvents.append(processEvent(eventItem: eventItem, eventType: EventType.purchase, eventName: "", prefix: JsonKey.CriteriaItem.CartEventPrefix.purchaseItemPrefix)) + + } else if let eventType = eventItem[JsonKey.eventType] as? String, eventType == EventType.updateCart { + processedEvents.append(processEvent(eventItem: eventItem, eventType: EventType.customEvent, eventName: EventType.updateCart, prefix: JsonKey.CriteriaItem.CartEventPrefix.updateCartItemPrefix)) + } + eventItem.removeValue(forKey: JsonKey.CommerceItem.dataFields) + } + return processedEvents + } + + func extractKeys(jsonObject: [String: Any]) -> [String] { + return Array(jsonObject.keys) + } + + func extractKeys(dict: [AnyHashable: Any]) -> [String] { + var keys: [String] = [] + for key in dict.keys { + if let stringKey = key as? String { + // If needed, use stringKey which is now guaranteed to be a String + keys.append(stringKey) + } + } + return keys + } + + func evaluateTree(node: [String: Any], localEventData: [[AnyHashable: Any]]) -> Bool { + if let searchQueries = node[JsonKey.CriteriaItem.searchQueries] as? [[String: Any]], let combinator = node[JsonKey.CriteriaItem.combinator] as? String { + if combinator == JsonKey.CriteriaItem.Combinator.and { + for query in searchQueries { + if !evaluateTree(node: query, localEventData: localEventData) { + return false // If any subquery fails, return false + } + } + return true // If all subqueries pass, return true + } else if combinator == JsonKey.CriteriaItem.Combinator.or { + for query in searchQueries { + if evaluateTree(node: query, localEventData: localEventData) { + return true // If any subquery passes, return true + } + } + return false // If all subqueries fail, return false + } else if combinator == JsonKey.CriteriaItem.Combinator.not { + for var query in searchQueries { + query["isNot"] = true + if evaluateTree(node: query, localEventData: localEventData) { + return false // If all subquery passes, return false + } + } + return true // If any subqueries fail, return true + } + } else if node[JsonKey.CriteriaItem.searchCombo] is [String: Any] { + return evaluateSearchQueries(node: node, localEventData: localEventData) + } + + return false + } + + func evaluateSearchQueries(node: [String: Any], localEventData: [[AnyHashable: Any]]) -> Bool { + // Make a mutable copy of the node + var mutableNode = node + for (index, eventData) in localEventData.enumerated() { + guard let trackingType = eventData[JsonKey.eventType] as? String else { continue } + let dataType = mutableNode[JsonKey.eventType] as? String + if eventData[JsonKey.CriteriaItem.criteriaId] == nil && dataType == trackingType { + if let searchCombo = mutableNode[JsonKey.CriteriaItem.searchCombo] as? [String: Any] { + let searchQueries = searchCombo[JsonKey.CriteriaItem.searchQueries] as? [[AnyHashable: Any]] ?? [] + let combinator = searchCombo[JsonKey.CriteriaItem.combinator] as? String ?? "" + let isNot = node["isNot"] as? Bool ?? false + if evaluateEvent(eventData: eventData, searchQueries: searchQueries, combinator: combinator) { + if var minMatch = mutableNode[JsonKey.CriteriaItem.minMatch] as? Int { + minMatch -= 1 + if minMatch > 0 { + mutableNode[JsonKey.CriteriaItem.minMatch] = minMatch + continue + } + } + if isNot && index + 1 != localEventData.count { + continue + } + return true + } else if (isNot){ + return false; + } + } + } + } + return false + } + + + // Evaluate the event based on search queries and combinator + private func evaluateEvent(eventData: [AnyHashable: Any], searchQueries: [[AnyHashable: Any]], combinator: String) -> Bool { + return evaluateFieldLogic(searchQueries: searchQueries, eventData: eventData) + } + + + + // Check if item criteria exists in search queries + private func doesItemCriteriaExist(searchQueries: [[AnyHashable: Any]]) -> Bool { + return searchQueries.contains { query in + if let field = query[JsonKey.CriteriaItem.field] as? String { + return field.hasPrefix(JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix) || + field.hasPrefix(JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix) + } + return false + } + } + + // Check if an item matches the search queries + private func doesItemMatchQueries(item: [String: Any], searchQueries: [[AnyHashable: Any]]) -> Bool { + // Filter searchQueries based on whether the item's keys contain the query field + var filteredSearchQueries: [[AnyHashable: Any]] = [] + for searchQuery in searchQueries { + if let field = searchQuery[JsonKey.CriteriaItem.field] as? String { + if field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.updateCartItemPrefix) || + field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.purchaseItemPrefix) { + if !item.keys.contains(where: { $0 == field }) { + return false + } + filteredSearchQueries.append(searchQuery) + } + } + } + // Return false if no queries are left after filtering + if filteredSearchQueries.isEmpty { + return false + } + + let result = filteredSearchQueries.allSatisfy { query in + let field = query[JsonKey.CriteriaItem.field] + if let value = item[field as! String], let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String{ + return evaluateComparison(comparatorType: comparatorType, matchObj: value, valueToCompare: query[JsonKey.CriteriaItem.value] ?? query[JsonKey.CriteriaItem.values]) + } + return false + } + + if !result { + return result + } + + if !filteredSearchQueries.isEmpty { + return true + } + + return false + } + + // Evaluate the field logic against the event data + private func evaluateFieldLogic(searchQueries: [[AnyHashable: Any]], eventData: [AnyHashable: Any]) -> Bool { + let localDataKeys = Array(eventData.keys) + var itemMatchedResult = false + var itemsKey: String? = nil + + if localDataKeys.contains(JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix) { + itemsKey = JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix + } else if localDataKeys.contains(JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix) { + itemsKey = JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix + } + if let itemsKey = itemsKey { + if let items = eventData[itemsKey] as? [[String: Any]] { + let result = items.contains { doesItemMatchQueries(item: $0, searchQueries: searchQueries) } + if !result && doesItemCriteriaExist(searchQueries: searchQueries) { + return result + } + itemMatchedResult = result + } + } + + // Assuming localDataKeys is [String] + let filteredLocalDataKeys = localDataKeys.filter { $0 as! String != JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix } + if filteredLocalDataKeys.isEmpty { + return itemMatchedResult + } + + // Assuming searchQueries is [[String: Any]] + let filteredSearchQueries = searchQueries.filter { query in + if let field = query[JsonKey.CriteriaItem.field] as? String { + return !field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.updateCartItemPrefix) && + !field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.purchaseItemPrefix) + } + return false + } + + if filteredSearchQueries.isEmpty { + return itemMatchedResult + } + + let matchResult = filteredSearchQueries.allSatisfy { query in + let field = query[JsonKey.CriteriaItem.field] as! String + var doesKeyExist = false + if let eventType = query[JsonKey.eventType] as? String, eventType == EventType.customEvent, let fieldType = query[JsonKey.CriteriaItem.fieldType] as? String, fieldType == "object", let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String, comparatorType == JsonKey.CriteriaItem.Comparator.IsSet, let eventName = eventData[JsonKey.eventName] as? String { + if (eventName == EventType.updateCart && field == eventName) || + (field == eventName) { + return true + } + } else { + doesKeyExist = filteredLocalDataKeys.filter {$0 as! String == field }.count > 0 + } + + if field.contains(".") { + var fields = field.split(separator: ".").map { String($0) } + if let type = eventData[JsonKey.eventType] as? String, let name = eventData[JsonKey.eventName] as? String, type == EventType.customEvent, name == fields.first { + fields = Array(fields.dropFirst()) + } + + var fieldValue: Any = eventData + var isSubFieldArray = false + var isSubMatch = false + + for subField in fields { + if let subFieldValue = (fieldValue as? [String: Any])?[subField] { + if let arrayValue = subFieldValue as? [[String: Any]] { + isSubFieldArray = true + isSubMatch = arrayValue.contains { item in + let data = fields.reversed().reduce([String: Any]()) { acc, key in + if key == subField { + return [key: item] + } + return [key: acc] + } + return evaluateFieldLogic(searchQueries: searchQueries, eventData: eventData.merging(data) { $1 }) + } + } else { + fieldValue = subFieldValue + } + } + } + + if isSubFieldArray { + return isSubMatch + } + + if let valueFromObj = getFieldValue(data: eventData, field: field), let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String { + return evaluateComparison(comparatorType: comparatorType, matchObj: valueFromObj, valueToCompare: query[JsonKey.CriteriaItem.value] ?? query[JsonKey.CriteriaItem.values]) + } + } else if doesKeyExist { + if let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String, (evaluateComparison(comparatorType: comparatorType, matchObj: eventData[field] ?? "", valueToCompare: query[JsonKey.CriteriaItem.value] ?? query[JsonKey.CriteriaItem.values])) { + return true + } + } + + return false + } + return matchResult + } + + + func getFieldValue(data: Any, field: String) -> Any? { + var fields = field.split(separator: ".").map(String.init) + if let dictionary = data as? [String: Any] ,let dataType = dictionary[JsonKey.eventType] as? String, dataType == EventType.customEvent, let firstField = fields.first, let eventName = dictionary[JsonKey.eventName] as? String, firstField == eventName { + fields.removeFirst() + } + var currentValue: Any? = data + for (index, currentField) in fields.enumerated() { + if index == fields.count - 1 { + if let currentDict = currentValue as? [String: Any] { + return currentDict[currentField] + } + } else { + if let currentDict = currentValue as? [String: Any], let nextValue = currentDict[currentField] { + currentValue = nextValue + } else { + return nil + } + } + } + return nil + } + + + func evaluateComparison(comparatorType: String, matchObj: Any, valueToCompare: Any?) -> Bool { + if var stringValue = valueToCompare as? String { + if let doubleValue = Double(stringValue) { + stringValue = formattedDoubleValue(doubleValue) + } + + switch comparatorType { + case JsonKey.CriteriaItem.Comparator.Equals: + return compareValueEquality(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.DoesNotEquals: + return !compareValueEquality(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.IsSet: + return compareValueIsSet(matchObj) + case JsonKey.CriteriaItem.Comparator.GreaterThan: + return compareNumericValues(matchObj, stringValue, compareOperator: >) + case JsonKey.CriteriaItem.Comparator.LessThan: + return compareNumericValues(matchObj, stringValue, compareOperator: <) + case JsonKey.CriteriaItem.Comparator.GreaterThanOrEqualTo: + return compareNumericValues(matchObj, stringValue, compareOperator: >=) + case JsonKey.CriteriaItem.Comparator.LessThanOrEqualTo: + return compareNumericValues(matchObj, stringValue, compareOperator: <=) + case JsonKey.CriteriaItem.Comparator.Contains: + return compareStringContains(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.StartsWith: + return compareStringStartsWith(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.MatchesRegex: + return compareWithRegex(matchObj, pattern: stringValue) + default: + return false + } + } else if var arrayOfString = valueToCompare as? [String] { + arrayOfString = arrayOfString.compactMap({ stringValue in + if let doubleValue = Double(stringValue) { + return formattedDoubleValue(doubleValue) + } + return stringValue + }) + switch comparatorType { + case JsonKey.CriteriaItem.Comparator.Equals: + return compareValuesEquality(matchObj, arrayOfString) + case JsonKey.CriteriaItem.Comparator.DoesNotEquals: + return !compareValuesEquality(matchObj, arrayOfString) + default: + return false + } + } + return false + } + + func formattedDoubleValue(_ d: Double) -> String { + if d == Double(Int64(d)) { + return String(format: "%lld", Int64(d)) + } else { + return String(format: "%f", d).trimmingCharacters(in: CharacterSet(charactersIn: "0")) + } + } + + func compareValueEquality(_ sourceTo: Any, _ stringValue: String) -> Bool { + switch (sourceTo, stringValue) { + case (let doubleNumber as Double, let value): return doubleNumber == Double(value) + case (let intNumber as Int, let value): return intNumber == Int(value) + case (let longNumber as Int64, let value): return longNumber == Int64(value) + case (let booleanValue as Bool, let value): return booleanValue == Bool(value) + case (let stringTypeValue as String, let value): return stringTypeValue == value + case (let doubleNumbers as [Double], let value): + guard let doubleValue = Double(value) else { return false } + return doubleNumbers.contains(doubleValue) + case (let intNumbers as [Int], let value): + guard let intValue = Int(value) else { return false } + return intNumbers.contains(intValue) + case (let longNumbers as [Int64], let value): + guard let intValue = Int64(value) else { return false } + return longNumbers.contains(intValue) + case (let stringTypeValues as [String], let value): + return stringTypeValues.contains(value) + default: return false + } + } + + func compareValuesEquality(_ sourceTo: Any, _ stringsValue: [String]) -> Bool { + switch (sourceTo, stringsValue) { + case (let doubleNumber as Double, let values): return values.compactMap({Double($0)}).contains(doubleNumber) + case (let intNumber as Int, let values): return values.compactMap({Int($0)}).contains(intNumber) + case (let longNumber as Int64, let values): return values.compactMap({Int64($0)}).contains(longNumber) + case (let booleanValue as Bool, let values): return values.compactMap({Bool($0)}).contains(booleanValue) + case (let stringTypeValue as String, let values): return values.contains(stringTypeValue) + case (let doubleNumbers as [Double], let values): + let set1 = Set(doubleNumbers) + let set2 = Set(values.compactMap({Double($0)})) + return !set1.intersection(set2).isEmpty + case (let intNumbers as [Int], let values): + let set1 = Set(intNumbers) + let set2 = Set(values.compactMap({Int($0)})) + return !set1.intersection(set2).isEmpty + case (let longNumbers as [Int64], let values): + let set1 = Set(longNumbers) + let set2 = Set(values.compactMap({Int64($0)})) + return !set1.intersection(set2).isEmpty + case (let stringTypeValues as [String], let values): + let set1 = Set(stringTypeValues) + let set2 = Set(values) + return !set1.intersection(set2).isEmpty + default: return false + } + } + + func compareValueIsSet(_ sourceTo: Any?) -> Bool { + switch sourceTo { + case let doubleValue as Double: + return !doubleValue.isNaN // Checks if the Double is not NaN (not a number) + + case _ as Int: + return true // Ints are always set (0 is a valid value) + + case _ as Int64: + return true // Int64s are always set (0 is a valid value) + + case _ as Bool: + return true // Bools are always set (false is a valid value) + + case let stringValue as String: + return !stringValue.isEmpty // Checks if the string is not empty + + case let arrayValue as [Any]: + return !arrayValue.isEmpty // Checks if the array is not empty + + case let dictValue as [AnyHashable: Any]: + return !dictValue.isEmpty // Checks if the dictionary is not empty + + default: + return sourceTo != nil // Return false for nil or other unspecified types + } + } + + func compareNumericValues(_ sourceTo: Any, _ stringValue: String, compareOperator: (Double, Double) -> Bool) -> Bool { + if let sourceNumber = Double(stringValue) { + switch sourceTo { + case let doubleNumber as Double: + return compareOperator(doubleNumber, sourceNumber) + case let intNumber as Int: + return compareOperator(Double(intNumber), sourceNumber) + case let longNumber as Int64: + return compareOperator(Double(longNumber), sourceNumber) + case let stringNumber as String: + if let doubleFromString = Double(stringNumber) { + return compareOperator(doubleFromString, sourceNumber) + } else { + return false // Handle the case where string cannot be converted to a Double + } + case (let doubleNumbers as [Double]): + for value in doubleNumbers { + if compareOperator(Double(value), sourceNumber) { + return true + } + } + return false + case (let intNumbers as [Int]): + for value in intNumbers { + if compareOperator(Double(value), sourceNumber) { + return true + } + } + return false + case (let longNumbers as [Int64]): + for value in longNumbers { + if compareOperator(Double(value), sourceNumber) { + return true + } + } + return false + case (let stringTypeValues as [String]): + for value in stringTypeValues { + if let doubleFromString = Double(value), compareOperator(doubleFromString, sourceNumber) { + return true + } + } + return false + default: + return false + } + } else { + return false // Handle the case where stringValue cannot be converted to a Double + } + } + + func compareStringContains(_ sourceTo: Any, _ stringValue: String) -> Bool { + if let stringTypeValue = sourceTo as? String { + // sourceTo is a String + return stringTypeValue.contains(stringValue) + } else if let arrayTypeValue = sourceTo as? [String] { + // sourceTo is an Array of String + return arrayTypeValue.contains(stringValue) + } + return false + } + + func compareStringStartsWith(_ sourceTo: Any, _ stringValue: String) -> Bool { + if let stringTypeValue = sourceTo as? String { + // sourceTo is a String + return stringTypeValue.hasPrefix(stringValue) + } else if let arrayTypeValue = sourceTo as? [String] { + // sourceTo is an Array of String + for value in arrayTypeValue { + if value.hasPrefix(stringValue) { + return true + } + } + } + return false + } + + func compareWithRegex(_ sourceTo: Any, pattern: String) -> Bool { + if let stringTypeValue = sourceTo as? String { + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(stringTypeValue.startIndex.. Double { + return lastCriteriaFetch + } + + /// Sets the last criteria fetch time in milliseconds + public func updateLastCriteriaFetch(currentTime: Double) { + lastCriteriaFetch = currentTime + } + + /// Creates a user after criterias met and login the user and then sync the data through track APIs + private func createUnknownUser(_ criteriaId: String) { + var unknownUserSessions = convertToDictionary(data: localStorage.unknownUserSessions?.itbl_unknown_user_sessions) + let userId = IterableUtil.generateUUID() + unknownUserSessions[JsonKey.matchedCriteriaId] = Int(criteriaId) + let appName = Bundle.main.appPackageName ?? "" + notificationStateProvider.isNotificationsEnabled { isEnabled in + if !appName.isEmpty && isEnabled { + unknownUserSessions[JsonKey.mobilePushOptIn] = appName + } + + //track unknown user session for new user + IterableAPI.implementation?.apiClient.trackUnknownUserSession( + createdAt: IterableUtil.secondsFromEpoch(for: self.dateProvider.currentDate), + withUserId: userId, + dataFields: self.localStorage.unknownUserUpdate, + requestJson: unknownUserSessions + ).onError { error in + self.isCriteriaMatched = false + if error.httpStatusCode == 409 { + self.getUnknownUserCriteria() // refetch the criteria + } + }.onSuccess { success in + self.localStorage.userIdUnknownUser = userId + self.config.unknownUserHandler?.onUnknownUserCreated(userId: userId) + + IterableAPI.implementation?.setUserId(userId, isUnknownUser: true) + + // Send consent data after session creation + self.sendConsentAfterCriteriaMatch(userId: userId) + + self.syncNonSyncedEvents() + } + } + } + + /// Checks if criterias are being met and returns criteriaId if it matches the criteria. + private func evaluateCriteriaAndReturnID() -> String? { + guard let criteriaData = localStorage.criteriaData else { return nil } + + var events = [[AnyHashable: Any]]() + + if let unknownUserEvents = localStorage.unknownUserEvents { + events.append(contentsOf: unknownUserEvents) + } + + if let userUpdate = localStorage.unknownUserUpdate { + events.append(userUpdate) + } + + guard events.count > 0 else { return nil } + + return CriteriaCompletionChecker(unknownUserCriteria: criteriaData, unknownUserEvents: events).getMatchedCriteria() + } + + /// Stores event data locally + private func storeEventData(type: String, data: [AnyHashable: Any], shouldOverWrite: Bool = false) { + // Early return if no AUT consent was given + if !self.localStorage.visitorUsageTracked { + ITBInfo("UUA CONSENT NOT GIVEN - no events being stored") + return + } + + if type == EventType.updateUser { + processAndStoreUserUpdate(data: data) + } else { + processAndStoreEvent(type: type, data: data) + } + + if let criteriaId = evaluateCriteriaAndReturnID(), !isCriteriaMatched { + isCriteriaMatched = true + createUnknownUser(criteriaId) + } + } + + /// Stores User Update data + private func processAndStoreUserUpdate(data: [AnyHashable: Any]) { + var userUpdate = localStorage.unknownUserUpdate ?? [:] + + // Merge new data into userUpdate + userUpdate.merge(data) { (_, new) in new } + + userUpdate.setValue(for: JsonKey.eventType, value: EventType.updateUser) + userUpdate.setValue(for: JsonKey.eventTimeStamp, value: IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) + + localStorage.unknownUserUpdate = userUpdate + } + + /// Stores all other event data + private func processAndStoreEvent(type: String, data: [AnyHashable: Any]) { + var eventsDataObjects: [[AnyHashable: Any]] = localStorage.unknownUserEvents ?? [] + + var newEventData = data + newEventData.setValue(for: JsonKey.eventType, value: type) + newEventData.setValue(for: JsonKey.eventTimeStamp, value: IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) // this we use as unique idenfier too + + eventsDataObjects.append(newEventData) + + if eventsDataObjects.count > config.eventThresholdLimit { + eventsDataObjects = eventsDataObjects.suffix(config.eventThresholdLimit) + } + + localStorage.unknownUserEvents = eventsDataObjects + } + + /// Sends consent data after user meets criteria and anonymous user is created + private func sendConsentAfterCriteriaMatch(userId: String) { + guard let consentTimestamp = localStorage.visitorConsentTimestamp else { + ITBInfo("No consent timestamp found, skipping consent tracking") + return + } + + IterableAPI.implementation?.apiClient.trackConsent( + consentTimestamp: consentTimestamp, + email: nil, + userId: userId, + isUserKnown: false + ).onSuccess { _ in + ITBInfo("Consent tracked successfully for criteria match") + }.onError { error in + ITBError("Failed to track consent for criteria match: \(error)") + } + } +} diff --git a/swift-sdk/Internal/UnknownUserManagerProtocol.swift b/swift-sdk/Internal/UnknownUserManagerProtocol.swift new file mode 100644 index 000000000..5627f1ee3 --- /dev/null +++ b/swift-sdk/Internal/UnknownUserManagerProtocol.swift @@ -0,0 +1,20 @@ +// +// UnknownUserManagerProtocol.swift +// +// +// Created by HARDIK MASHRU on 09/11/23. +// +import Foundation +@objc public protocol UnknownUserManagerProtocol { + func trackUnknownUserEvent(name: String, dataFields: [AnyHashable: Any]?) + func trackUnknownUserPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) + func trackUnknownUserUpdateCart(items: [CommerceItem]) + func trackUnknownUserTokenRegistration(token: String) + func trackUnknownUserUpdateUser(_ dataFields: [AnyHashable: Any]) + func updateUnknownUserSession() + func getLastCriteriaFetch() -> Double + func updateLastCriteriaFetch(currentTime: Double) + func getUnknownUserCriteria() + func syncEvents() + func clearVisitorEventsAndUserData() +} diff --git a/swift-sdk/Internal/UnknownUserMerge.swift b/swift-sdk/Internal/UnknownUserMerge.swift new file mode 100644 index 000000000..190875274 --- /dev/null +++ b/swift-sdk/Internal/UnknownUserMerge.swift @@ -0,0 +1,44 @@ +// +// UnknownUserMerge.swift +// Iterable-iOS-SDK +// +// Created by Hani Vora on 19/12/23. +// + +import Foundation + +protocol UnknownUserMergeProtocol { + func tryMergeUser(destinationUser: String?, isEmail: Bool, merge: Bool, onMergeResult: @escaping MergeActionHandler) +} + +class UnknownUserMerge: UnknownUserMergeProtocol { + + var unknownUserManager: UnknownUserManagerProtocol + var apiClient: ApiClient + private var localStorage: LocalStorageProtocol + + init(apiClient: ApiClient, unknownUserManager: UnknownUserManagerProtocol, localStorage: LocalStorageProtocol) { + self.apiClient = apiClient + self.unknownUserManager = unknownUserManager + self.localStorage = localStorage + } + + func tryMergeUser(destinationUser: String?, isEmail: Bool, merge: Bool, onMergeResult: @escaping MergeActionHandler) { + let unknownUserId = localStorage.userIdUnknownUser + + if (unknownUserId != nil && destinationUser != nil && merge) { + let destinationEmail = isEmail ? destinationUser : nil + let destinationUserId = isEmail ? nil : destinationUser + + apiClient.mergeUser(sourceEmail: nil, sourceUserId: unknownUserId, destinationEmail: destinationEmail, destinationUserId: destinationUserId).onSuccess {_ in + onMergeResult(MergeResult.mergesuccessful, nil) + }.onError {error in + print("Merge failed error: \(error)") + onMergeResult(MergeResult.mergefailed, error.reason) + } + } else { + // this will return mergeResult true in case of unknown userId doesn't exist or destinationUserIdOrEmail is nil because merge is not required + onMergeResult(MergeResult.mergenotrequired, nil) + } + } +} diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index f288e3170..af1bef29f 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -131,6 +131,13 @@ extension DependencyContainerProtocol { func createRedirectNetworkSession(delegate: RedirectNetworkSessionDelegate) -> NetworkSessionProtocol { RedirectNetworkSession(delegate: delegate) } + + func createUnknownUserManager(config: IterableConfig) -> UnknownUserManagerProtocol { + UnknownUserManager(config:config, + localStorage: localStorage, + dateProvider: dateProvider, + notificationStateProvider: notificationStateProvider) + } private func createTaskScheduler(persistenceContextProvider: IterablePersistenceContextProvider, healthMonitor: HealthMonitor) -> IterableTaskScheduler { @@ -148,4 +155,8 @@ extension DependencyContainerProtocol { notificationCenter: notificationCenter, connectivityManager: NetworkConnectivityManager()) } + + func createUnknownUserMerge(apiClient: ApiClient, unknownUserManager: UnknownUserManagerProtocol, localStorage: LocalStorageProtocol) -> UnknownUserMergeProtocol { + UnknownUserMerge(apiClient: apiClient, unknownUserManager: unknownUserManager, localStorage: localStorage) + } } diff --git a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift index 2737a3a73..f29a7fea7 100644 --- a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift +++ b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift @@ -45,6 +45,24 @@ class IterableKeychain { } } + var userIdUnknownUser: String? { + get { + let data = wrapper.data(forKey: Const.Keychain.Key.userIdUnknownUser) + + return data.flatMap { String(data: $0, encoding: .utf8) } + } + + set { + guard let token = newValue, + let data = token.data(using: .utf8) else { + wrapper.removeValue(forKey: Const.Keychain.Key.userIdUnknownUser) + return + } + + wrapper.set(data, forKey: Const.Keychain.Key.userIdUnknownUser) + } + } + var authToken: String? { get { let data = wrapper.data(forKey: Const.Keychain.Key.authToken) diff --git a/swift-sdk/Internal/Utilities/LocalStorage.swift b/swift-sdk/Internal/Utilities/LocalStorage.swift index a6e049ec6..c4777b074 100644 --- a/swift-sdk/Internal/Utilities/LocalStorage.swift +++ b/swift-sdk/Internal/Utilities/LocalStorage.swift @@ -5,6 +5,7 @@ import Foundation struct LocalStorage: LocalStorageProtocol { + init(userDefaults: UserDefaults = UserDefaults.standard, keychain: IterableKeychain = IterableKeychain()) { iterableUserDefaults = IterableUserDefaults(userDefaults: userDefaults) @@ -19,6 +20,14 @@ struct LocalStorage: LocalStorageProtocol { } } + var userIdUnknownUser: String? { + get { + keychain.userIdUnknownUser + } set { + keychain.userIdUnknownUser = newValue + } + } + var email: String? { get { keychain.email @@ -67,6 +76,54 @@ struct LocalStorage: LocalStorageProtocol { } } + var unknownUserEvents: [[AnyHashable: Any]]? { + get { + iterableUserDefaults.unknownUserEvents + } set { + iterableUserDefaults.unknownUserEvents = newValue + } + } + + var unknownUserUpdate: [AnyHashable: Any]? { + get { + iterableUserDefaults.unknownUserUpdate + } set { + iterableUserDefaults.unknownUserUpdate = newValue + } + } + + var unknownUserSessions: IterableUnknownUserSessionsWrapper? { + get { + iterableUserDefaults.unknownUserSessions + } set { + iterableUserDefaults.unknownUserSessions = newValue + } + } + + var criteriaData: Data? { + get { + iterableUserDefaults.criteriaData + } set { + iterableUserDefaults.criteriaData = newValue + } + } + + var visitorUsageTracked: Bool { + get { + iterableUserDefaults.visitorUsageTracked + } set { + iterableUserDefaults.visitorUsageTracked = newValue + } + } + + var visitorConsentTimestamp: Int64? { + get { + iterableUserDefaults.visitorConsentTimestamp + } set { + iterableUserDefaults.visitorConsentTimestamp = newValue + } + } + var isNotificationsEnabled: Bool { get { iterableUserDefaults.isNotificationsEnabled diff --git a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift index 420d076db..197f2e90d 100644 --- a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift @@ -7,6 +7,8 @@ import Foundation protocol LocalStorageProtocol { var userId: String? { get set } + var userIdUnknownUser: String? { get set } + var email: String? { get set } var authToken: String? { get set } @@ -18,6 +20,18 @@ protocol LocalStorageProtocol { var sdkVersion: String? { get set } var offlineMode: Bool { get set } + + var visitorUsageTracked: Bool { get set } + + var unknownUserEvents: [[AnyHashable: Any]]? { get set } + + var visitorConsentTimestamp: Int64? { get set } + + var unknownUserUpdate: [AnyHashable: Any]? { get set } + + var criteriaData: Data? { get set } + + var unknownUserSessions: IterableUnknownUserSessionsWrapper? { get set } var isNotificationsEnabled: Bool { get set } diff --git a/swift-sdk/Internal/api-client/ApiClient.swift b/swift-sdk/Internal/api-client/ApiClient.swift index 26aebf1d9..c7dd3e44c 100644 --- a/swift-sdk/Internal/api-client/ApiClient.swift +++ b/swift-sdk/Internal/api-client/ApiClient.swift @@ -41,6 +41,20 @@ class ApiClient { return apiCallRequest.convertToURLRequest(sentAt: currentDate) } + func convertToURLRequestWithoutCreatedAt(iterableRequest: IterableRequest) -> URLRequest? { + guard let authProvider = authProvider else { + return nil + } + + let currentDate = dateProvider.currentDate + let apiCallRequest = IterableAPICallRequest(apiKey: apiKey, + endpoint: endpoint, + authToken: authProvider.auth.authToken, + deviceMetadata: deviceMetadata, + iterableRequest: iterableRequest) + return apiCallRequest.convertToURLRequest(sentAt: currentDate) + } + func send(iterableRequestResult result: Result) -> Pending { switch result { case let .success(iterableRequest): @@ -50,6 +64,15 @@ class ApiClient { } } + func sendWithoutCreatedAt(iterableRequestResult result: Result) -> Pending { + switch result { + case let .success(iterableRequest): + return sendWithoutCreatedAt(iterableRequest: iterableRequest) + case let .failure(iterableError): + return SendRequestError.createErroredFuture(reason: iterableError.localizedDescription) + } + } + func send(iterableRequestResult result: Result) -> Pending where T: Decodable { switch result { case let .success(iterableRequest): @@ -67,6 +90,14 @@ class ApiClient { return RequestSender.sendRequest(urlRequest, usingSession: networkSession) } + func sendWithoutCreatedAt(iterableRequest: IterableRequest) -> Pending { + guard let urlRequest = convertToURLRequestWithoutCreatedAt(iterableRequest: iterableRequest) else { + return SendRequestError.createErroredFuture() + } + + return RequestSender.sendRequest(urlRequest, usingSession: networkSession) + } + func send(iterableRequest: IterableRequest) -> Pending where T: Decodable { guard let urlRequest = convertToURLRequest(iterableRequest: iterableRequest) else { return SendRequestError.createErroredFuture() @@ -96,6 +127,7 @@ class ApiClient { // MARK: - API REQUEST CALLS extension ApiClient: ApiClientProtocol { + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { let result = createRequestCreator().flatMap { $0.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, notificationsEnabled: notificationsEnabled) } @@ -130,6 +162,12 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func updateCart(items: [CommerceItem], createdAt: Int) -> Pending { + let result = createRequestCreator().flatMap { $0.createUpdateCartRequest(items: items, createdAt: createdAt) } + + return sendWithoutCreatedAt(iterableRequestResult: result) + } + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, @@ -143,6 +181,17 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func track(purchase total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackPurchaseRequest(total, + items: items, + dataFields: dataFields, + createdAt: createdAt) } + return send(iterableRequestResult: result) + } + func track(pushOpen campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Pending { let result = createRequestCreator().flatMap { $0.createTrackPushOpenRequest(campaignId, templateId: templateId, @@ -158,6 +207,18 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func track(event eventName: String, withBody body: [AnyHashable: Any]?) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackEventRequest(eventName, + withBody: body) } + return sendWithoutCreatedAt(iterableRequestResult: result) + } + + func track(event eventName: String, body: [AnyHashable: Any]?, dataFields: [AnyHashable: Any]?) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackEventRequest(eventName, + dataFields: dataFields) } + return send(iterableRequestResult: result) + } + func updateSubscriptions(_ emailListIds: [NSNumber]? = nil, unsubscribedChannelIds: [NSNumber]? = nil, unsubscribedMessageTypeIds: [NSNumber]? = nil, @@ -216,6 +277,26 @@ extension ApiClient: ApiClientProtocol { let result = createRequestCreator().flatMap { $0.createGetRemoteConfigurationRequest() } return send(iterableRequestResult: result) } + + func mergeUser(sourceEmail: String?, sourceUserId: String?, destinationEmail: String?, destinationUserId: String?) -> Pending { + let result = createRequestCreator().flatMap { $0.createMergeUserRequest(sourceEmail, sourceUserId, destinationEmail, destinationUserId) } + return send(iterableRequestResult: result) + } + + func getCriteria() -> Pending { + let result = createRequestCreator().flatMap { $0.createGetCriteriaRequest() } + return send(iterableRequestResult: result) + } + + func trackUnknownUserSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackUnknownUserSessionRequest(createdAt: createdAt, withUserId: userId, dataFields: dataFields, requestJson: requestJson) } + return send(iterableRequestResult: result) + } + + func trackConsent(consentTimestamp: Int64, email: String?, userId: String?, isUserKnown: Bool) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackConsentRequest(consentTimestamp: consentTimestamp, email: email, userId: userId, isUserKnown: isUserKnown) } + return send(iterableRequestResult: result) + } // MARK: - Embedded Messaging diff --git a/swift-sdk/Internal/api-client/ApiClientProtocol.swift b/swift-sdk/Internal/api-client/ApiClientProtocol.swift index ce3f377a6..32bb717ff 100644 --- a/swift-sdk/Internal/api-client/ApiClientProtocol.swift +++ b/swift-sdk/Internal/api-client/ApiClientProtocol.swift @@ -13,12 +13,18 @@ protocol ApiClientProtocol: AnyObject { func updateCart(items: [CommerceItem]) -> Pending + func updateCart(items: [CommerceItem], createdAt: Int) -> Pending + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, campaignId: NSNumber?, templateId: NSNumber?) -> Pending + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, createdAt: Int) -> Pending + func track(pushOpen campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Pending func track(event eventName: String, dataFields: [AnyHashable: Any]?) -> Pending + func track(event eventName: String, withBody body: [AnyHashable: Any]?) -> Pending + func updateSubscriptions(_ emailListIds: [NSNumber]?, unsubscribedChannelIds: [NSNumber]?, unsubscribedMessageTypeIds: [NSNumber]?, @@ -45,6 +51,14 @@ protocol ApiClientProtocol: AnyObject { func disableDevice(forAllUsers allUsers: Bool, hexToken: String) -> Pending func getRemoteConfiguration() -> Pending + + func mergeUser(sourceEmail: String?, sourceUserId: String?, destinationEmail: String?, destinationUserId: String?) -> Pending + + func getCriteria() -> Pending + + func trackUnknownUserSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending + + func trackConsent(consentTimestamp: Int64, email: String?, userId: String?, isUserKnown: Bool) -> Pending func getEmbeddedMessages() -> Pending diff --git a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift index 60abce12e..70e47e364 100644 --- a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift @@ -49,6 +49,21 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createUpdateCartRequest(items: items, createdAt: createdAt) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -71,6 +86,26 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createTrackPurchaseRequest(total, + items: items, + dataFields: dataFields, + createdAt: createdAt) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -110,6 +145,23 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + ITBInfo() + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createTrackEventRequest(event, + withBody: body) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackInAppOpen(_ message: IterableInAppMessage, location: InAppLocation, diff --git a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift index af0a1d8c7..c332a46f7 100644 --- a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift @@ -85,6 +85,17 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "updateCart") } + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.updateCart(items: items, createdAt: createdAt) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "updateCart") + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -103,6 +114,16 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "trackPurchase") } + func trackPurchase(_ total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable : Any]?, createdAt: Int, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending { + sendRequest(requestProvider: { apiClient.track(purchase: total, + items: items, + dataFields: dataFields, + createdAt: createdAt)}, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "trackPurchase") + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -132,6 +153,17 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "trackEvent") } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]? = nil, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.track(event: event, withBody: body) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "trackEvent") + } + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler? = nil, diff --git a/swift-sdk/Internal/api-client/Request/RequestCreator.swift b/swift-sdk/Internal/api-client/Request/RequestCreator.swift index 9a14fba24..9a2785b3e 100644 --- a/swift-sdk/Internal/api-client/Request/RequestCreator.swift +++ b/swift-sdk/Internal/api-client/Request/RequestCreator.swift @@ -62,7 +62,7 @@ struct RequestCreator { setCurrentUser(inDict: &body) - if auth.email == nil, auth.userId != nil { + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { body[JsonKey.preferUserId] = true } @@ -79,7 +79,7 @@ struct RequestCreator { setCurrentUser(inDict: &body) - if auth.email == nil, auth.userId != nil { + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { body[JsonKey.preferUserId] = true } @@ -102,9 +102,33 @@ struct RequestCreator { let itemsToSerialize = items.map { $0.toDictionary() } - let body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + var body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, JsonKey.Commerce.items: itemsToSerialize] + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { + body[JsonKey.preferUserId] = true + } + return .success(.post(createPostRequest(path: Const.Path.updateCart, body: body))) + } + + func createUpdateCartRequest(items: [CommerceItem], createdAt: Int) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + var apiUserDict = [AnyHashable: Any]() + + setCurrentUser(inDict: &apiUserDict) + let itemsToSerialize = items.map { $0.toDictionary() } + + var body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + JsonKey.Body.createdAt: createdAt, + JsonKey.Commerce.items: itemsToSerialize] + + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { + body[JsonKey.preferUserId] = true + } + return .success(.post(createPostRequest(path: Const.Path.updateCart, body: body))) } @@ -128,6 +152,10 @@ struct RequestCreator { JsonKey.Commerce.items: itemsToSerialize, JsonKey.Commerce.total: total] + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { + body[JsonKey.preferUserId] = true + } + if let dataFields = dataFields { body[JsonKey.dataFields] = dataFields } @@ -142,6 +170,37 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.trackPurchase, body: body))) } + func createTrackPurchaseRequest(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + + var apiUserDict = [AnyHashable: Any]() + + setCurrentUser(inDict: &apiUserDict) + + let itemsToSerialize = items.map { $0.toDictionary() } + + var body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + JsonKey.Body.createdAt: createdAt, + JsonKey.Commerce.items: itemsToSerialize, + JsonKey.Commerce.total: total] + + if auth.email == nil, (auth.userId != nil || auth.userIdUnknownUser != nil) { + body[JsonKey.preferUserId] = true + } + + if let dataFields = dataFields { + body[JsonKey.dataFields] = dataFields + } + + return .success(.post(createPostRequest(path: Const.Path.trackPurchase, body: body))) + } + func createTrackPushOpenRequest(_ campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Result { if case .none = auth.emailOrUserId { ITBError(Self.authMissingMessage) @@ -192,6 +251,24 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.trackEvent, body: body))) } + func createTrackEventRequest(_ eventName: String, withBody body: [AnyHashable: Any]?) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + + var postBody = [AnyHashable: Any]() + if let _body = body { + postBody = _body + } + + setCurrentUser(inDict: &postBody) + + postBody.setValue(for: JsonKey.eventName, value: eventName) + + return .success(.post(createPostRequest(path: Const.Path.trackEvent, body: postBody))) + } + func createUpdateSubscriptionsRequest(_ emailListIds: [NSNumber]? = nil, unsubscribedChannelIds: [NSNumber]? = nil, unsubscribedMessageTypeIds: [NSNumber]? = nil, @@ -580,6 +657,70 @@ struct RequestCreator { return .success(.get(createGetRequest(forPath: Const.Path.getRemoteConfiguration, withArgs: args as! [String: String]))) } + func createMergeUserRequest(_ sourceEmail: String?, _ sourceUserId: String?, _ destinationEmail: String?, _ destinationUserId: String?) -> Result { + var body = [AnyHashable: Any]() + + if IterableUtil.isNotNullOrEmpty(string: sourceEmail) { + body.setValue(for: JsonKey.sourceEmail, value: sourceEmail) + } + + if IterableUtil.isNotNullOrEmpty(string: sourceUserId) { + body.setValue(for: JsonKey.sourceUserId, value: sourceUserId) + } + + if IterableUtil.isNotNullOrEmpty(string: destinationEmail) { + body.setValue(for: JsonKey.destinationEmail, value: destinationEmail) + } + + if IterableUtil.isNotNullOrEmpty(string: destinationUserId) { + body.setValue(for: JsonKey.destinationUserId, value: destinationUserId) + } + return .success(.post(createPostRequest(path: Const.Path.mergeUser, body: body))) + } + + func createGetCriteriaRequest() -> Result { + let body: [AnyHashable: Any] = [:] + return .success(.get(createGetRequest(forPath: Const.Path.getCriteria, withArgs: body as! [String: String]))) + } + + func createTrackUnknownUserSessionRequest(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Result { + var body = [AnyHashable: Any]() + + var userDict = [AnyHashable: Any]() + userDict[JsonKey.userId] = userId + userDict[JsonKey.preferUserId] = true + userDict[JsonKey.mergeNestedObjects] = true + userDict[JsonKey.createNewFields] = true + if let dataFields = dataFields { + userDict[JsonKey.dataFields] = dataFields + } + + body.setValue(for: JsonKey.Commerce.user, value: userDict) + body.setValue(for: JsonKey.Body.createdAt, value: createdAt) + body.setValue(for: JsonKey.deviceInfo, value: deviceMetadata.asDictionary()) + body.setValue(for: JsonKey.unknownSessionContext, value: requestJson) + return .success(.post(createPostRequest(path: Const.Path.trackUnknownUserSession, body: body))) + } + + func createTrackConsentRequest(consentTimestamp: Int64, email: String?, userId: String?, isUserKnown: Bool) -> Result { + var body = [AnyHashable: Any]() + + body.setValue(for: JsonKey.consentTimestamp, value: consentTimestamp) + + if let email = email { + body.setValue(for: JsonKey.email, value: email) + } + + if let userId = userId { + body.setValue(for: JsonKey.userId, value: userId) + } + + body.setValue(for: JsonKey.isUserKnown, value: isUserKnown) + body.setValue(for: JsonKey.deviceInfo, value: deviceMetadata.asDictionary()) + + return .success(.post(createPostRequest(path: Const.Path.trackConsent, body: body))) + } + // MARK: - PRIVATE private static let authMissingMessage = "Both email and userId are nil" @@ -612,6 +753,8 @@ struct RequestCreator { dict.setValue(for: JsonKey.email, value: email) case let .userId(userId): dict.setValue(for: JsonKey.userId, value: userId) + case let .userIdUnknownUser(userId): + dict.setValue(for: JsonKey.userId, value: userId) case .none: ITBInfo("Current user is unavailable") } @@ -623,6 +766,8 @@ struct RequestCreator { dict.setValue(for: JsonKey.userKey, value: email) case let .userId(userId): dict.setValue(for: JsonKey.userKey, value: userId) + case let .userIdUnknownUser(userId): + dict.setValue(for: JsonKey.userKey, value: userId) case .none: ITBInfo("Current user is unavailable") } diff --git a/swift-sdk/Internal/api-client/Request/RequestHandler.swift b/swift-sdk/Internal/api-client/Request/RequestHandler.swift index 913844cef..d414ab9c4 100644 --- a/swift-sdk/Internal/api-client/Request/RequestHandler.swift +++ b/swift-sdk/Internal/api-client/Request/RequestHandler.swift @@ -96,6 +96,19 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.updateCart(items: items, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -115,6 +128,23 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.trackPurchase(total, + items: items, + dataFields: dataFields, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -147,6 +177,19 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.track(event: event, + withBody: body, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler?, diff --git a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift index 1243605b1..809fbc1d1 100644 --- a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift +++ b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift @@ -32,6 +32,12 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -41,6 +47,14 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -56,6 +70,12 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackInAppOpen(_ message: IterableInAppMessage, location: InAppLocation, diff --git a/swift-sdk/IterableTokenGenerator.swift b/swift-sdk/IterableTokenGenerator.swift new file mode 100644 index 000000000..62c3040d1 --- /dev/null +++ b/swift-sdk/IterableTokenGenerator.swift @@ -0,0 +1,87 @@ +// +// IterableTokenGenerator.swift +// swift-sdk +// +// Created by Apple on 22/10/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import UIKit +import CryptoKit + +@objcMembers public final class IterableTokenGenerator: NSObject { + + public static func generateJwtForEial(secret: String, iat:Int, exp: Int, email:String) -> String { + struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + struct Payload: Encodable { + var email = "" + var iat = Int(Date().timeIntervalSince1970) + var exp = Int(Date().timeIntervalSince1970) + 60 + + } + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = headerJsonData.urlEncodedBase64() + + let payloadJsonData = try! JSONEncoder().encode(Payload(email: email, iat: iat, exp: exp)) + let payloadBase64 = payloadJsonData.urlEncodedBase64() + + 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 = Data(signature).urlEncodedBase64() + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + + public static func generateJwtForUserId(secret: String, iat:Int, exp: Int, userId:String) -> String { + struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + struct Payload: Encodable { + var userId = "" + var iat = Int(Date().timeIntervalSince1970) + var exp = Int(Date().timeIntervalSince1970) + 60 + + } + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = headerJsonData.urlEncodedBase64() + + let payloadJsonData = try! JSONEncoder().encode(Payload(userId: userId, iat: iat, exp: exp)) + let payloadBase64 = payloadJsonData.urlEncodedBase64() + + 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 = Data(signature).urlEncodedBase64() + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + +} + +extension Data { + func urlEncodedBase64() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/swift-sdk/Resources/anoncriteria_response.json b/swift-sdk/Resources/anoncriteria_response.json new file mode 100644 index 000000000..67907a8a7 --- /dev/null +++ b/swift-sdk/Resources/anoncriteria_response.json @@ -0,0 +1,130 @@ +{ + "count":2, + "criteriaList":[ + { + "criteriaId":12345, + "searchQuery":{ + "combinator":"And", + "searchQueries":[ + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"purchase", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"4.67" + }, + { + "field":"shoppingCartItems.quantity", + "fieldType":"long", + "comparatorType":"GreaterThan", + "dataType":"purchase", + "id":3, + "valueLong":2, + "value":"2" + }, + { + "field":"total", + "fieldType":"long", + "comparatorType":"GreaterThanOrEqualTo", + "dataType":"purchase", + "id":4, + "valueLong":10, + "value":"10" + } + ] + } + } + ] + }, + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"Or", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId":5678, + "searchQuery":{ + "combinator":"Or", + "searchQueries":[ + { + "combinator":"Or", + "searchQueries":[ + { + "dataType":"user", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"itblInternal.emailDomain", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"user", + "id":6, + "value":"gmail.com" + } + ] + } + }, + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + }, + { + "field":"createdAt", + "fieldType":"date", + "comparatorType":"GreaterThan", + "dataType":"customEvent", + "id":10, + "dateRange":{ + + }, + "isRelativeDate":false, + "value":"1731513963000" + } + ] + } + } + ] + } + ] + } + } + ] +} diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index b121def7f..83d613f5a 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -125,16 +125,31 @@ import UIKit }.onError { _ in callback?(false) } + + if let implementation, config.enableUnknownUserActivation, !implementation.isSDKInitialized(), implementation.getVisitorUsageTracked() { + ITBInfo("UUA ENABLED AND CONSENT GIVEN - Criteria fetched") + implementation.unknownUserManager.getUnknownUserCriteria() + implementation.unknownUserManager.updateUnknownUserSession() + } + } + + public static func setVisitorUsageTracked(isVisitorUsageTracked: Bool) { + if let _implementation = implementation { + _implementation.setVisitorUsageTracked(isVisitorUsageTracked: isVisitorUsageTracked) + } } + public static func getVisitorUsageTracked() -> Bool { + return implementation?.getVisitorUsageTracked() ?? false + } // MARK: - SDK - public static func setEmail(_ email: String?, _ authToken: String? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { - implementation?.setEmail(email, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler) + public static func setEmail(_ email: String?, _ authToken: String? = nil, _ identityResolution: IterableIdentityResolution? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { + implementation?.setEmail(email, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler, identityResolution: identityResolution) } - public static func setUserId(_ userId: String?, _ authToken: String? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { - implementation?.setUserId(userId, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler) + public static func setUserId(_ userId: String?, _ authToken: String? = nil, _ identityResolution: IterableIdentityResolution? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { + implementation?.setUserId(userId, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler, identityResolution: identityResolution) } /// Handle a Universal Link @@ -235,7 +250,7 @@ import UIKit /// - SeeAlso: IterableConfig @objc(registerToken:) public static func register(token: Data) { - implementation?.register(token: token) + register(token: token, onSuccess: nil, onFailure: nil) } /// Register this device's token with Iterable @@ -253,7 +268,8 @@ import UIKit /// - SeeAlso: IterableConfig, OnSuccessHandler, OnFailureHandler @objc(registerToken:onSuccess:OnFailure:) public static func register(token: Data, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { - implementation?.register(token: token, onSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + implementation.register(token: token, onSuccess: onSuccess, onFailure: onFailure) } @objc(pauseAuthRetries:) @@ -272,12 +288,12 @@ import UIKit /// /// - SeeAlso: IterableConfig public static func disableDeviceForCurrentUser() { - implementation?.disableDeviceForCurrentUser() + disableDeviceForCurrentUser(withOnSuccess: nil, onFailure: nil) } /// Disable this device's token in Iterable, for all users on this device. public static func disableDeviceForAllUsers() { - implementation?.disableDeviceForAllUsers() + disableDeviceForAllUsers(withOnSuccess: nil, onFailure: nil) } /// Disable this device's token in Iterable, for the current user, with custom completion blocks @@ -288,7 +304,9 @@ import UIKit /// /// - SeeAlso: OnSuccessHandler, OnFailureHandler public static func disableDeviceForCurrentUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) } /// Disable this device's token in Iterable, for all users of this device, with custom completion blocks. @@ -299,7 +317,9 @@ import UIKit /// /// - SeeAlso: OnSuccessHandler, OnFailureHandler public static func disableDeviceForAllUsers(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure) } /// Updates the available user fields @@ -316,10 +336,11 @@ import UIKit mergeNestedObjects: Bool, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { + implementation?.updateUser(dataFields, - mergeNestedObjects: mergeNestedObjects, - onSuccess: onSuccess, - onFailure: onFailure) + mergeNestedObjects: mergeNestedObjects, + onSuccess: onSuccess, + onFailure: onFailure) } /// Updates the current user's email @@ -333,8 +354,17 @@ import UIKit /// /// - SeeAlso: OnSuccessHandler, OnFailureHandler @objc(updateEmail:onSuccess:onFailure:) - public static func updateEmail(_ newEmail: String, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.updateEmail(newEmail, onSuccess: onSuccess, onFailure: onFailure) + public static func updateEmail( + _ newEmail: String, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler? + ) { + updateEmail( + newEmail, + withToken: nil, + onSuccess: onSuccess, + onFailure: onFailure + ) } /// Updates the current user's email, and set the new authentication token @@ -350,10 +380,17 @@ import UIKit /// - SeeAlso: OnSuccessHandler, OnFailureHandler @objc(updateEmail:withToken:onSuccess:onFailure:) public static func updateEmail(_ newEmail: String, - withToken token: String, + withToken token: String? = nil, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.updateEmail(newEmail, withToken: token, onSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.updateEmail( + newEmail, + withToken: token, + onSuccess: onSuccess, + onFailure: onFailure + ) } /// Tracks what's in the shopping cart (or equivalent) at this point in time @@ -364,7 +401,7 @@ import UIKit /// - SeeAlso: CommerceItem @objc(updateCart:) public static func updateCart(items: [CommerceItem]) { - implementation?.updateCart(items: items) + updateCart(items: items, onSuccess: nil, onFailure: nil) } /// Tracks what's in the shopping cart (or equivalent) at this point in time @@ -379,6 +416,7 @@ import UIKit public static func updateCart(items: [CommerceItem], onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + implementation?.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) } @@ -391,7 +429,15 @@ import UIKit /// - SeeAlso: CommerceItem @objc(trackPurchase:items:) public static func track(purchase withTotal: NSNumber, items: [CommerceItem]) { - implementation?.trackPurchase(withTotal, items: items) + track( + purchase: withTotal, + items: items, + dataFields: nil, + campaignId: nil, + templateId: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a purchase with additional data @@ -404,7 +450,15 @@ import UIKit /// - SeeAlso: CommerceItem @objc(trackPurchase:items:dataFields:) public static func track(purchase withTotal: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) { - implementation?.trackPurchase(withTotal, items: items, dataFields: dataFields) + track( + purchase: withTotal, + items: items, + dataFields: dataFields, + campaignId: nil, + templateId: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a purchase with additional data and custom completion blocks. @@ -423,11 +477,15 @@ import UIKit dataFields: [AnyHashable: Any]?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.trackPurchase(withTotal, - items: items, - dataFields: dataFields, - onSuccess: onSuccess, - onFailure: onFailure) + track( + purchase: withTotal, + items: items, + dataFields: dataFields, + campaignId: nil, + templateId: nil, + onSuccess: onSuccess, + onFailure: onFailure + ) } /// Tracks a purchase with additional data and custom completion blocks. @@ -450,13 +508,14 @@ import UIKit templateId: NSNumber?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + implementation?.trackPurchase(withTotal, - items: items, - dataFields: dataFields, - campaignId: campaignId, - templateId: templateId, - onSuccess: onSuccess, - onFailure: onFailure) + items: items, + dataFields: dataFields, + campaignId: campaignId, + templateId: templateId, + onSuccess: onSuccess, + onFailure: onFailure) } @@ -466,7 +525,12 @@ import UIKit /// - userInfo: the `userInfo` parameter from the push notification payload @objc(trackPushOpen:) public static func track(pushOpen userInfo: [AnyHashable: Any]) { - implementation?.trackPushOpen(userInfo) + track( + pushOpen: userInfo, + dataFields: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a `pushOpen` event with a push notification and optional additional data @@ -476,7 +540,12 @@ import UIKit /// - dataFields: A `Dictionary` containing any additional information to save along with the event @objc(trackPushOpen:dataFields:) public static func track(pushOpen userInfo: [AnyHashable: Any], dataFields: [AnyHashable: Any]?) { - implementation?.trackPushOpen(userInfo, dataFields: dataFields) + track( + pushOpen: userInfo, + dataFields: dataFields, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a `pushOpen` event with a push notification, optional additional data, and custom completion blocks @@ -493,7 +562,9 @@ import UIKit dataFields: [AnyHashable: Any]?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.trackPushOpen(userInfo, + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackPushOpen(userInfo, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) @@ -517,11 +588,15 @@ import UIKit messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) { - implementation?.trackPushOpen(campaignId, - templateId: templateId, - messageId: messageId, - appAlreadyRunning: appAlreadyRunning, - dataFields: dataFields) + track( + pushOpen: campaignId, + templateId: templateId, + messageId: messageId, + appAlreadyRunning: appAlreadyRunning, + dataFields: dataFields, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a `pushOpen` event for the specified campaign and template IDs, whether the app was already @@ -546,7 +621,9 @@ import UIKit dataFields: [AnyHashable: Any]?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.trackPushOpen(campaignId, + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackPushOpen(campaignId, templateId: templateId, messageId: messageId, appAlreadyRunning: appAlreadyRunning, @@ -563,7 +640,12 @@ import UIKit /// - Remark: Pass in the custom event data. @objc(track:) public static func track(event eventName: String) { - implementation?.track(eventName) + track( + event: eventName, + dataFields: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a custom event @@ -575,7 +657,12 @@ import UIKit /// - Remark: Pass in the custom event data. @objc(track:dataFields:) public static func track(event eventName: String, dataFields: [AnyHashable: Any]?) { - implementation?.track(eventName, dataFields: dataFields) + track( + event: eventName, + dataFields: dataFields, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a custom event @@ -613,7 +700,9 @@ import UIKit subscribedMessageTypeIds: [NSNumber]?, campaignId: NSNumber?, templateId: NSNumber?) { - implementation?.updateSubscriptions(emailListIds, + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.updateSubscriptions(emailListIds, unsubscribedChannelIds: unsubscribedChannelIds, unsubscribedMessageTypeIds: unsubscribedMessageTypeIds, subscribedMessageTypeIds: subscribedMessageTypeIds, @@ -630,17 +719,23 @@ import UIKit /// - embeddedSession: the embedded session data type to track @objc(embeddedSession:) public static func track(embeddedSession: IterableEmbeddedSession) { - implementation?.track(embeddedSession: embeddedSession) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(embeddedSession: embeddedSession) } @objc(embeddedMessageClick:buttonIdentifier:clickedUrl:) public static func track(embeddedMessageClick: IterableEmbeddedMessage, buttonIdentifier: String?, clickedUrl: String) { - implementation?.track(embeddedMessageClick: embeddedMessageClick, buttonIdentifier: buttonIdentifier, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(embeddedMessageClick: embeddedMessageClick, buttonIdentifier: buttonIdentifier, clickedUrl: clickedUrl) } @objc(embeddedMessageReceived:) public static func track(embeddedMessageReceived: IterableEmbeddedMessage) { - implementation?.track(embeddedMessageReceived: embeddedMessageReceived) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(embeddedMessageReceived: embeddedMessageReceived) } // MARK: In-App Notifications @@ -657,7 +752,9 @@ import UIKit /// - SeeAlso: IterableInAppDelegate @objc(trackInAppOpen:location:) public static func track(inAppOpen message: IterableInAppMessage, location: InAppLocation = .inApp) { - implementation?.trackInAppOpen(message, location: location) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppOpen(message, location: location) } /// Tracks an `InAppClick` event @@ -671,7 +768,9 @@ import UIKit /// - clickedUrl: The URL of the button or link that was clicked @objc(trackInAppClick:location:clickedUrl:) public static func track(inAppClick message: IterableInAppMessage, location: InAppLocation = .inApp, clickedUrl: String) { - implementation?.trackInAppClick(message, location: location, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClick(message, location: location, clickedUrl: clickedUrl) } /// Tracks an `InAppClose` event @@ -681,7 +780,9 @@ import UIKit /// - clickedUrl: The url that was clicked to close the in-app. It will be `nil` when the message is closed by clicking `back`. @objc(trackInAppClose:clickedUrl:) public static func track(inAppClose message: IterableInAppMessage, clickedUrl: String?) { - implementation?.trackInAppClose(message, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClose(message, clickedUrl: clickedUrl) } /// Tracks an `InAppClose` event @@ -692,7 +793,9 @@ import UIKit /// - clickedUrl: The URL that was clicked to close the in-app. It will be `nil` when the message is closed by clicking `back`. @objc(trackInAppClose:location:clickedUrl:) public static func track(inAppClose message: IterableInAppMessage, location: InAppLocation, clickedUrl: String?) { - implementation?.trackInAppClose(message, location: location, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClose(message, location: location, clickedUrl: clickedUrl) } /// Tracks an `InAppClose` event @@ -704,7 +807,9 @@ import UIKit /// - clickedUrl: The url that was clicked to close the in-app. It will be `nil` when the message is closed by clicking `back`. @objc(trackInAppClose:location:source:clickedUrl:) public static func track(inAppClose message: IterableInAppMessage, location: InAppLocation, source: InAppCloseSource, clickedUrl: String?) { - implementation?.trackInAppClose(message, location: location, source: source, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClose(message, location: location, source: source, clickedUrl: clickedUrl) } /// Consumes the notification and removes it from the list of in-app messages @@ -714,7 +819,9 @@ import UIKit /// - location: The location from where this message was shown. `inbox` or `inApp`. @objc(inAppConsume:location:) public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp) { - implementation?.inAppConsume(message: message, location: location) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.inAppConsume(message: message, location: location) } /// Consumes the notification and removes it from the list of in-app messages @@ -725,7 +832,9 @@ import UIKit /// - source: The source of deletion `inboxSwipe` or `deleteButton`. @objc(inAppConsume:location:source:) public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp, source: InAppDeleteSource) { - implementation?.inAppConsume(message: message, location: location, source: source) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.inAppConsume(message: message, location: location, source: source) } /// Tracks analytics data from a session of using an inbox UI @@ -735,7 +844,9 @@ import UIKit /// - inboxSession: the inbox session data type to track @objc(trackInboxSession:) public static func track(inboxSession: IterableInboxSession) { - implementation?.track(inboxSession: inboxSession) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(inboxSession: inboxSession) } // MARK: - Private/Internal diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index 62f89c14e..efe59b7d8 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -74,6 +74,11 @@ public struct IterableAPIMobileFrameworkInfo: Codable { @objc func onAuthFailure(_ authFailure: AuthFailure) } +/// The delegate for getting the UserId once unknown user session tracked +@objc public protocol IterableUnknownUserHandler: AnyObject { + @objc func onUnknownUserCreated(userId: String) +} + /// Iterable Configuration Object. Use this when initializing the API. @objcMembers public class IterableConfig: NSObject { @@ -100,7 +105,11 @@ public class IterableConfig: NSObject { /// Implement this protocol to enable token-based authentication with the Iterable SDK public weak var authDelegate: IterableAuthDelegate? - + + /// Implement this protocol to get userId once the userId set for Unknown User + public weak var unknownUserHandler: IterableUnknownUserHandler? + + /// When set to `true`, IterableSDK will automatically register and deregister /// notification tokens. public var autoPushRegistration = true @@ -147,8 +156,20 @@ public class IterableConfig: NSObject { /// Sets data region which determines data center and endpoints used by the SDK public var dataRegion: String = IterableDataRegion.US + /// When set to `true`, IterableSDK will track all events when users are not logged into the application. + public var enableUnknownUserActivation = true + + /// Enables fetching of unknown user criteria on foreground when set to `true` + /// By default, the SDK will fetch unknown user criteria on foreground. + public var enableForegroundCriteriaFetch = true + /// Allows for fetching embedded messages. public var enableEmbeddedMessaging = false + + // How many events can be stored in the local storage. By default limt is 100. + public var eventThresholdLimit: Int = 100 + + public var identityResolution: IterableIdentityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) /// The type of mobile framework we are using. public var mobileFrameworkInfo: IterableAPIMobileFrameworkInfo? diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index 56aaed4f0..59dac2c3c 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -7,6 +7,15 @@ import Foundation @testable import IterableSDK class MockLocalStorage: LocalStorageProtocol { + + var userIdUnknownUser: String? + + var unknownUserEvents: [[AnyHashable : Any]]? + + var criteriaData: Data? + + var unknownUserSessions: IterableSDK.IterableUnknownUserSessionsWrapper? + var userId: String? = nil var email: String? = nil @@ -20,7 +29,13 @@ class MockLocalStorage: LocalStorageProtocol { var sdkVersion: String? = nil var offlineMode: Bool = false + + var visitorUsageTracked: Bool = true + var visitorConsentTimestamp: Int64? + + var unknownUserUpdate: [AnyHashable : Any]? + var isNotificationsEnabled: Bool = false var hasStoredNotificationSetting: Bool = false diff --git a/tests/offline-events-tests/RequestHandlerTests.swift b/tests/offline-events-tests/RequestHandlerTests.swift index 4b4d7122d..79749b3d1 100644 --- a/tests/offline-events-tests/RequestHandlerTests.swift +++ b/tests/offline-events-tests/RequestHandlerTests.swift @@ -1230,7 +1230,7 @@ class RequestHandlerTests: XCTestCase { extension RequestHandlerTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdUnknownUser: nil) } } diff --git a/tests/offline-events-tests/TaskProcessorTests.swift b/tests/offline-events-tests/TaskProcessorTests.swift index 4b3195886..7b696e1f9 100644 --- a/tests/offline-events-tests/TaskProcessorTests.swift +++ b/tests/offline-events-tests/TaskProcessorTests.swift @@ -14,7 +14,7 @@ class TaskProcessorTests: XCTestCase { let dataFields = ["var1": "val1", "var2": "val2"] let expectation1 = expectation(description: #function) - let auth = Auth(userId: nil, email: email, authToken: nil) + let auth = Auth(userId: nil, email: email, authToken: nil, userIdUnknownUser: nil) let config = IterableConfig() let networkSession = MockNetworkSession() let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: apiKey, config: config, networkSession: networkSession) @@ -221,7 +221,7 @@ class TaskProcessorTests: XCTestCase { let eventName = "CustomEvent1" let dataFields = ["var1": "val1", "var2": "val2"] - let auth = Auth(userId: nil, email: email, authToken: nil) + let auth = Auth(userId: nil, email: email, authToken: nil, userIdUnknownUser: nil) let requestCreator = RequestCreator(auth: auth, deviceMetadata: deviceMetadata) guard case let Result.success(trackEventRequest) = requestCreator.createTrackEventRequest(eventName, dataFields: dataFields) else { diff --git a/tests/offline-events-tests/TaskRunnerTests.swift b/tests/offline-events-tests/TaskRunnerTests.swift index 5777a4f01..69843fd42 100644 --- a/tests/offline-events-tests/TaskRunnerTests.swift +++ b/tests/offline-events-tests/TaskRunnerTests.swift @@ -421,6 +421,6 @@ class TaskRunnerTests: XCTestCase { extension TaskRunnerTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdUnknownUser: nil) } } diff --git a/tests/offline-events-tests/TaskSchedulerTests.swift b/tests/offline-events-tests/TaskSchedulerTests.swift index 4abd18d15..73ed3dca4 100644 --- a/tests/offline-events-tests/TaskSchedulerTests.swift +++ b/tests/offline-events-tests/TaskSchedulerTests.swift @@ -123,6 +123,6 @@ class TaskSchedulerTests: XCTestCase { extension TaskSchedulerTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdUnknownUser: nil) } } diff --git a/tests/unit-tests/BlankApiClient.swift b/tests/unit-tests/BlankApiClient.swift index 5ce30d227..82df13157 100644 --- a/tests/unit-tests/BlankApiClient.swift +++ b/tests/unit-tests/BlankApiClient.swift @@ -7,6 +7,41 @@ import Foundation @testable import IterableSDK class BlankApiClient: ApiClientProtocol { + func trackConsent(consentTimestamp: Int64, email: String?, userId: String?, isUserKnown: Bool) -> IterableSDK.Pending { + Pending() + } + + + func updateCart(items: [IterableSDK.CommerceItem], createdAt: Int) -> IterableSDK.Pending { + Pending() + } + + func track(purchase total: NSNumber, items: [IterableSDK.CommerceItem], dataFields: [AnyHashable : Any]?, createdAt: Int) -> IterableSDK.Pending { + Pending() + } + + func mergeUser(sourceEmail: String?, sourceUserId: String?, destinationEmail: String?, destinationUserId: String?) -> IterableSDK.Pending { + Pending() + } + + func trackUnknownUserSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable : Any]?, requestJson: [AnyHashable : Any]) -> IterableSDK.Pending { + Pending() + } + + func getCriteria() -> IterableSDK.Pending { + Pending() + } + + + func track(event eventName: String, dataFields: [AnyHashable : Any]?) -> IterableSDK.Pending { + Pending() + } + + func track(event eventName: String, withBody body: [AnyHashable : Any]?) -> IterableSDK.Pending { + Pending() + } + + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { Pending() } @@ -31,10 +66,6 @@ class BlankApiClient: ApiClientProtocol { Pending() } - func track(event eventName: String, dataFields: [AnyHashable : Any]?) -> Pending { - Pending() - } - func updateSubscriptions(_ emailListIds: [NSNumber]?, unsubscribedChannelIds: [NSNumber]?, unsubscribedMessageTypeIds: [NSNumber]?, subscribedMessageTypeIds: [NSNumber]?, campaignId: NSNumber?, templateId: NSNumber?) -> Pending { Pending() } @@ -79,6 +110,10 @@ class BlankApiClient: ApiClientProtocol { Pending() } + func mergeUser(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String) -> IterableSDK.Pending { + Pending() + } + func getEmbeddedMessages() -> Pending { Pending() } diff --git a/tests/unit-tests/CombinationComplexCriteria.swift b/tests/unit-tests/CombinationComplexCriteria.swift new file mode 100644 index 000000000..bf2dd8b9b --- /dev/null +++ b/tests/unit-tests/CombinationComplexCriteria.swift @@ -0,0 +1,594 @@ +// +// CombinationComplexCriteria.swift +// unit-tests +// +// Created by Apple on 05/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class CombinationComplexCriteria: XCTestCase { + //MARK: Comparator test For End + private let mockDataComplexCriteria1 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "290", + "name": "Complex Criteria Unit Test #1", + "createdAt": 1722532861551, + "updatedAt": 1722532861551, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "B", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "C", + "fieldType": "string" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "saved_cars.color", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + }, + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "LessThanOrEqualTo", + "value": "100", + "fieldType": "double" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "reason", + "comparatorType": "Equals", + "value": "testing", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testComplexCriteria1Success() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "Alex"] + ], + ["dataType": "customEvent", + "eventName": "saved_cars", + "dataFields": ["color":"black"] + ], + ["dataType": "customEvent", + "eventName": "animal-found", + "dataFields": ["vaccinated":true] + ], + ["dataType": "purchase", + "dataFields": ["total": 30, + "reason":"testing"] + ] + ] + + + let expectedCriteriaId = "290" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria1)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + + func testComplexCriteria1Failed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "Alex"] + ], + ["dataType": "customEvent", + "eventName": "saved_cars", + "dataFields": ["color":""] + ], + ["dataType": "customEvent", + "eventName": "animal-found", + "dataFields": ["vaccinated":true] + ], + ["dataType": "purchase", + "dataFields": ["total": 30, + "reason":"testing"] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria1)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataComplexCriteria2 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "291", + "name": "Complex Criteria Unit Test #2", + "createdAt": 1722533473263, + "updatedAt": 1722533473263, + "searchQuery": { + "combinator": "Or", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "B", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "C", + "fieldType": "string" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "saved_cars.color", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + }, + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "GreaterThanOrEqualTo", + "value": "100", + "fieldType": "double" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "reason", + "comparatorType": "DoesNotEqual", + "value": "gift", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testComplexCriteria2Success() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "xcode"] + ], + ["dataType": "customEvent", + "eventName": "saved_cars", + "dataFields": ["color":"black"] + ], + ["dataType": "customEvent", + "eventName": "animal-found", + "dataFields": ["vaccinated":true] + ], + ["dataType": "purchase", + "dataFields": ["total": 110, + "reason":"testing"] + ] + ] + + let expectedCriteriaId = "291" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria2)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testComplexCriteria2Failed() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "Alex"] + ], + ["dataType": "purchase", + "dataFields": ["total": 10, + "reason":"gift"] + ] + ] + + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria2)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataComplexCriteria3 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "292", + "name": "Complex Criteria Unit Test #3", + "createdAt": 1722533789589, + "updatedAt": 1722533838989, + "searchQuery": { + "combinator": "Not", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "lastName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + } + ] + }, + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "C", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "false", + "fieldType": "boolean" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "animal-found.count", + "comparatorType": "LessThan", + "value": "5", + "fieldType": "long" + } + ] + } + } + ] + }, + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "LessThanOrEqualTo", + "value": "10", + "fieldType": "double" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "shoppingCartItems.quantity", + "comparatorType": "LessThanOrEqualTo", + "value": "34", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testComplexCriteria3Success() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"purchase", + "createdAt": 1699246745093, + "items": [["id": "12", "name": "coffee", "price": 100, "quantity": 2]] + ] + ] + + let expectedCriteriaId = "292" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria3)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testComplexCriteria3Success2() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"purchase", + "createdAt": 1699246745067, + "items": [["id": "12", "name": "kittens", "price": 2, "quantity": 2]] + ] + ] + + let expectedCriteriaId = "292" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria3)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testComplexCriteria3Fail() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"purchase", + "createdAt": 1699246745093, + "items": [["id": "12", "name": "coffee", "price": 100, "quantity": 2]] + ], + ["dataType":"user", + "dataFields": ["firstName": "Alex", "lastName":"Aris"] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataComplexCriteria3)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/CombinationLogicEventTypeCriteria.swift b/tests/unit-tests/CombinationLogicEventTypeCriteria.swift new file mode 100644 index 000000000..0e48ee84d --- /dev/null +++ b/tests/unit-tests/CombinationLogicEventTypeCriteria.swift @@ -0,0 +1,1160 @@ +// +// CombinationLogicEventTypeCriteria.swift +// unit-tests +// +// Created by Apple on 06/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class CombinationLogicEventTypeCriteria: XCTestCase { + + //MARK: Comparator test For End + private let mockDataCombinatUserAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "David" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "total", + "fieldType": "double", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 6, + "value": "10" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataUserAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["total": "10", + "dataType": "customEvent" + ] + ] + + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUserAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUserAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David1" + ], + ["total": "10", + "dataType": "customEvent" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUserAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataCombinatUserOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "David" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "total", + "fieldType": "double", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 6, + "value": "10" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUserOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["total": "12", + "dataType": "customEvent" + ] + ] + + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUserOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUserOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David1" + ], + ["total": "12", + "dataType": "customEvent" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUserOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataCombinatUserNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "David" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "total", + "fieldType": "double", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 6, + "value": "10" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + //First -> Wrong + //Secon -> Correct + + + func testCompareDataUserNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Devidson" + ], + ["total": "13", + "dataType": "customEvent" + ] + ] + + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUserNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUserNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["total": "10", + "dataType": "customEvent" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUserNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For UpdateCart And + private let mockDataCombinatUpdateCartAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 8, + "value": "fried" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 10, + "value": "David" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUpdateCartAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUpdateCartAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUpdateCartAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["items": [["id": "12", + "name": "frieded", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUpdateCartAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For UpdateCart And + private let mockDataCombinatUpdateCartOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 8, + "value": "fried" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 10, + "value": "David" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUpdateCartOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Davidson" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUpdateCartOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUpdateCartOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Davidjson" + ], + ["items": [["id": "12", + "name": "frieded", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUpdateCartOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For UpdateCart And + private let mockDataCombinatUpdateCartNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 8, + "value": "fried" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 10, + "value": "David" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUpdateCartNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Davidson" + ], + ["items": [["id": "12", + "name": "friedddd", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUpdateCartNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUpdateCartNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatUpdateCartNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + + //MARK: Comparator test For Purchase And + private let mockDataCombinatPurchaseAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 14, + "value": "fried" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For Purchase Or + private let mockDataCombinatPurchaseOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 14, + "value": "fried" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 14, + "value": "fried" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseCustomEventAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 16, + "value": "birthday" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseCustomEventAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseCustomEventAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseCustomEventAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday1" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseCustomEventAnd)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseCustomEventOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 16, + "value": "birthday" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseCustomEventOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday1" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseCustomEventOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseCustomEventOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday1" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseCustomEventOr)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseCustomEventNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 16, + "value": "birthday" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseCustomEventNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "beef", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "anniversary" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseCustomEventNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseCustomEventNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCombinatPurchaseCustomEventNot)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} + diff --git a/tests/unit-tests/ComparatorDataTypeWithArrayInput.swift b/tests/unit-tests/ComparatorDataTypeWithArrayInput.swift new file mode 100644 index 000000000..5e64785fe --- /dev/null +++ b/tests/unit-tests/ComparatorDataTypeWithArrayInput.swift @@ -0,0 +1,712 @@ +// +// ComparatorDataTypeWithArrayInput.swift +// unit-tests +// +// Created by Apple on 21/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ComparatorDataTypeWithArrayInput: XCTestCase { + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + //MARK: Comparator Equal For MileStoneYear Array + private let mockDataMileStoneYearEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearEqualSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1997, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1998, 2002, 2020, 2024] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator DoesNotEqual For MileStoneYear Array + private let mockDataMileStoneYearDoesNotEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "DoesNotEqual", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearDoesNotEqualSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1998, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearDoesNotEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearDoesNotEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1997, 2002, 2020, 2024] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearDoesNotEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator GreaterThan For MileStoneYear Array + private let mockDataMileStoneYearGreaterThan = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "GreaterThan", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearGreaterThanSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1998, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearGreaterThan)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearGreaterThanFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1997] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearGreaterThan)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator GreaterThanOrEqualTo For MileStoneYear Array + private let mockDataMileStoneYearGreaterThanOrEqualTo = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearGreaterThanOrEqualToSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1997, 1998, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearGreaterThanOrEqualTo)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearGreaterThanOrEqualToFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1996] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearGreaterThanOrEqualTo)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator LessThan For MileStoneYear Array + private let mockDataMileStoneYearLessThan = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "LessThan", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearLessThanSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1996, 1998] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearLessThan)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearLessThanFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1997, 1999, 2002, 2004] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearLessThan)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator LessThanOrEqualTo For MileStoneYear Array + private let mockDataMileStoneYearLessThanOrEquaTo = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "LessThanOrEqualTo", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearLessThanOrEqualToSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1996, 1998] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearLessThanOrEquaTo)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearLessThanOrEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1998, 1999, 2002, 2004] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataMileStoneYearLessThanOrEquaTo)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator Contain For String Array + private let mockDataForArrayContains = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "addresses", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 2, + "value": "US" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMockDataForArrayContainsSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": ["US", "UK", "USA"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForArrayContains)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMockDataForArrayContainsFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": ["UK", "USA"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForArrayContains)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator Contain For String Array + private let mockDataForArrayStartWith = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "addresses", + "fieldType": "string", + "comparatorType": "StartsWith", + "dataType": "user", + "id": 2, + "value": "US" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMockDataForArrayStartWithSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "US, New York", + "US, San Francisco", + "US, San Diego", + "US, Los Angeles", + "JP, Tokyo", + "DE, Berlin", + "GB, London"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForArrayStartWith)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMockDataForArrayStartWithFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "JP", + "Tokyo", + "DE, Berlin", + "GB", + "London"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForArrayStartWith)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator Contain For String Array + private let mockDataForArrayMatchRegex = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "addresses", + "fieldType": "string", + "comparatorType": "MatchesRegex", + "dataType": "user", + "id": 2, + "value": "^(JP|DE|GB)" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMockDataForArrayMatchRegexSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "JP", + "Tokyo", + "DE, Berlin", + "GB", + "London"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForArrayMatchRegex)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMockDataForArrayMatchRegexFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "US, New York", + "US, San Francisco", + "US, San Diego", + "US, Los Angeles", + ] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForArrayMatchRegex)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator DoesNotEqual For MileStoneYear Array + private let mockDataStringArrayMixCriteArea = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "382", + "name": "comparison_for_Array_data_types_or", + "createdAt": 1724315593795, + "updatedAt": 1724315593795, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "milestoneYears", + "comparatorType": "GreaterThan", + "value": "1997", + "fieldType": "long" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "button-clicked.animal", + "comparatorType": "DoesNotEqual", + "value": "giraffe", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "LessThanOrEqualTo", + "value": "200", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataStringArrayDoesNotEqualSuccess() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1998, 1999, 2002, 2004] + ], + [ + "dataType": "customEvent", + "eventName": "button-clicked", + "dataFields": ["animal": ["zirraf", "horse"]] + ], + [ + "dataType": "purchase", + "total": [199.99, 210.0, 220.20, 250.10] + ] + ] + let expectedCriteriaId = "382" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataStringArrayMixCriteArea)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataStringArrayDoesNotEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1996,1997] + ], + [ + "dataType": "customEvent", + "eventName": "button-clicked", + "dataFields": ["animal": ["zirraf", "horse", "giraffe"]] + + ], + [ + "dataType": "purchase", + "total": [210.0, 220.20, 250.10] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataStringArrayMixCriteArea)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/ComparatorTypeDoesNotEqualMatchTest.swift b/tests/unit-tests/ComparatorTypeDoesNotEqualMatchTest.swift new file mode 100644 index 000000000..1f1342dbe --- /dev/null +++ b/tests/unit-tests/ComparatorTypeDoesNotEqualMatchTest.swift @@ -0,0 +1,258 @@ +// +// DoesNotEqualCriteriaMatch.swift +// unit-tests +// +// Created by Apple on 01/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ComparatorTypeDoesNotEqualMatchTest: XCTestCase { + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + private let mokeDataBool = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "194", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "subscribed", + "fieldType": "boolean", + "comparatorType": "DoesNotEqual", + "dataType": "user", + "id": 25, + "value": "true" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataSuccessForBool() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["subscribed": false + ]]] + let expectedCriteriaId = "194" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataBool)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForBool() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["subscribed": true, + ]]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataBool)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataString = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "195", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "phoneNumber", + "comparatorType": "DoesNotEqual", + "value": "57688559", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataSuccessForString() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["phoneNumber": "123456" + ]]] + let expectedCriteriaId = "195" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataString)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForString() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["phoneNumber": "57688559" + ]]] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataString)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + private let mokeDataDouble = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "196", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "savings", + "comparatorType": "DoesNotEqual", + "value": "19.99", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + func testCompareDataSuccessForDouble() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["savings": 9.99 + ]]] + let expectedCriteriaId = "196" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataDouble)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForDouble() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["savings": 19.99 + ]]] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataDouble)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataLong = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "197", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "DoesNotEqual", + "value": "15", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataSuccessForLong() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["eventTimeStamp": 20 + ]]] + let expectedCriteriaId = "197" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataLong)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForLong() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["eventTimeStamp": 15 + ]]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataLong)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + +} + diff --git a/tests/unit-tests/ConsentTrackingTests.swift b/tests/unit-tests/ConsentTrackingTests.swift new file mode 100644 index 000000000..7f42f3bf8 --- /dev/null +++ b/tests/unit-tests/ConsentTrackingTests.swift @@ -0,0 +1,362 @@ +// +// ConsentTrackingTests.swift +// swift-sdk +// +// Created by Iterable Team on 23/01/2025. +// Copyright © 2025 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class ConsentTrackingTests: XCTestCase { + private var mockNetworkSession: MockNetworkSession! + private var mockDateProvider: MockDateProvider! + private var mockLocalStorage: MockLocalStorage! + private var internalAPI: InternalIterableAPI! + private static let apiKey = "test-api-key" + private static let testEmail = "test@example.com" + private static let testUserId = "test-user-123" + private static let consentTimestamp: Int64 = 1639490139 + + override func setUp() { + super.setUp() + mockNetworkSession = MockNetworkSession() + mockDateProvider = MockDateProvider() + mockLocalStorage = MockLocalStorage() + + // Set up consent timestamp + mockLocalStorage.visitorConsentTimestamp = ConsentTrackingTests.consentTimestamp + mockLocalStorage.visitorUsageTracked = true + + let config = IterableConfig() + config.enableUnknownUserActivation = true + // Needed so register(token:) can succeed in tests + config.pushIntegrationName = "test-push-integration" + + internalAPI = InternalIterableAPI.initializeForTesting( + apiKey: ConsentTrackingTests.apiKey, + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + localStorage: mockLocalStorage + ) + } + + override func tearDown() { + mockNetworkSession = nil + mockDateProvider = nil + mockLocalStorage = nil + internalAPI = nil + super.tearDown() + } + + // MARK: - Criteria Match Scenario Tests + + func testConsentSentAfterCriteriaMatch() { + let expectation = XCTestExpectation(description: "Consent tracked after criteria match") + + var consentRequestReceived = false + + mockNetworkSession.responseCallback = { url in + let urlString = url.absoluteString + + if urlString.contains(Const.Path.trackConsent) { + consentRequestReceived = true + expectation.fulfill() + return MockNetworkSession.MockResponse(statusCode: 200) + } + + return MockNetworkSession.MockResponse(statusCode: 200) + } + + // Verify consent request body using requestCallback + mockNetworkSession.requestCallback = { urlRequest in + if urlRequest.url?.absoluteString.contains(Const.Path.trackConsent) == true { + let body = urlRequest.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.consentTimestamp] as? Int, Int(ConsentTrackingTests.consentTimestamp)) + XCTAssertEqual(body?[JsonKey.isUserKnown] as? Bool, false) + XCTAssertNotNil(body?[JsonKey.userId] as? String) + XCTAssertNil(body?[JsonKey.email]) + } + } + + // Directly test the consent sending logic by calling the API method + // This simulates what happens when criteria are met and anonymous user is created + let testUserId = "test-anon-user-id" + + internalAPI.apiClient.trackConsent( + consentTimestamp: ConsentTrackingTests.consentTimestamp, + email: nil, + userId: testUserId, + isUserKnown: false + ) + + wait(for: [expectation], timeout: 5.0) + + XCTAssertTrue(consentRequestReceived) + } + + func testConsentTimestampSentInMilliseconds() { + // Use a test date in seconds + let testDateInSeconds = Date(timeIntervalSince1970: 1639490139) // December 14, 2021 + mockDateProvider.currentDate = testDateInSeconds + + // Set consent which should store timestamp in milliseconds + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: true) + + // Verify the timestamp was stored in milliseconds format + guard let storedTimestamp = mockLocalStorage.visitorConsentTimestamp else { + XCTFail("Expected visitorConsentTimestamp to be set after calling setVisitorUsageTracked(true)") + return + } + + let expectedTimestampInSeconds = Int64(testDateInSeconds.timeIntervalSince1970) + let expectedTimestampInMilliseconds = expectedTimestampInSeconds * 1000 + + // Verify the stored timestamp is in milliseconds (much larger than seconds) + XCTAssertEqual(storedTimestamp, expectedTimestampInMilliseconds, "Stored timestamp should be in milliseconds") + XCTAssertTrue(storedTimestamp > expectedTimestampInSeconds, "Timestamp should be in milliseconds, not seconds") + + // Verify it converts back to the correct date when divided by 1000 + let convertedDate = Date(timeIntervalSince1970: TimeInterval(storedTimestamp) / 1000.0) + XCTAssertEqual(convertedDate, testDateInSeconds, "Timestamp should convert back to original date when divided by 1000") + + // Verify the timestamp format: should be 13 digits (milliseconds since epoch) + let timestampString = String(storedTimestamp) + XCTAssertEqual(timestampString.count, 13, "Millisecond timestamp should have 13 digits") + + // Test that API call can be made with the millisecond timestamp (this verifies integration) + let expectation = XCTestExpectation(description: "API call completed") + _ = internalAPI.apiClient.trackConsent( + consentTimestamp: storedTimestamp, + email: nil, + userId: "test-user", + isUserKnown: false + ).onSuccess { _ in + expectation.fulfill() + }.onError { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testConsentNotSentWhenNoConsentTimestamp() { + let expectation = XCTestExpectation(description: "No consent request when no timestamp") + expectation.isInverted = true + + // Clear consent timestamp + mockLocalStorage.visitorConsentTimestamp = nil + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() // This should not happen + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + // Simulate criteria being met + mockLocalStorage.criteriaData = "mock-criteria".data(using: .utf8) + internalAPI.track("test-event") + + wait(for: [expectation], timeout: 2.0) + } + + // MARK: - Replay Scenario Tests + + func testConsentSentOnEmailSetForReplayScenario() { + let expectation = XCTestExpectation(description: "Consent tracked on email set") + + // Set up replay scenario (no anonymous user ID) + mockLocalStorage.userIdUnknownUser = nil + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() + return MockNetworkSession.MockResponse(statusCode: 200) + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + mockNetworkSession.requestCallback = { urlRequest in + if urlRequest.url?.absoluteString.contains(Const.Path.trackConsent) == true { + let body = urlRequest.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.consentTimestamp] as? Int, Int(ConsentTrackingTests.consentTimestamp)) + XCTAssertEqual(body?[JsonKey.isUserKnown] as? Bool, true) + XCTAssertEqual(body?[JsonKey.email] as? String, ConsentTrackingTests.testEmail) + XCTAssertNil(body?[JsonKey.userId]) + } + } + + internalAPI.setEmail(ConsentTrackingTests.testEmail) + // Consent is sent after successful device registration + internalAPI.register(token: "test-token") + + wait(for: [expectation], timeout: 5.0) + } + + func testConsentSentOnUserIdSetForReplayScenario() { + let expectation = XCTestExpectation(description: "Consent tracked on userId set") + + // Set up replay scenario (no anonymous user ID) + mockLocalStorage.userIdUnknownUser = nil + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() + return MockNetworkSession.MockResponse(statusCode: 200) + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + mockNetworkSession.requestCallback = { urlRequest in + if urlRequest.url?.absoluteString.contains(Const.Path.trackConsent) == true { + let body = urlRequest.httpBody?.json() as? [String: Any] + XCTAssertEqual(body?[JsonKey.consentTimestamp] as? Int, Int(ConsentTrackingTests.consentTimestamp)) + XCTAssertEqual(body?[JsonKey.isUserKnown] as? Bool, true) + XCTAssertEqual(body?[JsonKey.userId] as? String, ConsentTrackingTests.testUserId) + XCTAssertNil(body?[JsonKey.email]) + } + } + + internalAPI.setUserId(ConsentTrackingTests.testUserId) + // Consent is sent after successful device registration + internalAPI.register(token: "test-token") + + wait(for: [expectation], timeout: 5.0) + } + + func testConsentNotSentWhenAnonUserExists() { + let expectation = XCTestExpectation(description: "No consent when anon user exists") + expectation.isInverted = true + + // Set up scenario with existing anonymous user (no replay needed) + mockLocalStorage.userIdUnknownUser = "existing-anon-user-id" + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() // This should not happen + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + internalAPI.setEmail(ConsentTrackingTests.testEmail) + // Consent is sent after successful device registration + internalAPI.register(token: "test-token") + + wait(for: [expectation], timeout: 2.0) + } + + func testConsentNotSentWhenNoTracking() { + let expectation = XCTestExpectation(description: "No consent when tracking disabled") + expectation.isInverted = true + + // Disable anonymous usage tracking + mockLocalStorage.visitorUsageTracked = false + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() // This should not happen + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + internalAPI.setEmail(ConsentTrackingTests.testEmail) + // Consent is sent after successful device registration + internalAPI.register(token: "test-token") + + wait(for: [expectation], timeout: 2.0) + } + + func testConsentNotSentWhenAnonActivationDisabled() { + let expectation = XCTestExpectation(description: "No consent when anon activation disabled") + expectation.isInverted = true + + // Create API with anon activation disabled + let config = IterableConfig() + config.enableUnknownUserActivation = false + + let apiWithoutAnonActivation = InternalIterableAPI.initializeForTesting( + apiKey: ConsentTrackingTests.apiKey, + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + localStorage: mockLocalStorage + ) + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() // This should not happen + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + apiWithoutAnonActivation.setEmail(ConsentTrackingTests.testEmail) + + wait(for: [expectation], timeout: 2.0) + } + + // MARK: - Error Handling Tests + + func testConsentTrackingErrorHandling() { + let expectation = XCTestExpectation(description: "Error handling for consent tracking") + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() + // Simulate network error + return MockNetworkSession.MockResponse(statusCode: 500, error: NSError(domain: "TestError", code: 500, userInfo: nil)) + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + // Directly invoke consent tracking to verify error handling + internalAPI.apiClient.trackConsent( + consentTimestamp: ConsentTrackingTests.consentTimestamp, + email: ConsentTrackingTests.testEmail, + userId: nil, + isUserKnown: true + ) + + wait(for: [expectation], timeout: 5.0) + // Test should not crash on error - error is logged internally + } + + // MARK: - Device Info Tests + + func testConsentRequestIncludesDeviceInfo() { + let expectation = XCTestExpectation(description: "Device info included in consent request") + + mockNetworkSession.responseCallback = { url in + if url.absoluteString.contains(Const.Path.trackConsent) { + expectation.fulfill() + return MockNetworkSession.MockResponse(statusCode: 200) + } + return MockNetworkSession.MockResponse(statusCode: 200) + } + + mockNetworkSession.requestCallback = { urlRequest in + if urlRequest.url?.absoluteString.contains(Const.Path.trackConsent) == true { + let body = urlRequest.httpBody?.json() as? [String: Any] + let deviceInfo = body?[JsonKey.deviceInfo] as? [String: Any] + + XCTAssertNotNil(deviceInfo) + XCTAssertNotNil(deviceInfo?[JsonKey.deviceId]) + XCTAssertEqual(deviceInfo?[JsonKey.platform] as? String, JsonValue.iOS) + XCTAssertNotNil(deviceInfo?[JsonKey.appPackageName]) + } + } + + // Invoke via API client to validate device info payload + internalAPI.apiClient.trackConsent( + consentTimestamp: ConsentTrackingTests.consentTimestamp, + email: ConsentTrackingTests.testEmail, + userId: nil, + isUserKnown: true + ) + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/tests/unit-tests/CustomEventUserUpdateTestCaseTests.swift b/tests/unit-tests/CustomEventUserUpdateTestCaseTests.swift new file mode 100644 index 000000000..e7083275d --- /dev/null +++ b/tests/unit-tests/CustomEventUserUpdateTestCaseTests.swift @@ -0,0 +1,268 @@ +// +// CustomEventUserUpdateTestCaseTests.swift +// unit-tests +// +// Created by Apple on 16/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class CustomEventUserUpdateTestCaseTests: XCTestCase { + + private let mockData = """ + { + "count": 48, + "criteriaSets": [ + { + "criteriaId": "48", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "button-clicked", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "button-clicked.lastPageViewed", + "comparatorType": "Equals", + "value": "signup page", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithCustomEventCriteriaFailed1() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked.lastPageViewed": "signup page"] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaFailed2() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked.button-clicked.lastPageViewed": "signup page"] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaFailed3() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked": ["button-clicked.lastPageViewed": "signup page"]] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaFailed4() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked": ["lastPageViewed": "signup page"]] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaSuccessCase() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "signup page"] + ]] + let expectedCriteriaId = "48" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + + private let mockDataForMultiLevelNested = """ + { + "count": 3, + "criteriaSets": [ + { + "criteriaId": "425", + "name": "Multi level Nested field criteria", + "createdAt": 1726811375306, + "updatedAt": 1726811375306, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "button-clicked.updateCart.updatedShoppingCartItems.quantity", + "comparatorType": "Equals", + "value": "10", + "fieldType": "long" + }, + { + "dataType": "customEvent", + "field": "button-clicked.browserVisit.website.domain", + "comparatorType": "Equals", + "value": "https://mybrand.com/socks", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMultiLevelNestedFailed1() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "updateCart": [ + "updatedShoppingCartItems": [ + "quantity": 10 + ] + ], + "browserVisit": [ + "website.domain": "https://mybrand.com/socks" + ] + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForMultiLevelNested)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedFailed2() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "updateCart": [ + "updatedShoppingCartItems.quantity": 10 + ], + "browserVisit": [ + "website.domain": "https://mybrand.com/socks" + ] + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForMultiLevelNested)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedFailed3() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "button-clicked": [ + "updateCart": [ + "updatedShoppingCartItems": [ + "quantity": 10 + ] + ], + "browserVisit": [ + "website": [ + "domain": "https://mybrand.com/socks" + ] + ] + ] + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForMultiLevelNested)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedFailed4() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "quantity": 10, + "domain": "https://mybrand.com/socks" + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForMultiLevelNested)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedSuccessCase() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "updateCart": [ + "updatedShoppingCartItems": [ + "quantity": 10 + ] + ], + "browserVisit": [ + "website": [ + "domain": "https://mybrand.com/socks" + ] + ] + ] + ] + ] + let expectedCriteriaId = "425" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForMultiLevelNested)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } +} + diff --git a/tests/unit-tests/DataTypeComparatorSearchQueryCriteria.swift b/tests/unit-tests/DataTypeComparatorSearchQueryCriteria.swift new file mode 100644 index 000000000..83e4ee8b0 --- /dev/null +++ b/tests/unit-tests/DataTypeComparatorSearchQueryCriteria.swift @@ -0,0 +1,634 @@ +// +// SavingComplexCriteriaMatch.swift +// unit-tests +// +// Created by Apple on 01/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class DataTypeComparatorSearchQueryCriteria: XCTestCase { + + //MARK: Comparator test For Equal + private let mockDataEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "Equals", + "value": "3", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "Equals", + "value": "19.99", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "likes_boba", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "Equals", + "value": "Chaina", + "fieldType": "String" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 19.99, + "eventTimeStamp": 3, + "likes_boba": true, + "country":"Chaina"] + ]] + + let expectedCriteriaId = "285" + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataEqualFailed() { + + //let eventItems: [[AnyHashable: Any]] = [["dataType":"user","savings": 10.1]] + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 10.99, + "eventTimeStamp": 30, + "likes_boba": false, + "country":"Taiwan"] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For DoesNotEqual + private let mockDataDoesNotEquals = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "DoesNotEqual", + "value": "3", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "DoesNotEqual", + "value": "19.99", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "likes_boba", + "comparatorType": "DoesNotEqual", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + func testCompareDataDoesNotEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 11.2, + "eventTimeStamp": 30, + "likes_boba": false] + ]] + let expectedCriteriaId = "285" + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataDoesNotEquals)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataDoesNotEqualFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 19.99, + "eventTimeStamp": 30, + "likes_boba": true] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataDoesNotEquals)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For LessThan and LessThanOrEqual + private let mockDataLessThanOrEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "289", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "LessThan", + "value": "15", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "LessThan", + "value": "15", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "290", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "LessThanOrEqualTo", + "value": "17", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "LessThanOrEqualTo", + "value": "17", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + func testCompareDataLessThanSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 10, + "eventTimeStamp": 14] + ]] + let expectedCriteriaId = "289" + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataLessThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataLessThanFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 18, + "eventTimeStamp": 18] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataLessThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataLessThanOrEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 17, + "eventTimeStamp": 14] + ]] + let expectedCriteriaId = "290" + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataLessThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataLessThanOrEqualFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 18, + "eventTimeStamp": 12] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataLessThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For GreaterThan and GreaterThanOrEqual + private let mockDataGreaterThanOrEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "290", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "GreaterThan", + "value": "50", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "GreaterThan", + "value": "55", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "291", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "GreaterThanOrEqualTo", + "value": "20", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "GreaterThanOrEqualTo", + "value": "20", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + func testCompareDataGreaterThanSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 56, + "eventTimeStamp": 51] + ]] + let expectedCriteriaId = "290" + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataGreaterThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataGreaterThanFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 5, + "eventTimeStamp": 3] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataGreaterThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataGreaterThanOrEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", + "dataFields":[ + "savings": 20, + "eventTimeStamp": 30] + ]] + let expectedCriteriaId = "291" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataGreaterThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataGreaterThanOrEqualFailed() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 18, + "eventTimeStamp":16] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataGreaterThanOrEqual)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For IsSet + private let mockDataIsSet = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "IsSet", + "value": "", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "IsSet", + "value": "", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "saved_cars", + "comparatorType": "IsSet", + "value": "", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "IsSet", + "value": "", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataIsSetySuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 10, + "eventTimeStamp":20, + "saved_cars":"10", + "country": "Taiwan"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataIsSet)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": "", + "eventTimeStamp":"", + "saved_cars":"", + "country": ""] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataIsSet)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For IsSet + private let mockDataContainRegexStartWith = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "288", + "name": "Criteria_Country_User", + "createdAt": 1722511481998, + "updatedAt": 1722511481998, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "MatchesRegex", + "value": "^T.*iwa.*n$", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "StartsWith", + "value": "T", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "Contains", + "value": "wan", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataMatchesRegexSuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"Taiwan"]] + let expectedCriteriaId = "288" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataContainRegexStartWith)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataMatchesRegexFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"Chaina", + "phoneNumber": "1212567"]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataContainRegexStartWith)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataStartWithFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"Chaina"]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataContainRegexStartWith)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataContainFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"ina"]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataContainRegexStartWith)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + +} diff --git a/tests/unit-tests/IsOneOfInNotOneOfCriteareaTest.swift b/tests/unit-tests/IsOneOfInNotOneOfCriteareaTest.swift new file mode 100644 index 000000000..8c4798e7e --- /dev/null +++ b/tests/unit-tests/IsOneOfInNotOneOfCriteareaTest.swift @@ -0,0 +1,246 @@ +// +// IsOneOfInNonOfCriteareaTest.swift +// unit-tests +// +// Created by Apple on 02/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class IsOneOfInNotOneOfCriteareaTest: XCTestCase { + + //MARK: Comparator test For End + private let mockDataIsOneOf = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "299", + "name": "Criteria_IsNonOf_Is_One_of", + "createdAt": 1722851586508, + "updatedAt": 1725268680330, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "Equals", + "values": [ + "China", + "Japan", + "Kenya" + ] + }, + { + "dataType": "user", + "field": "addresses", + "comparatorType": "Equals", + "values": [ + "JP", + "DE", + "GB" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareIsOneOfSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "China", + "addresses": ["US", "UK", "JP", "DE", "GB"] + ] + ] + ] + + + let expectedCriteriaId = "299" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataIsOneOf)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareIsOneOfFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "Korea", + "addresses": ["US", "UK"] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataIsOneOf)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For End + private let mockDataIsNotOneOf = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "299", + "name": "Criteria_IsNonOf_Is_One_of", + "createdAt": 1722851586508, + "updatedAt": 1725268680330, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "DoesNotEqual", + "values": [ + "China", + "Japan", + "Kenya" + ] + }, + { + "dataType": "user", + "field": "addresses", + "comparatorType": "DoesNotEqual", + "values": [ + "JP", + "DE", + "GB" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareIsNotOneOfSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "Korea", + "addresses": ["US", "UK"] + ] + ] + ] + + + let expectedCriteriaId = "299" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataIsNotOneOf)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareIsNotOneOfFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "China", + "addresses": ["US", "UK", "JP", "DE", "GB"] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataIsNotOneOf)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + private let mockDataCrashTest = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "403", + "name": "button-clicked.animal isNotOneOf [cat,giraffe,hippo,horse]", + "createdAt": 1725471874865, + "updatedAt": 1725631049514, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "button-clicked.animal", + "comparatorType": "DoesNotEqual", + "values": [ + "cat", + "giraffe", + "hippo", + "horse" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareMockDataCrashTest() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"customEvent", + "dataFields": ["button-clicked": ["animal":"dog"]] + ] + ] + + + let expectedCriteriaId = "403" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCrashTest)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + +} diff --git a/tests/unit-tests/IterableAPIResponseTests.swift b/tests/unit-tests/IterableAPIResponseTests.swift index c97800f5b..fc8ab4ce5 100644 --- a/tests/unit-tests/IterableAPIResponseTests.swift +++ b/tests/unit-tests/IterableAPIResponseTests.swift @@ -10,6 +10,7 @@ class IterableAPIResponseTests: XCTestCase { private let apiKey = "zee_api_key" private let email = "user@example.com" private let authToken = "asdf" + private let dateProvider = MockDateProvider() func testHeadersInGetRequest() { @@ -280,16 +281,153 @@ class IterableAPIResponseTests: XCTestCase { deviceMetadata: InternalIterableAPI.initializeForTesting().deviceMetadata, dateProvider: dateProvider) } + + // MARK: - Consent API Tests + + func testTrackConsentSuccess() { + let expectation = XCTestExpectation(description: "Track consent success") + let consentTimestamp: Int64 = 1639490139 + let email = "test@example.com" + let userId = "test-user-123" + + let mockNetworkSession = MockNetworkSession(statusCode: 200, json: ["success": true]) + + let apiClient = createApiClient(networkSession: mockNetworkSession) + + apiClient.trackConsent( + consentTimestamp: consentTimestamp, + email: email, + userId: userId, + isUserKnown: true + ).onSuccess { response in + XCTAssertNotNil(response) + expectation.fulfill() + }.onError { error in + XCTFail("Expected success but got error: \(error)") + } + + wait(for: [expectation], timeout: testExpectationTimeout) + + // Verify the request was made to the correct endpoint + XCTAssertNotNil(mockNetworkSession.getRequest(withEndPoint: Const.Path.trackConsent)) + } + + func testTrackConsentWithOnlyTimestamp() { + let expectation = XCTestExpectation(description: "Track consent with minimal data") + let consentTimestamp: Int64 = 1639490139 + + let mockNetworkSession = MockNetworkSession(statusCode: 200) + + let apiClient = createApiClient(networkSession: mockNetworkSession) + + apiClient.trackConsent( + consentTimestamp: consentTimestamp, + email: nil, + userId: nil, + isUserKnown: false + ).onSuccess { response in + XCTAssertNotNil(response) + expectation.fulfill() + }.onError { error in + XCTFail("Expected success but got error: \(error)") + } + + wait(for: [expectation], timeout: testExpectationTimeout) + } + + func testTrackConsentError() { + let expectation = XCTestExpectation(description: "Track consent error handling") + let consentTimestamp: Int64 = 1639490139 + + let mockNetworkSession = MockNetworkSession(statusCode: 400, json: ["error": "Bad request"]) + + let apiClient = createApiClient(networkSession: mockNetworkSession) + + apiClient.trackConsent( + consentTimestamp: consentTimestamp, + email: "test@example.com", + userId: nil, + isUserKnown: true + ).onSuccess { response in + XCTFail("Expected error but got success") + }.onError { error in + XCTAssertNotNil(error) + expectation.fulfill() + } + + wait(for: [expectation], timeout: testExpectationTimeout) + } + + func testTrackConsentNetworkError() { + let expectation = XCTestExpectation(description: "Track consent network error") + let consentTimestamp: Int64 = 1639490139 + + let mockNetworkSession = NoNetworkNetworkSession() + + let apiClient = createApiClient(networkSession: mockNetworkSession) + + apiClient.trackConsent( + consentTimestamp: consentTimestamp, + email: "test@example.com", + userId: nil, + isUserKnown: true + ).onSuccess { response in + XCTFail("Expected error but got success") + }.onError { error in + XCTAssertNotNil(error) + expectation.fulfill() + } + + wait(for: [expectation], timeout: testExpectationTimeout) + } + + func testTrackConsentRequestFormat() { + let consentTimestamp: Int64 = 1639490139 + let email = "test@example.com" + let userId = "test-user-123" + + let mockNetworkSession = MockNetworkSession(statusCode: 200) + mockNetworkSession.requestCallback = { urlRequest in + // Verify request format + XCTAssertEqual(urlRequest.httpMethod, "POST") + XCTAssertTrue(urlRequest.url?.absoluteString.contains(Const.Path.trackConsent) == true) + + if let body = urlRequest.httpBody?.json() as? [String: Any] { + XCTAssertEqual(body[JsonKey.consentTimestamp] as? Int, Int(consentTimestamp)) + XCTAssertEqual(body[JsonKey.email] as? String, email) + XCTAssertEqual(body[JsonKey.userId] as? String, userId) + XCTAssertEqual(body[JsonKey.isUserKnown] as? Bool, true) + + // Verify device info is included + let deviceInfo = body[JsonKey.deviceInfo] as? [String: Any] + XCTAssertNotNil(deviceInfo) + XCTAssertNotNil(deviceInfo?[JsonKey.deviceId]) + XCTAssertEqual(deviceInfo?[JsonKey.platform] as? String, JsonValue.iOS) + XCTAssertNotNil(deviceInfo?[JsonKey.appPackageName]) + } else { + XCTFail("Request body should be valid JSON") + } + } + + let apiClient = createApiClient(networkSession: mockNetworkSession) + + apiClient.trackConsent( + consentTimestamp: consentTimestamp, + email: email, + userId: userId, + isUserKnown: true + ) + } } extension IterableAPIResponseTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: email, authToken: authToken) + Auth(userId: nil, email: email, authToken: authToken, userIdUnknownUser: nil) } } class AuthProviderNoToken: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdUnknownUser: nil) } } diff --git a/tests/unit-tests/IterableAPITests.swift b/tests/unit-tests/IterableAPITests.swift index 05de25b81..048c4eb83 100644 --- a/tests/unit-tests/IterableAPITests.swift +++ b/tests/unit-tests/IterableAPITests.swift @@ -1315,5 +1315,106 @@ class IterableAPITests: XCTestCase { XCTAssertEqual(localStorage.authToken, authToken) userDefaults.removePersistentDomain(forName: "upgrade.test") } + + // MARK: - Consent Timestamp Tests + + func testSetVisitorUsageTrackedStoresConsentTimestamp() { + let mockDateProvider = MockDateProvider() + let testDate = Date(timeIntervalSince1970: 1639490139) + mockDateProvider.currentDate = testDate + + let mockLocalStorage = MockLocalStorage() + let internalAPI = InternalIterableAPI.initializeForTesting( + apiKey: IterableAPITests.apiKey, + dateProvider: mockDateProvider, + localStorage: mockLocalStorage + ) + + // Test consent given (true) stores timestamp + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: true) + + XCTAssertEqual(mockLocalStorage.visitorConsentTimestamp, Int64(testDate.timeIntervalSince1970 * 1000)) + XCTAssertTrue(mockLocalStorage.visitorUsageTracked) + } + + func testSetVisitorUsageTrackedClearsConsentTimestamp() { + let mockDateProvider = MockDateProvider() + let testDate = Date(timeIntervalSince1970: 1639490139) + mockDateProvider.currentDate = testDate + + let mockLocalStorage = MockLocalStorage() + mockLocalStorage.visitorConsentTimestamp = Int64(testDate.timeIntervalSince1970 * 1000) + + let internalAPI = InternalIterableAPI.initializeForTesting( + apiKey: IterableAPITests.apiKey, + dateProvider: mockDateProvider, + localStorage: mockLocalStorage + ) + + // Test consent revoked (false) clears timestamp + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: false) + + XCTAssertNil(mockLocalStorage.visitorConsentTimestamp) + XCTAssertFalse(mockLocalStorage.visitorUsageTracked) + } + + func testSetVisitorUsageTrackedMultipleCalls() { + let mockDateProvider = MockDateProvider() + let firstDate = Date(timeIntervalSince1970: 1639490139) + let secondDate = Date(timeIntervalSince1970: 1639490200) + + let mockLocalStorage = MockLocalStorage() + let internalAPI = InternalIterableAPI.initializeForTesting( + apiKey: IterableAPITests.apiKey, + dateProvider: mockDateProvider, + localStorage: mockLocalStorage + ) + + // First consent given + mockDateProvider.currentDate = firstDate + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: true) + XCTAssertEqual(mockLocalStorage.visitorConsentTimestamp, Int64(firstDate.timeIntervalSince1970 * 1000)) + + // Consent revoked + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: false) + XCTAssertNil(mockLocalStorage.visitorConsentTimestamp) + + // Consent given again with new timestamp + mockDateProvider.currentDate = secondDate + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: true) + XCTAssertEqual(mockLocalStorage.visitorConsentTimestamp, Int64(secondDate.timeIntervalSince1970 * 1000)) + } + + func testSetVisitorUsageTrackedStoresTimestampInMilliseconds() { + let mockDateProvider = MockDateProvider() + let testDate = Date(timeIntervalSince1970: 1639490139) // December 14, 2021 + mockDateProvider.currentDate = testDate + + let mockLocalStorage = MockLocalStorage() + let internalAPI = InternalIterableAPI.initializeForTesting( + apiKey: IterableAPITests.apiKey, + dateProvider: mockDateProvider, + localStorage: mockLocalStorage + ) + + // Test consent given stores timestamp in milliseconds + internalAPI.setVisitorUsageTracked(isVisitorUsageTracked: true) + + let expectedTimestampInSeconds = Int64(testDate.timeIntervalSince1970) + let expectedTimestampInMilliseconds = expectedTimestampInSeconds * 1000 + let actualTimestamp = mockLocalStorage.visitorConsentTimestamp! + + // Verify timestamp is in milliseconds (much larger than seconds) + XCTAssertEqual(actualTimestamp, expectedTimestampInMilliseconds) + XCTAssertTrue(actualTimestamp > expectedTimestampInSeconds, "Timestamp should be in milliseconds, not seconds") + + // Verify the timestamp makes sense when converted back to seconds + let convertedBackToSeconds = actualTimestamp / 1000 + XCTAssertEqual(convertedBackToSeconds, expectedTimestampInSeconds) + + // Verify it's actually a December 2021 date when converted properly + let dateFromMilliseconds = Date(timeIntervalSince1970: TimeInterval(actualTimestamp) / 1000.0) + XCTAssertEqual(dateFromMilliseconds, testDate) + } } diff --git a/tests/unit-tests/IterableApiCriteriaFetchTests.swift b/tests/unit-tests/IterableApiCriteriaFetchTests.swift new file mode 100644 index 000000000..672cc5f1f --- /dev/null +++ b/tests/unit-tests/IterableApiCriteriaFetchTests.swift @@ -0,0 +1,182 @@ +// +// IterableApiCriteriaFetchTests.swift +// swift-sdk +// +// Created by Joao Dordio on 30/01/2025. +// Copyright © 2025 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class IterableApiCriteriaFetchTests: XCTestCase { + private var mockNetworkSession: MockNetworkSession! + private var mockDateProvider: MockDateProvider! + private var mockNotificationCenter: MockNotificationCenter! + private var internalApi: InternalIterableAPI! + private var mockApplicationStateProvider: MockApplicationStateProvider! + private static let apiKey = "zeeApiKey" + let localStorage = MockLocalStorage() + + override func setUp() { + super.setUp() + mockNetworkSession = MockNetworkSession() + mockDateProvider = MockDateProvider() + mockNotificationCenter = MockNotificationCenter() + mockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active) + } + + override func tearDown() { + mockNetworkSession = nil + mockDateProvider = nil + mockNotificationCenter = nil + internalApi = nil + mockApplicationStateProvider = nil + super.tearDown() + } + + func testForegroundCriteriaFetchWhenConditionsMet() { + let expectation1 = expectation(description: "First criteria fetch") + expectation1.expectedFulfillmentCount = 2 + + mockNetworkSession.responseCallback = { urlRequest in + if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true { + expectation1.fulfill() + } + return nil + } + + let config = IterableConfig() + config.enableUnknownUserActivation = true + config.enableForegroundCriteriaFetch = true + + // Set up localStorage to have visitor usage tracking enabled for the first criteria fetch during initialization + localStorage.visitorUsageTracked = true + + IterableAPI.initializeForTesting(apiKey: IterableApiCriteriaFetchTests.apiKey, + config: config, + networkSession: mockNetworkSession, + localStorage: localStorage) + + // Manually trigger the criteria fetch logic that happens in initialize2() but not in initializeForTesting() + if let implementation = IterableAPI.implementation, config.enableUnknownUserActivation, !implementation.isSDKInitialized(), implementation.getVisitorUsageTracked() { + implementation.unknownUserManager.getUnknownUserCriteria() + implementation.unknownUserManager.updateUnknownUserSession() + } + + internalApi = InternalIterableAPI.initializeForTesting( + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + localStorage: localStorage, + applicationStateProvider: mockApplicationStateProvider, + notificationCenter: mockNotificationCenter + ) + + internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true) + sleep(5) + // Simulate app coming to foreground + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [expectation1], timeout: testExpectationTimeout) + } + + func testCriteriaFetchNotCalledWhenDisabled() { + let expectation1 = expectation(description: "No criteria fetch") + expectation1.isInverted = true + + mockNetworkSession.responseCallback = { urlRequest in + if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true { + expectation1.fulfill() + } + return nil + } + + let config = IterableConfig() + config.enableUnknownUserActivation = true + config.enableForegroundCriteriaFetch = false + + internalApi = InternalIterableAPI.initializeForTesting( + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + localStorage: localStorage, + applicationStateProvider: mockApplicationStateProvider, + notificationCenter: mockNotificationCenter + ) + internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true) + + // Simulate app coming to foreground + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [expectation1], timeout: testExpectationTimeout) + } + + func testForegroundCriteriaFetchWithCooldown() { + let expectation1 = expectation(description: "First criteria fetch") + let expectation2 = expectation(description: "Second criteria fetch") + let expectation3 = expectation(description: "No third fetch during cooldown") + expectation3.isInverted = true + + var fetchCount = 0 + mockNetworkSession.responseCallback = { urlRequest in + if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true { + fetchCount += 1 + switch fetchCount { + case 1: expectation1.fulfill() + case 2: expectation2.fulfill() + case 3: expectation3.fulfill() + default: break + } + } + return nil + } + + let config = IterableConfig() + config.enableUnknownUserActivation = true + config.enableForegroundCriteriaFetch = true + + // Set up localStorage to have visitor usage tracking enabled for the first criteria fetch during initialization + localStorage.visitorUsageTracked = true + + IterableAPI.initializeForTesting(apiKey: IterableApiCriteriaFetchTests.apiKey, + config: config, + networkSession: mockNetworkSession, + localStorage: localStorage) + + // Manually trigger the criteria fetch logic that happens in initialize2() but not in initializeForTesting() + if let implementation = IterableAPI.implementation, config.enableUnknownUserActivation, !implementation + .isSDKInitialized(), implementation + .getVisitorUsageTracked() { + implementation.unknownUserManager.getUnknownUserCriteria() + implementation.unknownUserManager.updateUnknownUserSession() + } + + internalApi = InternalIterableAPI.initializeForTesting( + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + localStorage: localStorage, + applicationStateProvider: mockApplicationStateProvider, + notificationCenter: mockNotificationCenter + ) + + internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true) + + sleep(5) + + // First foreground + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + // Second foreground after some time + mockDateProvider.currentDate = mockDateProvider.currentDate.addingTimeInterval(130) // After cooldown + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + // Third foreground during cooldown + mockDateProvider.currentDate = mockDateProvider.currentDate.addingTimeInterval(10) // Within cooldown + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [expectation1, expectation2, expectation3], timeout: testExpectationTimeout) + } +} diff --git a/tests/unit-tests/LocalStorageTests.swift b/tests/unit-tests/LocalStorageTests.swift index 208ff53e5..0e935d39f 100644 --- a/tests/unit-tests/LocalStorageTests.swift +++ b/tests/unit-tests/LocalStorageTests.swift @@ -157,4 +157,26 @@ class LocalStorageTests: XCTestCase { let retrieved = retriever(retrievedLocalStorage) XCTAssertEqual(value, retrieved) } + + func testVisitorConsentTimestamp() { + let saver = { (storage: LocalStorageProtocol, value: Int64) -> Void in + var localStorage = storage + localStorage.visitorConsentTimestamp = value + } + let retriever = { (storage: LocalStorageProtocol) -> Int64? in + storage.visitorConsentTimestamp + } + + // Test storing a timestamp + let testTimestamp: Int64 = 1639490139 + testLocalStorage(saver: saver, retriever: retriever, value: testTimestamp) + + // Test storing nil (clearing the timestamp) + let localStorage = LocalStorage(userDefaults: LocalStorageTests.getTestUserDefaults()) + var mutableLocalStorage = localStorage + mutableLocalStorage.visitorConsentTimestamp = nil + + let retrievedLocalStorage = LocalStorage(userDefaults: LocalStorageTests.getTestUserDefaults()) + XCTAssertNil(retrievedLocalStorage.visitorConsentTimestamp) + } } diff --git a/tests/unit-tests/NestedFieldSupportForArrayData.swift b/tests/unit-tests/NestedFieldSupportForArrayData.swift new file mode 100644 index 000000000..fd975147c --- /dev/null +++ b/tests/unit-tests/NestedFieldSupportForArrayData.swift @@ -0,0 +1,326 @@ +// +// NestedFieldSupportForArrayData.swift +// unit-tests +// +// Created by Apple on 27/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class NestedFieldSupportForArrayData: XCTestCase { + //MARK: Comparator test For End + private let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "168", + "name": "nested testing", + "createdAt": 1721251169153, + "updatedAt": 1723488175352, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "furniture", + "comparatorType": "IsSet", + "value": "", + "fieldType": "nested" + }, + { + "dataType": "user", + "field": "furniture.furnitureColor", + "comparatorType": "Equals", + "value": "White", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "furniture.furnitureType", + "comparatorType": "Equals", + "value": "Sofa", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testNestedFieldSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"user", + "email":"user@example.com", + "dataFields":[ + "furniture": [ + [ + "furnitureType": "Sofa", + "furnitureColor": "White", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "furnitureType": "Table", + "furnitureColor": "Gray", + "lengthInches": 20, + "widthInches": 30 + ], + ] + ] + ] + ] + + + let expectedCriteriaId = "168" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testNestedFieldFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"user", + "email":"user@example.com", + "dataFields":[ + "furniture": [ + [ + "furnitureType": "Sofa", + "furnitureColor": "Gray", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "furnitureType": "Table", + "furnitureColor": "White", + "lengthInches": 20, + "widthInches": 30 + ], + ] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataForUserArray = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "436", + "name": "Criteria 2.1 - 09252024 Bug Bash", + "createdAt": 1727286807360, + "updatedAt": 1727950464167, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "furniture.material.type", + "comparatorType": "Contains", + "value": "table", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "furniture.material.color", + "comparatorType": "Equals", + "values": [ + "black" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + func testNestedFieldArrayValueUserSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "dataFields": [ + "furniture": [ + "material": [ + [ + "type": "table", + "color": "black", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "type": "Sofa", + "color": "Gray", + "lengthInches": 20, + "widthInches": 30 + ] + ] + ] + ] + ] + ] + + + let expectedCriteriaId = "436" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataForUserArray)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testNestedFieldArrayUserValueFail() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "dataFields": [ + "furniture": [ + "material": [ + [ + "type": "Chair", + "color": "black", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "type": "Sofa", + "color": "black", + "lengthInches": 20, + "widthInches": 30 + ] + ] + ] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataForUserArray)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataForEventArray = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "459", + "name": "event a.h.b=d && a.h.c=g", + "createdAt": 1727717997842, + "updatedAt": 1728024187962, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "TopLevelArrayObject.a.h.b", + "comparatorType": "Equals", + "value": "d", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "TopLevelArrayObject.a.h.c", + "comparatorType": "Equals", + "value": "g", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + func testNestedFieldArrayValueEventSuccess() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "eventName": "TopLevelArrayObject", + "dataFields": [ + "a": ["h": [["b": "e", + "c": "h"], + ["b": "d", + "c": "g"]]] + ] + ] + ] + + + let expectedCriteriaId = "459" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataForEventArray)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testNestedFieldArrayEventValueFail() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "eventName": "TopLevelArrayObject", + "dataFields": [ + "a": ["h": [["b": "d", + "c": "h"], + ["b": "e", + "c": "g"]]] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mokeDataForEventArray)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/RequestCreatorTests.swift b/tests/unit-tests/RequestCreatorTests.swift index c9b53dcf2..3d4c97ad3 100644 --- a/tests/unit-tests/RequestCreatorTests.swift +++ b/tests/unit-tests/RequestCreatorTests.swift @@ -148,7 +148,7 @@ class RequestCreatorTests: XCTestCase { } func testGetInAppMessagesRequestFailure() { - let auth = Auth(userId: nil, email: nil, authToken: nil) + let auth = Auth(userId: nil, email: nil, authToken: nil, userIdUnknownUser: nil) let requestCreator = RequestCreator(auth: auth, deviceMetadata: deviceMetadata) let failingRequest = requestCreator.createGetInAppMessagesRequest(1) @@ -377,9 +377,9 @@ class RequestCreatorTests: XCTestCase { private let locationKeyPath = "\(JsonKey.inAppMessageContext).\(JsonKey.inAppLocation)" - private let userlessAuth = Auth(userId: nil, email: nil, authToken: nil) + private let userlessAuth = Auth(userId: nil, email: nil, authToken: nil, userIdUnknownUser: nil) - private let userIdAuth = Auth(userId: "ein", email: nil, authToken: nil) + private let userIdAuth = Auth(userId: "ein", email: nil, authToken: nil, userIdUnknownUser: nil) private let deviceMetadata = DeviceMetadata(deviceId: IterableUtil.generateUUID(), platform: JsonValue.iOS, @@ -433,10 +433,104 @@ class RequestCreatorTests: XCTestCase { private func getEmptyInAppContent() -> IterableHtmlInAppContent { IterableHtmlInAppContent(edgeInsets: .zero, html: "") } + + // MARK: - Consent Request Tests + + func testCreateTrackConsentRequestWithEmail() { + let consentTimestamp: Int64 = 1639490139 + let email = "test@example.com" + + let urlRequest = convertToUrlRequest(createRequestCreator().createTrackConsentRequest( + consentTimestamp: consentTimestamp, + email: email, + userId: nil, + isUserKnown: true + )) + + TestUtils.validateHeader(urlRequest, apiKey) + TestUtils.validate(request: urlRequest, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackConsent) + + let body = urlRequest.bodyDict + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.consentTimestamp), value: Int(consentTimestamp), inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.email), value: email, inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.isUserKnown), value: true, inDictionary: body) + XCTAssertNil(body[JsonKey.userId]) + + TestUtils.validateDeviceInfo(inBody: body, withDeviceId: deviceMetadata.deviceId) + } + + func testCreateTrackConsentRequestWithUserId() { + let consentTimestamp: Int64 = 1639490139 + let userId = "test-user-123" + + let urlRequest = convertToUrlRequest(createRequestCreator().createTrackConsentRequest( + consentTimestamp: consentTimestamp, + email: nil, + userId: userId, + isUserKnown: false + )) + + TestUtils.validateHeader(urlRequest, apiKey) + TestUtils.validate(request: urlRequest, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackConsent) + + let body = urlRequest.bodyDict + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.consentTimestamp), value: Int(consentTimestamp), inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.userId), value: userId, inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.isUserKnown), value: false, inDictionary: body) + XCTAssertNil(body[JsonKey.email]) + + TestUtils.validateDeviceInfo(inBody: body, withDeviceId: deviceMetadata.deviceId) + } + + func testCreateTrackConsentRequestWithBothEmailAndUserId() { + let consentTimestamp: Int64 = 1639490139 + let email = "test@example.com" + let userId = "test-user-123" + + let urlRequest = convertToUrlRequest(createRequestCreator().createTrackConsentRequest( + consentTimestamp: consentTimestamp, + email: email, + userId: userId, + isUserKnown: true + )) + + TestUtils.validateHeader(urlRequest, apiKey) + TestUtils.validate(request: urlRequest, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackConsent) + + let body = urlRequest.bodyDict + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.consentTimestamp), value: Int(consentTimestamp), inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.email), value: email, inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.userId), value: userId, inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.isUserKnown), value: true, inDictionary: body) + + TestUtils.validateDeviceInfo(inBody: body, withDeviceId: deviceMetadata.deviceId) + } + + func testCreateTrackConsentRequestMinimal() { + let consentTimestamp: Int64 = 1639490139 + + let urlRequest = convertToUrlRequest(createRequestCreator().createTrackConsentRequest( + consentTimestamp: consentTimestamp, + email: nil, + userId: nil, + isUserKnown: false + )) + + TestUtils.validateHeader(urlRequest, apiKey) + TestUtils.validate(request: urlRequest, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackConsent) + + let body = urlRequest.bodyDict + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.consentTimestamp), value: Int(consentTimestamp), inDictionary: body) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.isUserKnown), value: false, inDictionary: body) + XCTAssertNil(body[JsonKey.email]) + XCTAssertNil(body[JsonKey.userId]) + + TestUtils.validateDeviceInfo(inBody: body, withDeviceId: deviceMetadata.deviceId) + } } extension RequestCreatorTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: email, authToken: nil) + Auth(userId: nil, email: email, authToken: nil, userIdUnknownUser: nil) } } diff --git a/tests/unit-tests/UnknownUserComplexCriteriaMatchTests.swift b/tests/unit-tests/UnknownUserComplexCriteriaMatchTests.swift new file mode 100644 index 000000000..050b6448f --- /dev/null +++ b/tests/unit-tests/UnknownUserComplexCriteriaMatchTests.swift @@ -0,0 +1,603 @@ +// +// UnknownsUserComplexCriteriaMatchTests.swift +// unit-tests +// +// Created by vishwa on 26/06/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class UnknownUserComplexCriteriaMatchTests: XCTestCase { + + private let mockDataForCriteria1 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "49", + "name": "updateCart", + "createdAt": 1716561779683, + "updatedAt": 1717423966940, + "searchQuery": { + "combinator": "Or", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 23, + "value": "button-clicked" + }, + { + "field": "button-clicked.animal", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 25, + "value": "giraffe" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 28, + "value": "120" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 29, + "valueLong": 100, + "value": "100" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 31, + "value": "monitor" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 32, + "valueLong": 5, + "value": "5" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 34, + "value": "Japan" + }, + { + "field": "preferred_car_models", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 36, + "value": "Honda" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataForCriteria2 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "51", + "name": "Contact Property", + "createdAt": 1716561944428, + "updatedAt": 1716561944428, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 2, + "value": "button-clicked" + }, + { + "field": "button-clicked.lastPageViewed", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 4, + "value": "welcome page" + } + ] + } + }, + { + "dataType": "customEvent", + "minMatch": 2, + "maxMatch": 3, + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 6, + "value": "85" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 7, + "valueLong": 50, + "value": "50" + } + ] + } + } + ] + }, + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 16, + "isFiltering": false, + "value": "coffee" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 17, + "valueLong": 2, + "value": "2" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 19, + "value": "USA" + }, + { + "field": "preferred_car_models", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 21, + "value": "Subaru" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataForCriteria3 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "50", + "name": "purchase", + "createdAt": 1716561874633, + "updatedAt": 1716561874633, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 2, + "value": "button-clicked" + }, + { + "field": "button-clicked.lastPageViewed", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 4, + "value": "welcome page" + } + ] + } + }, + { + "dataType": "customEvent", + "minMatch": 2, + "maxMatch": 3, + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 6, + "value": "85" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 7, + "valueLong": 50, + "value": "50" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 9, + "value": "coffee" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 10, + "valueLong": 2, + "value": "2" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 12, + "value": "USA" + }, + { + "field": "preferred_car_models", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 14, + "value": "Subaru" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataForCriteria4 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "48", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 1, + "value": "sneakers" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "LessThanOrEqualTo", + "dataType": "purchase", + "id": 2, + "valueLong": 3, + "value": "3" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 4, + "value": "slippers" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 5, + "valueLong": 3, + "value": "3" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithCriteria1Success() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["animal": "giraffe"] + ], [ + "items": [["id": "12", "name": "keyboard", "price": 130, "quantity": 110]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ]] + let expectedCriteriaId = "49" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria1)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCriteria1Failure() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["animal": "giraffe22"] + ], [ + "items": [["id": "12", "name": "keyboard", "price": 130, "quantity": 110]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria1)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCriteria2Success() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "welcome page"] + ], ["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "USA", "dataFields": ["preferred_car_models": "Subaru"] + ]] + let expectedCriteriaId = "51" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria2)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCriteria2Failure() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "welcome page"] + ], ["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "USA", "dataFields": ["preferred_car_models": "Mazda"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria2)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCriteria3Success() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "items": [["id": "12", "name": "keyboard", "price": 90, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "121", "name": "keyboard2", "price": 100, "quantity": 80]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ], [ + "dataType": "user", "createdAt": 1699246745093, "dataFields": [ "phone_number": "999999", "country": "USA", "preferred_car_models": "Subaru"] + ], [ + "dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "welcome page"] + ], ] + + let expectedCriteriaId = "50" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria3)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + + } + + func testCompareDataWithCriteria3Failure() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked2", "dataFields": ["lastPageViewed": "welcome page"] + ], [ + "items": [["id": "12", "name": "keyboard", "price": 90, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "121", "name": "keyboard2", "price": 100, "quantity": 80]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ], [ + "dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "US", "dataFields": ["preferred_car_models": "Subaru"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria3)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCriteria4Success() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "slippers", "price": 4.67, "quantity": 5]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = "48" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria4)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCriteria4Failure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "sneakers", "price": 4.67, "quantity": 2]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataForCriteria4)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + +} + diff --git a/tests/unit-tests/UnknownUserCriteriaIsSetTests.swift b/tests/unit-tests/UnknownUserCriteriaIsSetTests.swift new file mode 100644 index 000000000..7c9512cc5 --- /dev/null +++ b/tests/unit-tests/UnknownUserCriteriaIsSetTests.swift @@ -0,0 +1,353 @@ +// +// File.swift +// +// +// Created by vishwa on 27/06/24. +// + +import XCTest + +@testable import IterableSDK + +class UnknownUserCriteriaIsSetTests: XCTestCase { + + private let mockDataUserProperty = """ + { + "count": 1, + "criteriaSets": [ + + { + "criteriaId": "1", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "user", + "id": 25, + "value": "" + }, + { + "field": "eventTimeStamp", + "fieldType": "long", + "comparatorType": "IsSet", + "dataType": "user", + "id": 26, + "valueLong": null, + "value": "" + }, + { + "field": "phoneNumberDetails", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "user", + "id": 28, + "value": "" + }, + { + "field": "shoppingCartItems.price", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "user", + "id": 30, + "value": "" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataCustomEvent = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "1", + "name": "updateCart", + "createdAt": 1716561779683, + "updatedAt": 1717423966940, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "button-clicked", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 2, + "value": "" + }, + { + "field": "button-clicked.animal", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 4, + "value": "" + }, + { + "field": "button-clicked.clickCount", + "fieldType": "long", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 5, + "valueLong": null, + "value": "" + }, + { + "field": "total", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 9, + "value": "" + } + ] + } + } + ] + } + ] + } + }, + + ] + } + """ + + private let mockDataPurchase = """ + { + "count": 1, + "criteriaSets": [ + + { + "criteriaId": "1", + "name": "purchase", + "createdAt": 1716561874633, + "updatedAt": 1716561874633, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 1, + "value": "" + }, + { + "field": "shoppingCartItems.price", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 3, + "value": "" + }, + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 5, + "value": "" + }, + { + "field": "total", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 7, + "value": "" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataUpdateCart = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "1", + "name": "Contact Property", + "createdAt": 1716561944428, + "updatedAt": 1716561944428, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 9, + "value": "" + }, + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 13, + "value": "" + }, + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 15, + "value": "" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 16, + "valueLong": null, + "value": "" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataIsSetUserPropertySuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phoneNumberDetails": "999999", "country": "UK", "eventTimeStamp": "1234567890", "shoppingCartItems.price": "33"]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataUserProperty)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetUserPropertyFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phoneNumberDetails": "999999", "country": "", "eventTimeStamp": "", "shoppingCartItems.price": "33"]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataUserProperty)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + func testCompareDataIsSetCustomEventSuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "customEvent", "eventName":"button-clicked", "dataFields": ["button-clicked":"cc", "animal": "aa", "clickCount": "1", "total": "10"]]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCustomEvent)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetCustomEventFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "customEvent", "eventName":"vvv", "dataFields": ["button-clicked":"", "animal": "", "clickCount": "1", "total": "10"]]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataCustomEvent)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataIsSetPurchaseSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataPurchase)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetPurchaseFailure() { + let eventItems: [[AnyHashable: Any]] = [ [ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataPurchase)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataIsSetUpdateCartSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 90, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataUpdateCart)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetUpdateCartFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 90]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockDataUpdateCart)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/UnknownUserCriteriaMatchTests.swift b/tests/unit-tests/UnknownUserCriteriaMatchTests.swift new file mode 100644 index 000000000..642a9744b --- /dev/null +++ b/tests/unit-tests/UnknownUserCriteriaMatchTests.swift @@ -0,0 +1,364 @@ +// +// File.swift +// +// +// Created by HARDIK MASHRU on 14/11/23. +// + +import XCTest + +@testable import IterableSDK + +class UnknownUserCriteriaMatchTests: XCTestCase { + + private let mockData = """ + { + "count": 4, + "criteriaSets": [ + { + "criteriaId": "49", + "name": "updateCart", + "createdAt": 1716561779683, + "updatedAt": 1717423966940, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "updateCart", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "updateCart.updatedShoppingCartItems.price", + "comparatorType": "Equals", + "value": "10.0", + "fieldType": "double" + } + ] + }, + "minMatch": 2, + "maxMatch": 3 + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "updateCart", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "updateCart.updatedShoppingCartItems.quantity", + "comparatorType": "GreaterThanOrEqualTo", + "value": "50", + "fieldType": "long" + }, + { + "dataType": "customEvent", + "field": "updateCart.updatedShoppingCartItems.price", + "comparatorType": "GreaterThanOrEqualTo", + "value": "50", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "51", + "name": "Contact Property", + "createdAt": 1716561944428, + "updatedAt": 1716561944428, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "Equals", + "value": "UK", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "preferred_car_models", + "comparatorType": "Contains", + "value": "Mazda", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "50", + "name": "purchase", + "createdAt": 1716561874633, + "updatedAt": 1716561874633, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "shoppingCartItems.name", + "comparatorType": "Equals", + "value": "keyboard", + "fieldType": "string" + }, + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"4.67" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "shoppingCartItems.quantity", + "comparatorType": "GreaterThanOrEqualTo", + "value": "3", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "48", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "button-clicked", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "button-clicked.lastPageViewed", + "comparatorType": "Equals", + "value": "signup page", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithUserCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "UK"]] + let expectedCriteriaId = "51" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithUserCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "US"]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "signup page"] + ]] + let expectedCriteriaId = "48" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCustomEventCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button", "dataFields": ["lastPageViewed": "signup page"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithUpdateCartCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 50, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ]] + let expectedCriteriaId = "49" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + + } + + func testCompareDataWithUpdateCartCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 40, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "customEvent", + "dataFields": ["campaignId": "1234"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithMinMatchCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 10.0, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ],[ + "items": [["id": "13", "name": "keyboard2", "price": 10.0, "quantity": 4]], + "createdAt": 1699246745093, + "dataType": "updateCart"]] + let expectedCriteriaId = "49" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithMinMatchCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 10, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "customEvent", + "dataFields": ["campaignId": "1234"] + ],["dataType": "customEvent", "eventName": "processing_cancelled"]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithANDCombinatorSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase", + "dataFields": ["campaignId": "1234"] + ]] + let expectedCriteriaId = "50" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + + } + + func testCompareDataWithANDCombinatorFail() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 4.67, "quantity": 2]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + func testCompareDataWithORCombinatorSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 5.9, "quantity": 4]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = "50" + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithORCombinatorFail() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 2.9, "quantity": 1]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(unknownUserCriteria: data(from: mockData)!, unknownUserEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/UserMergeScenariosTests.swift b/tests/unit-tests/UserMergeScenariosTests.swift new file mode 100644 index 000000000..c9ddaf469 --- /dev/null +++ b/tests/unit-tests/UserMergeScenariosTests.swift @@ -0,0 +1,1022 @@ +// +// UserMergeScenariosTests.swift +// unit-tests +// +// Created by vishwa on 04/07/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class UserMergeScenariosTests: XCTestCase, AuthProvider { + private static let apiKey = "zeeApiKey" + private let authToken = "asdf" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + var auth: Auth { + Auth(userId: nil, email: nil, authToken: authToken, userIdUnknownUser: nil) + } + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "96", + "name": "Purchase: isSet Comparator", + "createdAt": 1719328487701, + "updatedAt": 1719328487701, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "testEvent", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + func testCriteriaNotMetUserIdDefault() { // criteria not met with merge default with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + waitForDuration(seconds: 5) + + if localStorage.unknownUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetUserIdReplayTrueMergeFalse() { // criteria not met with merge false with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + IterableAPI.setUserId("testuser123", nil, identityResolution) + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + if localStorage.unknownUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetUserIdReplayFalseMergeFalse() { // criteria not met with merge true with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: false) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetUserIdReplayFalseMergeTrue() { // criteria not met with merge true with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: true) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetUserIdDefault() { // criteria met with merge default with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user nil") + } else { + XCTFail("Expected unknown user nil but found") + } + + IterableAPI.setUserId("testuser123") + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetUserIdMergeFalse() { // criteria met with merge false with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user to be found") + } else { + XCTFail("Expected unknown user but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetUserIdMergeTrue() { // criteria met with merge true with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user nil") + } else { + XCTFail("Expected unknown user nil but found") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + waitForDuration(seconds: 3) + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIdentifiedUserIdDefault() { // current user identified with setUserId default + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdUnknownUser != nil { + XCTFail("Expected unknown user nil but found") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") + } + + IterableAPI.setUserId("testuseranotheruser") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuseranotheruser", "Expected userId to be 'testuseranotheruser'") + } else { + XCTFail("Expected userId but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func testIdentifiedUserIdMergeFalse() { // current user identified with setUserId merge false + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if localStorage.userIdUnknownUser != nil { + XCTFail("Expected unknown user nil but found") + } else { + XCTAssertNil(localStorage.userIdUnknownUser, "Expected unknown user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + IterableAPI.setUserId("testuseranotheruser", nil, identityResolution) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuseranotheruser", "Expected userId to be 'testuseranotheruser'") + } else { + XCTFail("Expected userId but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func testIdentifiedUserIdMergeTrue() { // current user identified with setUserId true + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdUnknownUser != nil { + XCTFail("Expected unknown user nil but found") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + IterableAPI.setUserId("testuseranotheruser", nil, identityResolution) + waitForDuration(seconds: 3) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuseranotheruser", "Expected userId to be 'testuseranotheruser'") + } else { + XCTFail("Expected userId but found nil") + } + + // Verify "merge user" API call is not made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + XCTFail("Expected merge user API call was made") + } else { + apiCallExpectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailDefault() { // criteria not met with merge default with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + waitForDuration(seconds: 5) + + if localStorage.unknownUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailReplayTrueMergeFalse() { // criteria not met with merge false with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + if localStorage.unknownUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailReplayFalseMergeFalse() { // criteria not met with merge true with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: false) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailReplayFalseMergeTrue() { // criteria not met with merge true with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnUnknownUserToKnown: true) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetEmailDefault() { // criteria met with merge default with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user") + } else { + XCTFail("Expected unknown user but found nil") + } + + IterableAPI.setEmail("testuser123@test.com") + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetEmailMergeFalse() { // criteria met with merge false with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user") + } else { + XCTFail("Expected unknown user but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetEmailMergeTrue() { // criteria met with merge true with setEmail + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user") + } else { + XCTFail("Expected unknown user but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIdentifiedEmailDefault() { // current user identified with setEmail default + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdUnknownUser != nil { + XCTFail("Expected unknown user nil but found") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") + } + + IterableAPI.setEmail("testuseranotheruser@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIdentifiedEmailMergeFalse() { // current user identified with setEmail merge false + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdUnknownUser != nil { + XCTFail("Expected unknown user nil but found") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: false) + IterableAPI.setEmail("testuseranotheruser@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func testIdentifiedEmailMergeTrue() { // current user identified with setEmail true + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdUnknownUser != nil { + XCTFail("Expected unknown user nil but found") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected unknown user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + IterableAPI.setEmail("testuseranotheruser@test.com", nil, identityResolution) + waitForDuration(seconds: 3) + + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + // Verify "merge user" API call is made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + XCTFail("merge user API call was made unexpectedly") + } else { + // Pass the test if the API call was not made + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetTwice() { + let config = IterableConfig() + config.enableUnknownUserActivation = true + + let mockSession = MockNetworkSession() + + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + IterableAPI.track(event: "testEvent") + IterableAPI.track(event: "testEvent") + + waitForDuration(seconds: 3) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user nil") + } else { + XCTFail("Expected unknown user nil but found") + } + + // Verify that unknown user session request was made exactly once + let unknownUserSessionRequest = mockSession.getRequest(withEndPoint: Const.Path.trackUnknownUserSession) + XCTAssertNotNil(unknownUserSessionRequest, "Unknown user session request should not be nil") + + // Count total requests with unknown user session endpoint + let unknownUserSessionRequests = mockSession.requests.filter { request in + request.url?.absoluteString.contains(Const.Path.trackUnknownUserSession) == true + } + XCTAssertEqual(unknownUserSessionRequests.count, 1, "Unknown user session should be called exactly once") + + // Verify track events were made + let trackRequests = mockSession.requests.filter { request in + request.url?.absoluteString.contains(Const.Path.trackEvent) == true + } + XCTAssertEqual(trackRequests.count, 2, "Track event should be called twice") + } +} + + diff --git a/tests/unit-tests/ValidateCustomEventUserUpdateAPITest.swift b/tests/unit-tests/ValidateCustomEventUserUpdateAPITest.swift new file mode 100644 index 000000000..520ed5c76 --- /dev/null +++ b/tests/unit-tests/ValidateCustomEventUserUpdateAPITest.swift @@ -0,0 +1,215 @@ +// +// ValidateCustomEventUserUpdateAPITest.swift +// unit-tests +// +// Created by Apple on 17/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ValidateCustomEventUserUpdateAPITest: XCTestCase, AuthProvider { + private static let apiKey = "zeeApiKey" + private let authToken = "asdf" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + var auth: Auth { + Auth(userId: nil, email: nil, authToken: authToken, userIdUnknownUser: nil) + } + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + + let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "6", + "name": "EventCriteria", + "createdAt": 1719328487701, + "updatedAt": 1719328487701, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "animal-found", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.type", + "comparatorType": "Equals", + "value": "cat", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.count", + "comparatorType": "Equals", + "value": "6", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + func testCriteriaCustomEventCheck() { // criteria not met with merge false with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + + // Disable consent tracking for this test to avoid interference with request capture + localStorage.visitorConsentTimestamp = nil + + IterableAPI.initializeForTesting(apiKey: ValidateCustomEventUserUpdateAPITest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + let checker = CriteriaCompletionChecker(unknownUserCriteria: jsonData, unknownUserEvents:localStorage.unknownUserEvents ?? []) + let matchedCriteriaId = checker.getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, "6") + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + waitForDuration(seconds: 3) + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user") + } else { + XCTFail("Expected unknown user but found nil") + } + + IterableAPI.logoutUser() + + waitForDuration(seconds: 3) + + + IterableAPI.setUserId("testuser123") + + + waitForDuration(seconds: 3) + + if localStorage.unknownUserEvents != nil { + XCTFail("Expected local stored Event nil but found") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Event found nil as user logout") + } + + + let dataFields = ["type": "cat", + "count": 6, + "vaccinated": true] as [String : Any] + IterableAPI.track(event: "animal-found", dataFields: dataFields) + + waitForDuration(seconds: 3) + // Get the last trackEvent request instead of the first one (needed because consent tracking creates additional requests) + let trackEventRequests = self.mockSession.requests.filter { request in + request.url?.absoluteString.contains(Const.Path.trackEvent) == true + } + + if let request = trackEventRequests.last { + print(request) + TestUtils.validate(request: request, apiEndPoint: Endpoint.api, path: Const.Path.trackEvent) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.eventName), value: "animal-found", inDictionary: request.bodyDict) + + + //Check direct key exist failure + TestUtils.validateNil(keyPath: KeyPath(keys: "count"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: "type"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: "vaccinated"), inDictionary: request.bodyDict) + + + //Check inside dataFields with nested key exist success + TestUtils.validateExists(keyPath: KeyPath(keys: JsonKey.dataFields, "count"), type: Int.self, inDictionary: request.bodyDict) + TestUtils.validateExists(keyPath: KeyPath(keys: JsonKey.dataFields, "type"), type: String.self, inDictionary: request.bodyDict) + TestUtils.validateExists(keyPath: KeyPath(keys: JsonKey.dataFields, "vaccinated"), type: Bool.self, inDictionary: request.bodyDict) + + + //Check inside dataFields with nested key success + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.dataFields, "type"), value: "cat", inDictionary: request.bodyDict) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.dataFields, "count"), value: 6, inDictionary: request.bodyDict) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.dataFields, "vaccinated"), value: true, inDictionary: request.bodyDict) + + //Check inside dataFields with nested key failure + TestUtils.validateNil(keyPath: KeyPath(keys: JsonKey.dataFields, "animal-found.count"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: JsonKey.dataFields, "animal-found.type"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: JsonKey.dataFields, "animal-found.vaccinated"), inDictionary: request.bodyDict) + + } else { + XCTFail("Expected track event API call was not made") + + } + + } + + +} diff --git a/tests/unit-tests/ValidateStoredEventCheckUnknownToKnownUserTest.swift b/tests/unit-tests/ValidateStoredEventCheckUnknownToKnownUserTest.swift new file mode 100644 index 000000000..ed0afb9e3 --- /dev/null +++ b/tests/unit-tests/ValidateStoredEventCheckUnknownToKnownUserTest.swift @@ -0,0 +1,78 @@ +// +// ValidateStoredEventCheckUnknownToKnownUserTest.swift +// unit-tests +// +// Created by Apple on 20/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ValidateStoredEventCheckUnknownToKnownUserTest: XCTestCase, AuthProvider { + private static let apiKey = "zeeApiKey" + private let authToken = "asdf" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + var auth: Auth { + Auth(userId: nil, email: nil, authToken: authToken, userIdUnknownUser: nil) + } + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + func testCriteriaCustomEventCheck() { // criteria not met with merge false with setUserId + let config = IterableConfig() + config.enableUnknownUserActivation = true + IterableAPI.initializeForTesting(apiKey: ValidateStoredEventCheckUnknownToKnownUserTest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", "count": 16, "vaccinated": true]) + IterableAPI.track(purchase: 10.0, items: [CommerceItem(id: "mocha", name: "Mocha", price: 10.0, quantity: 17, dataFields: nil)]) + IterableAPI.updateCart(items: [CommerceItem(id: "fdsafds", name: "sneakers", price: 4, quantity: 3, dataFields: ["timestemp_createdAt": Int(Date().timeIntervalSince1970)])]) + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + waitForDuration(seconds: 3) + + IterableAPI.setUserId("testuser123") + + if self.localStorage.unknownUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.unknownUserEvents, "Expected events to be nil") + } + + self.waitForDuration(seconds: 3) + + //Sync Completed + if self.localStorage.unknownUserEvents != nil { + XCTFail("Expected local stored Event nil but found") + } else { + XCTAssertNil(self.localStorage.unknownUserEvents, "Event found nil as event Sync Completed") + } + } + + +} diff --git a/tests/unit-tests/ValidateTokenForDestinationUserTest.swift b/tests/unit-tests/ValidateTokenForDestinationUserTest.swift new file mode 100644 index 000000000..80d7eb350 --- /dev/null +++ b/tests/unit-tests/ValidateTokenForDestinationUserTest.swift @@ -0,0 +1,331 @@ +// +// ValidateTokenForDestinationUserTest.swift +// unit-tests +// +// Created by Apple on 22/10/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ValidateTokenForDestinationUserTest: XCTestCase { + + private static let apiKey = "zeeApiKey" + private static let email = "user@example.com" + private static let userId = "testUserId" + private static let userIdUnknownUserToken = "JWTAnnonToken" + private static let mergeUserIdToken = "mergeUserIdToken" + private static let mergeUserEmailToken = "mergeUserEmailToken" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "6", + "name": "EventCriteria", + "createdAt": 1719328487701, + "updatedAt": 1719328487701, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "animal-found", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.type", + "comparatorType": "Equals", + "value": "cat", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.count", + "comparatorType": "Equals", + "value": "6", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + class DefaultAuthDelegate: IterableAuthDelegate { + var authTokenGenerator: (() -> String?) + + init(_ authTokenGenerator: @escaping () -> String?) { + self.authTokenGenerator = authTokenGenerator + } + + func onAuthTokenRequested(completion: @escaping AuthTokenRetrievalHandler) { + completion(authTokenGenerator()) + } + + func onAuthFailure(_ authFailure: AuthFailure) { + + } + } + + private func createAuthDelegate(_ authTokenGenerator: @escaping () -> String?) -> IterableAuthDelegate { + return DefaultAuthDelegate(authTokenGenerator) + } + + func testCriteriaUserIdTokenCheck() { // criteria not met with merge false with setUserId + + let authDelegate = createAuthDelegate({ + if self.localStorage.userIdUnknownUser == IterableAPI.userId { + return ValidateTokenForDestinationUserTest.userIdUnknownUserToken + } else if IterableAPI.userId == ValidateTokenForDestinationUserTest.userId { + return ValidateTokenForDestinationUserTest.mergeUserIdToken + } else { + return nil + } + + }) + + let config = IterableConfig() + config.enableUnknownUserActivation = true + config.authDelegate = authDelegate + IterableAPI.initializeForTesting(apiKey: ValidateTokenForDestinationUserTest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let expectation = XCTestExpectation(description: "testTrackEventWithCreateAnnonUser") + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + let checker = CriteriaCompletionChecker(unknownUserCriteria: jsonData, unknownUserEvents:localStorage.unknownUserEvents ?? []) + let matchedCriteriaId = checker.getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, "6") + + waitForDuration(seconds: 5) + + let trackDataField = ["type": "cat", + "count": 6, + "vaccinated": true] as [String : Any] + IterableAPI.track(event: "animal-found", dataFields:trackDataField , onSuccess: { _ in + let request = self.mockSession.getRequest(withEndPoint: Const.Path.trackEvent)! + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackEvent, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.userIdUnknownUserToken)") + } + expectation.fulfill() + }) { reason, _ in + expectation.fulfill() + if let reason = reason { + XCTFail("encountered error: \(reason)") + } else { + XCTFail("encountered error") + } + } + + wait(for: [expectation], timeout: testExpectationTimeout) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user") + } else { + XCTFail("Expected unknown user but found nil") + } + XCTAssertEqual(IterableAPI.userId, localStorage.userIdUnknownUser) + XCTAssertNil(IterableAPI.email) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.userIdUnknownUserToken) + + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + IterableAPI.setUserId(ValidateTokenForDestinationUserTest.userId, nil, identityResolution) + + // Verify "merge user" API call is made + let expectation1 = XCTestExpectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let request = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.mergeUser, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.mergeUserIdToken)") + } + expectation1.fulfill() + } else { + expectation1.fulfill() + XCTFail("Expected merge user API call was not made") + } + } + wait(for: [expectation1], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.userId, ValidateTokenForDestinationUserTest.userId) + XCTAssertNil(IterableAPI.email) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.mergeUserIdToken) + } + + func testCriteriaEmailTokenCheck() { // criteria not met with merge false with setUserId + + let authDelegate = createAuthDelegate({ + if self.localStorage.userIdUnknownUser == IterableAPI.userId { + return ValidateTokenForDestinationUserTest.userIdUnknownUserToken + } else if IterableAPI.userId == ValidateTokenForDestinationUserTest.userId { + return ValidateTokenForDestinationUserTest.mergeUserIdToken + } else if IterableAPI.email == ValidateTokenForDestinationUserTest.email { + return ValidateTokenForDestinationUserTest.mergeUserEmailToken + } else { + return nil + } + + }) + + let config = IterableConfig() + config.enableUnknownUserActivation = true + config.authDelegate = authDelegate + IterableAPI.initializeForTesting(apiKey: ValidateTokenForDestinationUserTest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + if let events = localStorage.unknownUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let expectation = XCTestExpectation(description: "testTrackEventWithCreateAnnonUser") + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + let checker = CriteriaCompletionChecker(unknownUserCriteria: jsonData, unknownUserEvents:localStorage.unknownUserEvents ?? []) + let matchedCriteriaId = checker.getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, "6") + + waitForDuration(seconds: 5) + + let trackDataField = ["type": "cat", + "count": 6, + "vaccinated": true] as [String : Any] + IterableAPI.track(event: "animal-found", dataFields:trackDataField , onSuccess: { _ in + let request = self.mockSession.getRequest(withEndPoint: Const.Path.trackEvent)! + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackEvent, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.userIdUnknownUserToken)") + } + expectation.fulfill() + }) { reason, _ in + expectation.fulfill() + if let reason = reason { + XCTFail("encountered error: \(reason)") + } else { + XCTFail("encountered error") + } + } + + wait(for: [expectation], timeout: testExpectationTimeout) + + if let unknownUser = localStorage.userIdUnknownUser { + XCTAssertFalse(unknownUser.isEmpty, "Expected unknown user") + } else { + XCTFail("Expected unknown user but found nil") + } + XCTAssertEqual(IterableAPI.userId, localStorage.userIdUnknownUser) + XCTAssertNil(IterableAPI.email) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.userIdUnknownUserToken) + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnUnknownUserToKnown: true) + IterableAPI.setEmail(ValidateTokenForDestinationUserTest.email, nil, identityResolution) + + // Verify "merge user" API call is made + let expectation1 = XCTestExpectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let request = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.mergeUser, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.mergeUserEmailToken)") + } + expectation1.fulfill() + } else { + expectation1.fulfill() + XCTFail("Expected merge user API call was not made") + } + } + wait(for: [expectation1], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.email, ValidateTokenForDestinationUserTest.email) + XCTAssertNil(IterableAPI.userId) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.mergeUserEmailToken) + } +}