From 7ed64c951b3186589dea7b20cb4542986d1e8488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Wed, 23 Jul 2025 12:11:35 -0700 Subject: [PATCH 01/39] Icons and styles --- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 10486 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 10486 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 10486 bytes .../src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 10486 bytes .../src/main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10486 bytes .../src/main/res/values/colors.xml | 10 ++++++++++ .../src/main/res/values/strings.xml | 12 ++++++++++++ .../src/main/res/values/styles.xml | 14 ++++++++++++++ 8 files changed, 36 insertions(+) create mode 100644 integration-tests/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 integration-tests/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 integration-tests/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 integration-tests/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 integration-tests/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 integration-tests/src/main/res/values/colors.xml create mode 100644 integration-tests/src/main/res/values/strings.xml create mode 100644 integration-tests/src/main/res/values/styles.xml diff --git a/integration-tests/src/main/res/mipmap-hdpi/ic_launcher.png b/integration-tests/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..aee44e138434630332d88b1680f33c4b24c70ab3 GIT binary patch literal 10486 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 + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/integration-tests/src/main/res/values/strings.xml b/integration-tests/src/main/res/values/strings.xml new file mode 100644 index 000000000..133a66e3c --- /dev/null +++ b/integration-tests/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + + Integration Tests + Push Notification Test + In-App Message Test + Embedded Message Test + Deep Link Test + Run All Tests + Test Passed + Test Failed + Test Running... + \ No newline at end of file diff --git a/integration-tests/src/main/res/values/styles.xml b/integration-tests/src/main/res/values/styles.xml new file mode 100644 index 000000000..b1dbee217 --- /dev/null +++ b/integration-tests/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file From b4dcc20f4e24e0892cdaf03cb05f54776d6e51cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Wed, 23 Jul 2025 12:17:03 -0700 Subject: [PATCH 02/39] Instruction files Firebase setup md explains structure of integration test project and also required configuraiton to make it work Readme.md is integration test project's read indicating what all feature work is going to be included in the project. and eventually how to run those tests and requirements for those. build.gradle is added as a boiler plate code. It is non-checked generated files. A revisit to remove unncessary code should be done as needed. Current aim to to achieve end-to-end integration test workflow setup. Hence not targetting to line by line check if something better is availabel OR if a line is really needed or not google-service.json.template is added just for reference. can totally delete this file later. proguard rules added by Cursor. Not sure if we need this. There is a open issue created by developer indicating we dont need it anymore. But need to verify that. Super important part of this commit is the settings.gradle file which will now allow integration test to access the api and api-ui folders --- integration-tests/FIREBASE_SETUP.md | 167 +++++++++ integration-tests/README.md | 319 ++++++++++++++++++ integration-tests/build.gradle | 147 ++++++++ .../google-services.json.template | 29 ++ integration-tests/proguard-rules.pro | 39 +++ settings.gradle | 2 +- 6 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 integration-tests/FIREBASE_SETUP.md create mode 100644 integration-tests/README.md create mode 100644 integration-tests/build.gradle create mode 100644 integration-tests/google-services.json.template create mode 100644 integration-tests/proguard-rules.pro diff --git a/integration-tests/FIREBASE_SETUP.md b/integration-tests/FIREBASE_SETUP.md new file mode 100644 index 000000000..7a8cb1015 --- /dev/null +++ b/integration-tests/FIREBASE_SETUP.md @@ -0,0 +1,167 @@ +# Firebase Setup for Integration Tests + +This guide explains how to set up Firebase for the Iterable Android SDK integration tests. + +## Overview + +The integration tests use Firebase Cloud Messaging (FCM) to receive push notifications sent from the Iterable backend. Here's how it works: + +1. **Iterable Backend** → Sends push notifications via Firebase +2. **Firebase** → Delivers notifications to the device +3. **IntegrationFirebaseMessagingService** → Receives and processes notifications +4. **Iterable SDK** → Handles notification display and interaction +5. **Tests** → Verify notification delivery and functionality + +## Setup Steps + +### 1. Create Firebase Project + +1. Go to [Firebase Console](https://console.firebase.google.com/) +2. Click "Create a project" +3. Enter project name: `iterable-integration-tests` +4. Enable Google Analytics (optional) +5. Click "Create project" + +### 2. Add Android App to Firebase + +1. In Firebase Console, click "Add app" → "Android" +2. Enter package name: `com.iterable.integration.tests` +3. Enter app nickname: `Integration Tests` +4. Click "Register app" + +### 3. Download Configuration File + +1. Download `google-services.json` +2. Place it in the `integration-tests/` directory +3. The file should be at: `integration-tests/google-services.json` + +### 4. Get Firebase Server Key + +1. In Firebase Console, go to Project Settings +2. Click "Cloud Messaging" tab +3. Copy the "Server key" (starts with `AAAA...`) +4. This key will be used in Iterable backend configuration + +### 5. Configure Iterable Backend + +1. In Iterable dashboard, go to your test project +2. Navigate to Settings → Mobile Apps +3. Add Firebase configuration: + - **Firebase Server Key**: Paste the server key from step 4 + - **Package Name**: `com.iterable.integration.tests` + - **App Name**: `Integration Tests` + +### 6. Set Environment Variables + +```bash +# Mobile API Key (for app-side SDK) +export ITERABLE_API_KEY="your_mobile_api_key" + +# Server API Key (for backend API calls) +export ITERABLE_SERVER_API_KEY="your_server_api_key" + +# Project ID +export ITERABLE_PROJECT_ID="your_project_id" +``` + +## File Structure + +After setup, your project should look like this: + +``` +integration-tests/ +├── google-services.json # Firebase configuration +├── build.gradle # Module build file +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── com/iterable/integration/tests/ +│ │ │ ├── services/ +│ │ │ │ └── IntegrationFirebaseMessagingService.kt +│ │ │ └── utils/ +│ │ │ └── IntegrationTestUtils.kt +│ │ └── AndroidManifest.xml +│ └── androidTest/ +│ └── java/ +│ └── com/iterable/integration/tests/ +│ └── PushNotificationIntegrationTest.kt +└── README.md +``` + +## Testing Push Notifications + +### Manual Testing + +1. **Install the app** + ```bash + ./gradlew :integration-tests:installDebug + ``` + +2. **Launch the app** + - Navigate to "Push Notification Tests" + - Send test notifications via Iterable backend + - Verify notifications appear + +### Automated Testing + +```bash +# Run push notification tests +./gradlew :integration-tests:connectedCheck -Pandroid.testInstrumentationRunnerArguments.class=com.iterable.integration.tests.PushNotificationIntegrationTest +``` + +## Troubleshooting + +### Common Issues + +1. **Notifications not received** + - Verify `google-services.json` is in correct location + - Check Firebase Server Key in Iterable settings + - Ensure app has notification permissions + +2. **Build errors** + - Verify Google Services plugin is applied + - Check package name matches in `google-services.json` + - Ensure Firebase dependencies are included + +3. **FCM token not registered** + - Check `IntegrationFirebaseMessagingService` logs + - Verify Firebase project configuration + - Ensure app is properly signed + +### Debug Commands + +```bash +# Check if FCM token is generated +adb logcat | grep "FCM" + +# Check notification permissions +adb shell dumpsys notification | grep "iterable" + +# Test push notification manually +adb shell am broadcast -a com.google.android.c2dm.intent.RECEIVE \ + -n com.iterable.integration.tests/.services.IntegrationFirebaseMessagingService \ + --es "message" "test" +``` + +## Security Notes + +1. **Never commit `google-services.json`** to version control +2. **Use environment variables** for API keys in CI/CD +3. **Restrict Firebase project** to test environments only +4. **Monitor usage** to prevent abuse + +## Next Steps + +After Firebase setup is complete: + +1. **Run initial tests** to verify configuration +2. **Set up CI/CD** with environment variables +3. **Create test campaigns** in Iterable backend +4. **Configure notification templates** for testing + +## Support + +For issues with: +- **Firebase setup**: Check [Firebase documentation](https://firebase.google.com/docs) +- **Iterable configuration**: Contact Iterable support +- **Integration tests**: Check logs and test reports \ No newline at end of file diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 000000000..e067f40f4 --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,319 @@ +# Iterable Android SDK Integration Tests + +This module provides comprehensive integration testing for the Iterable Android SDK, ensuring that all critical features work correctly with the actual Iterable backend. + +## Overview + +The integration tests are designed to verify end-to-end functionality of the SDK, including: + +1. **Push Notifications** - Configuration, delivery, display, and interaction +2. **In-App Messages** - Display, interaction, and deep linking +3. **Embedded Messages** - Eligibility, display, and metrics +4. **Deep Linking** - URL handling, app navigation, and intent processing + +## Architecture + +### Test Structure + +``` +integration-tests/ +├── src/ +│ ├── main/ +│ │ ├── java/com/iterable/integration/tests/ +│ │ │ ├── MainActivity.kt # Main test app activity +│ │ │ ├── activities/ # Test-specific activities +│ │ │ ├── services/ +│ │ │ │ └── IntegrationFirebaseMessagingService.kt # Firebase service +│ │ │ └── utils/ +│ │ │ └── IntegrationTestUtils.kt # Test utilities +│ │ └── res/ # App resources +│ └── androidTest/ +│ └── java/com/iterable/integration/tests/ +│ ├── BaseIntegrationTest.kt # Base test class +│ ├── PushNotificationIntegrationTest.kt # Push notification tests +│ ├── InAppMessageIntegrationTest.kt # In-app message tests +│ ├── EmbeddedMessageIntegrationTest.kt # Embedded message tests +│ └── DeepLinkIntegrationTest.kt # Deep link tests +``` + +### Key Components + +1. **BaseIntegrationTest** - Common setup, utilities, and test infrastructure +2. **IntegrationTestUtils** - Helper methods for all test scenarios +3. **Test Activities** - UI for manual testing and verification +4. **Firebase Services** - Handle push notifications from Iterable backend +5. **Test Receivers** - Capture and verify test events + +## Setup + +### Prerequisites + +1. **Iterable Project Setup** + - Create a test project in Iterable production + - Note the API key and project ID + - Configure push notification settings + +2. **Firebase Setup** + - Create a Firebase project + - Add the Android app to Firebase + - Download `google-services.json` + - Configure Firebase Cloud Messaging + +3. **Environment Variables** + ```bash + export ITERABLE_API_KEY="your_mobile_api_key" + export ITERABLE_PROJECT_ID="your_project_id" + export ITERABLE_SERVER_API_KEY="your_server_api_key" + ``` + +### Installation + +1. **Add the module to your project** + ```gradle + // settings.gradle + include ':integration-tests' + ``` + +2. **Configure the build** + ```gradle + // integration-tests/build.gradle + // Already configured with all necessary dependencies + ``` + +3. **Add Firebase configuration** + ```bash + # Copy google-services.json to integration-tests/ + cp google-services.json integration-tests/ + ``` + +## Firebase Integration + +### How Push Notifications Work + +1. **Iterable Backend** sends push notifications via Firebase Cloud Messaging +2. **Firebase** delivers the notification to the device +3. **IntegrationFirebaseMessagingService** receives the notification +4. **Iterable SDK** processes the notification and displays it +5. **Tests** verify the notification was received and displayed correctly + +### Firebase Configuration + +1. **Add Android App to Firebase** + - Package name: `com.iterable.integration.tests` + - SHA-1 fingerprint for your signing key + +2. **Download Configuration** + - `google-services.json` file + - Place in `integration-tests/` directory + +3. **Configure Iterable Backend** + - Add Firebase Server Key to Iterable project settings + - Configure push notification campaigns + +## Test Scenarios + +### 1. Push Notifications + +**Tests Covered:** +- ✅ Push notification configuration +- ✅ Device receives push notification +- ✅ Device has permission granted +- ✅ Push notification is displayed +- ✅ Push delivery metrics are captured +- ✅ Tapping notification tracks open +- ✅ Tapping buttons with deep link invokes handlers +- ✅ Silent push works for in-app messages + +**Manual Testing:** +1. Launch the integration test app +2. Navigate to "Push Notification Tests" +3. Send test notifications via Iterable backend +4. Verify notification display and interaction + +**Automated Testing:** +```bash +./gradlew :integration-tests:connectedCheck +``` + +### 2. In-App Messages + +**Tests Covered:** +- ✅ Silent push triggers in-app message +- ✅ In-app message is displayed +- ✅ Track in-app open metrics +- ✅ In-app message deep linking +- ✅ Custom action handlers + +**Manual Testing:** +1. Navigate to "In-App Message Tests" +2. Trigger test events +3. Verify message display and interaction + +### 3. Embedded Messages + +**Tests Covered:** +- ✅ Project eligibility configuration +- ✅ User list management +- ✅ Eligible message display +- ✅ Embedded metrics verification +- ✅ Deep linking functionality +- ✅ User profile eligibility changes + +**Manual Testing:** +1. Navigate to "Embedded Message Tests" +2. Toggle user eligibility +3. Verify message display and metrics + +### 4. Deep Linking + +**Tests Covered:** +- ✅ SMS/Email flow with URL launch +- ✅ Project tracking domain configuration +- ✅ Associated domains setup (iOS equivalent) +- ✅ Intent filters configuration +- ✅ Deep link handler invocation + +**Manual Testing:** +1. Navigate to "Deep Link Tests" +2. Test various deep link scenarios +3. Verify app navigation and handler calls + +## Running Tests + +### Local Development + +```bash +# Run all integration tests +./gradlew :integration-tests:connectedCheck + +# Run specific test class +./gradlew :integration-tests:connectedCheck -Pandroid.testInstrumentationRunnerArguments.class=com.iterable.integration.tests.PushNotificationIntegrationTest + +# Run with coverage +./gradlew :integration-tests:jacocoIntegrationTestReport +``` + +### CI/CD Integration + +The tests are designed to run in CI environments with: + +1. **Emulator Setup** - Android emulator with API 21+ +2. **Environment Variables** - API keys and configuration +3. **Firebase Configuration** - Real push notification testing +4. **Test Reporting** - Coverage and test results + +### Manual Testing + +1. **Install the app** + ```bash + ./gradlew :integration-tests:installDebug + ``` + +2. **Launch the app** + - Navigate through different test scenarios + - Verify functionality manually + - Check logs for detailed information + +## Configuration + +### Iterable Backend Configuration + +1. **Create Test Campaigns** + - Push notification campaigns + - In-app message campaigns + - Embedded message campaigns + +2. **Configure User Lists** + - Test user lists for embedded messages + - Eligibility criteria + +3. **Set Up Deep Links** + - Configure tracking domains + - Set up destination URLs + +4. **Configure Firebase Integration** + - Add Firebase Server Key to project settings + - Configure push notification templates + +### Firebase Configuration + +1. **Add Android App** + - Package name: `com.iterable.integration.tests` + - SHA-1 fingerprint + +2. **Download Configuration** + - `google-services.json` file + - Firebase Server Key for Iterable backend + +## Test Data Management + +### Test Users +- Email: `integration.test@iterable.com` +- User ID: `integration_test_user` + +### Test Campaigns +- Push notifications: `test_push_campaign` +- In-app messages: `test_inapp_campaign` +- Embedded messages: `test_embedded_campaign` + +### Test Events +- `test_event` - Triggers in-app messages +- `test_embedded_event` - Triggers embedded messages + +## Troubleshooting + +### Common Issues + +1. **Push Notifications Not Received** + - Verify Firebase configuration + - Check FCM token registration + - Ensure notification permissions + - Verify Iterable backend configuration + +2. **In-App Messages Not Displaying** + - Verify campaign configuration + - Check user eligibility + - Review event tracking + +3. **Deep Links Not Working** + - Verify intent filter configuration + - Check URL scheme registration + - Test with adb commands + +### Debug Information + +Enable debug logging: +```kotlin +Log.d("IntegrationTest", "Debug information") +``` + +Check test state: +```kotlin +testUtils.resetTestStates() +``` + +## Contributing + +When adding new integration tests: + +1. **Extend BaseIntegrationTest** for common functionality +2. **Use IntegrationTestUtils** for helper methods +3. **Follow the test naming convention** - `test[Feature][Scenario]` +4. **Add manual test activities** for UI verification +5. **Update this README** with new test scenarios + +## Best Practices + +1. **Test Isolation** - Each test should be independent +2. **Timeout Handling** - Use appropriate timeouts for async operations +3. **State Management** - Reset test state between runs +4. **Error Handling** - Graceful handling of test failures +5. **Logging** - Comprehensive logging for debugging + +## Future Enhancements + +1. **Test Orchestration** - Automated test sequence execution +2. **Performance Testing** - SDK performance under load +3. **Stress Testing** - High-volume message testing +4. **Cross-Platform Testing** - iOS equivalent tests +5. **Analytics Integration** - Test result analytics and reporting \ No newline at end of file diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle new file mode 100644 index 000000000..15bc7a16e --- /dev/null +++ b/integration-tests/build.gradle @@ -0,0 +1,147 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'jacoco' +apply plugin: 'com.google.gms.google-services' // Add Google Services plugin + +android { + compileSdk 34 + + namespace 'com.iterable.integration.tests' + testNamespace 'com.iterable.integration.tests.test' + + defaultConfig { + applicationId "com.iterable.integration.tests" + minSdkVersion 21 + targetSdkVersion 34 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + + // Integration test specific configurations + buildConfigField "String", "ITERABLE_API_KEY", "\"${System.getenv('ITERABLE_API_KEY') ?: 'test_api_key'}\"" + buildConfigField "String", "ITERABLE_PROJECT_ID", "\"${System.getenv('ITERABLE_PROJECT_ID') ?: 'test_project_id'}\"" + buildConfigField "String", "ITERABLE_SERVER_API_KEY", "\"${System.getenv('ITERABLE_SERVER_API_KEY') ?: 'test_server_api_key'}\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + debug { + enableAndroidTestCoverage true + } + } + + testOptions { + unitTests.includeAndroidResources = true + animationsDisabled = true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } +} + +dependencies { + // Core Android dependencies + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.9.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.annotation:annotation:1.7.0' + implementation 'androidx.fragment:fragment:1.6.2' + + // Iterable SDK modules + implementation project(':iterableapi') + implementation project(':iterableapi-ui') + + // Firebase for push notifications (app side only) + implementation platform('com.google.firebase:firebase-bom:32.7.0') + implementation 'com.google.firebase:firebase-messaging' + implementation 'com.google.firebase:firebase-analytics' + + // Network and HTTP + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + + // Testing dependencies + testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.test:runner:1.5.2' + testImplementation 'androidx.test.espresso:espresso-core:3.5.1' + testImplementation 'androidx.test.ext:junit:1.1.5' + testImplementation 'androidx.test:rules:1.5.0' + testImplementation 'org.mockito:mockito-core:5.3.1' + testImplementation 'org.robolectric:robolectric:4.11.1' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + + // Android instrumentation testing + androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + androidTestImplementation 'org.mockito:mockito-android:5.3.1' + androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' + androidTestImplementation 'androidx.fragment:fragment-testing:1.6.2' + + // Additional testing utilities + androidTestImplementation 'com.squareup.retrofit2:retrofit-mock:2.9.0' + androidTestImplementation 'org.awaitility:awaitility:4.2.0' + androidTestImplementation 'com.google.code.gson:gson:2.8.9' +} + +// Jacoco coverage for integration tests +tasks.withType(Test) { + jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] +} + +task jacocoIntegrationTestReport(type: JacocoReport, dependsOn: ['connectedCheck']) { + group = "reporting" + description = "Generate Jacoco code coverage report for integration tests" + + reports { + xml.required = true + html.required = true + csv.required = false + } + + def fileFilter = [ + '**/*Test*.*', + '**/AutoValue_*.*', + '**/*JavascriptBridge.class', + '**/R.class', + '**/R$*.class', + '**/Manifest*.*', + 'android/**/*.*', + '**/BuildConfig.*', + '**/*$ViewBinder*.*', + '**/*$ViewInjector*.*', + '**/Lambda$*.class', + '**/Lambda.class', + '**/*Lambda.class', + '**/*Lambda*.class', + '**/*$InjectAdapter.class', + '**/*$ModuleAdapter.class', + '**/*$ViewInjector*.class', + ] + + def debugTree = fileTree(dir: "${buildDir}/intermediates/javac/debug/classes", excludes: fileFilter) + def mainSrc = "${project.projectDir}/src/main/java" + + sourceDirectories.from = files([mainSrc]) + classDirectories.from = files([debugTree]) + executionData.from = fileTree(dir: "$buildDir", include: "outputs/code_coverage/debugAndroidTest/connected/**/*.ec") +} \ No newline at end of file diff --git a/integration-tests/google-services.json.template b/integration-tests/google-services.json.template new file mode 100644 index 000000000..ce67839af --- /dev/null +++ b/integration-tests/google-services.json.template @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "YOUR_PROJECT_NUMBER", + "project_id": "YOUR_FIREBASE_PROJECT_ID", + "storage_bucket": "YOUR_FIREBASE_PROJECT_ID.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "YOUR_MOBILE_SDK_APP_ID", + "android_client_info": { + "package_name": "com.iterable.integration.tests" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "YOUR_FIREBASE_API_KEY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/integration-tests/proguard-rules.pro b/integration-tests/proguard-rules.pro new file mode 100644 index 000000000..dae2ca962 --- /dev/null +++ b/integration-tests/proguard-rules.pro @@ -0,0 +1,39 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# Keep Iterable SDK classes +-keep class com.iterable.iterableapi.** { *; } +-keep class com.iterable.integration.tests.** { *; } + +# Keep Firebase classes +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } + +# Keep OkHttp classes +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } + +# Keep Gson classes +-keep class com.google.gson.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 8f401382b..c0b41f491 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':app', ':iterableapi', ':iterableapi-ui' +include ':iterableapi', ':iterableapi-ui', ':app', ':integration-tests' From c0b588ae055d3c9c8d8b007fca0de65958edad84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Wed, 23 Jul 2025 12:19:23 -0700 Subject: [PATCH 03/39] All Basic Empty Activities added Quite impressed by no touch approach. Basic acitivies provided by Cursor is quite good looking already --- .../src/main/AndroidManifest.xml | 111 ++++++++++++++++ .../integration/tests/MainActivity.kt | 95 ++++++++++++++ .../tests/activities/DeepLinkTestActivity.kt | 26 ++++ .../activities/EmbeddedMessageTestActivity.kt | 22 ++++ .../activities/InAppMessageTestActivity.kt | 22 ++++ .../PushNotificationTestActivity.kt | 22 ++++ .../res/layout/activity_deep_link_test.xml | 25 ++++ .../layout/activity_embedded_message_test.xml | 25 ++++ .../layout/activity_in_app_message_test.xml | 25 ++++ .../src/main/res/layout/activity_main.xml | 118 ++++++++++++++++++ .../activity_push_notification_test.xml | 25 ++++ 11 files changed, 516 insertions(+) create mode 100644 integration-tests/src/main/AndroidManifest.xml create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/activities/DeepLinkTestActivity.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/activities/EmbeddedMessageTestActivity.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/activities/InAppMessageTestActivity.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt create mode 100644 integration-tests/src/main/res/layout/activity_deep_link_test.xml create mode 100644 integration-tests/src/main/res/layout/activity_embedded_message_test.xml create mode 100644 integration-tests/src/main/res/layout/activity_in_app_message_test.xml create mode 100644 integration-tests/src/main/res/layout/activity_main.xml create mode 100644 integration-tests/src/main/res/layout/activity_push_notification_test.xml diff --git a/integration-tests/src/main/AndroidManifest.xml b/integration-tests/src/main/AndroidManifest.xml new file mode 100644 index 000000000..54524a897 --- /dev/null +++ b/integration-tests/src/main/AndroidManifest.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt new file mode 100644 index 000000000..90d82c022 --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt @@ -0,0 +1,95 @@ +package com.iterable.integration.tests + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.iterable.iterableapi.IterableApi +import com.iterable.iterableapi.IterableConfig +import com.iterable.iterableapi.IterableUrlHandler +import com.iterable.integration.tests.activities.* +import com.iterable.integration.tests.utils.IntegrationTestUtils + +class MainActivity : AppCompatActivity() { + + companion object { + private const val TAG = "IntegrationMainActivity" + const val EXTRA_DEEP_LINK_URL = "deep_link_url" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + initializeIterableSDK() + setupUI() + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private fun initializeIterableSDK() { + try { + val config = IterableConfig.Builder() + .setAutoPushRegistration(true) + .setEnableEmbeddedMessaging(true) + .setUrlHandler(object : IterableUrlHandler { + override fun handleIterableURL(url: android.net.Uri, context: com.iterable.iterableapi.IterableActionContext): Boolean { + Log.d(TAG, "Deep link handled: $url") + // Navigate to deep link test activity + val intent = Intent(this@MainActivity, DeepLinkTestActivity::class.java) + intent.putExtra(EXTRA_DEEP_LINK_URL, url.toString()) + startActivity(intent) + return true + } + }) + .build() + + IterableApi.initialize(this, BuildConfig.ITERABLE_API_KEY, config) + IterableApi.getInstance().setEmail("integration.test@iterable.com") + + Log.d(TAG, "Iterable SDK initialized successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize Iterable SDK", e) + } + } + + private fun setupUI() { + // Set API key text + findViewById(R.id.tvApiKey).text = "API Key: ${BuildConfig.ITERABLE_API_KEY}" + + findViewById(R.id.btnPushNotifications).setOnClickListener { + startActivity(Intent(this@MainActivity, PushNotificationTestActivity::class.java)) + } + + findViewById(R.id.btnInAppMessages).setOnClickListener { + startActivity(Intent(this@MainActivity, InAppMessageTestActivity::class.java)) + } + + findViewById(R.id.btnEmbeddedMessages).setOnClickListener { + startActivity(Intent(this@MainActivity, EmbeddedMessageTestActivity::class.java)) + } + + findViewById(R.id.btnDeepLinking).setOnClickListener { + startActivity(Intent(this@MainActivity, DeepLinkTestActivity::class.java)) + } + + findViewById(R.id.btnRunAllTests).setOnClickListener { + IntegrationTestUtils(this@MainActivity).runAllIntegrationTests(this@MainActivity) + } + } + + private fun handleIntent(intent: Intent?) { + intent?.data?.let { uri -> + Log.d(TAG, "Received deep link: $uri") + // Handle deep link + val intent = Intent(this, DeepLinkTestActivity::class.java) + intent.putExtra(EXTRA_DEEP_LINK_URL, uri.toString()) + startActivity(intent) + } + } +} \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/activities/DeepLinkTestActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/activities/DeepLinkTestActivity.kt new file mode 100644 index 000000000..0e25ea026 --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/activities/DeepLinkTestActivity.kt @@ -0,0 +1,26 @@ +package com.iterable.integration.tests.activities + +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.iterable.integration.tests.R + +class DeepLinkTestActivity : AppCompatActivity() { + + companion object { + private const val TAG = "DeepLinkTest" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_deep_link_test) + + Log.d(TAG, "Deep Link Test Activity started") + + // Handle deep link URL if passed as extra + intent.getStringExtra("deep_link_url")?.let { url -> + Log.d(TAG, "Received deep link URL: $url") + // TODO: Implement deep link handling logic + } + } +} \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/activities/EmbeddedMessageTestActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/activities/EmbeddedMessageTestActivity.kt new file mode 100644 index 000000000..73754d054 --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/activities/EmbeddedMessageTestActivity.kt @@ -0,0 +1,22 @@ +package com.iterable.integration.tests.activities + +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.iterable.integration.tests.R + +class EmbeddedMessageTestActivity : AppCompatActivity() { + + companion object { + private const val TAG = "EmbeddedMessageTest" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_embedded_message_test) + + Log.d(TAG, "Embedded Message Test Activity started") + + // TODO: Implement embedded message test UI and logic + } +} \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/activities/InAppMessageTestActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/activities/InAppMessageTestActivity.kt new file mode 100644 index 000000000..3cd69a1be --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/activities/InAppMessageTestActivity.kt @@ -0,0 +1,22 @@ +package com.iterable.integration.tests.activities + +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.iterable.integration.tests.R + +class InAppMessageTestActivity : AppCompatActivity() { + + companion object { + private const val TAG = "InAppMessageTest" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_in_app_message_test) + + Log.d(TAG, "In-App Message Test Activity started") + + // TODO: Implement in-app message test UI and logic + } +} \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt new file mode 100644 index 000000000..1eeab9cf3 --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/activities/PushNotificationTestActivity.kt @@ -0,0 +1,22 @@ +package com.iterable.integration.tests.activities + +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.iterable.integration.tests.R + +class PushNotificationTestActivity : AppCompatActivity() { + + companion object { + private const val TAG = "PushNotificationTest" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_push_notification_test) + + Log.d(TAG, "Push Notification Test Activity started") + + // TODO: Implement push notification test UI and logic + } +} \ No newline at end of file diff --git a/integration-tests/src/main/res/layout/activity_deep_link_test.xml b/integration-tests/src/main/res/layout/activity_deep_link_test.xml new file mode 100644 index 000000000..cbc4c01c4 --- /dev/null +++ b/integration-tests/src/main/res/layout/activity_deep_link_test.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/integration-tests/src/main/res/layout/activity_embedded_message_test.xml b/integration-tests/src/main/res/layout/activity_embedded_message_test.xml new file mode 100644 index 000000000..29f5d7d72 --- /dev/null +++ b/integration-tests/src/main/res/layout/activity_embedded_message_test.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/integration-tests/src/main/res/layout/activity_in_app_message_test.xml b/integration-tests/src/main/res/layout/activity_in_app_message_test.xml new file mode 100644 index 000000000..fc481ef3a --- /dev/null +++ b/integration-tests/src/main/res/layout/activity_in_app_message_test.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/integration-tests/src/main/res/layout/activity_main.xml b/integration-tests/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..cb0fb59ba --- /dev/null +++ b/integration-tests/src/main/res/layout/activity_main.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/integration-tests/src/main/res/layout/activity_push_notification_test.xml b/integration-tests/src/main/res/layout/activity_push_notification_test.xml new file mode 100644 index 000000000..8034d9639 --- /dev/null +++ b/integration-tests/src/main/res/layout/activity_push_notification_test.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file From 3fc227aa00c48f0dcedb85071083cef13d451dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Wed, 23 Jul 2025 12:21:30 -0700 Subject: [PATCH 04/39] IMP - Integration Test suite These are files which cover the test scenarios All of it is currently placeholders but they do abstract what exactly they are going to do. Its important that I mention what overacrhing prompt was to understand how these files were created. Business Critical Integration Testing setup for SDKs in general. The idea of this project is to have the SDK fort intact. As we make progress by adding new features and overcoming customer problems and bugs, its important the functionality that we promise and abide by does not break. Unit tests are there doing that job during the PRs pointing out certain faults. But this project will introduce Integration Test and we will carve out some use cases from the possibility of numerous permutation of use cases to help establish state of SDK at any given point. The over-encompassing ticket describes main features of SDK and have separate tickets to consider one in-depth flow with that feature. The project will have end to end integration test established with Iterable Backend. It means there will be a project created in production with API key which the test suite can refer from. Below we can discuss the feature and its test case and what all is needed for it. Integration Test Setup Have the setup ready for integration tests Iterable Project Setup Run the app/test project locally CI setup 2. Push Notification Implement Integration Test to verify: Push notification configuration on iOS and Android platform Device receives push notification Device has permission granted to show notification CI uses backend server keys to send notification. Push notification is displayed Push delivery metrics are captured Tapping on Push notification leads to trackPush opens Tapping on buttons with deep link invokes SDK handlers. 3. InApp messages Implement integration test to verify: Silent push works Confirm In App is displayed Track In App Open metric are validated Confirm In App is able to Deep link Handlers are called and app navigated to certain module 4. Embedded messages Implement Integration test to verify: Project to have eligibility and user list ready Receive eligible message for signed in user Embedded messages are displayed Embedded metrics are verified Deep linking works Things to consider - Silent push flow check User becomes ineligible to eligible > backend triggers push > and flow continues User profile details. On a button click / toggle between values > which should match the criteria> And after affirmation of embedded messages toggle it back to default value. 5. Deep linking Implement Integration to verify: SMS/Email Flow. Launch the app with the url. Project setting to have tracking and destination domain Associated domains setup > iOS Launch the app with certain link. Intent filters set on Android | Assetlinks configuration on iOS | Android App Links | iOS Universal Links Deep linking handler is invoked. We will start from top to bottom. --- .../integration/tests/BaseIntegrationTest.kt | 168 +++++++++ .../tests/PushNotificationIntegrationTest.kt | 206 +++++++++++ .../IntegrationFirebaseMessagingService.kt | 62 ++++ .../tests/utils/IntegrationTestUtils.kt | 327 ++++++++++++++++++ 4 files changed, 763 insertions(+) create mode 100644 integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt create mode 100644 integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt new file mode 100644 index 000000000..9d1b50037 --- /dev/null +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt @@ -0,0 +1,168 @@ +package com.iterable.integration.tests + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.iterable.iterableapi.IterableApi +import com.iterable.iterableapi.IterableConfig +import com.iterable.integration.tests.utils.IntegrationTestUtils +import org.awaitility.Awaitility +import org.awaitility.core.ConditionTimeoutException +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +abstract class BaseIntegrationTest { + + companion object { + const val TIMEOUT_SECONDS = 30L + const val POLL_INTERVAL_SECONDS = 1L + } + + protected lateinit var context: Context + protected lateinit var testUtils: IntegrationTestUtils + + @Before + open fun setUp() { + context = ApplicationProvider.getApplicationContext() + testUtils = IntegrationTestUtils(context) + + // Initialize Iterable SDK for testing + initializeIterableSDK() + + // Setup test environment + setupTestEnvironment() + } + + @After + open fun tearDown() { + // Cleanup test environment + cleanupTestEnvironment() + } + + private fun initializeIterableSDK() { + val config = IterableConfig.Builder() + .setAutoPushRegistration(true) + .setEnableEmbeddedMessaging(true) + .setInAppHandler { message -> + // Handle in-app messages during tests + com.iterable.iterableapi.IterableInAppHandler.InAppResponse.SHOW + } + .setCustomActionHandler { action, context -> + // Handle custom actions during tests + true + } + .setUrlHandler { url, context -> + // Handle URLs during tests + true + } + .build() + + IterableApi.initialize(context, BuildConfig.ITERABLE_API_KEY, config) + IterableApi.getInstance().setEmail("integration.test@iterable.com") + } + + private fun setupTestEnvironment() { + // Grant notification permissions + grantNotificationPermissions() + + // Setup test data + setupTestData() + } + + private fun cleanupTestEnvironment() { + // Clear any test data + clearTestData() + } + + private fun grantNotificationPermissions() { + // Grant notification permissions for Android 13+ + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.executeShellCommand( + "pm grant ${context.packageName} android.permission.POST_NOTIFICATIONS" + ) + } + } + + private fun setupTestData() { + // Setup any test-specific data + } + + private fun clearTestData() { + // Clear any test-specific data + } + + /** + * Wait for a condition to be true with timeout + */ + protected fun waitForCondition(condition: () -> Boolean, timeoutSeconds: Long = TIMEOUT_SECONDS): Boolean { + return try { + Awaitility.await() + .atMost(timeoutSeconds, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL_SECONDS, TimeUnit.SECONDS) + .until { condition() } + true + } catch (e: ConditionTimeoutException) { + false + } + } + + /** + * Wait for a push notification to be received + */ + protected fun waitForPushNotification(timeoutSeconds: Long = TIMEOUT_SECONDS): Boolean { + return waitForCondition({ + testUtils.hasReceivedPushNotification() + }, timeoutSeconds) + } + + /** + * Wait for an in-app message to be displayed + */ + protected fun waitForInAppMessage(timeoutSeconds: Long = TIMEOUT_SECONDS): Boolean { + return waitForCondition({ + testUtils.hasInAppMessageDisplayed() + }, timeoutSeconds) + } + + /** + * Wait for an embedded message to be displayed + */ + protected fun waitForEmbeddedMessage(timeoutSeconds: Long = TIMEOUT_SECONDS): Boolean { + return waitForCondition({ + testUtils.hasEmbeddedMessageDisplayed() + }, timeoutSeconds) + } + + /** + * Send a test push notification + */ + protected fun sendTestPushNotification(campaignId: String = "test_campaign"): Boolean { + return testUtils.sendPushNotification(campaignId) + } + + /** + * Trigger an in-app message + */ + protected fun triggerInAppMessage(eventName: String = "test_event"): Boolean { + return testUtils.triggerInAppMessage(eventName) + } + + /** + * Trigger an embedded message + */ + protected fun triggerEmbeddedMessage(placementId: Int = 0): Boolean { + return testUtils.triggerEmbeddedMessage(placementId) + } + + /** + * Simulate a deep link + */ + protected fun simulateDeepLink(url: String): Boolean { + return testUtils.simulateDeepLink(url) + } +} \ No newline at end of file diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt new file mode 100644 index 000000000..b112a9ffc --- /dev/null +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt @@ -0,0 +1,206 @@ +package com.iterable.integration.tests + +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.iterable.iterableapi.IterableApi +import com.iterable.iterableapi.IterableConfig +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PushNotificationIntegrationTest : BaseIntegrationTest() { + + private lateinit var uiDevice: UiDevice + private lateinit var notificationManager: NotificationManager + + override fun setUp() { + super.setUp() + uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + @Test + fun testPushNotificationConfiguration() { + // Test 1: Verify push notification configuration + assertTrue("Push notification should be configured", isPushNotificationConfigured()) + } + + @Test + fun testDeviceReceivesPushNotification() { + // Test 2: Device receives push notification + val campaignId = "test_push_campaign" + + // Send test push notification + assertTrue("Should send push notification", sendTestPushNotification(campaignId)) + + // Wait for notification to be received + assertTrue("Device should receive push notification", waitForPushNotification()) + } + + @Test + fun testNotificationPermissionGranted() { + // Test 3: Device has permission granted to show notification + assertTrue("Notification permission should be granted", hasNotificationPermission()) + } + + @Test + fun testPushNotificationDisplayed() { + // Test 4: Push notification is displayed + val campaignId = "test_display_campaign" + + // Send test push notification + sendTestPushNotification(campaignId) + + // Wait for notification to be displayed + assertTrue("Push notification should be displayed", waitForNotificationDisplayed()) + } + + @Test + fun testPushDeliveryMetricsCaptured() { + // Test 5: Push delivery metrics are captured + val campaignId = "test_metrics_campaign" + + // Send test push notification + sendTestPushNotification(campaignId) + + // Wait for metrics to be captured + assertTrue("Push delivery metrics should be captured", waitForMetricsCaptured()) + } + + @Test + fun testTappingPushNotificationTracksOpen() { + // Test 6: Tapping on Push notification leads to trackPush opens + val campaignId = "test_tap_campaign" + + // Send test push notification + sendTestPushNotification(campaignId) + + // Wait for notification to appear + assertTrue("Notification should appear", waitForNotificationDisplayed()) + + // Tap on the notification + tapOnNotification() + + // Verify trackPush open is called + assertTrue("trackPush open should be called", waitForTrackPushOpen()) + } + + @Test + fun testTappingButtonWithDeepLinkInvokesHandlers() { + // Test 7: Tapping on buttons with deep link invokes SDK handlers + val campaignId = "test_deeplink_campaign" + + // Send test push notification with deep link button + sendTestPushNotificationWithDeepLink(campaignId) + + // Wait for notification to appear + assertTrue("Notification should appear", waitForNotificationDisplayed()) + + // Tap on the deep link button + tapOnDeepLinkButton() + + // Verify deep link handler is invoked + assertTrue("Deep link handler should be invoked", waitForDeepLinkHandlerInvoked()) + } + + @Test + fun testSilentPushWorks() { + // Test 8: Silent push works (for in-app messages) + val campaignId = "test_silent_campaign" + + // Send silent push notification + sendSilentPushNotification(campaignId) + + // Verify silent push is processed + assertTrue("Silent push should be processed", waitForSilentPushProcessed()) + } + + // Helper methods + private fun isPushNotificationConfigured(): Boolean { + return try { + // Check if Firebase is configured + val firebaseApp = com.google.firebase.FirebaseApp.getInstance() + firebaseApp != null + } catch (e: Exception) { + false + } + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationManager.areNotificationsEnabled() + } else { + true // Pre-Android 13, notifications are enabled by default + } + } + + private fun waitForNotificationDisplayed(): Boolean { + return waitForCondition({ + // Check if notification is in the notification shade + uiDevice.findObject(UiSelector().textContains("Iterable")).exists() + }) + } + + private fun waitForMetricsCaptured(): Boolean { + return waitForCondition({ + // Check if metrics were sent to Iterable backend + testUtils.hasMetricsBeenSent() + }) + } + + private fun tapOnNotification() { + // Find and tap on the notification + val notification = uiDevice.findObject(UiSelector().textContains("Iterable")) + if (notification.exists()) { + notification.click() + } + } + + private fun waitForTrackPushOpen(): Boolean { + return waitForCondition({ + // Check if trackPush open event was sent + testUtils.hasTrackPushOpenBeenCalled() + }) + } + + private fun sendTestPushNotificationWithDeepLink(campaignId: String): Boolean { + return testUtils.sendPushNotificationWithDeepLink(campaignId) + } + + private fun tapOnDeepLinkButton() { + // Find and tap on the deep link button in notification + val deepLinkButton = uiDevice.findObject(UiSelector().textContains("Open")) + if (deepLinkButton.exists()) { + deepLinkButton.click() + } + } + + private fun waitForDeepLinkHandlerInvoked(): Boolean { + return waitForCondition({ + // Check if deep link handler was invoked + testUtils.hasDeepLinkHandlerBeenInvoked() + }) + } + + private fun sendSilentPushNotification(campaignId: String): Boolean { + return testUtils.sendSilentPushNotification(campaignId) + } + + private fun waitForSilentPushProcessed(): Boolean { + return waitForCondition({ + // Check if silent push was processed + testUtils.hasSilentPushBeenProcessed() + }) + } +} \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt b/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt new file mode 100644 index 000000000..7dcf43efa --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt @@ -0,0 +1,62 @@ +package com.iterable.integration.tests.services + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.iterable.integration.tests.utils.IntegrationTestUtils + +class IntegrationFirebaseMessagingService : FirebaseMessagingService() { + + companion object { + private const val TAG = "IntegrationFCMService" + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "New FCM token: $token") + + // Register the token with Iterable SDK + try { + com.iterable.iterableapi.IterableApi.getInstance().registerForPush() + Log.d(TAG, "FCM token registered with Iterable SDK") + } catch (e: Exception) { + Log.e(TAG, "Failed to register FCM token with Iterable SDK", e) + } + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + + Log.d(TAG, "Received FCM message: ${remoteMessage.messageId}") + Log.d(TAG, "Message data: ${remoteMessage.data}") + Log.d(TAG, "Message notification: ${remoteMessage.notification}") + + // Check if this is a silent push for in-app messages + val isSilent = remoteMessage.data["silent"] == "true" + val isInAppMessage = remoteMessage.data["inAppMessage"] == "true" + + if (isSilent && isInAppMessage) { + Log.d(TAG, "Received silent push for in-app message") + // The Iterable SDK will handle the silent push automatically + // We just need to track that it was received + IntegrationTestUtils(this).setSilentPushProcessed(true) + } else { + Log.d(TAG, "Received regular push notification") + // Regular push notification - Iterable SDK will handle display + IntegrationTestUtils(this).setPushNotificationReceived(true) + } + + // Let the Iterable SDK handle the message + // The SDK will automatically process the message and display notifications + } + + override fun onMessageSent(msgId: String) { + super.onMessageSent(msgId) + Log.d(TAG, "FCM message sent: $msgId") + } + + override fun onSendError(msgId: String, exception: Exception) { + super.onSendError(msgId, exception) + Log.e(TAG, "FCM send error for message $msgId", exception) + } +} \ No newline at end of file diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt b/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt new file mode 100644 index 000000000..332d3749f --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/utils/IntegrationTestUtils.kt @@ -0,0 +1,327 @@ +package com.iterable.integration.tests.utils + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.google.gson.Gson +import com.iterable.iterableapi.IterableApi +import com.iterable.iterableapi.IterableInAppMessage +import com.iterable.iterableapi.IterableEmbeddedMessage +import com.iterable.integration.tests.BuildConfig +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean + +class IntegrationTestUtils(private val context: Context) { + + companion object { + private const val TAG = "IntegrationTestUtils" + private const val ITERABLE_API_BASE_URL = "https://api.iterable.com" + private const val ITERABLE_SEND_PUSH_ENDPOINT = "/api/push/target" + } + + private val httpClient = OkHttpClient() + private val gson = Gson() + + // Test state tracking + private val pushNotificationReceived = AtomicBoolean(false) + private val inAppMessageDisplayed = AtomicBoolean(false) + private val embeddedMessageDisplayed = AtomicBoolean(false) + private val metricsSent = AtomicBoolean(false) + private val trackPushOpenCalled = AtomicBoolean(false) + private val deepLinkHandlerInvoked = AtomicBoolean(false) + private val silentPushProcessed = AtomicBoolean(false) + + // Reset all test states + fun resetTestStates() { + pushNotificationReceived.set(false) + inAppMessageDisplayed.set(false) + embeddedMessageDisplayed.set(false) + metricsSent.set(false) + trackPushOpenCalled.set(false) + deepLinkHandlerInvoked.set(false) + silentPushProcessed.set(false) + } + + // Push Notification Methods - Using Iterable Backend API + fun sendPushNotification(campaignId: String): Boolean { + return try { + val payload = createIterablePushNotificationPayload(campaignId) + + val request = Request.Builder() + .url("$ITERABLE_API_BASE_URL$ITERABLE_SEND_PUSH_ENDPOINT") + .addHeader("Api-Key", BuildConfig.ITERABLE_API_KEY) + .addHeader("Content-Type", "application/json") + .post(payload.toRequestBody("application/json".toMediaType())) + .build() + + val response = httpClient.newCall(request).execute() + val success = response.isSuccessful + + if (success) { + pushNotificationReceived.set(true) + Log.d(TAG, "Push notification sent via Iterable backend successfully") + } else { + Log.e(TAG, "Failed to send push notification via Iterable backend: ${response.code} - ${response.body?.string()}") + } + + success + } catch (e: Exception) { + Log.e(TAG, "Error sending push notification via Iterable backend", e) + false + } + } + + fun sendPushNotificationWithDeepLink(campaignId: String): Boolean { + return try { + val payload = createIterablePushNotificationWithDeepLinkPayload(campaignId) + + val request = Request.Builder() + .url("$ITERABLE_API_BASE_URL$ITERABLE_SEND_PUSH_ENDPOINT") + .addHeader("Api-Key", BuildConfig.ITERABLE_API_KEY) + .addHeader("Content-Type", "application/json") + .post(payload.toRequestBody("application/json".toMediaType())) + .build() + + val response = httpClient.newCall(request).execute() + val success = response.isSuccessful + + if (success) { + Log.d(TAG, "Push notification with deep link sent via Iterable backend successfully") + } else { + Log.e(TAG, "Failed to send push notification with deep link: ${response.code} - ${response.body?.string()}") + } + + success + } catch (e: Exception) { + Log.e(TAG, "Error sending push notification with deep link via Iterable backend", e) + false + } + } + + fun sendSilentPushNotification(campaignId: String): Boolean { + return try { + val payload = createIterableSilentPushNotificationPayload(campaignId) + + val request = Request.Builder() + .url("$ITERABLE_API_BASE_URL$ITERABLE_SEND_PUSH_ENDPOINT") + .addHeader("Api-Key", BuildConfig.ITERABLE_API_KEY) + .addHeader("Content-Type", "application/json") + .post(payload.toRequestBody("application/json".toMediaType())) + .build() + + val response = httpClient.newCall(request).execute() + val success = response.isSuccessful + + if (success) { + silentPushProcessed.set(true) + Log.d(TAG, "Silent push notification sent via Iterable backend successfully") + } else { + Log.e(TAG, "Failed to send silent push notification: ${response.code} - ${response.body?.string()}") + } + + success + } catch (e: Exception) { + Log.e(TAG, "Error sending silent push notification via Iterable backend", e) + false + } + } + + fun hasReceivedPushNotification(): Boolean { + return pushNotificationReceived.get() + } + + fun setPushNotificationReceived(received: Boolean) { + pushNotificationReceived.set(received) + } + + fun hasSilentPushBeenProcessed(): Boolean { + return silentPushProcessed.get() + } + + fun setSilentPushProcessed(processed: Boolean) { + silentPushProcessed.set(processed) + } + + // In-App Message Methods + fun triggerInAppMessage(eventName: String): Boolean { + return try { + IterableApi.getInstance().track(eventName) + Log.d(TAG, "In-app message triggered for event: $eventName") + true + } catch (e: Exception) { + Log.e(TAG, "Error triggering in-app message", e) + false + } + } + + fun hasInAppMessageDisplayed(): Boolean { + return inAppMessageDisplayed.get() + } + + fun setInAppMessageDisplayed(displayed: Boolean) { + inAppMessageDisplayed.set(displayed) + } + + // Embedded Message Methods + fun triggerEmbeddedMessage(placementId: Int): Boolean { + return try { + // Trigger embedded message by updating user profile + val userData = JSONObject().apply { + put("embeddedMessageEligible", true) + put("placementId", placementId) + } + + IterableApi.getInstance().updateUser(userData) + Log.d(TAG, "Embedded message triggered for placement: $placementId") + true + } catch (e: Exception) { + Log.e(TAG, "Error triggering embedded message", e) + false + } + } + + fun hasEmbeddedMessageDisplayed(): Boolean { + return embeddedMessageDisplayed.get() + } + + fun setEmbeddedMessageDisplayed(displayed: Boolean) { + embeddedMessageDisplayed.set(displayed) + } + + // Deep Link Methods + fun simulateDeepLink(url: String): Boolean { + return try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = android.net.Uri.parse(url) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(intent) + deepLinkHandlerInvoked.set(true) + Log.d(TAG, "Deep link simulated: $url") + true + } catch (e: Exception) { + Log.e(TAG, "Error simulating deep link", e) + false + } + } + + fun hasDeepLinkHandlerBeenInvoked(): Boolean { + return deepLinkHandlerInvoked.get() + } + + // Metrics Methods + fun hasMetricsBeenSent(): Boolean { + return metricsSent.get() + } + + fun setMetricsSent(sent: Boolean) { + metricsSent.set(sent) + } + + fun hasTrackPushOpenBeenCalled(): Boolean { + return trackPushOpenCalled.get() + } + + fun setTrackPushOpenCalled(called: Boolean) { + trackPushOpenCalled.set(called) + } + + // Utility Methods + private fun getFCMToken(): String { + // In a real implementation, this would get the actual FCM token + // For testing purposes, we'll use a mock token + return "mock_fcm_token_for_testing" + } + + // Iterable Backend API Payloads + private fun createIterablePushNotificationPayload(campaignId: String): String { + val dataFields = JSONObject().apply { + put("testType", "push_notification") + put("campaignId", campaignId) + } + + val metadata = JSONObject().apply { + put("title", "Test Push Notification") + put("body", "This is a test push notification") + put("sound", "default") + put("badge", 1) + } + + return JSONObject().apply { + put("campaignId", campaignId) + put("recipientEmail", "integration.test@iterable.com") + put("dataFields", dataFields) + put("allowRepeatMarketingSends", false) + put("metadata", metadata) + }.toString() + } + + private fun createIterablePushNotificationWithDeepLinkPayload(campaignId: String): String { + val dataFields = JSONObject().apply { + put("testType", "push_notification_deeplink") + put("campaignId", campaignId) + put("deepLink", "iterable://integration.tests/deeplink") + } + + val primaryButton = JSONObject().apply { + put("text", "Open") + put("action", "open_deep_link") + } + + val actionButtons = JSONObject().apply { + put("primary", primaryButton) + } + + val metadata = JSONObject().apply { + put("title", "Test Deep Link Push") + put("body", "Tap to open deep link") + put("sound", "default") + put("badge", 1) + put("actionButtons", actionButtons) + } + + return JSONObject().apply { + put("campaignId", campaignId) + put("recipientEmail", "integration.test@iterable.com") + put("dataFields", dataFields) + put("allowRepeatMarketingSends", false) + put("metadata", metadata) + }.toString() + } + + private fun createIterableSilentPushNotificationPayload(campaignId: String): String { + val dataFields = JSONObject().apply { + put("testType", "silent_push") + put("campaignId", campaignId) + put("silent", true) + put("inAppMessage", true) + } + + val metadata = JSONObject().apply { + put("contentAvailable", true) + put("silent", true) + } + + return JSONObject().apply { + put("campaignId", campaignId) + put("recipientEmail", "integration.test@iterable.com") + put("dataFields", dataFields) + put("allowRepeatMarketingSends", false) + put("metadata", metadata) + }.toString() + } + + // Run all integration tests + fun runAllIntegrationTests(context: Context) { + Log.d(TAG, "Starting all integration tests...") + + // This would typically be called from a test orchestrator + // For now, we'll just log that it was called + Log.d(TAG, "All integration tests completed") + } +} \ No newline at end of file From db7b314f44ee7f3dc1e5f4ddbd61fc26aa319e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Wed, 23 Jul 2025 12:22:27 -0700 Subject: [PATCH 05/39] Workflow for CI --- .github/workflows/integration-tests.yml | 230 ++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 .github/workflows/integration-tests.yml diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..feca7d2c8 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,230 @@ +name: Integration Tests + +on: + push: + branches: [ master, develop ] + paths: + - 'integration-tests/**' + - 'iterableapi/**' + - 'iterableapi-ui/**' + pull_request: + branches: [ master, develop ] + paths: + - 'integration-tests/**' + - 'iterableapi/**' + - 'iterableapi-ui/**' + schedule: + # Run integration tests daily at 2 AM UTC + - cron: '0 2 * * *' + +jobs: + integration-tests: + name: Integration Tests + runs-on: macos-latest + + strategy: + matrix: + api-level: [21, 29, 34] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Android SDK + uses: android-actions/setup-android@v2 + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties + echo "ndk.dir=$ANDROID_SDK_ROOT/ndk" >> local.properties + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Create Android Virtual Device + run: | + echo "Creating AVD..." + echo "no" | avdmanager create avd \ + -n "test_device_api_${{ matrix.api-level }}" \ + -k "system-images;android-${{ matrix.api-level }};google_apis;x86_64" \ + -c 2048M \ + -f + + - name: Start Android Emulator + run: | + echo "Starting emulator..." + $ANDROID_SDK_ROOT/emulator/emulator \ + -avd test_device_api_${{ matrix.api-level }} \ + -no-audio \ + -no-window \ + -no-snapshot \ + -camera-back none \ + -camera-front none \ + -gpu swiftshader_indirect & + + - name: Wait for emulator + run: | + echo "Waiting for emulator..." + adb wait-for-device + adb shell input keyevent 82 + adb shell input keyevent 82 + + - name: Grant notification permissions + run: | + echo "Granting notification permissions..." + adb shell pm grant com.iterable.integration.tests android.permission.POST_NOTIFICATIONS + + - name: Run Integration Tests + env: + ITERABLE_API_KEY: ${{ secrets.ITERABLE_API_KEY }} + ITERABLE_PROJECT_ID: ${{ secrets.ITERABLE_PROJECT_ID }} + ITERABLE_SERVER_API_KEY: ${{ secrets.ITERABLE_SERVER_API_KEY }} + run: | + echo "Running integration tests..." + ./gradlew :integration-tests:connectedCheck \ + -Pandroid.testInstrumentationRunnerArguments.class=com.iterable.integration.tests.PushNotificationIntegrationTest \ + --info + + - name: Generate Test Report + if: always() + run: | + echo "Generating test report..." + ./gradlew :integration-tests:jacocoIntegrationTestReport + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: integration-test-results-api-${{ matrix.api-level }} + path: | + integration-tests/build/reports/ + integration-tests/build/outputs/ + + - name: Upload Coverage Report + if: always() + uses: actions/upload-artifact@v3 + with: + name: integration-test-coverage-api-${{ matrix.api-level }} + path: integration-tests/build/reports/jacoco/ + + - name: Stop emulator + if: always() + run: | + echo "Stopping emulator..." + adb emu kill + + integration-tests-nightly: + name: Nightly Integration Tests + runs-on: macos-latest + if: github.event_name == 'schedule' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Android SDK + uses: android-actions/setup-android@v2 + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties + echo "ndk.dir=$ANDROID_SDK_ROOT/ndk" >> local.properties + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Create Android Virtual Device + run: | + echo "Creating AVD..." + echo "no" | avdmanager create avd \ + -n "nightly_test_device" \ + -k "system-images;android-34;google_apis;x86_64" \ + -c 2048M \ + -f + + - name: Start Android Emulator + run: | + echo "Starting emulator..." + $ANDROID_SDK_ROOT/emulator/emulator \ + -avd nightly_test_device \ + -no-audio \ + -no-window \ + -no-snapshot \ + -camera-back none \ + -camera-front none \ + -gpu swiftshader_indirect & + + - name: Wait for emulator + run: | + echo "Waiting for emulator..." + adb wait-for-device + adb shell input keyevent 82 + adb shell input keyevent 82 + + - name: Grant notification permissions + run: | + echo "Granting notification permissions..." + adb shell pm grant com.iterable.integration.tests android.permission.POST_NOTIFICATIONS + + - name: Run All Integration Tests + env: + ITERABLE_API_KEY: ${{ secrets.ITERABLE_API_KEY }} + ITERABLE_PROJECT_ID: ${{ secrets.ITERABLE_PROJECT_ID }} + ITERABLE_SERVER_API_KEY: ${{ secrets.ITERABLE_SERVER_API_KEY }} + run: | + echo "Running all integration tests..." + ./gradlew :integration-tests:connectedCheck --info + + - name: Generate Test Report + if: always() + run: | + echo "Generating test report..." + ./gradlew :integration-tests:jacocoIntegrationTestReport + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: nightly-integration-test-results + path: | + integration-tests/build/reports/ + integration-tests/build/outputs/ + + - name: Upload Coverage Report + if: always() + uses: actions/upload-artifact@v3 + with: + name: nightly-integration-test-coverage + path: integration-tests/build/reports/jacoco/ + + - name: Stop emulator + if: always() + run: | + echo "Stopping emulator..." + adb emu kill \ No newline at end of file From 2342d48c5ae09d9d4106285fbc34355985095bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Wed, 23 Jul 2025 12:23:05 -0700 Subject: [PATCH 06/39] Adding ignore to /.idea --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6d832f70d..97b977888 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ pom.xml.versionsBackup pom.xml.next release.properties -jacoco.exec \ No newline at end of file +jacoco.exec +integration-tests/.idea/ From 652d2f9e4de1b811bda2201e2358aa76dbf32667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Tue, 29 Jul 2025 14:45:04 -0700 Subject: [PATCH 07/39] Campaign Trigger Page added --- .../integration/tests/BaseIntegrationTest.kt | 37 ++- .../tests/CampaignTriggerIntegrationTest.kt | 164 ++++++++++++ .../src/main/AndroidManifest.xml | 5 + .../integration/tests/MainActivity.kt | 11 +- .../activities/CampaignTriggerTestActivity.kt | 244 ++++++++++++++++++ .../tests/utils/IntegrationTestUtils.kt | 140 ++++++++++ .../layout/activity_campaign_trigger_test.xml | 161 ++++++++++++ .../src/main/res/layout/activity_main.xml | 8 + 8 files changed, 767 insertions(+), 3 deletions(-) create mode 100644 integration-tests/src/androidTest/java/com/iterable/integration/tests/CampaignTriggerIntegrationTest.kt create mode 100644 integration-tests/src/main/java/com/iterable/integration/tests/activities/CampaignTriggerTestActivity.kt create mode 100644 integration-tests/src/main/res/layout/activity_campaign_trigger_test.xml diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt index 9d1b50037..dd7431e74 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt @@ -62,7 +62,12 @@ abstract class BaseIntegrationTest { .build() IterableApi.initialize(context, BuildConfig.ITERABLE_API_KEY, config) - IterableApi.getInstance().setEmail("integration.test@iterable.com") + + // Set the user email for integration testing + val userEmail = "akshay.ayyanchira@iterable.com" + IterableApi.getInstance().setEmail(userEmail) + + Log.d("BaseIntegrationTest", "Iterable SDK initialized with email: $userEmail") } private fun setupTestEnvironment() { @@ -165,4 +170,34 @@ abstract class BaseIntegrationTest { protected fun simulateDeepLink(url: String): Boolean { return testUtils.simulateDeepLink(url) } + + /** + * Trigger a campaign via Iterable API + */ + protected fun triggerCampaignViaAPI(campaignId: Int, recipientEmail: String = "akshay.ayyanchira@iterable.com", dataFields: Map? = null, callback: ((Boolean) -> Unit)? = null) { + testUtils.triggerCampaignViaAPI(campaignId, recipientEmail, dataFields, callback) + } + + /** + * Trigger a push campaign via Iterable API + */ + protected fun triggerPushCampaignViaAPI(campaignId: Int, recipientEmail: String = "akshay.ayyanchira@iterable.com", dataFields: Map? = null, callback: ((Boolean) -> Unit)? = null) { + testUtils.triggerPushCampaignViaAPI(campaignId, recipientEmail, dataFields, callback) + } + + /** + * Wait for a campaign to be triggered and processed + */ + protected fun waitForCampaignTrigger(campaignId: Int, timeoutSeconds: Long = TIMEOUT_SECONDS): Boolean { + // Trigger the campaign + val triggered = triggerCampaignViaAPI(campaignId) + if (!triggered) { + return false + } + + // Wait for the campaign to be processed (in-app message or push notification) + return waitForCondition({ + testUtils.hasInAppMessageDisplayed() || testUtils.hasReceivedPushNotification() + }, timeoutSeconds) + } } \ No newline at end of file diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/CampaignTriggerIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/CampaignTriggerIntegrationTest.kt new file mode 100644 index 000000000..f9c4a5cb2 --- /dev/null +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/CampaignTriggerIntegrationTest.kt @@ -0,0 +1,164 @@ +package com.iterable.integration.tests + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CampaignTriggerIntegrationTest : BaseIntegrationTest() { + + companion object { + private const val TAG = "CampaignTriggerIntegrationTest" + + // Test campaign IDs - these should be configured in your Iterable project + private const val TEST_INAPP_CAMPAIGN_ID = 14332357 // Example campaign ID from your curl command + private const val TEST_PUSH_CAMPAIGN_ID = 14332358 // Example push campaign ID + private const val TEST_EMBEDDED_CAMPAIGN_ID = 14332359 // Example embedded campaign ID + + // Test user email + private const val TEST_USER_EMAIL = "akshay.ayyanchira@iterable.com" + } + + @Test + fun testInAppCampaignTriggerViaAPI() { + Log.d(TAG, "Testing in-app campaign trigger via API") + + // Reset test states + testUtils.resetTestStates() + + // Trigger the campaign via API with callback + var campaignTriggered = false + triggerCampaignViaAPI(TEST_INAPP_CAMPAIGN_ID, TEST_USER_EMAIL) { success -> + campaignTriggered = success + } + + // Wait for campaign to be triggered + val triggered = waitForCondition({ campaignTriggered }, 10) + assertTrue("Campaign trigger should succeed", triggered) + + // Wait for the in-app message to be displayed + val displayed = waitForInAppMessage() + assertTrue("In-app message should be displayed after campaign trigger", displayed) + + Log.d(TAG, "In-app campaign trigger test completed successfully") + } + + @Test + fun testPushCampaignTriggerViaAPI() { + Log.d(TAG, "Testing push campaign trigger via API") + + // Reset test states + testUtils.resetTestStates() + + // Trigger the push campaign via API with callback + var campaignTriggered = false + triggerPushCampaignViaAPI(TEST_PUSH_CAMPAIGN_ID, TEST_USER_EMAIL) { success -> + campaignTriggered = success + } + + // Wait for campaign to be triggered + val triggered = waitForCondition({ campaignTriggered }, 10) + assertTrue("Push campaign trigger should succeed", triggered) + + // Wait for the push notification to be received + val received = waitForPushNotification() + assertTrue("Push notification should be received after campaign trigger", received) + + Log.d(TAG, "Push campaign trigger test completed successfully") + } + + @Test + fun testCampaignTriggerWithDataFields() { + Log.d(TAG, "Testing campaign trigger with data fields") + + // Reset test states + testUtils.resetTestStates() + + // Create test data fields + val dataFields = mapOf( + "firstName" to "Jane", + "lastName" to "Smith", + "purchaseAmount" to 42.42, + "testType" to "integration_test" + ) + + // Trigger the campaign via API with data fields + val success = triggerCampaignViaAPI(TEST_INAPP_CAMPAIGN_ID, TEST_USER_EMAIL, dataFields) + assertTrue("Campaign trigger with data fields should succeed", success) + + // Wait for the campaign to be processed + val processed = waitForCampaignTrigger(TEST_INAPP_CAMPAIGN_ID) + assertTrue("Campaign should be processed with data fields", processed) + + Log.d(TAG, "Campaign trigger with data fields test completed successfully") + } + + @Test + fun testCampaignTriggerWithCustomUser() { + Log.d(TAG, "Testing campaign trigger with custom user") + + // Reset test states + testUtils.resetTestStates() + + // Use a different test user + val customUserEmail = "integration.test@iterable.com" + + // Trigger the campaign via API for custom user + val success = triggerCampaignViaAPI(TEST_INAPP_CAMPAIGN_ID, customUserEmail) + assertTrue("Campaign trigger for custom user should succeed", success) + + // Wait for the campaign to be processed + val processed = waitForCampaignTrigger(TEST_INAPP_CAMPAIGN_ID) + assertTrue("Campaign should be processed for custom user", processed) + + Log.d(TAG, "Campaign trigger with custom user test completed successfully") + } + + @Test + fun testMultipleCampaignTriggers() { + Log.d(TAG, "Testing multiple campaign triggers") + + // Reset test states + testUtils.resetTestStates() + + // Trigger multiple campaigns + val campaign1Success = triggerCampaignViaAPI(TEST_INAPP_CAMPAIGN_ID, TEST_USER_EMAIL) + val campaign2Success = triggerPushCampaignViaAPI(TEST_PUSH_CAMPAIGN_ID, TEST_USER_EMAIL) + + assertTrue("First campaign trigger should succeed", campaign1Success) + assertTrue("Second campaign trigger should succeed", campaign2Success) + + // Wait for at least one campaign to be processed + val processed = waitForCondition({ + testUtils.hasInAppMessageDisplayed() || testUtils.hasReceivedPushNotification() + }) + assertTrue("At least one campaign should be processed", processed) + + Log.d(TAG, "Multiple campaign triggers test completed successfully") + } + + @Test + fun testCampaignTriggerErrorHandling() { + Log.d(TAG, "Testing campaign trigger error handling") + + // Test with invalid campaign ID + val invalidCampaignId = 99999999 + val success = triggerCampaignViaAPI(invalidCampaignId, TEST_USER_EMAIL) + + // The API call might succeed but the campaign might not exist + // We'll just verify the method doesn't crash + Log.d(TAG, "Campaign trigger with invalid ID result: $success") + + // Test with invalid email + val invalidEmail = "invalid@email.com" + val success2 = triggerCampaignViaAPI(TEST_INAPP_CAMPAIGN_ID, invalidEmail) + + // The API call might succeed but the user might not exist + // We'll just verify the method doesn't crash + Log.d(TAG, "Campaign trigger with invalid email result: $success2") + + Log.d(TAG, "Campaign trigger error handling test completed") + } +} \ No newline at end of file diff --git a/integration-tests/src/main/AndroidManifest.xml b/integration-tests/src/main/AndroidManifest.xml index 54524a897..3a14cf5f1 100644 --- a/integration-tests/src/main/AndroidManifest.xml +++ b/integration-tests/src/main/AndroidManifest.xml @@ -74,6 +74,11 @@ android:exported="false" android:label="Deep Link Tests" /> + + (R.id.btnCampaignTrigger).setOnClickListener { + startActivity(Intent(this@MainActivity, CampaignTriggerTestActivity::class.java)) + } + findViewById(R.id.btnRunAllTests).setOnClickListener { IntegrationTestUtils(this@MainActivity).runAllIntegrationTests(this@MainActivity) } diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/activities/CampaignTriggerTestActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/activities/CampaignTriggerTestActivity.kt new file mode 100644 index 000000000..6336bc343 --- /dev/null +++ b/integration-tests/src/main/java/com/iterable/integration/tests/activities/CampaignTriggerTestActivity.kt @@ -0,0 +1,244 @@ +package com.iterable.integration.tests.activities + +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.iterable.integration.tests.R +import com.iterable.integration.tests.utils.IntegrationTestUtils + +class CampaignTriggerTestActivity : AppCompatActivity() { + + companion object { + private const val TAG = "CampaignTriggerTestActivity" + + // Test campaign IDs - these should be configured in your Iterable project + private const val TEST_INAPP_CAMPAIGN_ID = 14332357 + private const val TEST_PUSH_CAMPAIGN_ID = 14332358 + private const val TEST_USER_EMAIL = "akshay.ayyanchira@iterable.com" + } + + private lateinit var testUtils: IntegrationTestUtils + private lateinit var logTextView: TextView + private lateinit var campaignIdEditText: EditText + private lateinit var userEmailEditText: EditText + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_campaign_trigger_test) + + testUtils = IntegrationTestUtils(this) + + // Initialize UI components first + logTextView = findViewById(R.id.logTextView) + campaignIdEditText = findViewById(R.id.campaignIdEditText) + userEmailEditText = findViewById(R.id.userEmailEditText) + + // Set default values + campaignIdEditText.setText(TEST_INAPP_CAMPAIGN_ID.toString()) + userEmailEditText.setText(TEST_USER_EMAIL) + + // Setup button click listeners + setupButtonListeners() + + // Now ensure user is signed in (after UI is initialized) + ensureUserSignedIn() + + logMessage("Campaign Trigger Test Activity initialized") + logMessage("Default campaign ID: $TEST_INAPP_CAMPAIGN_ID") + logMessage("Default user email: $TEST_USER_EMAIL") + logMessage("User signed in: ${com.iterable.iterableapi.IterableApi.getInstance().getEmail()}") + } + + private fun ensureUserSignedIn() { + val success = testUtils.ensureUserSignedIn(TEST_USER_EMAIL) + if (success) { + logMessage("✅ User signed in successfully") + } else { + logMessage("❌ Failed to sign in user") + } + } + + private fun setupButtonListeners() { + // Test in-app campaign trigger + findViewById