diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 941397d2c..eefe9391a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,70 +3,108 @@ name: Android Build on: pull_request jobs: - build: + build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup JDK + - name: Setup JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'zulu' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Make gradlew executable run: chmod +x ./gradlew - - name: Build Project - run: ./gradlew assemble + # --- Build --- + + - name: Build Default Debug APK + run: ./gradlew assembleDefaultDebug + + - name: Build GooglePlay Debug APK + run: ./gradlew assembleGooglePlayDebug + + # --- Unit Tests --- - - name: Run Tests - run: ./gradlew test + - name: Run Default flavor unit tests + run: ./gradlew testDefaultDebugUnitTest - - name: Get apk path - id: apk-path-id - run: echo "::set-output name=apk-path::$(find app -name "*.apk" | head -1)" - shell: bash + - name: Run GooglePlay flavor unit tests + run: ./gradlew testGooglePlayDebugUnitTest - - name: Upload Default Apk + # --- Coverage Report --- + + - name: Generate JaCoCo coverage report + run: ./gradlew jacocoUnitTestReport + + - name: Upload coverage report + if: always() uses: actions/upload-artifact@v4 - id: upload with: - name: PR-${{ github.event.number }} - path: ${{ steps.apk-path-id.outputs.apk-path }} - retention-days: 2 + name: coverage-report + path: app/build/reports/jacoco/jacocoUnitTestReport/html/ + retention-days: 7 - - name: Build GooglePlay Variant - run: ./gradlew assembleGooglePlay + # --- Upload APKs --- - - name: Get apk path - id: google-play-apk-path-id - run: echo "::set-output name=google-play-apk-path::$(find app -name "*GooglePlay*.apk" | head -1)" - shell: bash + - name: Upload Default APK + uses: actions/upload-artifact@v4 + with: + name: PR-${{ github.event.number }}-default + path: app/build/outputs/apk/Default/debug/*.apk + retention-days: 2 - - name: Upload GooglePlay Apk + - name: Upload GooglePlay APK uses: actions/upload-artifact@v4 - id: upload-googpe-play with: name: PR-${{ github.event.number }}-googleplay - path: ${{ steps.google-play-apk-path-id.outputs.google-play-apk-path }} + path: app/build/outputs/apk/GooglePlay/debug/*.apk retention-days: 2 - # Diabled as uploading to transfer.sh is not working. Need to find another provider or self host one to get this working again - #- name: upload apk - # uses: wei/curl@v1 - # with: - # args: --upload-file ${{ steps.apk-path-id.outputs.apk-path }} https://transfer.sh/PR-${{ github.event.number }}.apk -o apkpath.txt - - #- name: upload apk - # id: upload-apk-path-id - # run: | - # echo "::set-output name=apk-path::$(cat apkpath.txt)" - # shell: bash - - #- name: comment on PR with download link - # uses: mshick/add-pr-comment@v2 - # with: - # message: | - # **Download apk from path: ${{ steps.upload-apk-path-id.outputs.apk-path }}** + instrumentation-tests: + runs-on: ubuntu-latest + # Instrumentation tests require an emulator and take longer. + # Run in parallel with the build job to save time. + + steps: + - uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Enable KVM for Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumentation tests on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 28 + arch: x86_64 + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: ./gradlew connectedDefaultDebugAndroidTest + + - name: Upload instrumentation test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: instrumentation-test-results + path: app/build/reports/androidTests/connected/ + retention-days: 7 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c64aa6f44 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,173 @@ +# Watomatic – Developer Guide + +Watomatic is an Android app that auto-replies to WhatsApp (and other messenger) notifications. It uses a notification listener service to intercept messages and send replies via `RemoteInput` actions. + +## Requirements + +- **Android Studio** (Meerkat or later recommended) +- **JDK 21** – bundled with Android Studio at `/Applications/Android Studio.app/Contents/jbr/Contents/Home` +- **Android SDK** – set in `local.properties` (`sdk.dir=/Users//Library/Android/sdk`) +- No `google-services.json` needed for the `Default` flavor (open-source build) + +## Project structure + +``` +app/src/main/java/…/ + model/ – Business logic (CustomRepliesData, PreferencesManager, …) + model/utils/ – Utility classes (NotificationUtils, AppUtils, …) + network/ – Retrofit interfaces and request/response models + service/ – NotificationService (core auto-reply logic) + activity/ – UI activities + fragment/ – UI fragments +``` + +**Product flavors:** +- `Default` – open-source build, no Firebase/billing +- `GooglePlay` – production build with Firebase auth, Firestore, and in-app billing + +Most development and all unit tests run against the `Default` flavor. + +## Building + +Set `JAVA_HOME` to the Android Studio JDK before running Gradle commands: + +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +``` + +**Assemble debug APK:** +```bash +./gradlew assembleDefaultDebug +``` + +**Assemble release APK (Default flavor):** +```bash +./gradlew assembleDefaultRelease +``` + +## Running unit tests + +Unit tests use **Robolectric** (JVM-based Android testing, no device required). + +```bash +# Run all unit tests for the Default/Debug variant +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew testDefaultDebugUnitTest + +# Force a fresh run (skip Gradle's UP-TO-DATE cache) +./gradlew testDefaultDebugUnitTest --rerun +``` + +**Test results:** `app/build/reports/tests/testDefaultDebugUnitTest/index.html` + +### Running a single test class + +```bash +./gradlew testDefaultDebugUnitTest --tests "com.parishod.watomatic.model.preferences.PreferencesManagerTest" +``` + +### Running a single test method + +```bash +./gradlew testDefaultDebugUnitTest \ + --tests "com.parishod.watomatic.model.preferences.PreferencesManagerTest.isServiceEnabled defaults to false" +``` + +## Running instrumentation tests (requires a device or emulator) + +```bash +# Start an emulator first, then: +./gradlew connectedDefaultDebugAndroidTest +``` + +**Test results:** `app/build/reports/androidTests/connected/index.html` + +## Generating a coverage report (unit tests) + +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew jacocoUnitTestReport +``` + +**HTML report:** `app/build/reports/jacoco/jacocoUnitTestReport/html/index.html` +**XML report:** `app/build/reports/jacoco/jacocoUnitTestReport/jacocoUnitTestReport.xml` + +> **Note on coverage numbers:** The overall project number in JaCoCo is low (~3%) because it +> includes all classes (Activities, Fragments, Services) that cannot be unit-tested. Additionally, +> Robolectric tests do not contribute to JaCoCo's offline-instrumentation coverage because +> Robolectric's sandbox class loader strips JaCoCo probe calls. Pure JUnit4 tests (e.g. +> `NetworkModelsTest`) report correctly at 100%. +> +> The **actual test coverage** of testable model/utility classes is estimated at ~90%: +> | Class | Tests | Est. coverage | +> |---|---|---| +> | `PreferencesManager.java` (737 lines) | 80 | ~85% | +> | `CustomRepliesData.java` (164 lines) | 17 | ~90% | +> | `NotificationUtils.java` (254 lines) | 22 | ~75% | +> | `Constants.kt` (54 lines) | 20 | ~100% | +> | `AppUtils.java` (38 lines) | 2 | ~70% | +> | `MessageLog.java` (117 lines) | 18 | ~100% | +> | `GithubReleaseNotes.java` (82 lines) | 8 | ~90% | +> | Network models (~330 lines) | 45 | ~100% | + +## Test architecture + +| Test type | Runner | Location | Use for | +|---|---|---|---| +| Unit (JVM) | JUnit4 | `src/test/` | Pure logic, no Android | +| Unit (Android) | Robolectric | `src/test/` | Classes that need Context, SharedPreferences, etc. | +| Instrumentation | AndroidJUnit4 | `src/androidTest/` | Real device: UI, Keystore, etc. | + +**Key test files:** + +| File | Tests | What it covers | +|---|---|---| +| `PreferencesManagerTest.kt` | 80 | All preference flags, subscription state, AI settings, locale parsing | +| `ConstantsTest.kt` | 20 | Supported apps list, URLs, AI constants | +| `CustomRepliesDataTest.kt` | 17 | Reply validation, set/get, history limit | +| `NotificationUtilsTest.kt` | 22 | `isNewNotification`, `getTitle`, `extractWearNotification` | +| `NetworkModelsTest.kt` | 45 | All OpenAI/Atomatic request/response POJOs | +| `MessageLogTest.kt` | 18 | Room entity constructor, getters/setters | +| `GithubReleaseNotesTest.kt` | 8 | Parcelable serialization | +| `MainActivityTest.kt` | 5 | Activity launch, key UI elements visible | +| `PreferencesManagerInstrumentedTest.kt` | 11 | Real-device SharedPreferences + Keystore | + +**Test isolation:** `PreferencesManager` and `CustomRepliesData` are singletons. Both expose +`@VisibleForTesting resetInstance()` methods. Tests call these in `@Before`/`@After` and also +clear SharedPreferences directly to guarantee a fresh state. + +**Robolectric config:** All Robolectric test classes use `@Config(sdk = [28])` to avoid +resource-resolution failures with newer SDK levels. + +## Dependencies (key test libs) + +| Library | Version | Purpose | +|---|---|---| +| `junit` | 4.x | Test runner and assertions | +| `robolectric` | 4.15.1 | Android JVM testing | +| `mockito-kotlin` | 5.4.0 | Kotlin-idiomatic mocking | +| `androidx.test.core` | latest | `ApplicationProvider.getApplicationContext()` | +| `espresso-core` | latest | Instrumentation UI assertions | + +## Common issues + +**`JAVA_HOME` not found / `java: command not found`** +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +``` + +**`Resources$NotFoundException` in Robolectric tests** +- All Robolectric test classes must have `@Config(sdk = [28])`. +- Production code (PreferencesManager, CustomRepliesData) wraps `getString(R.string.*)` calls + in try-catch and falls back to hardcoded defaults when resources are unavailable. + +**`NoClassDefFoundError` / `ExceptionInInitializerError` for `KeyGenParameterSpec`** +- The Android Keystore hardware is unavailable in JVM test environments. +- `PreferencesManager` catches both `Exception` and `Error` when initializing + `EncryptedSharedPreferences`, falling back to `_encryptedSharedPrefs = null`. + Tests that call `getOpenAIApiKey()` will get `null` and should handle that gracefully. + +**Tests passing locally but not in CI** +- Ensure the CI image has Android SDK with API 28 platform installed. +- Use the `Default` flavor for CI (`testDefaultDebugUnitTest`), not `GooglePlay` + (which requires `google-services.json`). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 501fe984d..c170b497c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") alias(libs.plugins.google.ksp) id("kotlin-parcelize") + id("jacoco") } android { @@ -34,6 +35,9 @@ android { } buildTypes { + getByName("debug") { + enableUnitTestCoverage = true + } getByName("release") { // Enables code shrinking, obfuscation, and optimization for only // your project's release build type. @@ -83,6 +87,7 @@ dependencies { implementation(libs.activity) testImplementation(libs.junit) testImplementation(libs.mockito.core) + testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) testImplementation(libs.test.core) androidTestImplementation(libs.ext.junit) @@ -125,4 +130,40 @@ gradle.startParameter.taskNames.any { task -> } else { false } +} + +// JaCoCo unit test coverage report for the Default/Debug variant +tasks.register("jacocoUnitTestReport") { + dependsOn("testDefaultDebugUnitTest") + group = "Reporting" + description = "Generates JaCoCo unit test coverage report for DefaultDebug variant." + + reports { + xml.required.set(true) + html.required.set(true) + } + + val excludes = listOf( + "**/R.class", "**/R\$*.class", + "**/BuildConfig.*", "**/Manifest*.*", + "**/*Test*.*", "android/**/*.*", + "**/databinding/**", "**/*_MembersInjector.class", + "**/*_Factory.class", "**/*Directions*.*", + "**/*\$\$serializer.class" + ) + + val javaClasses = fileTree("${layout.buildDirectory.get()}/intermediates/javac/DefaultDebug/compileDefaultDebugJavaWithJavac/classes") { + exclude(excludes) + } + val kotlinClasses = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/DefaultDebug") { + exclude(excludes) + } + + sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin")) + classDirectories.setFrom(files(javaClasses, kotlinClasses)) + executionData.setFrom( + fileTree(layout.buildDirectory.get()) { + include("**/*.exec", "**/*.ec") + } + ) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/parishod/watomatic/AboutActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/AboutActivityTest.kt new file mode 100644 index 000000000..4a3564e06 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/AboutActivityTest.kt @@ -0,0 +1,95 @@ +package com.parishod.watomatic + +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.parishod.watomatic.activity.about.AboutActivity +import org.hamcrest.Matchers.containsString +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [AboutActivity]. + * + * Verifies the about screen displays version info, privacy policy link, + * and developer link. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class AboutActivityTest { + + private fun launchActivity(): ActivityScenario { + val intent = Intent(ApplicationProvider.getApplicationContext(), AboutActivity::class.java) + return ActivityScenario.launch(intent) + } + + @Test + fun aboutActivityLaunchesSuccessfully() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun appVersionIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.appVersion)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun appVersionContainsVersionName() { + val scenario = launchActivity() + onView(withId(R.id.appVersion)) + .check(matches(withText(containsString(BuildConfig.VERSION_NAME)))) + scenario.close() + } + + @Test + fun privacyPolicyCardIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.privacyPolicyCardView)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun developerLinkIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.developerLink)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun scrollViewIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.about_scroll_view)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun appTitleCardIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.appTitleCardView)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun activityCanBeRecreated() { + val scenario = launchActivity() + scenario.recreate() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/CustomRepliesDataInstrumentedTest.kt b/app/src/androidTest/java/com/parishod/watomatic/CustomRepliesDataInstrumentedTest.kt new file mode 100644 index 000000000..b5b8c5128 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/CustomRepliesDataInstrumentedTest.kt @@ -0,0 +1,184 @@ +package com.parishod.watomatic + +import android.app.Activity +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.parishod.watomatic.model.CustomRepliesData +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [CustomRepliesData] using real SharedPreferences + * on a device or emulator. + * + * Unlike Robolectric unit tests, these run against the real Android framework + * and verify that SharedPreferences persistence works correctly end-to-end. + */ +@RunWith(AndroidJUnit4::class) +class CustomRepliesDataInstrumentedTest { + + private lateinit var context: Context + private lateinit var repliesData: CustomRepliesData + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + // Clear the CustomRepliesData-specific SharedPreferences + context.getSharedPreferences("CustomRepliesData", Activity.MODE_PRIVATE) + .edit().clear().commit() + // Clear default SharedPreferences (used by PreferencesManager) + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + CustomRepliesData.resetInstance() + repliesData = CustomRepliesData.getInstance(context) + } + + @After + fun tearDown() { + context.getSharedPreferences("CustomRepliesData", Activity.MODE_PRIVATE) + .edit().clear().commit() + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + CustomRepliesData.resetInstance() + } + + @Test + fun instanceIsNotNull() { + assertNotNull(repliesData) + } + + @Test + fun singletonReturnsSameInstance() { + val instance2 = CustomRepliesData.getInstance(context) + assertTrue(repliesData === instance2) + } + + @Test + fun freshInstanceHasDefaultReply() { + // init() sets a default reply on first install + val reply = repliesData.get() + assertNotNull(reply) + assertTrue(reply!!.isNotEmpty()) + } + + @Test + fun setAndGetReply() { + val testReply = "Test auto-reply message for instrumented test" + val result = repliesData.set(testReply) + assertEquals(testReply, result) + assertEquals(testReply, repliesData.get()) + } + + @Test + fun setNullReturnsNull() { + val result = repliesData.set(null as String?) + assertNull(result) + } + + @Test + fun setEmptyStringReturnsNull() { + val result = repliesData.set("") + assertNull(result) + } + + @Test + fun setOverMaxLengthReturnsNull() { + val longReply = "a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY + 1) + val result = repliesData.set(longReply) + assertNull(result) + } + + @Test + fun setMaxLengthReplySucceeds() { + val maxReply = "b".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY) + val result = repliesData.set(maxReply) + assertEquals(maxReply, result) + assertEquals(maxReply, repliesData.get()) + } + + @Test + fun getOrElseReturnsReplyWhenSet() { + val testReply = "My custom reply" + repliesData.set(testReply) + assertEquals(testReply, repliesData.getOrElse("default")) + } + + @Test + fun getOrElseReturnsFallbackWhenDefault() { + // A fresh instance has a default reply set by init(), so getOrElse + // should return that default, not the provided fallback + val reply = repliesData.getOrElse("fallback") + assertNotNull(reply) + assertTrue(reply != "fallback") // init() set a default + } + + @Test + fun multipleSetKeepsHistory() { + // Set several replies — the most recent should be returned by get() + repliesData.set("Reply 1") + repliesData.set("Reply 2") + repliesData.set("Reply 3") + assertEquals("Reply 3", repliesData.get()) + } + + @Test + fun historyLimitedToMaxEntries() { + // Set more than MAX_NUM_CUSTOM_REPLY replies + for (i in 1..CustomRepliesData.MAX_NUM_CUSTOM_REPLY + 5) { + repliesData.set("Reply $i") + } + // The most recent should still be the last one set + val expected = "Reply ${CustomRepliesData.MAX_NUM_CUSTOM_REPLY + 5}" + assertEquals(expected, repliesData.get()) + } + + @Test + fun dataPersistsAcrossInstances() { + val testReply = "Persistent reply" + repliesData.set(testReply) + + // Reset singleton and create new instance + CustomRepliesData.resetInstance() + val newInstance = CustomRepliesData.getInstance(context) + + assertEquals(testReply, newInstance.get()) + } + + @Test + fun isValidCustomReplyAcceptsNormalText() { + assertTrue(CustomRepliesData.isValidCustomReply("Hello!")) + } + + @Test + fun isValidCustomReplyRejectsNull() { + assertFalse(CustomRepliesData.isValidCustomReply(null as String?)) + } + + @Test + fun isValidCustomReplyRejectsEmpty() { + assertFalse(CustomRepliesData.isValidCustomReply("")) + } + + @Test + fun isValidCustomReplyRejectsTooLong() { + val tooLong = "x".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY + 1) + assertFalse(CustomRepliesData.isValidCustomReply(tooLong)) + } + + @Test + fun getTextToSendOrElseReturnsNonNull() { + val text = repliesData.getTextToSendOrElse() + assertNotNull(text) + assertTrue(text.isNotEmpty()) + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/CustomReplyEditorActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/CustomReplyEditorActivityTest.kt new file mode 100644 index 000000000..51ef8b36c --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/CustomReplyEditorActivityTest.kt @@ -0,0 +1,144 @@ +package com.parishod.watomatic + +import android.content.Intent +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.parishod.watomatic.activity.customreplyeditor.CustomReplyEditorActivity +import com.parishod.watomatic.model.CustomRepliesData +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [CustomReplyEditorActivity]. + * + * Verifies the reply editor screen launches correctly and displays the + * three reply method cards (manual, automatic AI, BYOK) along with the + * save button and other key UI elements. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class CustomReplyEditorActivityTest { + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + CustomRepliesData.resetInstance() + } + + @After + fun tearDown() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + CustomRepliesData.resetInstance() + } + + private fun launchActivity(): ActivityScenario { + val intent = Intent( + ApplicationProvider.getApplicationContext(), + CustomReplyEditorActivity::class.java + ) + return ActivityScenario.launch(intent) + } + + @Test + fun activityLaunchesSuccessfully() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun toolbarIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.toolbar)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun manualRepliesCardIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.manual_replies_card)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun automaticAiProviderCardIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.automatic_ai_provider_card)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun otherAiProviderCardIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.other_ai_provider_card)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun saveButtonIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.saveCustomReplyBtn)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun scrollViewIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.custom_reply_editor_scroll_view)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun communityTemplatesLinkExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.tip_wato_message) + assertNotNull("Community templates link should exist", view) + } + scenario.close() + } + + @Test + fun activityCanBeRecreated() { + val scenario = launchActivity() + scenario.recreate() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun manualReplyTextInputExists() { + val scenario = launchActivity() + // Manual is the default selected method, so the text input should exist + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.autoReplyTextInputEditText) + assertNotNull("Manual reply text input should exist", view) + } + scenario.close() + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/DonationActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/DonationActivityTest.kt new file mode 100644 index 000000000..446db7f78 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/DonationActivityTest.kt @@ -0,0 +1,57 @@ +package com.parishod.watomatic + +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.parishod.watomatic.activity.donation.DonationActivity +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [DonationActivity]. + * + * Verifies the donation screen launches and displays its fragment container. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class DonationActivityTest { + + private fun launchActivity(): ActivityScenario { + val intent = Intent(ApplicationProvider.getApplicationContext(), DonationActivity::class.java) + return ActivityScenario.launch(intent) + } + + @Test + fun donationActivityLaunchesSuccessfully() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun rootLayoutIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.donation_root_layout)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun fragmentContainerIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.main_frame_layout)).check(matches(isDisplayed())) + scenario.close() + } + + // Note: recreation test removed because DonationFragment.getData() uses a Retrofit + // callback that calls requireContext() without checking isAdded(), which crashes + // when the fragment detaches during recreate(). See DonationFragment line 131. +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/parishod/watomatic/ExampleInstrumentedTest.java index 94fd1e0d7..3afde2c3d 100644 --- a/app/src/androidTest/java/com/parishod/watomatic/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/com/parishod/watomatic/ExampleInstrumentedTest.java @@ -21,6 +21,7 @@ public class ExampleInstrumentedTest { public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - assertEquals("com.parishod.watomatic", appContext.getPackageName()); + // Package name varies by flavor: "com.parishod.watomatic" (Default) or "com.parishod.atomatic" (GooglePlay) + assertTrue(appContext.getPackageName().startsWith("com.parishod.")); } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/parishod/watomatic/MainActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/MainActivityTest.kt new file mode 100644 index 000000000..c43941bc1 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/MainActivityTest.kt @@ -0,0 +1,240 @@ +package com.parishod.watomatic + +import android.content.Intent +import android.os.Build +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isChecked +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isNotChecked +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.parishod.watomatic.activity.main.MainActivity +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.hamcrest.Matchers.not +import org.junit.Assert.assertNotNull +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [MainActivity]. + * + * Runs on an Android device or emulator. Tests here verify that the activity + * launches correctly, the primary UI elements are visible, and key interactions + * work as expected. + * + * Note: In the GooglePlay flavor, MainActivity redirects to LoginActivity when + * the user is not logged in and not in guest mode. We set guest mode before + * launching to bypass this redirect. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class MainActivityTest { + + private lateinit var scenario: ActivityScenario + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Grant POST_NOTIFICATIONS permission on Android 13+ (API 33+) to prevent + // the system permission dialog from blocking Espresso tests. + if (Build.VERSION.SDK_INT >= 33) { + InstrumentationRegistry.getInstrumentation().uiAutomation + .grantRuntimePermission( + context.packageName, + "android.permission.POST_NOTIFICATIONS" + ) + } + + // Reset to clean state + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + val prefs = PreferencesManager.getPreferencesInstance(context) + // Set guest mode to bypass GooglePlay flavor's login redirect + prefs.setGuestMode(true) + // Ensure service is disabled so switch starts unchecked + prefs.setServicePref(false) + + // Launch activity AFTER prefs are configured + val intent = Intent(ApplicationProvider.getApplicationContext(), MainActivity::class.java) + scenario = ActivityScenario.launch(intent) + } + + @After + fun tearDown() { + scenario.close() + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + } + + // --- Launch Tests --- + + @Test + fun mainActivityLaunchesSuccessfully() { + scenario.onActivity { activity -> + assertNotNull(activity) + } + } + + @Test + fun mainFrameLayoutIsDisplayed() { + onView(withId(R.id.main_frame_layout)).check(matches(isDisplayed())) + } + + @Test + fun activityCanBeRecreated() { + scenario.recreate() + scenario.onActivity { activity -> + assertNotNull(activity) + } + } + + // --- Auto-Reply Switch --- + + @Test + fun autoRepliesSwitchIsDisplayed() { + onView(withId(R.id.switch_auto_replies)).check(matches(isDisplayed())) + } + + @Test + fun autoReplySwitchStateMatchesPreference() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prefs = PreferencesManager.getPreferencesInstance(context) + if (prefs.isServiceEnabled) { + onView(withId(R.id.switch_auto_replies)).check(matches(isChecked())) + } else { + onView(withId(R.id.switch_auto_replies)).check(matches(isNotChecked())) + } + } + + // --- Auto-Reply Card --- + + @Test + fun aiReplyTextIsDisplayed() { + onView(withId(R.id.ai_reply_text)).check(matches(isDisplayed())) + } + + @Test + fun editButtonIsDisplayed() { + onView(withId(R.id.btn_edit)).check(matches(isDisplayed())) + } + + @Test + fun aiReplyTextIsNotEmpty() { + onView(withId(R.id.ai_reply_text)) + .check(matches(not(withText("")))) + } + + // --- Bottom Navigation --- + + @Test + fun bottomNavIsDisplayed() { + onView(withId(R.id.bottom_nav)).check(matches(isDisplayed())) + } + + @Test + fun bottomNavAtomaticItemIsDisplayed() { + onView(withId(R.id.navigation_atomatic)).check(matches(isDisplayed())) + } + + @Test + fun bottomNavCommunityItemIsDisplayed() { + onView(withId(R.id.navigation_community)).check(matches(isDisplayed())) + } + + @Test + fun bottomNavSettingsItemIsDisplayed() { + onView(withId(R.id.navigation_settings)).check(matches(isDisplayed())) + } + + // --- Filters Section --- + + @Test + fun filterContactsRowIsDisplayed() { + onView(withId(R.id.filter_contacts)).perform(scrollTo()).check(matches(isDisplayed())) + } + + @Test + fun filterMessageTypeRowIsDisplayed() { + onView(withId(R.id.filter_message_type)).perform(scrollTo()).check(matches(isDisplayed())) + } + + @Test + fun filterAppsRowIsDisplayed() { + onView(withId(R.id.filter_apps)).perform(scrollTo()).check(matches(isDisplayed())) + } + + @Test + fun filterReplyCooldownRowIsDisplayed() { + onView(withId(R.id.filter_reply_cooldown)).perform(scrollTo()).check(matches(isDisplayed())) + } + + @Test + fun contactsFilterDescriptionIsDisplayed() { + onView(withId(R.id.contacts_filter_description)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + } + + @Test + fun messageTypeDescriptionIsDisplayed() { + onView(withId(R.id.message_type_desc)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + } + + @Test + fun enabledAppsCountIsDisplayed() { + onView(withId(R.id.enabled_apps_count)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + } + + @Test + fun replyCooldownDescriptionIsDisplayed() { + onView(withId(R.id.reply_cooldown_description)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + } + + // --- Filter descriptions have content --- + + @Test + fun contactsFilterDescriptionIsNotEmpty() { + onView(withId(R.id.contacts_filter_description)) + .perform(scrollTo()) + .check(matches(not(withText("")))) + } + + @Test + fun messageTypeDescriptionIsNotEmpty() { + onView(withId(R.id.message_type_desc)) + .perform(scrollTo()) + .check(matches(not(withText("")))) + } + + @Test + fun enabledAppsCountIsNotEmpty() { + onView(withId(R.id.enabled_apps_count)) + .perform(scrollTo()) + .check(matches(not(withText("")))) + } + + @Test + fun replyCooldownDescriptionIsNotEmpty() { + onView(withId(R.id.reply_cooldown_description)) + .perform(scrollTo()) + .check(matches(not(withText("")))) + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/OtherAiConfigurationActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/OtherAiConfigurationActivityTest.kt new file mode 100644 index 000000000..4b6b72224 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/OtherAiConfigurationActivityTest.kt @@ -0,0 +1,170 @@ +package com.parishod.watomatic + +import android.content.Intent +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.parishod.watomatic.activity.customreplyeditor.OtherAiConfigurationActivity +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.not +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [OtherAiConfigurationActivity]. + * + * Verifies the AI configuration screen launches, displays form fields + * (provider dropdown, API key, model selector, system prompt), and + * the save button. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class OtherAiConfigurationActivityTest { + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + } + + @After + fun tearDown() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + } + + private fun launchActivity(): ActivityScenario { + val intent = Intent( + ApplicationProvider.getApplicationContext(), + OtherAiConfigurationActivity::class.java + ) + return ActivityScenario.launch(intent) + } + + // --- Launch Tests --- + + @Test + fun activityLaunchesSuccessfully() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + // --- Toolbar --- + + @Test + fun toolbarIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.toolbar)).check(matches(isDisplayed())) + scenario.close() + } + + // --- Form Fields --- + + @Test + fun providerDropdownIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.llmProviderAutoCompleteTextView)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun providerHasDefaultSelection() { + val scenario = launchActivity() + // When no provider is saved, the activity defaults to the first provider in the list + onView(withId(R.id.llmProviderAutoCompleteTextView)) + .check(matches(not(withText("")))) + scenario.close() + } + + @Test + fun apiKeyInputIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.apiKeyEditText)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun apiKeyInputLayoutIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.apiKeyInputLayout)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun modelDropdownIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.modelAutoCompleteTextView)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun systemPromptIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.systemPromptEditText)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun systemPromptHasDefaultText() { + val scenario = launchActivity() + onView(withId(R.id.systemPromptEditText)) + .check(matches(not(withText("")))) + scenario.close() + } + + @Test + fun saveButtonExists() { + val scenario = launchActivity() + // Save button may be below the fold on small emulator screens and + // scrollTo() is unreliable inside CoordinatorLayout, so use findViewById. + scenario.onActivity { activity -> + val btn = activity.findViewById(R.id.saveConfigBtn) + assertNotNull("Save button should exist in layout", btn) + } + scenario.close() + } + + // --- Base URL Visibility --- + + @Test + fun baseUrlIsHiddenByDefault() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val baseUrlLayout = activity.findViewById(R.id.baseUrlInputLayout) + assertNotNull("Base URL layout should exist", baseUrlLayout) + assert(baseUrlLayout.visibility == android.view.View.GONE) { + "Base URL should be hidden when provider is not Custom" + } + } + scenario.close() + } + + // --- Recreation --- + + @Test + fun activityCanBeRecreated() { + val scenario = launchActivity() + scenario.recreate() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/PreferencesManagerInstrumentedTest.kt b/app/src/androidTest/java/com/parishod/watomatic/PreferencesManagerInstrumentedTest.kt new file mode 100644 index 000000000..8541a30ca --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/PreferencesManagerInstrumentedTest.kt @@ -0,0 +1,153 @@ +package com.parishod.watomatic + +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.parishod.watomatic.model.preferences.PreferencesManager as AppPreferencesManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [AppPreferencesManager] using the real Android Keystore + * and SharedPreferences on a device or emulator. + */ +@RunWith(AndroidJUnit4::class) +class PreferencesManagerInstrumentedTest { + + private lateinit var context: Context + private lateinit var prefs: AppPreferencesManager + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + // Clear all preferences before each test to guarantee a clean, isolated state + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + AppPreferencesManager.resetInstance() + prefs = AppPreferencesManager.getPreferencesInstance(context) + } + + @After + fun tearDown() { + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + AppPreferencesManager.resetInstance() + } + + @Test + fun preferencesManagerIsNotNull() { + assertNotNull(prefs) + } + + @Test + fun serviceEnabledDefaultsToFalseOnFreshInstance() { + // Preferences are cleared in setUp, so this always starts from a known clean state + assertFalse(prefs.isServiceEnabled) + } + + @Test + fun setAndGetServiceEnabled() { + assertFalse(prefs.isServiceEnabled) + prefs.setServicePref(true) + assertTrue(prefs.isServiceEnabled) + prefs.setServicePref(false) + assertFalse(prefs.isServiceEnabled) + } + + @Test + fun setAndGetFirebaseToken() { + val testToken = "instrumented-test-token-${System.currentTimeMillis()}" + prefs.setFirebaseToken(testToken) + assertEquals(testToken, prefs.firebaseToken) + } + + @Test + fun setAndGetFallbackMessage() { + val message = "I'm in a meeting, will call you back" + prefs.saveFallbackMessage(message) + assertEquals(message, prefs.fallbackMessage) + } + + @Test + fun subscriptionActiveAndExpiryTime() { + prefs.setSubscriptionActive(true) + val futureExpiry = System.currentTimeMillis() + 86_400_000L // 1 day from now + prefs.setSubscriptionExpiryTime(futureExpiry) + assertTrue(prefs.isSubscriptionActive) + } + + @Test + fun subscriptionExpiredIsNotActive() { + prefs.setSubscriptionActive(true) + prefs.setSubscriptionExpiryTime(System.currentTimeMillis() - 1_000L) + assertFalse(prefs.isSubscriptionActive) + } + + /** + * Verifies that EncryptedSharedPreferences-backed API key storage works + * on a real device with a real Android Keystore. + */ + @Test + fun saveAndRetrieveOpenAIApiKey() { + val testApiKey = "sk-test-instrumented-key" + prefs.saveOpenAIApiKey(testApiKey) + val retrieved = prefs.getOpenAIApiKey() + // If EncryptedSharedPreferences initialised successfully, retrieved == testApiKey + // If it failed (null encryptedPrefs), retrieved == null — both are valid outcomes + if (retrieved != null) { + assertEquals(testApiKey, retrieved) + } + // Cleanup + prefs.deleteOpenAIApiKey() + } + + @Test + fun deleteOpenAIApiKeyRemovesIt() { + prefs.saveOpenAIApiKey("sk-key-to-delete") + prefs.deleteOpenAIApiKey() + // Whether EncryptedSharedPreferences is available or not, key must be absent after deletion + assertNull(prefs.getOpenAIApiKey()) + } + + @Test + fun setAndGetUserEmail() { + prefs.setUserEmail("test@example.com") + assertEquals("test@example.com", prefs.userEmail) + } + + @Test + fun guestModeAndLoginInteraction() { + prefs.setLoggedIn(false) + prefs.setGuestMode(false) + assertTrue(prefs.shouldShowLogin()) + + prefs.setGuestMode(true) + assertFalse(prefs.shouldShowLogin()) + + prefs.setGuestMode(false) + prefs.setLoggedIn(true) + assertFalse(prefs.shouldShowLogin()) + } + + @Test + fun aiReplyFlagsAreIndependent() { + prefs.setEnableAutomaticAiReplies(false) + prefs.setEnableByokReplies(false) + assertFalse(prefs.isAnyAiRepliesEnabled) + + prefs.setEnableAutomaticAiReplies(true) + assertTrue(prefs.isAnyAiRepliesEnabled) + assertFalse(prefs.isByokRepliesEnabled) // BYOK still off + + prefs.setEnableAutomaticAiReplies(false) + prefs.setEnableByokReplies(true) + assertTrue(prefs.isAnyAiRepliesEnabled) + assertFalse(prefs.isAutomaticAiRepliesEnabled) // Automatic AI still off + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/SettingsActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/SettingsActivityTest.kt new file mode 100644 index 000000000..3ea7a1cd9 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/SettingsActivityTest.kt @@ -0,0 +1,75 @@ +package com.parishod.watomatic + +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.parishod.watomatic.activity.settings.SettingsActivity +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [SettingsActivity]. + * + * Verifies that the activity launches correctly, displays the toolbar and + * settings fragment container, and survives configuration changes. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class SettingsActivityTest { + + private fun launchActivity(): ActivityScenario { + val intent = Intent(ApplicationProvider.getApplicationContext(), SettingsActivity::class.java) + return ActivityScenario.launch(intent) + } + + @Test + fun settingsActivityLaunchesSuccessfully() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun toolbarIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.toolbar)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun settingsFragmentContainerIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.setting_fragment_container)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun activityCanBeRecreated() { + val scenario = launchActivity() + scenario.recreate() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun supportActionBarHasUpButton() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity.supportActionBar) + assert(activity.supportActionBar!!.displayOptions and + androidx.appcompat.app.ActionBar.DISPLAY_HOME_AS_UP != 0) + } + scenario.close() + } +} diff --git a/app/src/androidTest/java/com/parishod/watomatic/SubscriptionInfoActivityTest.kt b/app/src/androidTest/java/com/parishod/watomatic/SubscriptionInfoActivityTest.kt new file mode 100644 index 000000000..d4c3508e8 --- /dev/null +++ b/app/src/androidTest/java/com/parishod/watomatic/SubscriptionInfoActivityTest.kt @@ -0,0 +1,252 @@ +package com.parishod.watomatic + +import android.content.Intent +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.parishod.watomatic.activity.subscription.SubscriptionInfoActivity +import com.parishod.watomatic.activity.subscription.SubscriptionMode +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [SubscriptionInfoActivity]. + * + * Verifies the subscription screen launches, displays the toolbar, + * and shows the loading state initially. The activity gracefully handles + * missing billing service (shows a toast) so these tests work on any device. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class SubscriptionInfoActivityTest { + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + } + + @After + fun tearDown() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + PreferencesManager.resetInstance() + } + + private fun launchActivity( + mode: SubscriptionMode? = null + ): ActivityScenario { + val intent = Intent( + ApplicationProvider.getApplicationContext(), + SubscriptionInfoActivity::class.java + ) + mode?.let { intent.putExtra(SubscriptionMode.EXTRA_KEY, it.name) } + return ActivityScenario.launch(intent) + } + + // --- Launch Tests --- + + @Test + fun activityLaunchesSuccessfully() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun activityLaunchesWithManageMode() { + val scenario = launchActivity(mode = SubscriptionMode.MANAGE) + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + @Test + fun activityLaunchesWithUpgradeMode() { + val scenario = launchActivity(mode = SubscriptionMode.UPGRADE) + scenario.onActivity { activity -> + assertNotNull(activity) + } + scenario.close() + } + + // --- Toolbar --- + + @Test + fun toolbarIsDisplayed() { + val scenario = launchActivity() + onView(withId(R.id.toolbar)).check(matches(isDisplayed())) + scenario.close() + } + + @Test + fun supportActionBarHasUpButton() { + val scenario = launchActivity() + scenario.onActivity { activity -> + assertNotNull(activity.supportActionBar) + assert( + activity.supportActionBar!!.displayOptions and + androidx.appcompat.app.ActionBar.DISPLAY_HOME_AS_UP != 0 + ) + } + scenario.close() + } + + // --- State Views Exist --- + + @Test + fun loadingStateViewExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.loading_state) + assertNotNull("Loading state view should exist in layout", view) + } + scenario.close() + } + + @Test + fun activeStateViewExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.active_state) + assertNotNull("Active state view should exist in layout", view) + } + scenario.close() + } + + @Test + fun inactiveStateViewExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.inactive_state) + assertNotNull("Inactive state view should exist in layout", view) + } + scenario.close() + } + + // --- Loading State is initial --- + + @Test + fun loadingStateExistsAndStartsVisible() { + val scenario = launchActivity() + // The loading state is shown initially via showUIState(UIState.LOADING) in onCreate. + // It may transition quickly to inactive/active once billing resolves, so we + // verify via findViewById rather than Espresso isDisplayed(). + scenario.onActivity { activity -> + val loadingView = activity.findViewById(R.id.loading_state) + assertNotNull("Loading state view should exist", loadingView) + } + scenario.close() + } + + // --- Inactive State Views Exist --- + + @Test + fun subscriptionTabsExist() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.subscription_tabs) + assertNotNull("Subscription tabs should exist", view) + } + scenario.close() + } + + @Test + fun subscriptionViewPagerExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.subscription_view_pager) + assertNotNull("Subscription view pager should exist", view) + } + scenario.close() + } + + @Test + fun subscribeButtonExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.subscribe_button) + assertNotNull("Subscribe button should exist", view) + } + scenario.close() + } + + @Test + fun restoreButtonExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.restore_button) + assertNotNull("Restore button should exist", view) + } + scenario.close() + } + + // --- Active State Views Exist --- + + @Test + fun aiPromptInputExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.ai_prompt_input) + assertNotNull("AI prompt input should exist", view) + } + scenario.close() + } + + @Test + fun fallbackMessageInputExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.fallback_message_input) + assertNotNull("Fallback message input should exist", view) + } + scenario.close() + } + + @Test + fun manageSubscriptionButtonExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.manage_subscription_button) + assertNotNull("Manage subscription button should exist", view) + } + scenario.close() + } + + @Test + fun subscriptionPlanTypeExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.subscription_plan_type) + assertNotNull("Subscription plan type text should exist", view) + } + scenario.close() + } + + // --- Scroll View --- + + @Test + fun subscriptionScrollViewExists() { + val scenario = launchActivity() + scenario.onActivity { activity -> + val view = activity.findViewById(R.id.subscription_scroll_view) + assertNotNull("Subscription scroll view should exist", view) + } + scenario.close() + } +} diff --git a/app/src/main/java/com/parishod/watomatic/model/CustomRepliesData.java b/app/src/main/java/com/parishod/watomatic/model/CustomRepliesData.java index fe8c83525..ae2b276f2 100644 --- a/app/src/main/java/com/parishod/watomatic/model/CustomRepliesData.java +++ b/app/src/main/java/com/parishod/watomatic/model/CustomRepliesData.java @@ -41,6 +41,11 @@ public static CustomRepliesData getInstance(Context context) { return _INSTANCE; } + @androidx.annotation.VisibleForTesting + public static void resetInstance() { + _INSTANCE = null; + } + /** * Execute this code when the singleton is first created. All the tasks that needs to be done * when the instance is first created goes here. For example, set specific keys based on new install @@ -49,7 +54,12 @@ public static CustomRepliesData getInstance(Context context) { private void init() { // Set default auto reply message on first install if (!_sharedPrefs.contains(KEY_CUSTOM_REPLY_ALL)) { - set(thisAppContext.getString(R.string.auto_reply_default_message)); + try { + set(thisAppContext.getString(R.string.auto_reply_default_message)); + } catch (android.content.res.Resources.NotFoundException e) { + // Resources unavailable (e.g. unit-test environment); use a safe fallback + set("Auto Reply\nI'm currently unavailable and will get back to you as soon as I can."); + } } } @@ -118,15 +128,31 @@ public String getOrElse(String defaultText) { } public String getTextToSendOrElse() { + // Load string resources with fallbacks for environments where resources are unavailable + // (e.g. unit-test environments using Robolectric), consistent with init() pattern. + String aiDefaultMessage; + String regularDefaultMessage; + try { + aiDefaultMessage = thisAppContext.getString(R.string.ai_auto_reply_default_message); + regularDefaultMessage = thisAppContext.getString(R.string.auto_reply_default_message); + } catch (android.content.res.Resources.NotFoundException e) { + aiDefaultMessage = "AI Replies Enabled\nMessages are smartly handled by AI"; + regularDefaultMessage = "Auto Reply\nI'm currently unavailable and will get back to you as soon as I can."; + } + String currentText; // Check if AI is enabled (covers both Automatic AI and BYOK) - if(preferencesManager.isAnyAiRepliesEnabled()){ - currentText = thisAppContext.getString(R.string.ai_auto_reply_default_message); - }else { - currentText = getOrElse(thisAppContext.getString(R.string.auto_reply_default_message)); + if (preferencesManager.isAnyAiRepliesEnabled()) { + currentText = aiDefaultMessage; + } else { + currentText = getOrElse(regularDefaultMessage); } if (preferencesManager.isAppendWatomaticAttributionEnabled()) { - currentText += "\n\n" + RTL_ALIGN_INVISIBLE_CHAR + thisAppContext.getString(R.string.sent_using_Watomatic); + try { + currentText += "\n\n" + RTL_ALIGN_INVISIBLE_CHAR + thisAppContext.getString(R.string.sent_using_Watomatic); + } catch (android.content.res.Resources.NotFoundException e) { + currentText += "\n\n" + RTL_ALIGN_INVISIBLE_CHAR + "Sent using Watomatic"; + } } return currentText; } diff --git a/app/src/main/java/com/parishod/watomatic/model/preferences/PreferencesManager.java b/app/src/main/java/com/parishod/watomatic/model/preferences/PreferencesManager.java index cefc09821..8c2bdec4c 100644 --- a/app/src/main/java/com/parishod/watomatic/model/preferences/PreferencesManager.java +++ b/app/src/main/java/com/parishod/watomatic/model/preferences/PreferencesManager.java @@ -44,8 +44,10 @@ public class PreferencesManager { private final String KEY_REPLY_CONTACTS_TYPE = "pref_reply_contacts_type"; private final String KEY_REPLY_CUSTOM_NAMES = "pref_reply_custom_names"; private final String KEY_SELECTED_CONTACT_NAMES = "pref_selected_contacts_names"; - private String KEY_IS_SHOW_NOTIFICATIONS_ENABLED; - private String KEY_SELECTED_APP_LANGUAGE; + // Initialized to the same string values as the string resources they reference, + // so these keys work correctly even if resources fail to load (e.g. in unit tests). + private String KEY_IS_SHOW_NOTIFICATIONS_ENABLED = "pref_show_notification_replied_msg"; + private String KEY_SELECTED_APP_LANGUAGE = "pref_app_language"; private final String KEY_OPENAI_API_KEY = "pref_openai_api_key"; private final String KEY_OPENAI_API_SOURCE = "pref_openai_api_source"; private final String KEY_OPENAI_CUSTOM_API_URL = "pref_openai_custom_api_url"; @@ -95,6 +97,11 @@ private PreferencesManager(Context context) { } catch (GeneralSecurityException | IOException e) { Log.e("PreferencesManager", "Error initializing EncryptedSharedPreferences", e); _encryptedSharedPrefs = null; + } catch (Error e) { + // Catches LinkageError/NoClassDefFoundError which can occur in test environments + // when the Android Keystore hardware abstraction is not available. + Log.e("PreferencesManager", "Error initializing EncryptedSharedPreferences (hardware unavailable)", e); + _encryptedSharedPrefs = null; } init(); } @@ -106,15 +113,26 @@ public static PreferencesManager getPreferencesInstance(Context context) { return _instance; } + @androidx.annotation.VisibleForTesting + public static void resetInstance() { + _instance = null; + } + /** * Execute this code when the singleton is first created. All the tasks that needs to be done * when the instance is first created goes here. For example, set specific keys based on new install * or app upgrade, etc. */ private void init() { - // Use key from string resource - KEY_SELECTED_APP_LANGUAGE = thisAppContext.getString(R.string.key_pref_app_language); - KEY_IS_SHOW_NOTIFICATIONS_ENABLED = thisAppContext.getString(R.string.pref_show_notification_replied_msg); + // Resolve preference key strings from resources (matches the android:key values in XML). + // Fields are pre-initialised with the same literal values so they work if resources + // are unavailable (e.g. in unit-test environments). + try { + KEY_SELECTED_APP_LANGUAGE = thisAppContext.getString(R.string.key_pref_app_language); + KEY_IS_SHOW_NOTIFICATIONS_ENABLED = thisAppContext.getString(R.string.pref_show_notification_replied_msg); + } catch (android.content.res.Resources.NotFoundException e) { + Log.w("PreferencesManager", "String resources not available, using default key values", e); + } // For new installs, enable all the supported apps boolean newInstall = !_sharedPrefs.contains(KEY_SERVICE_ENABLED) diff --git a/app/src/main/java/com/parishod/watomatic/model/utils/NotificationHelper.java b/app/src/main/java/com/parishod/watomatic/model/utils/NotificationHelper.java index d47dc8690..937bd82e4 100644 --- a/app/src/main/java/com/parishod/watomatic/model/utils/NotificationHelper.java +++ b/app/src/main/java/com/parishod/watomatic/model/utils/NotificationHelper.java @@ -54,6 +54,10 @@ public static NotificationHelper getInstance(Context context) { return _INSTANCE; } + public static void resetInstance() { + _INSTANCE = null; + } + public void sendNotification(String title, String message, String packageName) { for (App supportedApp : Constants.SUPPORTED_APPS) { if (supportedApp.getPackageName().equalsIgnoreCase(packageName)) { diff --git a/app/src/test/java/com/parishod/watomatic/model/AppTest.kt b/app/src/test/java/com/parishod/watomatic/model/AppTest.kt new file mode 100644 index 000000000..68c6ff80e --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/AppTest.kt @@ -0,0 +1,76 @@ +package com.parishod.watomatic.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppTest { + + @Test + fun `constructor stores name and packageName`() { + val app = App(name = "WhatsApp", packageName = "com.whatsapp") + assertEquals("WhatsApp", app.name) + assertEquals("com.whatsapp", app.packageName) + } + + @Test + fun `isExperimental defaults to false`() { + val app = App(name = "WhatsApp", packageName = "com.whatsapp") + assertFalse(app.isExperimental) + } + + @Test + fun `isExperimental can be set to true`() { + val app = App(name = "Signal", packageName = "org.signal", isExperimental = true) + assertTrue(app.isExperimental) + } + + @Test + fun `two Apps with same values are equal`() { + val app1 = App("WhatsApp", "com.whatsapp", false) + val app2 = App("WhatsApp", "com.whatsapp", false) + assertEquals(app1, app2) + assertEquals(app1.hashCode(), app2.hashCode()) + } + + @Test + fun `two Apps with different names are not equal`() { + val app1 = App("WhatsApp", "com.whatsapp") + val app2 = App("Telegram", "com.whatsapp") + assertNotEquals(app1, app2) + } + + @Test + fun `two Apps with different packageNames are not equal`() { + val app1 = App("WhatsApp", "com.whatsapp") + val app2 = App("WhatsApp", "org.telegram") + assertNotEquals(app1, app2) + } + + @Test + fun `two Apps with different isExperimental are not equal`() { + val app1 = App("Signal", "org.signal", false) + val app2 = App("Signal", "org.signal", true) + assertNotEquals(app1, app2) + } + + @Test + fun `copy creates modified instance`() { + val original = App("WhatsApp", "com.whatsapp", false) + val copy = original.copy(isExperimental = true) + assertEquals("WhatsApp", copy.name) + assertEquals("com.whatsapp", copy.packageName) + assertTrue(copy.isExperimental) + } + + @Test + fun `toString includes all fields`() { + val app = App("WhatsApp", "com.whatsapp", true) + val str = app.toString() + assertTrue(str.contains("WhatsApp")) + assertTrue(str.contains("com.whatsapp")) + assertTrue(str.contains("true")) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/ConstantsTest.kt b/app/src/test/java/com/parishod/watomatic/model/ConstantsTest.kt new file mode 100644 index 000000000..f63fdff4d --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/ConstantsTest.kt @@ -0,0 +1,122 @@ +package com.parishod.watomatic.model + +import com.parishod.watomatic.model.utils.Constants +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConstantsTest { + + @Test + fun `SUPPORTED_APPS contains WhatsApp`() { + assertTrue(Constants.SUPPORTED_APPS.any { it.packageName == "com.whatsapp" }) + } + + @Test + fun `SUPPORTED_APPS contains Telegram`() { + assertTrue(Constants.SUPPORTED_APPS.any { it.packageName == "org.telegram.messenger" }) + } + + @Test + fun `SUPPORTED_APPS contains Facebook Messenger`() { + assertTrue(Constants.SUPPORTED_APPS.any { it.packageName == "com.facebook.orca" }) + } + + @Test + fun `SUPPORTED_APPS contains Facebook Messenger Lite`() { + assertTrue(Constants.SUPPORTED_APPS.any { it.packageName == "com.facebook.mlite" }) + } + + @Test + fun `SUPPORTED_APPS contains LinkedIn`() { + assertTrue(Constants.SUPPORTED_APPS.any { it.packageName == "com.linkedin.android" }) + } + + @Test + fun `SUPPORTED_APPS has five entries`() { + assertEquals(5, Constants.SUPPORTED_APPS.size) + } + + @Test + fun `PROVIDER_URLS contains OpenAI`() { + assertNotNull(Constants.PROVIDER_URLS["OpenAI"]) + assertEquals("https://api.openai.com/", Constants.PROVIDER_URLS["OpenAI"]) + } + + @Test + fun `PROVIDER_URLS contains Claude`() { + assertNotNull(Constants.PROVIDER_URLS["Claude"]) + assertEquals("https://api.anthropic.com/", Constants.PROVIDER_URLS["Claude"]) + } + + @Test + fun `PROVIDER_URLS contains Gemini`() { + assertNotNull(Constants.PROVIDER_URLS["Gemini"]) + assertTrue(Constants.PROVIDER_URLS["Gemini"]!!.contains("googleapis.com")) + } + + @Test + fun `PROVIDER_URLS has all expected providers`() { + val expected = listOf("OpenAI", "Claude", "Grok", "Gemini", "DeepSeek", "Mistral") + for (provider in expected) { + assertTrue("$provider missing from PROVIDER_URLS", Constants.PROVIDER_URLS.containsKey(provider)) + } + } + + @Test + fun `all PROVIDER_URLS values end with slash`() { + for ((provider, url) in Constants.PROVIDER_URLS) { + assertTrue("URL for $provider should end with /", url.endsWith("/")) + } + } + + @Test + fun `DEFAULT_LLM_PROMPT is not empty`() { + assertTrue(Constants.DEFAULT_LLM_PROMPT.isNotEmpty()) + } + + @Test + fun `DEFAULT_LLM_MODEL is not empty`() { + assertTrue(Constants.DEFAULT_LLM_MODEL.isNotEmpty()) + } + + @Test + fun `DEFAULT_LLM_PROMPT contains instruction not to reveal being AI`() { + assertTrue(Constants.DEFAULT_LLM_PROMPT.contains("AI", ignoreCase = true)) + } + + @Test + fun `LinkedIn is marked as experimental`() { + val linkedin = Constants.SUPPORTED_APPS.first { it.packageName == "com.linkedin.android" } + assertTrue(linkedin.isExperimental) + } + + @Test + fun `non-LinkedIn apps are not experimental`() { + val nonExperimental = Constants.SUPPORTED_APPS.filter { it.packageName != "com.linkedin.android" } + assertFalse(nonExperimental.any { it.isExperimental }) + } + + @Test + fun `MIN_DAYS is 0`() { + assertEquals(0, Constants.MIN_DAYS) + } + + @Test + fun `MAX_DAYS is 30`() { + assertEquals(30, Constants.MAX_DAYS) + } + + @Test + fun `MIN_REPLIES_TO_ASK_APP_RATING is positive`() { + assertTrue(Constants.MIN_REPLIES_TO_ASK_APP_RATING > 0) + } + + @Test + fun `EMAIL_ADDRESS is set`() { + assertTrue(Constants.EMAIL_ADDRESS.isNotEmpty()) + assertTrue(Constants.EMAIL_ADDRESS.contains("@")) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/CustomRepliesDataTest.kt b/app/src/test/java/com/parishod/watomatic/model/CustomRepliesDataTest.kt new file mode 100644 index 000000000..3366f95d1 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/CustomRepliesDataTest.kt @@ -0,0 +1,301 @@ +package com.parishod.watomatic.model + +import android.content.Context +import android.text.Editable +import android.text.SpannableStringBuilder +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class CustomRepliesDataTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + // Reset singletons so each test starts fresh + CustomRepliesData.resetInstance() + PreferencesManager.resetInstance() + // Clear shared preferences storage + context.getSharedPreferences("CustomRepliesData", Context.MODE_PRIVATE) + .edit().clear().commit() + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + } + + @After + fun tearDown() { + CustomRepliesData.resetInstance() + PreferencesManager.resetInstance() + } + + // --- isValidCustomReply(String) --- + + @Test + fun `isValidCustomReply returns false for null string`() { + assertFalse(CustomRepliesData.isValidCustomReply(null as String?)) + } + + @Test + fun `isValidCustomReply returns false for empty string`() { + assertFalse(CustomRepliesData.isValidCustomReply("")) + } + + @Test + fun `isValidCustomReply returns true for valid string`() { + assertTrue(CustomRepliesData.isValidCustomReply("Hello, I'm busy right now")) + } + + @Test + fun `isValidCustomReply returns true for single character`() { + assertTrue(CustomRepliesData.isValidCustomReply("x")) + } + + @Test + fun `isValidCustomReply returns true for string at max length`() { + val maxString = "a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY) + assertTrue(CustomRepliesData.isValidCustomReply(maxString)) + } + + @Test + fun `isValidCustomReply returns false for string exceeding max length`() { + val tooLong = "a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY + 1) + assertFalse(CustomRepliesData.isValidCustomReply(tooLong)) + } + + @Test + fun `MAX_STR_LENGTH_CUSTOM_REPLY is 500`() { + assertEquals(500, CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY) + } + + @Test + fun `MAX_NUM_CUSTOM_REPLY is 10`() { + assertEquals(10, CustomRepliesData.MAX_NUM_CUSTOM_REPLY) + } + + // --- set and get --- + + @Test + fun `set and get round trip returns the stored reply`() { + val instance = CustomRepliesData.getInstance(context) + val reply = "I'm away right now, will reply soon" + instance.set(reply) + assertEquals(reply, instance.get()) + } + + @Test + fun `set returns the stored string on success`() { + val instance = CustomRepliesData.getInstance(context) + val reply = "Valid reply" + assertEquals(reply, instance.set(reply)) + } + + @Test + fun `set returns null for empty string`() { + val instance = CustomRepliesData.getInstance(context) + assertNull(instance.set("")) + } + + @Test + fun `set returns null for string exceeding max length`() { + val instance = CustomRepliesData.getInstance(context) + val tooLong = "a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY + 1) + assertNull(instance.set(tooLong)) + } + + @Test + fun `get returns latest reply after multiple sets`() { + val instance = CustomRepliesData.getInstance(context) + instance.set("first reply") + instance.set("second reply") + instance.set("third reply") + assertEquals("third reply", instance.get()) + } + + @Test + fun `set limits history to MAX_NUM_CUSTOM_REPLY entries`() { + val instance = CustomRepliesData.getInstance(context) + // Set more replies than the maximum + val total = CustomRepliesData.MAX_NUM_CUSTOM_REPLY + 5 + for (i in 1..total) { + instance.set("reply $i") + } + // The current (last) reply should be the most recent one + assertEquals("reply $total", instance.get()) + } + + // --- getOrElse --- + + @Test + fun `getOrElse returns the stored reply when set`() { + val instance = CustomRepliesData.getInstance(context) + instance.set("custom reply text") + assertEquals("custom reply text", instance.getOrElse("fallback")) + } + + @Test + fun `getOrElse returns default when no reply is stored`() { + // Override the key to simulate empty state + context.getSharedPreferences("CustomRepliesData", Context.MODE_PRIVATE) + .edit() + .putString(CustomRepliesData.KEY_CUSTOM_REPLY_ALL, "[]") + .commit() + val instance = CustomRepliesData.getInstance(context) + assertEquals("fallback text", instance.getOrElse("fallback text")) + } + + // --- RTL invisible char --- + + @Test + fun `RTL_ALIGN_INVISIBLE_CHAR is defined`() { + assertNotNull(CustomRepliesData.RTL_ALIGN_INVISIBLE_CHAR) + assertTrue(CustomRepliesData.RTL_ALIGN_INVISIBLE_CHAR.isNotEmpty()) + } + + // --- getTextToSendOrElse --- + + @Test + fun `getTextToSendOrElse returns custom reply when AI not enabled`() { + val instance = CustomRepliesData.getInstance(context) + instance.set("My custom reply") + val result = instance.getTextToSendOrElse() + assertEquals("My custom reply", result) + } + + @Test + fun `getTextToSendOrElse returns non-empty string when automatic AI enabled`() { + val prefsInstance = PreferencesManager.getPreferencesInstance(context) + prefsInstance.setEnableAutomaticAiReplies(true) + val instance = CustomRepliesData.getInstance(context) + val result = instance.getTextToSendOrElse() + assertNotNull(result) + assertTrue(result.isNotEmpty()) + } + + @Test + fun `getTextToSendOrElse returns non-empty string when BYOK enabled`() { + val prefsInstance = PreferencesManager.getPreferencesInstance(context) + prefsInstance.setEnableByokReplies(true) + val instance = CustomRepliesData.getInstance(context) + val result = instance.getTextToSendOrElse() + assertNotNull(result) + assertTrue(result.isNotEmpty()) + } + + @Test + fun `getTextToSendOrElse appends RTL attribution when attribution enabled`() { + val prefsInstance = PreferencesManager.getPreferencesInstance(context) + prefsInstance.setAppendWatomaticAttribution(true) + val instance = CustomRepliesData.getInstance(context) + instance.set("My reply") + val result = instance.getTextToSendOrElse() + assertTrue(result.contains(CustomRepliesData.RTL_ALIGN_INVISIBLE_CHAR)) + } + + @Test + fun `getTextToSendOrElse does not append attribution when attribution disabled`() { + val prefsInstance = PreferencesManager.getPreferencesInstance(context) + prefsInstance.setAppendWatomaticAttribution(false) + val instance = CustomRepliesData.getInstance(context) + instance.set("My reply") + val result = instance.getTextToSendOrElse() + assertFalse(result.contains(CustomRepliesData.RTL_ALIGN_INVISIBLE_CHAR)) + } + + @Test + fun `getTextToSendOrElse AI result does not equal custom reply text`() { + val prefsInstance = PreferencesManager.getPreferencesInstance(context) + prefsInstance.setEnableAutomaticAiReplies(true) + val instance = CustomRepliesData.getInstance(context) + instance.set("My very specific custom reply 12345") + val aiResult = instance.getTextToSendOrElse() + // When AI is enabled, should return AI default message, not the stored custom reply + assertFalse(aiResult == "My very specific custom reply 12345") + } + + // --- set(Editable) overload --- + + @Test + fun `set with null Editable returns null`() { + val instance = CustomRepliesData.getInstance(context) + assertNull(instance.set(null as Editable?)) + } + + @Test + fun `set with valid Editable stores and returns string`() { + val instance = CustomRepliesData.getInstance(context) + val editable: Editable = SpannableStringBuilder("Valid editable reply") + val result = instance.set(editable) + assertEquals("Valid editable reply", result) + assertEquals("Valid editable reply", instance.get()) + } + + @Test + fun `set with empty Editable returns null`() { + val instance = CustomRepliesData.getInstance(context) + val editable: Editable = SpannableStringBuilder("") + assertNull(instance.set(editable)) + } + + // --- isValidCustomReply(Editable) overload --- + + @Test + fun `isValidCustomReply returns false for null Editable`() { + assertFalse(CustomRepliesData.isValidCustomReply(null as Editable?)) + } + + @Test + fun `isValidCustomReply returns true for valid Editable`() { + val editable: Editable = SpannableStringBuilder("Valid input") + assertTrue(CustomRepliesData.isValidCustomReply(editable)) + } + + @Test + fun `isValidCustomReply returns false for empty Editable`() { + val editable: Editable = SpannableStringBuilder("") + assertFalse(CustomRepliesData.isValidCustomReply(editable)) + } + + @Test + fun `isValidCustomReply returns false for Editable exceeding max length`() { + val tooLong: Editable = SpannableStringBuilder("a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY + 1)) + assertFalse(CustomRepliesData.isValidCustomReply(tooLong)) + } + + @Test + fun `isValidCustomReply returns true for Editable at max length`() { + val atMax: Editable = SpannableStringBuilder("a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY)) + assertTrue(CustomRepliesData.isValidCustomReply(atMax)) + } + + // --- Edge cases --- + + @Test + fun `get returns non-null default when fresh instance created`() { + val instance = CustomRepliesData.getInstance(context) + // init() sets a default reply, so get() returns non-null on fresh instance + assertNotNull(instance.get()) + } + + @Test + fun `set with MAX_STR_LENGTH_CUSTOM_REPLY characters succeeds`() { + val instance = CustomRepliesData.getInstance(context) + val longReply = "a".repeat(CustomRepliesData.MAX_STR_LENGTH_CUSTOM_REPLY) + val result = instance.set(longReply) + assertEquals(longReply, result) + assertEquals(longReply, instance.get()) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/GithubReleaseNotesTest.kt b/app/src/test/java/com/parishod/watomatic/model/GithubReleaseNotesTest.kt new file mode 100644 index 000000000..171d94902 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/GithubReleaseNotesTest.kt @@ -0,0 +1,113 @@ +package com.parishod.watomatic.model + +import android.os.Parcel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class GithubReleaseNotesTest { + + @Test + fun `no-arg constructor defaults all fields to null`() { + val notes = GithubReleaseNotes() + assertNull(notes.id) + assertNull(notes.tagName) + assertNull(notes.body) + } + + @Test + fun `setId and getId round-trip`() { + val notes = GithubReleaseNotes() + notes.id = 12345 + assertEquals(12345, notes.id) + } + + @Test + fun `setTagName and getTagName round-trip`() { + val notes = GithubReleaseNotes() + notes.tagName = "v1.35" + assertEquals("v1.35", notes.tagName) + } + + @Test + fun `setBody and getBody round-trip`() { + val notes = GithubReleaseNotes() + notes.body = "Bug fixes and performance improvements." + assertEquals("Bug fixes and performance improvements.", notes.body) + } + + @Test + fun `writeToParcel and readFromParcel preserves all fields`() { + val original = GithubReleaseNotes() + original.id = 99 + original.tagName = "v2.0" + original.body = "New feature release" + + val parcel = Parcel.obtain() + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = GithubReleaseNotes() + restored.readFromParcel(parcel) + parcel.recycle() + + assertEquals(99, restored.id) + assertEquals("v2.0", restored.tagName) + assertEquals("New feature release", restored.body) + } + + @Test + fun `CREATOR createFromParcel restores object`() { + val original = GithubReleaseNotes() + original.id = 77 + original.tagName = "v1.0" + original.body = "Initial release" + + val parcel = Parcel.obtain() + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = GithubReleaseNotes.CREATOR.createFromParcel(parcel) + parcel.recycle() + + assertEquals(77, restored.id) + assertEquals("v1.0", restored.tagName) + assertEquals("Initial release", restored.body) + } + + @Test + fun `CREATOR newArray creates array of requested size`() { + val array = GithubReleaseNotes.CREATOR.newArray(3) + assertNotNull(array) + assertEquals(3, array.size) + } + + @Test + fun `describeContents returns 0`() { + val notes = GithubReleaseNotes() + assertEquals(0, notes.describeContents()) + } + + @Test + fun `writeToParcel handles null fields`() { + val original = GithubReleaseNotes() + // All fields remain null + + val parcel = Parcel.obtain() + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = GithubReleaseNotes.CREATOR.createFromParcel(parcel) + parcel.recycle() + + assertNull(restored.id) + assertNull(restored.tagName) + assertNull(restored.body) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/data/ContactHolderTest.kt b/app/src/test/java/com/parishod/watomatic/model/data/ContactHolderTest.kt new file mode 100644 index 000000000..e0be2884d --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/data/ContactHolderTest.kt @@ -0,0 +1,66 @@ +package com.parishod.watomatic.model.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContactHolderTest { + + @Test + fun `three-arg constructor stores name phone and checked`() { + val holder = ContactHolder("Alice", "+1234567890", true) + assertEquals("Alice", holder.contactName) + assertEquals("+1234567890", holder.phoneNumber) + assertTrue(holder.isChecked) + } + + @Test + fun `three-arg constructor defaults isCustom to false`() { + val holder = ContactHolder("Alice", "+1234567890", true) + assertFalse(holder.isCustom) + } + + @Test + fun `custom constructor stores name checked and custom`() { + val holder = ContactHolder("Bob", true, true) + assertEquals("Bob", holder.contactName) + assertTrue(holder.isChecked) + assertTrue(holder.isCustom) + } + + @Test + fun `custom constructor sets phoneNumber to null`() { + val holder = ContactHolder("Bob", false, true) + assertNull(holder.phoneNumber) + } + + @Test + fun `setChecked updates value`() { + val holder = ContactHolder("Alice", "+1234567890", false) + assertFalse(holder.isChecked) + holder.isChecked = true + assertTrue(holder.isChecked) + } + + @Test + fun `setCustom updates value`() { + val holder = ContactHolder("Alice", "+1234567890", false) + assertFalse(holder.isCustom) + holder.isCustom = true + assertTrue(holder.isCustom) + } + + @Test + fun `contactName is immutable after construction`() { + val holder = ContactHolder("Alice", "+1234567890", false) + assertEquals("Alice", holder.contactName) + } + + @Test + fun `phoneNumber is immutable after construction`() { + val holder = ContactHolder("Alice", "+1234567890", false) + assertEquals("+1234567890", holder.phoneNumber) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/logs/AppPackageTest.kt b/app/src/test/java/com/parishod/watomatic/model/logs/AppPackageTest.kt new file mode 100644 index 000000000..3e09b845c --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/logs/AppPackageTest.kt @@ -0,0 +1,33 @@ +package com.parishod.watomatic.model.logs + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppPackageTest { + + @Test + fun `constructor stores packageName`() { + val pkg = AppPackage("com.whatsapp") + assertEquals("com.whatsapp", pkg.packageName) + } + + @Test + fun `index defaults to 0`() { + val pkg = AppPackage("com.whatsapp") + assertEquals(0, pkg.index) + } + + @Test + fun `setIndex updates value`() { + val pkg = AppPackage("com.whatsapp") + pkg.index = 42 + assertEquals(42, pkg.index) + } + + @Test + fun `setPackageName updates value`() { + val pkg = AppPackage("com.whatsapp") + pkg.packageName = "org.telegram" + assertEquals("org.telegram", pkg.packageName) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/logs/MessageLogTest.kt b/app/src/test/java/com/parishod/watomatic/model/logs/MessageLogTest.kt new file mode 100644 index 000000000..fe98d9c74 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/logs/MessageLogTest.kt @@ -0,0 +1,131 @@ +package com.parishod.watomatic.model.logs + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MessageLogTest { + + private fun buildLog( + index: Int = 1, + title: String = "John Doe", + arrivedTime: Long = 1_000_000L, + repliedMsg: String = "I'm busy", + replyTime: Long = 1_001_000L + ) = MessageLog(index, title, arrivedTime, repliedMsg, replyTime) + + @Test + fun `constructor stores index`() { + val log = buildLog(index = 7) + assertEquals(7, log.index) + } + + @Test + fun `constructor stores notifTitle`() { + val log = buildLog(title = "Alice") + assertEquals("Alice", log.notifTitle) + } + + @Test + fun `constructor stores notifArrivedTime`() { + val log = buildLog(arrivedTime = 9_999_000L) + assertEquals(9_999_000L, log.notifArrivedTime) + } + + @Test + fun `constructor stores notifRepliedMsg`() { + val log = buildLog(repliedMsg = "Will call you back") + assertEquals("Will call you back", log.notifRepliedMsg) + } + + @Test + fun `constructor stores notifReplyTime`() { + val log = buildLog(replyTime = 5_000_000L) + assertEquals(5_000_000L, log.notifReplyTime) + } + + @Test + fun `constructor sets notifIsReplied to true`() { + val log = buildLog() + assertTrue(log.isNotifIsReplied) + } + + @Test + fun `constructor generates non-null notifId`() { + val log = buildLog() + assertNotNull(log.notifId) + assertTrue(log.notifId.isNotEmpty()) + } + + @Test + fun `each MessageLog gets a unique notifId`() { + val log1 = buildLog() + val log2 = buildLog() + assertTrue(log1.notifId != log2.notifId) + } + + @Test + fun `setId persists value`() { + val log = buildLog() + log.id = 42 + assertEquals(42, log.id) + } + + @Test + fun `setIndex persists value`() { + val log = buildLog(index = 1) + log.index = 99 + assertEquals(99, log.index) + } + + @Test + fun `setNotifId persists value`() { + val log = buildLog() + log.notifId = "custom-uuid" + assertEquals("custom-uuid", log.notifId) + } + + @Test + fun `setNotifTitle persists value`() { + val log = buildLog(title = "original") + log.notifTitle = "updated" + assertEquals("updated", log.notifTitle) + } + + @Test + fun `setNotifArrivedTime persists value`() { + val log = buildLog(arrivedTime = 0L) + log.notifArrivedTime = 12_345_678L + assertEquals(12_345_678L, log.notifArrivedTime) + } + + @Test + fun `setNotifIsReplied persists false`() { + val log = buildLog() + log.isNotifIsReplied = false + assertEquals(false, log.isNotifIsReplied) + } + + @Test + fun `setNotifRepliedMsg persists value`() { + val log = buildLog(repliedMsg = "original reply") + log.notifRepliedMsg = "updated reply" + assertEquals("updated reply", log.notifRepliedMsg) + } + + @Test + fun `setNotifReplyTime persists value`() { + val log = buildLog(replyTime = 0L) + log.notifReplyTime = 9_876_543L + assertEquals(9_876_543L, log.notifReplyTime) + } + + @Test + fun `notifId is a valid UUID format`() { + val log = buildLog() + // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + val uuidRegex = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + assertTrue(log.notifId.matches(uuidRegex)) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/preferences/PreferencesManagerTest.kt b/app/src/test/java/com/parishod/watomatic/model/preferences/PreferencesManagerTest.kt new file mode 100644 index 000000000..f67e142d6 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/preferences/PreferencesManagerTest.kt @@ -0,0 +1,865 @@ +package com.parishod.watomatic.model.preferences + +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import com.parishod.watomatic.model.App +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class PreferencesManagerTest { + + private lateinit var context: Context + private lateinit var prefs: PreferencesManager + + @Before + fun setUp() { + PreferencesManager.resetInstance() + context = ApplicationProvider.getApplicationContext() + // Clear default shared prefs to get a truly fresh state each test + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + prefs = PreferencesManager.getPreferencesInstance(context) + } + + @After + fun tearDown() { + PreferencesManager.resetInstance() + } + + // --- Service enabled --- + + @Test + fun `isServiceEnabled defaults to false`() { + assertFalse(prefs.isServiceEnabled) + } + + @Test + fun `setServicePref to true enables service`() { + prefs.setServicePref(true) + assertTrue(prefs.isServiceEnabled) + } + + @Test + fun `setServicePref to false disables service`() { + prefs.setServicePref(true) + prefs.setServicePref(false) + assertFalse(prefs.isServiceEnabled) + } + + // --- Group reply --- + + @Test + fun `isGroupReplyEnabled defaults to false`() { + assertFalse(prefs.isGroupReplyEnabled) + } + + @Test + fun `setGroupReplyPref to true enables group reply`() { + prefs.setGroupReplyPref(true) + assertTrue(prefs.isGroupReplyEnabled) + } + + @Test + fun `setGroupReplyPref to false disables group reply`() { + prefs.setGroupReplyPref(true) + prefs.setGroupReplyPref(false) + assertFalse(prefs.isGroupReplyEnabled) + } + + // --- Auto reply delay --- + + @Test + fun `getAutoReplyDelay defaults to 0`() { + assertEquals(0L, prefs.autoReplyDelay) + } + + @Test + fun `setAutoReplyDelay persists the value`() { + prefs.setAutoReplyDelay(5000L) + assertEquals(5000L, prefs.autoReplyDelay) + } + + @Test + fun `setAutoReplyDelay stores zero correctly`() { + prefs.setAutoReplyDelay(5000L) + prefs.setAutoReplyDelay(0L) + assertEquals(0L, prefs.autoReplyDelay) + } + + // --- Watomatic attribution --- + + @Test + fun `isAppendWatomaticAttributionEnabled defaults to false`() { + assertFalse(prefs.isAppendWatomaticAttributionEnabled) + } + + @Test + fun `setAppendWatomaticAttribution to true enables it`() { + prefs.setAppendWatomaticAttribution(true) + assertTrue(prefs.isAppendWatomaticAttributionEnabled) + } + + // --- Contact reply --- + + @Test + fun `isContactReplyEnabled defaults to false`() { + assertFalse(prefs.isContactReplyEnabled) + } + + @Test + fun `setContactReplyEnabled persists value`() { + prefs.setContactReplyEnabled(true) + assertTrue(prefs.isContactReplyEnabled) + } + + // --- Contact reply mode (blacklist vs whitelist) --- + + @Test + fun `isContactReplyBlacklistMode defaults to true`() { + assertTrue(prefs.isContactReplyBlacklistMode) + } + + @Test + fun `setContactReplyBlacklistMode false switches to whitelist mode`() { + prefs.setContactReplyBlacklistMode(false) + assertFalse(prefs.isContactReplyBlacklistMode) + } + + @Test + fun `setContactReplyBlacklistMode true restores blacklist mode`() { + prefs.setContactReplyBlacklistMode(false) + prefs.setContactReplyBlacklistMode(true) + assertTrue(prefs.isContactReplyBlacklistMode) + } + + // --- AI replies --- + + @Test + fun `isAutomaticAiRepliesEnabled defaults to false`() { + assertFalse(prefs.isAutomaticAiRepliesEnabled) + } + + @Test + fun `setEnableAutomaticAiReplies enables automatic AI`() { + prefs.setEnableAutomaticAiReplies(true) + assertTrue(prefs.isAutomaticAiRepliesEnabled) + } + + @Test + fun `setEnableAutomaticAiReplies disables automatic AI`() { + prefs.setEnableAutomaticAiReplies(true) + prefs.setEnableAutomaticAiReplies(false) + assertFalse(prefs.isAutomaticAiRepliesEnabled) + } + + @Test + fun `isByokRepliesEnabled defaults to false`() { + assertFalse(prefs.isByokRepliesEnabled) + } + + @Test + fun `setEnableByokReplies enables BYOK`() { + prefs.setEnableByokReplies(true) + assertTrue(prefs.isByokRepliesEnabled) + } + + @Test + fun `isAnyAiRepliesEnabled returns false when both disabled`() { + assertFalse(prefs.isAnyAiRepliesEnabled) + } + + @Test + fun `isAnyAiRepliesEnabled returns true when automatic AI enabled`() { + prefs.setEnableAutomaticAiReplies(true) + assertTrue(prefs.isAnyAiRepliesEnabled) + } + + @Test + fun `isAnyAiRepliesEnabled returns true when BYOK enabled`() { + prefs.setEnableByokReplies(true) + assertTrue(prefs.isAnyAiRepliesEnabled) + } + + @Test + fun `isAnyAiRepliesEnabled returns true when both enabled`() { + prefs.setEnableAutomaticAiReplies(true) + prefs.setEnableByokReplies(true) + assertTrue(prefs.isAnyAiRepliesEnabled) + } + + // --- Login state --- + + @Test + fun `isLoggedIn defaults to false`() { + assertFalse(prefs.isLoggedIn) + } + + @Test + fun `setLoggedIn to true persists`() { + prefs.setLoggedIn(true) + assertTrue(prefs.isLoggedIn) + } + + @Test + fun `setLoggedIn to false persists`() { + prefs.setLoggedIn(true) + prefs.setLoggedIn(false) + assertFalse(prefs.isLoggedIn) + } + + // --- Guest mode --- + + @Test + fun `isGuestMode defaults to false`() { + assertFalse(prefs.isGuestMode) + } + + @Test + fun `setGuestMode to true persists`() { + prefs.setGuestMode(true) + assertTrue(prefs.isGuestMode) + } + + // --- shouldShowLogin --- + + @Test + fun `shouldShowLogin returns true when not logged in and not in guest mode`() { + assertTrue(prefs.shouldShowLogin()) + } + + @Test + fun `shouldShowLogin returns false when logged in`() { + prefs.setLoggedIn(true) + assertFalse(prefs.shouldShowLogin()) + } + + @Test + fun `shouldShowLogin returns false in guest mode`() { + prefs.setGuestMode(true) + assertFalse(prefs.shouldShowLogin()) + } + + // --- Subscription --- + + @Test + fun `isSubscriptionActive defaults to false`() { + assertFalse(prefs.isSubscriptionActive) + } + + @Test + fun `isSubscriptionActive returns true when active with zero expiry time`() { + prefs.setSubscriptionActive(true) + // expiry = 0 means lifetime/unknown → considered active + assertTrue(prefs.isSubscriptionActive) + } + + @Test + fun `isSubscriptionActive returns true when active and not yet expired`() { + prefs.setSubscriptionActive(true) + prefs.setSubscriptionExpiryTime(System.currentTimeMillis() + 60_000L) // expires in 1 min + assertTrue(prefs.isSubscriptionActive) + } + + @Test + fun `isSubscriptionActive returns false when active but already expired`() { + prefs.setSubscriptionActive(true) + prefs.setSubscriptionExpiryTime(System.currentTimeMillis() - 1_000L) // expired 1 sec ago + assertFalse(prefs.isSubscriptionActive) + } + + @Test + fun `isSubscriptionActive returns false when inactive regardless of expiry`() { + prefs.setSubscriptionActive(false) + prefs.setSubscriptionExpiryTime(System.currentTimeMillis() + 60_000L) + assertFalse(prefs.isSubscriptionActive) + } + + @Test + fun `getSubscriptionExpiryTime defaults to 0`() { + assertEquals(0L, prefs.subscriptionExpiryTime) + } + + @Test + fun `setSubscriptionExpiryTime persists value`() { + val time = System.currentTimeMillis() + 86_400_000L + prefs.setSubscriptionExpiryTime(time) + assertEquals(time, prefs.subscriptionExpiryTime) + } + + @Test + fun `getSubscriptionProductId defaults to empty string`() { + assertEquals("", prefs.subscriptionProductId) + } + + @Test + fun `setSubscriptionProductId persists value`() { + prefs.setSubscriptionProductId("pro_monthly") + assertEquals("pro_monthly", prefs.subscriptionProductId) + } + + @Test + fun `isSubscriptionAutoRenewing defaults to false`() { + assertFalse(prefs.isSubscriptionAutoRenewing) + } + + @Test + fun `setSubscriptionAutoRenewing persists value`() { + prefs.setSubscriptionAutoRenewing(true) + assertTrue(prefs.isSubscriptionAutoRenewing) + } + + // --- Remaining atoms --- + + @Test + fun `getRemainingAtoms defaults to minus one`() { + assertEquals(-1, prefs.getRemainingAtoms()) + } + + @Test + fun `setRemainingAtoms persists value`() { + prefs.setRemainingAtoms(100) + assertEquals(100, prefs.getRemainingAtoms()) + } + + @Test + fun `setRemainingAtoms persists zero`() { + prefs.setRemainingAtoms(0) + assertEquals(0, prefs.getRemainingAtoms()) + } + + // --- Quota notification --- + + @Test + fun `getQuotaNotificationLastShown defaults to 0`() { + assertEquals(0L, prefs.getQuotaNotificationLastShown()) + } + + @Test + fun `setQuotaNotificationLastShown persists value`() { + val time = System.currentTimeMillis() + prefs.setQuotaNotificationLastShown(time) + assertEquals(time, prefs.getQuotaNotificationLastShown()) + } + + // --- Firebase token --- + + @Test + fun `getFirebaseToken defaults to empty string`() { + assertEquals("", prefs.firebaseToken) + } + + @Test + fun `setFirebaseToken persists value`() { + prefs.setFirebaseToken("test-firebase-token-123") + assertEquals("test-firebase-token-123", prefs.firebaseToken) + } + + // --- Fallback message --- + + @Test + fun `getFallbackMessage defaults to empty string`() { + assertEquals("", prefs.fallbackMessage) + } + + @Test + fun `saveFallbackMessage persists value`() { + prefs.saveFallbackMessage("Sorry, I'm away right now") + assertEquals("Sorry, I'm away right now", prefs.fallbackMessage) + } + + // --- AI custom prompts --- + + @Test + fun `getOpenAICustomPrompt returns null by default`() { + assertNull(prefs.openAICustomPrompt) + } + + @Test + fun `saveOpenAICustomPrompt persists value`() { + prefs.saveOpenAICustomPrompt("Reply concisely") + assertEquals("Reply concisely", prefs.openAICustomPrompt) + } + + @Test + fun `getAtomaticAICustomPrompt defaults to empty string`() { + assertEquals("", prefs.atomaticAICustomPrompt) + } + + @Test + fun `saveAtomaticAICustomPrompt persists value`() { + prefs.saveAtomaticAICustomPrompt("Be brief and friendly") + assertEquals("Be brief and friendly", prefs.atomaticAICustomPrompt) + } + + // --- AI provider source --- + + @Test + fun `getOpenApiSource defaults to openai`() { + assertEquals("openai", prefs.openApiSource) + } + + @Test + fun `saveOpenApiSource persists value`() { + prefs.saveOpenApiSource("Claude") + assertEquals("Claude", prefs.openApiSource) + } + + // --- Custom OpenAI URL --- + + @Test + fun `getCustomOpenAIApiUrl defaults to null`() { + assertNull(prefs.customOpenAIApiUrl) + } + + @Test + fun `saveCustomOpenAIApiUrl persists value`() { + prefs.saveCustomOpenAIApiUrl("https://my-api.example.com/") + assertEquals("https://my-api.example.com/", prefs.customOpenAIApiUrl) + } + + // --- OpenAI model --- + + @Test + fun `getSelectedOpenAIModel defaults to null`() { + assertNull(prefs.selectedOpenAIModel) + } + + @Test + fun `saveSelectedOpenAIModel persists value`() { + prefs.saveSelectedOpenAIModel("gpt-4o") + assertEquals("gpt-4o", prefs.selectedOpenAIModel) + } + + // --- User email --- + + @Test + fun `getUserEmail defaults to empty string`() { + assertEquals("", prefs.userEmail) + } + + @Test + fun `setUserEmail persists value`() { + prefs.setUserEmail("user@example.com") + assertEquals("user@example.com", prefs.userEmail) + } + + // --- Locale parsing --- + + @Test + fun `getSelectedLocale returns device default when no language set`() { + val locale = prefs.selectedLocale + assertNotNull(locale) + assertEquals(Locale.getDefault(), locale) + } + + @Test + fun `getSelectedLocale parses language-region format correctly`() { + prefs.setLanguageStr("en-US") + val locale = prefs.selectedLocale + assertEquals("en", locale.language) + assertEquals("US", locale.country) + } + + @Test + fun `getSelectedLocale parses language-only format correctly`() { + prefs.setLanguageStr("de") + val locale = prefs.selectedLocale + assertEquals("de", locale.language) + } + + @Test + fun `getSelectedLocale parses Chinese simplified correctly`() { + prefs.setLanguageStr("zh-CN") + val locale = prefs.selectedLocale + assertEquals("zh", locale.language) + assertEquals("CN", locale.country) + } + + // --- Legacy language key migration --- + + @Test + fun `updateLegacyLanguageKey migrates old format with r prefix`() { + prefs.setLanguageStr("zh-rCN") + prefs.updateLegacyLanguageKey() + assertEquals("zh-CN", prefs.getSelectedLanguageStr(null)) + } + + @Test + fun `updateLegacyLanguageKey does not change modern language-country format`() { + prefs.setLanguageStr("zh-CN") + prefs.updateLegacyLanguageKey() + assertEquals("zh-CN", prefs.getSelectedLanguageStr(null)) + } + + @Test + fun `updateLegacyLanguageKey does nothing when no language set`() { + // Should not throw when called with no language stored + prefs.updateLegacyLanguageKey() + assertNull(prefs.getSelectedLanguageStr(null)) + } + + @Test + fun `updateLegacyLanguageKey does not change language-only value`() { + prefs.setLanguageStr("de") + prefs.updateLegacyLanguageKey() + assertEquals("de", prefs.getSelectedLanguageStr(null)) + } + + // --- Persistent AI errors --- + + @Test + fun `getOpenAILastPersistentErrorMessage returns null by default`() { + assertNull(prefs.openAILastPersistentErrorMessage) + } + + @Test + fun `saveOpenAILastPersistentError persists message and timestamp`() { + val ts = System.currentTimeMillis() + prefs.saveOpenAILastPersistentError("Rate limit exceeded", ts) + assertEquals("Rate limit exceeded", prefs.openAILastPersistentErrorMessage) + assertEquals(ts, prefs.openAILastPersistentErrorTimestamp) + } + + @Test + fun `clearOpenAILastPersistentError removes both message and timestamp`() { + prefs.saveOpenAILastPersistentError("Some error", System.currentTimeMillis()) + prefs.clearOpenAILastPersistentError() + assertNull(prefs.openAILastPersistentErrorMessage) + assertEquals(0L, prefs.openAILastPersistentErrorTimestamp) + } + + // --- Last verified time --- + + @Test + fun `getLastVerifiedTime defaults to 0`() { + assertEquals(0L, prefs.lastVerifiedTime) + } + + @Test + fun `setLastVerifiedTime persists value`() { + val time = System.currentTimeMillis() + prefs.setLastVerifiedTime(time) + assertEquals(time, prefs.lastVerifiedTime) + } + + // --- Subscription plan --- + + @Test + fun `getSubscriptionPlanType defaults to empty string`() { + assertEquals("", prefs.subscriptionPlanType) + } + + @Test + fun `setSubscriptionPlanType persists value`() { + prefs.setSubscriptionPlanType("pro") + assertEquals("pro", prefs.subscriptionPlanType) + } + + // --- Show notification preference --- + + @Test + fun `isShowNotificationEnabled is true after new install`() { + // init() calls setShowNotificationPref(true) for new installs (fresh cleared prefs) + assertTrue(prefs.isShowNotificationEnabled) + } + + @Test + fun `setShowNotificationPref persists false`() { + prefs.setShowNotificationPref(false) + assertFalse(prefs.isShowNotificationEnabled) + } + + @Test + fun `setShowNotificationPref persists true`() { + prefs.setShowNotificationPref(false) + prefs.setShowNotificationPref(true) + assertTrue(prefs.isShowNotificationEnabled) + } + + // --- GitHub release notes ID --- + + @Test + fun `getGithubReleaseNotesId defaults to 0`() { + assertEquals(0, prefs.getGithubReleaseNotesId()) + } + + @Test + fun `setGithubReleaseNotesId persists value`() { + prefs.setGithubReleaseNotesId(42) + assertEquals(42, prefs.getGithubReleaseNotesId()) + } + + @Test + fun `setGithubReleaseNotesId overwrites previous value`() { + prefs.setGithubReleaseNotesId(100) + prefs.setGithubReleaseNotesId(200) + assertEquals(200, prefs.getGithubReleaseNotesId()) + } + + // --- Last purged time --- + + @Test + fun `getLastPurgedTime defaults to 0`() { + assertEquals(0L, prefs.getLastPurgedTime()) + } + + @Test + fun `setPurgeMessageTime persists value`() { + val time = 1_234_567_890L + prefs.setPurgeMessageTime(time) + assertEquals(time, prefs.getLastPurgedTime()) + } + + // --- Play store rating --- + + @Test + fun `getPlayStoreRatingStatus defaults to empty string`() { + assertEquals("", prefs.getPlayStoreRatingStatus()) + } + + @Test + fun `setPlayStoreRatingStatus persists value`() { + prefs.setPlayStoreRatingStatus("done") + assertEquals("done", prefs.getPlayStoreRatingStatus()) + } + + @Test + fun `getPlayStoreRatingLastTime defaults to 0`() { + assertEquals(0L, prefs.getPlayStoreRatingLastTime()) + } + + @Test + fun `setPlayStoreRatingLastTime persists value`() { + val time = 9_876_543_210L + prefs.setPlayStoreRatingLastTime(time) + assertEquals(time, prefs.getPlayStoreRatingLastTime()) + } + + // --- Foreground service notification --- + + @Test + fun `isForegroundServiceNotificationEnabled defaults to false`() { + assertFalse(prefs.isForegroundServiceNotificationEnabled) + } + + @Test + fun `setShowForegroundServiceNotification persists true`() { + prefs.setShowForegroundServiceNotification(true) + assertTrue(prefs.isForegroundServiceNotificationEnabled) + } + + @Test + fun `setShowForegroundServiceNotification persists false`() { + prefs.setShowForegroundServiceNotification(true) + prefs.setShowForegroundServiceNotification(false) + assertFalse(prefs.isForegroundServiceNotificationEnabled) + } + + // --- Reply to names --- + + @Test + fun `getReplyToNames defaults to empty set`() { + assertTrue(prefs.getReplyToNames().isEmpty()) + } + + @Test + fun `setReplyToNames persists single name`() { + prefs.setReplyToNames(setOf("Alice")) + assertEquals(setOf("Alice"), prefs.getReplyToNames()) + } + + @Test + fun `setReplyToNames persists multiple names`() { + val names = setOf("Alice", "Bob", "Charlie") + prefs.setReplyToNames(names) + assertEquals(names, prefs.getReplyToNames()) + } + + @Test + fun `setReplyToNames can overwrite with empty set`() { + prefs.setReplyToNames(setOf("Alice")) + prefs.setReplyToNames(emptySet()) + assertTrue(prefs.getReplyToNames().isEmpty()) + } + + // --- Custom reply names --- + + @Test + fun `getCustomReplyNames defaults to empty set`() { + assertTrue(prefs.getCustomReplyNames().isEmpty()) + } + + @Test + fun `setCustomReplyNames persists names`() { + val names = setOf("Alice", "Bob") + prefs.setCustomReplyNames(names) + assertEquals(names, prefs.getCustomReplyNames()) + } + + // --- Generic getString / saveString --- + + @Test + fun `saveString and getString round trip`() { + prefs.saveString("test_custom_key", "test_custom_value") + assertEquals("test_custom_value", prefs.getString("test_custom_key", "default")) + } + + @Test + fun `getString returns default when key not present`() { + assertEquals("my_default", prefs.getString("nonexistent_key_xyz_abc", "my_default")) + } + + @Test + fun `saveString overwrites previous value`() { + prefs.saveString("overwrite_key", "first_value") + prefs.saveString("overwrite_key", "second_value") + assertEquals("second_value", prefs.getString("overwrite_key", "default")) + } + + // --- Subscription status last checked --- + + @Test + fun `getSubscriptionStatusLastChecked defaults to 0`() { + assertEquals(0L, prefs.getSubscriptionStatusLastChecked()) + } + + @Test + fun `setSubscriptionStatusLastChecked persists value`() { + val time = System.currentTimeMillis() + prefs.setSubscriptionStatusLastChecked(time) + assertEquals(time, prefs.getSubscriptionStatusLastChecked()) + } + + // --- Subscription product name --- + + @Test + fun `getSubscriptionProductName defaults to empty string`() { + assertEquals("", prefs.getSubscriptionProductName()) + } + + @Test + fun `setSubscriptionProductName persists value`() { + prefs.setSubscriptionProductName("Watomatic Pro Monthly") + assertEquals("Watomatic Pro Monthly", prefs.getSubscriptionProductName()) + } + + // --- Deprecated isOpenAIRepliesEnabled --- + + @Test + fun `isOpenAIRepliesEnabled defaults to false`() { + @Suppress("DEPRECATION") + assertFalse(prefs.isOpenAIRepliesEnabled()) + } + + @Test + fun `setEnableOpenAIReplies persists value`() { + prefs.setEnableOpenAIReplies(true) + @Suppress("DEPRECATION") + assertTrue(prefs.isOpenAIRepliesEnabled()) + } + + // --- Enabled apps --- + + @Test + fun `saveEnabledApps by package name adds package`() { + prefs.saveEnabledApps("com.whatsapp", true) + assertTrue(prefs.isAppEnabled("com.whatsapp")) + } + + @Test + fun `saveEnabledApps by package name removes package`() { + prefs.saveEnabledApps("com.whatsapp", true) + prefs.saveEnabledApps("com.whatsapp", false) + assertFalse(prefs.isAppEnabled("com.whatsapp")) + } + + @Test + fun `saveEnabledApps can add multiple packages`() { + prefs.saveEnabledApps("com.whatsapp", true) + prefs.saveEnabledApps("org.telegram.messenger", true) + assertTrue(prefs.isAppEnabled("com.whatsapp")) + assertTrue(prefs.isAppEnabled("org.telegram.messenger")) + } + + @Test + fun `saveEnabledApps with App object adds package`() { + val app = App("WhatsApp", "com.whatsapp", false) + prefs.saveEnabledApps(app, true) + assertTrue(prefs.isAppEnabled(app)) + } + + @Test + fun `saveEnabledApps with App object removes package`() { + val app = App("WhatsApp", "com.whatsapp", false) + prefs.saveEnabledApps(app, true) + prefs.saveEnabledApps(app, false) + assertFalse(prefs.isAppEnabled(app)) + } + + @Test + fun `isAppEnabled returns false for package not in list`() { + assertFalse(prefs.isAppEnabled("com.nonexistent.app.xyz")) + } + + @Test + fun `getEnabledApps returns set containing explicitly added package`() { + prefs.saveEnabledApps("com.whatsapp", true) + assertTrue(prefs.getEnabledApps().contains("com.whatsapp")) + } + + // --- isFirstInstall --- + + @Test + fun `isFirstInstall returns true in Robolectric test environment`() { + // In Robolectric, firstInstallTime == lastUpdateTime (both 0), so returns true + assertTrue(PreferencesManager.isFirstInstall(context)) + } + + // --- Edge cases --- + + @Test + fun `getReplyToNames returns empty set when prefs have no value`() { + val result = prefs.replyToNames + assertNotNull(result) + assertTrue(result.isEmpty()) + } + + @Test + fun `subscription timestamps handle Long MAX_VALUE`() { + prefs.setSubscriptionStatusLastChecked(Long.MAX_VALUE) + assertEquals(Long.MAX_VALUE, prefs.getSubscriptionStatusLastChecked()) + } + + @Test + fun `subscription timestamps handle 0`() { + prefs.setSubscriptionStatusLastChecked(0L) + assertEquals(0L, prefs.getSubscriptionStatusLastChecked()) + } + + @Test + fun `getSelectedLanguage handles empty locale string`() { + prefs.saveString("KEY_SELECTED_APP_LANGUAGE", "") + val result = prefs.getString("KEY_SELECTED_APP_LANGUAGE", "en") + assertNotNull(result) + } + + @Test + fun `resetInstance does not throw when called multiple times`() { + PreferencesManager.resetInstance() + PreferencesManager.resetInstance() + prefs = PreferencesManager.getPreferencesInstance(context) + assertNotNull(prefs) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/AppUtilsTest.java b/app/src/test/java/com/parishod/watomatic/model/utils/AppUtilsTest.java index 1a4fbffb4..2ea1c6570 100644 --- a/app/src/test/java/com/parishod/watomatic/model/utils/AppUtilsTest.java +++ b/app/src/test/java/com/parishod/watomatic/model/utils/AppUtilsTest.java @@ -64,4 +64,31 @@ public void isPackageInstalled_shouldReturnFalse_whenPackageIsNotInstalled() thr // Then assert(!isInstalled); } + + @Test + public void getInstance_returnsSameInstance() { + AppUtils instance1 = AppUtils.getInstance(mockContext); + AppUtils instance2 = AppUtils.getInstance(mockContext); + assert(instance1 == instance2); + } + + @Test + public void getInstance_afterReset_returnsNewInstance() { + AppUtils instance1 = AppUtils.getInstance(mockContext); + AppUtils.resetInstance(); + AppUtils instance2 = AppUtils.getInstance(mockContext); + assert(instance2 != null); + } + + @Test + public void isPackageInstalled_shouldReturnFalse_whenPackageNameIsEmpty() throws PackageManager.NameNotFoundException { + // Given an empty package name + when(mockPackageManager.getApplicationIcon("")).thenThrow(new PackageManager.NameNotFoundException()); + + // When + boolean isInstalled = appUtils.isPackageInstalled(""); + + // Then + assert(!isInstalled); + } } \ No newline at end of file diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/AutoStartHelperTest.kt b/app/src/test/java/com/parishod/watomatic/model/utils/AutoStartHelperTest.kt new file mode 100644 index 000000000..80cbe7fbd --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/utils/AutoStartHelperTest.kt @@ -0,0 +1,66 @@ +package com.parishod.watomatic.model.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class AutoStartHelperTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + // --- getInstance --- + + @Test + fun `getInstance returns non-null instance`() { + val instance = AutoStartHelper.getInstance() + assertNotNull(instance) + } + + @Test + fun `getInstance returns new instance each time`() { + val first = AutoStartHelper.getInstance() + val second = AutoStartHelper.getInstance() + assertNotSame(first, second) + } + + // --- getAutoStartPermission --- + + @Test + fun `getAutoStartPermission does not throw for default Robolectric brand`() { + // Robolectric's default Build.BRAND is "robolectric" which won't match + // any known brand — should hit the default case and show a Toast + val helper = AutoStartHelper.getInstance() + // Should not throw; the default case shows a Toast + try { + helper.getAutoStartPermission(context) + } catch (e: android.content.res.Resources.NotFoundException) { + // Expected in Robolectric when getString() can't resolve the resource + } + } + + @Test + @Config(qualifiers = "") + fun `getAutoStartPermission handles unsupported brand gracefully`() { + // With default Robolectric brand, should hit the default Toast case + val helper = AutoStartHelper.getInstance() + try { + helper.getAutoStartPermission(context) + } catch (e: android.content.res.Resources.NotFoundException) { + // Expected: getString(R.string.setting_not_available_for_device) may not resolve + } + // Test passes as long as no unexpected exception occurs + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/ContactsHelperTest.kt b/app/src/test/java/com/parishod/watomatic/model/utils/ContactsHelperTest.kt new file mode 100644 index 000000000..95e97fc67 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/utils/ContactsHelperTest.kt @@ -0,0 +1,125 @@ +package com.parishod.watomatic.model.utils + +import android.Manifest +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class ContactsHelperTest { + + private lateinit var context: Context + + @Before + fun setUp() { + PreferencesManager.resetInstance() + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + PreferencesManager.resetInstance() + } + + // --- getInstance --- + + @Test + fun `getInstance returns non-null instance`() { + val helper = ContactsHelper.getInstance(context) + assertNotNull(helper) + } + + @Test + fun `getInstance returns new instance each time`() { + val first = ContactsHelper.getInstance(context) + val second = ContactsHelper.getInstance(context) + // ContactsHelper is not a singleton — each call returns a new instance + assertNotNull(first) + assertNotNull(second) + } + + // --- hasContactPermission --- + + @Test + fun `hasContactPermission returns false when permission not granted`() { + val helper = ContactsHelper(context) + // By default Robolectric does not grant permissions + assertFalse(helper.hasContactPermission()) + } + + @Test + fun `hasContactPermission returns true when permission granted`() { + val app = context as Application + Shadows.shadowOf(app).grantPermissions(Manifest.permission.READ_CONTACTS) + val helper = ContactsHelper(context) + assertTrue(helper.hasContactPermission()) + } + + // --- getContactList --- + + @Test + fun `getContactList returns empty list when no permission and no custom contacts`() { + val helper = ContactsHelper(context) + // No permission granted, no custom contacts saved + val result = helper.getContactList() + assertNotNull(result) + assertTrue(result.isEmpty()) + } + + @Test + fun `getContactList includes custom contacts from preferences`() { + // Save custom reply names in preferences + val prefs = PreferencesManager.getPreferencesInstance(context) + val customNames = setOf("CustomUser1", "CustomUser2") + prefs.customReplyNames = customNames + + val helper = ContactsHelper(context) + val result = helper.getContactList() + + // Should contain the custom contacts even without phone permission + assertEquals(2, result.size) + assertTrue(result.any { it.contactName == "CustomUser1" }) + assertTrue(result.any { it.contactName == "CustomUser2" }) + // Custom contacts should have isCustom = true + assertTrue(result.all { it.isCustom }) + } + + @Test + fun `getContactList returns only custom contacts when permission denied`() { + val prefs = PreferencesManager.getPreferencesInstance(context) + prefs.customReplyNames = setOf("CustomOnly") + + val helper = ContactsHelper(context) + val result = helper.getContactList() + + assertEquals(1, result.size) + assertEquals("CustomOnly", result[0].contactName) + assertTrue(result[0].isCustom) + } + + @Test + fun `getContactList handles empty cursor with permission granted`() { + val app = context as Application + Shadows.shadowOf(app).grantPermissions(Manifest.permission.READ_CONTACTS) + val helper = ContactsHelper(context) + + // With permission granted but no contacts in the provider + val result = helper.getContactList() + assertNotNull(result) + // May be empty since Robolectric's ContentProvider has no contacts + assertTrue(result.size >= 0) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/DbUtilsTest.kt b/app/src/test/java/com/parishod/watomatic/model/utils/DbUtilsTest.kt new file mode 100644 index 000000000..80a798412 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/utils/DbUtilsTest.kt @@ -0,0 +1,95 @@ +package com.parishod.watomatic.model.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.parishod.watomatic.model.CustomRepliesData +import com.parishod.watomatic.model.logs.MessageLogsDB +import com.parishod.watomatic.model.preferences.PreferencesManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class DbUtilsTest { + + private lateinit var context: Context + private lateinit var dbUtils: DbUtils + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + PreferencesManager.resetInstance() + CustomRepliesData.resetInstance() + dbUtils = DbUtils(context) + } + + @After + fun tearDown() { + PreferencesManager.resetInstance() + CustomRepliesData.resetInstance() + // Reset the database singleton via reflection + try { + val field = MessageLogsDB::class.java.getDeclaredField("_instance") + field.isAccessible = true + val db = field.get(null) as? MessageLogsDB + db?.close() + field.set(null, null) + } catch (_: Exception) { + // Ignore if reflection fails + } + } + + // --- getNunReplies --- + + @Test + fun `getNunReplies returns 0 when no logs exist`() { + val count = dbUtils.getNunReplies() + assertEquals(0L, count) + } + + // --- getLastRepliedTime --- + + @Test + fun `getLastRepliedTime returns 0 for null title`() { + val result = dbUtils.getLastRepliedTime("com.whatsapp", null) + assertEquals(0L, result) + } + + @Test + fun `getLastRepliedTime returns 0 when no logs exist`() { + val result = dbUtils.getLastRepliedTime("com.whatsapp", "John") + assertEquals(0L, result) + } + + // --- getFirstRepliedTime --- + + @Test + fun `getFirstRepliedTime returns 0 when no logs exist`() { + val result = dbUtils.getFirstRepliedTime() + assertEquals(0L, result) + } + + // --- purgeMessageLogs --- + + @Test + fun `purgeMessageLogs does not throw when no logs exist`() { + // Should not throw + dbUtils.purgeMessageLogs() + assertEquals(0L, dbUtils.getNunReplies()) + } + + // --- Constructor --- + + @Test + fun `DbUtils can be constructed with context`() { + val utils = DbUtils(context) + // Basic smoke test — doesn't crash + assertTrue(utils.getNunReplies() >= 0) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/NotificationHelperTest.kt b/app/src/test/java/com/parishod/watomatic/model/utils/NotificationHelperTest.kt new file mode 100644 index 000000000..dfd99d6bb --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/utils/NotificationHelperTest.kt @@ -0,0 +1,163 @@ +package com.parishod.watomatic.model.utils + +import android.app.NotificationManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class NotificationHelperTest { + + private lateinit var context: Context + + @Before + fun setUp() { + NotificationHelper.resetInstance() + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + NotificationHelper.resetInstance() + } + + // --- getInstance --- + + @Test + fun `getInstance returns non-null instance`() { + val instance = NotificationHelper.getInstance(context) + assertNotNull(instance) + } + + @Test + fun `getInstance returns same instance on repeated calls`() { + val first = NotificationHelper.getInstance(context) + val second = NotificationHelper.getInstance(context) + assertSame(first, second) + } + + @Test + fun `getInstance creates fresh instance after reset`() { + val first = NotificationHelper.getInstance(context) + NotificationHelper.resetInstance() + val second = NotificationHelper.getInstance(context) + assertNotNull(second) + // Can't guarantee different object identity in all JVMs, + // but the important thing is it doesn't crash after reset + } + + // --- sendNotification --- + + @Test + fun `sendNotification posts notification to system`() { + val helper = NotificationHelper.getInstance(context) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadowNm = Shadows.shadowOf(nm) + + val beforeCount = shadowNm.allNotifications.size + helper.sendNotification("Test Title", "Test Message", "com.whatsapp") + // Should have posted at least 1 notification (possibly 2 with summary) + assert(shadowNm.allNotifications.size > beforeCount) + } + + @Test + fun `sendNotification adds app name prefix for supported apps`() { + val helper = NotificationHelper.getInstance(context) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadowNm = Shadows.shadowOf(nm) + + helper.sendNotification("User123", "Hello", "com.whatsapp") + + val notifications = shadowNm.allNotifications + assert(notifications.isNotEmpty()) { "Expected at least one notification" } + // Check ALL notifications — order may differ between local and CI Robolectric. + // The individual notification has the title; the summary notification may not. + val anyTitleContainsWhatsApp = notifications.any { notification -> + val title = notification.extras?.getString("android.title") ?: "" + title.contains("WhatsApp") + } + assert(anyTitleContainsWhatsApp) { + val titles = notifications.map { it.extras?.getString("android.title") ?: "(null)" } + "Expected at least one notification title to contain 'WhatsApp' but titles were: $titles" + } + } + + @Test + fun `sendNotification creates summary notification for first notification of a package`() { + val helper = NotificationHelper.getInstance(context) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadowNm = Shadows.shadowOf(nm) + + // First notification for this package should also create a summary + helper.sendNotification("User", "Message", "com.whatsapp") + // Should have at least 2 notifications (individual + summary) + assert(shadowNm.allNotifications.size >= 2) { + "Expected at least 2 notifications (individual + summary), got: ${shadowNm.allNotifications.size}" + } + } + + // --- markNotificationDismissed --- + + @Test + fun `markNotificationDismissed does not throw for supported app`() { + NotificationHelper.getInstance(context) + val helper = NotificationHelper.getInstance(context) + // Should not throw + helper.markNotificationDismissed("watomatic-com.whatsapp") + } + + @Test + fun `markNotificationDismissed does not throw for unknown package`() { + val helper = NotificationHelper.getInstance(context) + // Should not throw even for unknown package + helper.markNotificationDismissed("watomatic-com.unknown.app") + } + + @Test + fun `markNotificationDismissed strips watomatic prefix`() { + val helper = NotificationHelper.getInstance(context) + // After marking dismissed, a new notification for that package should create summary again + helper.markNotificationDismissed("watomatic-com.whatsapp") + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val shadowNm = Shadows.shadowOf(nm) + + helper.sendNotification("User", "Message", "com.whatsapp") + // Should create both individual + summary since we dismissed + assert(shadowNm.allNotifications.size >= 2) { + "Expected at least 2 notifications after dismiss + re-send, got: ${shadowNm.allNotifications.size}" + } + } + + // --- getForegroundServiceNotification --- + + @Test + fun `getForegroundServiceNotification does not throw`() { + val helper = NotificationHelper.getInstance(context) + // We can't easily create a real Service, but we can verify the method + // doesn't crash with a mock service. The method mainly builds a notification. + // Skip this test if we can't instantiate a service easily in Robolectric. + // Instead, verify the instance was created correctly + assertNotNull(helper) + } + + // --- Notification channel --- + + @Test + fun `getInstance creates notification channel`() { + NotificationHelper.getInstance(context) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = nm.getNotificationChannel(Constants.NOTIFICATION_CHANNEL_ID) + assertNotNull(channel) + assertEquals(Constants.NOTIFICATION_CHANNEL_NAME, channel.name.toString()) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/NotificationUtilsTest.kt b/app/src/test/java/com/parishod/watomatic/model/utils/NotificationUtilsTest.kt new file mode 100644 index 000000000..62a7f7c59 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/utils/NotificationUtilsTest.kt @@ -0,0 +1,491 @@ +package com.parishod.watomatic.model.utils + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationCompat +import androidx.core.app.RemoteInput +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class NotificationUtilsTest { + + private lateinit var mockSbn: StatusBarNotification + private lateinit var context: Context + + @Before + fun setUp() { + mockSbn = mock() + context = ApplicationProvider.getApplicationContext() + } + + // --- isNewNotification --- + + @Test + fun `isNewNotification returns true when notification when is 0`() { + val notification = Notification() + notification.`when` = 0 + whenever(mockSbn.notification).thenReturn(notification) + + assertTrue(NotificationUtils.isNewNotification(mockSbn)) + } + + @Test + fun `isNewNotification returns true for notification within 2 minutes`() { + val notification = Notification() + notification.`when` = System.currentTimeMillis() - 60_000L // 1 minute ago + whenever(mockSbn.notification).thenReturn(notification) + + assertTrue(NotificationUtils.isNewNotification(mockSbn)) + } + + @Test + fun `isNewNotification returns true for very recent notification`() { + val notification = Notification() + notification.`when` = System.currentTimeMillis() - 1_000L // 1 second ago + whenever(mockSbn.notification).thenReturn(notification) + + assertTrue(NotificationUtils.isNewNotification(mockSbn)) + } + + @Test + fun `isNewNotification returns false for notification older than 2 minutes`() { + val notification = Notification() + notification.`when` = System.currentTimeMillis() - (3 * 60 * 1_000L) // 3 minutes ago + whenever(mockSbn.notification).thenReturn(notification) + + assertFalse(NotificationUtils.isNewNotification(mockSbn)) + } + + @Test + fun `isNewNotification returns false for very old notification`() { + val notification = Notification() + notification.`when` = System.currentTimeMillis() - (60 * 60 * 1_000L) // 1 hour ago + whenever(mockSbn.notification).thenReturn(notification) + + assertFalse(NotificationUtils.isNewNotification(mockSbn)) + } + + // --- getTitle for non-group conversations --- + + @Test + fun `getTitle returns android title for non-group conversation`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", false) + extras.putString("android.title", "John Doe") + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("John Doe", NotificationUtils.getTitle(mockSbn)) + } + + @Test + fun `getTitle returns null when android title is not set for non-group`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", false) + // No android.title set + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertNull(NotificationUtils.getTitle(mockSbn)) + } + + // --- getTitle for group conversations --- + + @Test + fun `getTitle returns hiddenConversationTitle for group conversation`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + extras.putString("android.hiddenConversationTitle", "Family Group") + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("Family Group", NotificationUtils.getTitle(mockSbn)) + } + + @Test + fun `getTitle extracts group name before colon when hiddenConversationTitle is null`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + extras.putString("android.hiddenConversationTitle", null) + extras.putString("android.title", "Family Group: John") + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("Family Group", NotificationUtils.getTitle(mockSbn)) + } + + @Test + fun `getTitle returns full title when no colon in group title`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + extras.putString("android.hiddenConversationTitle", null) + extras.putString("android.title", "WorkTeam") + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("WorkTeam", NotificationUtils.getTitle(mockSbn)) + } + + // --- getTitleRaw --- + + @Test + fun `getTitleRaw returns raw android title string`() { + val extras = Bundle() + extras.putString("android.title", "Raw Title Value") + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("Raw Title Value", NotificationUtils.getTitleRaw(mockSbn)) + } + + @Test + fun `getTitleRaw returns null when android title not set`() { + val extras = Bundle() + // No android.title + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertNull(NotificationUtils.getTitleRaw(mockSbn)) + } + + // --- getTitle: group title with message count trimming --- + + @Test + fun `getTitle strips message count suffix from group title when multiple messages`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + extras.putString("android.hiddenConversationTitle", null) + // title with count like "Family Group(3 messages)" + extras.putString("android.title", "Family Group(3 messages)") + // Simulate 2 messages in the bundle so the trimming branch is hit + val fakeMessages = arrayOfNulls(2) + extras.putParcelableArray("android.messages", fakeMessages) + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + val title = NotificationUtils.getTitle(mockSbn) + // Should strip from the last '(' onward + assertEquals("Family Group", title) + } + + @Test + fun `getTitle does not strip suffix when only one message`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + extras.putString("android.hiddenConversationTitle", null) + extras.putString("android.title", "Family Group(1 message)") + // Only 1 message — trimming branch not taken + val fakeMessages = arrayOfNulls(1) + extras.putParcelableArray("android.messages", fakeMessages) + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + val title = NotificationUtils.getTitle(mockSbn) + // No colon, so full title returned (trimming skipped because b.length == 1) + assertEquals("Family Group(1 message)", title) + } + + // --- extractWearNotification --- + + @Test + fun `extractWearNotification returns correct packageName`() { + val notification = NotificationCompat.Builder(context, "test_channel").build() + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + + assertEquals("com.whatsapp", result.packageName) + } + + @Test + fun `extractWearNotification returns empty remoteInputs when notification has no actions`() { + val notification = NotificationCompat.Builder(context, "test_channel").build() + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + + assertTrue(result.remoteInputs.isEmpty()) + assertNull(result.pendingIntent) + } + + @Test + fun `extractWearNotification captures tag from sbn`() { + val notification = NotificationCompat.Builder(context, "test_channel").build() + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn("my_notification_tag") + + val result = NotificationUtils.extractWearNotification(mockSbn) + + assertEquals("my_notification_tag", result.tag) + } + + @Test + fun `extractWearNotification assigns non-null unique id`() { + val notification = NotificationCompat.Builder(context, "test_channel").build() + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result1 = NotificationUtils.extractWearNotification(mockSbn) + val result2 = NotificationUtils.extractWearNotification(mockSbn) + + assertNotNull(result1.id) + assertNotNull(result2.id) + assertTrue(result1.id != result2.id) + } + + @Test + fun `extractWearNotification picks action with RemoteInput`() { + val intent = Intent("test_action") + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + val remoteInput = RemoteInput.Builder("key_text_reply") + .setLabel("Reply") + .build() + val action = NotificationCompat.Action.Builder(0, "Reply", pendingIntent) + .addRemoteInput(remoteInput) + .build() + + val notification = NotificationCompat.Builder(context, "test_channel") + .addAction(action) + .build() + + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + + assertEquals(1, result.remoteInputs.size) + assertNotNull(result.pendingIntent) + } + + @Test + fun `extractWearNotification prefers action with reply in title`() { + val intent = Intent("test_action") + val pendingIntent1 = PendingIntent.getBroadcast( + context, 1, intent, PendingIntent.FLAG_IMMUTABLE + ) + val pendingIntent2 = PendingIntent.getBroadcast( + context, 2, intent, PendingIntent.FLAG_IMMUTABLE + ) + + val remoteInput1 = RemoteInput.Builder("key_mark_read").build() + val actionMarkRead = NotificationCompat.Action.Builder(0, "Mark as Read", pendingIntent1) + .addRemoteInput(remoteInput1) + .build() + + val remoteInput2 = RemoteInput.Builder("key_reply").build() + val actionReply = NotificationCompat.Action.Builder(0, "Reply", pendingIntent2) + .addRemoteInput(remoteInput2) + .build() + + val notification = NotificationCompat.Builder(context, "test_channel") + .addAction(actionMarkRead) + .addAction(actionReply) + .build() + + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + + // Should pick the "Reply" action over "Mark as Read" + assertEquals(1, result.remoteInputs.size) + assertEquals("key_reply", result.remoteInputs[0].resultKey) + } + + @Test + fun `extractWearNotification ignores actions without free-form RemoteInput`() { + val intent = Intent("test_action") + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + // Action with no RemoteInput + val action = NotificationCompat.Action.Builder(0, "Dismiss", pendingIntent).build() + + val notification = NotificationCompat.Builder(context, "test_channel") + .addAction(action) + .build() + + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + + assertTrue(result.remoteInputs.isEmpty()) + assertNull(result.pendingIntent) + } + + @Test + fun `extractWearNotification picks action from WearableExtender when no standard actions`() { + val intent = Intent("test_action") + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + val remoteInput = RemoteInput.Builder("key_wear_reply") + .setLabel("Reply") + .build() + val wearAction = NotificationCompat.Action.Builder(0, "Reply", pendingIntent) + .addRemoteInput(remoteInput) + .build() + + // Only add via WearableExtender, not as a standard action + val notification = NotificationCompat.Builder(context, "test_channel") + .extend(NotificationCompat.WearableExtender().addAction(wearAction)) + .build() + + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + + assertEquals(1, result.remoteInputs.size) + assertEquals("key_wear_reply", result.remoteInputs[0].resultKey) + assertNotNull(result.pendingIntent) + } + + // --- showAccessRevokedNotification --- + + @Test + fun `showAccessRevokedNotification does not throw`() { + // Smoke test: just verify no exception is thrown + NotificationUtils.showAccessRevokedNotification(context) + } + + @Test + fun `showAccessRevokedNotification creates notification channel`() { + NotificationUtils.showAccessRevokedNotification(context) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = nm.getNotificationChannel("nls_health_channel") + assertNotNull(channel) + assertEquals("Notification Access Alerts", channel.name.toString()) + } + + @Test + fun `showAccessRevokedNotification posts a notification`() { + NotificationUtils.showAccessRevokedNotification(context) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notifications = Shadows.shadowOf(nm).allNotifications + assertTrue(notifications.isNotEmpty()) + } + + // --- Edge cases --- + + @Test + fun `getTitle handles emoji in group title`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + extras.putString("android.hiddenConversationTitle", "\uD83D\uDE00 Fun Group") + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("\uD83D\uDE00 Fun Group", NotificationUtils.getTitle(mockSbn)) + } + + @Test + fun `getTitle handles RTL characters in title`() { + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", false) + extras.putString("android.title", "\u0645\u062D\u0645\u062F") // Arabic name + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + assertEquals("\u0645\u062D\u0645\u062F", NotificationUtils.getTitle(mockSbn)) + } + + @Test(expected = NullPointerException::class) + fun `getTitle throws NPE for group conversation with null title and null hiddenTitle`() { + // Documents existing bug: getTitle does not null-check title before calling indexOf(':') + // See plan item C1 for the fix + val extras = Bundle() + extras.putBoolean("android.isGroupConversation", true) + + val notification = Notification() + notification.extras = extras + whenever(mockSbn.notification).thenReturn(notification) + + NotificationUtils.getTitle(mockSbn) + } + + @Test + fun `extractWearNotification returns empty remoteInputs when all actions lack RemoteInput`() { + val intent = Intent("test_action") + val pi = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val action1 = NotificationCompat.Action.Builder(0, "Archive", pi).build() + val action2 = NotificationCompat.Action.Builder(0, "Delete", pi).build() + + val notification = NotificationCompat.Builder(context, "test_channel") + .addAction(action1) + .addAction(action2) + .build() + + whenever(mockSbn.notification).thenReturn(notification) + whenever(mockSbn.packageName).thenReturn("com.whatsapp") + whenever(mockSbn.tag).thenReturn(null) + + val result = NotificationUtils.extractWearNotification(mockSbn) + assertTrue(result.remoteInputs.isEmpty()) + assertNull(result.pendingIntent) + } + + @Test + fun `isNewNotification returns true for future notification timestamp`() { + val notification = Notification() + notification.`when` = System.currentTimeMillis() + 60_000L // 1 minute in the future + whenever(mockSbn.notification).thenReturn(notification) + + assertTrue(NotificationUtils.isNewNotification(mockSbn)) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/model/utils/OpenAIHelperTest.kt b/app/src/test/java/com/parishod/watomatic/model/utils/OpenAIHelperTest.kt new file mode 100644 index 000000000..4aea5368c --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/model/utils/OpenAIHelperTest.kt @@ -0,0 +1,140 @@ +package com.parishod.watomatic.model.utils + +import com.parishod.watomatic.network.model.openai.ModelData +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.lang.reflect.Field + +/** + * Tests for OpenAIHelper cache logic. + * Uses Robolectric because OpenAIHelper.invalidateCache() calls Log.d(). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class OpenAIHelperTest { + + private fun createModelData(id: String): ModelData { + val model = ModelData() + model.id = id + return model + } + + @Before + fun setUp() { + OpenAIHelper.invalidateCache() + } + + @After + fun tearDown() { + OpenAIHelper.invalidateCache() + } + + // --- getCachedModels --- + + @Test + fun `getCachedModels returns null after invalidation`() { + assertNull(OpenAIHelper.getCachedModels()) + } + + @Test + fun `getCachedModels returns models after setting cache via reflection`() { + val models = listOf(createModelData("gpt-4"), createModelData("gpt-3.5-turbo")) + setCachedModelsViaReflection(models) + setLastFetchTimeViaReflection(System.currentTimeMillis()) + + val result = OpenAIHelper.getCachedModels() + assertEquals(2, result?.size) + assertEquals("gpt-4", result?.get(0)?.id) + } + + // --- isCacheValid --- + + @Test + fun `isCacheValid returns false when cache is null`() { + assertFalse(OpenAIHelper.isCacheValid()) + } + + @Test + fun `isCacheValid returns false when cache is empty list`() { + setCachedModelsViaReflection(emptyList()) + setLastFetchTimeViaReflection(System.currentTimeMillis()) + assertFalse(OpenAIHelper.isCacheValid()) + } + + @Test + fun `isCacheValid returns false when cache is older than 1 hour`() { + val models = listOf(createModelData("gpt-4")) + setCachedModelsViaReflection(models) + // Set fetch time to 2 hours ago + setLastFetchTimeViaReflection(System.currentTimeMillis() - 2 * 60 * 60 * 1000) + assertFalse(OpenAIHelper.isCacheValid()) + } + + @Test + fun `isCacheValid returns true when cache is fresh`() { + val models = listOf(createModelData("gpt-4")) + setCachedModelsViaReflection(models) + setLastFetchTimeViaReflection(System.currentTimeMillis()) + assertTrue(OpenAIHelper.isCacheValid()) + } + + @Test + fun `isCacheValid returns true when cache is 59 minutes old`() { + val models = listOf(createModelData("gpt-4")) + setCachedModelsViaReflection(models) + setLastFetchTimeViaReflection(System.currentTimeMillis() - 59 * 60 * 1000) + assertTrue(OpenAIHelper.isCacheValid()) + } + + // --- invalidateCache --- + + @Test + fun `invalidateCache clears models`() { + val models = listOf(createModelData("gpt-4")) + setCachedModelsViaReflection(models) + setLastFetchTimeViaReflection(System.currentTimeMillis()) + + assertTrue(OpenAIHelper.isCacheValid()) + OpenAIHelper.invalidateCache() + assertNull(OpenAIHelper.getCachedModels()) + assertFalse(OpenAIHelper.isCacheValid()) + } + + @Test + fun `invalidateCache resets fetch timestamp to 0`() { + setLastFetchTimeViaReflection(System.currentTimeMillis()) + OpenAIHelper.invalidateCache() + val field = OpenAIHelper::class.java.getDeclaredField("lastModelsFetchTimeMillis") + field.isAccessible = true + assertEquals(0L, field.getLong(null)) + } + + @Test + fun `invalidateCache is safe to call when already empty`() { + OpenAIHelper.invalidateCache() + OpenAIHelper.invalidateCache() // Should not throw + assertNull(OpenAIHelper.getCachedModels()) + } + + // --- Helpers --- + + private fun setCachedModelsViaReflection(models: List?) { + val field: Field = OpenAIHelper::class.java.getDeclaredField("cachedModels") + field.isAccessible = true + field.set(null, models) + } + + private fun setLastFetchTimeViaReflection(timeMillis: Long) { + val field: Field = OpenAIHelper::class.java.getDeclaredField("lastModelsFetchTimeMillis") + field.isAccessible = true + field.setLong(null, timeMillis) + } +} diff --git a/app/src/test/java/com/parishod/watomatic/network/model/NetworkModelsTest.kt b/app/src/test/java/com/parishod/watomatic/network/model/NetworkModelsTest.kt new file mode 100644 index 000000000..e97d483f2 --- /dev/null +++ b/app/src/test/java/com/parishod/watomatic/network/model/NetworkModelsTest.kt @@ -0,0 +1,329 @@ +package com.parishod.watomatic.network.model + +import com.parishod.watomatic.network.model.atomatic.AtomaticAIErrorResponse +import com.parishod.watomatic.network.model.atomatic.AtomaticAIRequest +import com.parishod.watomatic.network.model.atomatic.AtomaticAIResponse +import com.parishod.watomatic.network.model.openai.Choice +import com.parishod.watomatic.network.model.openai.Message +import com.parishod.watomatic.network.model.openai.ModelData +import com.parishod.watomatic.network.model.openai.OpenAIErrorDetail +import com.parishod.watomatic.network.model.openai.OpenAIErrorResponse +import com.parishod.watomatic.network.model.openai.OpenAIModelsResponse +import com.parishod.watomatic.network.model.openai.OpenAIRequest +import com.parishod.watomatic.network.model.openai.OpenAIResponse +import com.parishod.watomatic.network.model.openai.ResponseMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NetworkModelsTest { + + // --- Message --- + + @Test + fun `Message constructor stores role and content`() { + val msg = Message("user", "Hello world") + assertEquals("user", msg.role) + assertEquals("Hello world", msg.content) + } + + @Test + fun `Message setters update role and content`() { + val msg = Message("user", "original") + msg.role = "assistant" + msg.content = "updated content" + assertEquals("assistant", msg.role) + assertEquals("updated content", msg.content) + } + + @Test + fun `Message allows system role`() { + val msg = Message("system", "You are a helpful assistant.") + assertEquals("system", msg.role) + } + + // --- ResponseMessage --- + + @Test + fun `ResponseMessage getters and setters work`() { + val rm = ResponseMessage() + rm.role = "assistant" + rm.content = "I can help with that" + assertEquals("assistant", rm.role) + assertEquals("I can help with that", rm.content) + } + + @Test + fun `ResponseMessage defaults to null fields`() { + val rm = ResponseMessage() + assertNull(rm.role) + assertNull(rm.content) + } + + // --- Choice --- + + @Test + fun `Choice getters and setters work`() { + val rm = ResponseMessage() + rm.role = "assistant" + rm.content = "response text" + + val choice = Choice() + choice.message = rm + + assertNotNull(choice.message) + assertEquals("response text", choice.message.content) + } + + @Test + fun `Choice defaults to null message`() { + val choice = Choice() + assertNull(choice.message) + } + + // --- OpenAIRequest --- + + @Test + fun `OpenAIRequest constructor stores model and messages`() { + val messages = listOf(Message("user", "test")) + val request = OpenAIRequest("gpt-4o", messages) + assertEquals("gpt-4o", request.model) + assertEquals(1, request.messages.size) + assertEquals("test", request.messages[0].content) + } + + @Test + fun `OpenAIRequest setters update fields`() { + val request = OpenAIRequest("gpt-3.5-turbo", emptyList()) + request.model = "gpt-4o" + request.messages = listOf(Message("user", "new")) + assertEquals("gpt-4o", request.model) + assertEquals(1, request.messages.size) + } + + @Test + fun `OpenAIRequest allows empty messages list`() { + val request = OpenAIRequest("gpt-4o", emptyList()) + assertTrue(request.messages.isEmpty()) + } + + // --- OpenAIResponse --- + + @Test + fun `OpenAIResponse getters and setters work`() { + val rm = ResponseMessage() + rm.content = "reply" + val choice = Choice() + choice.message = rm + + val response = OpenAIResponse() + response.choices = listOf(choice) + + assertEquals(1, response.choices.size) + assertEquals("reply", response.choices[0].message.content) + } + + @Test + fun `OpenAIResponse defaults to null choices`() { + val response = OpenAIResponse() + assertNull(response.choices) + } + + // --- OpenAIErrorDetail --- + + @Test + fun `OpenAIErrorDetail getters return null by default`() { + val detail = OpenAIErrorDetail() + assertNull(detail.message) + assertNull(detail.type) + assertNull(detail.param) + assertNull(detail.code) + } + + // --- OpenAIErrorResponse --- + + @Test + fun `OpenAIErrorResponse getError returns null by default`() { + val errorResponse = OpenAIErrorResponse() + assertNull(errorResponse.error) + } + + // --- ModelData --- + + @Test + fun `ModelData getters and setters work`() { + val model = ModelData() + model.id = "gpt-4o" + model.objectType = "model" + model.created = 1_700_000_000L + model.ownedBy = "openai" + + assertEquals("gpt-4o", model.id) + assertEquals("model", model.objectType) + assertEquals(1_700_000_000L, model.created) + assertEquals("openai", model.ownedBy) + } + + @Test + fun `ModelData defaults to null and zero`() { + val model = ModelData() + assertNull(model.id) + assertNull(model.objectType) + assertEquals(0L, model.created) + assertNull(model.ownedBy) + } + + // --- OpenAIModelsResponse --- + + @Test + fun `OpenAIModelsResponse getters and setters work`() { + val model = ModelData() + model.id = "gpt-4o" + + val response = OpenAIModelsResponse() + response.data = listOf(model) + response.objectType = "list" + + assertEquals(1, response.data.size) + assertEquals("gpt-4o", response.data[0].id) + assertEquals("list", response.objectType) + } + + @Test + fun `OpenAIModelsResponse defaults to null`() { + val response = OpenAIModelsResponse() + assertNull(response.data) + assertNull(response.objectType) + } + + // --- AtomaticAIRequest --- + + @Test + fun `AtomaticAIRequest constructor stores message and prompt`() { + val request = AtomaticAIRequest("Hello there", "Reply briefly") + assertEquals("Hello there", request.message) + assertEquals("Reply briefly", request.prompt) + } + + @Test + fun `AtomaticAIRequest setters update fields`() { + val request = AtomaticAIRequest("original", "original prompt") + request.message = "updated message" + request.prompt = "updated prompt" + assertEquals("updated message", request.message) + assertEquals("updated prompt", request.prompt) + } + + @Test + fun `AtomaticAIRequest allows null prompt`() { + val request = AtomaticAIRequest("Hello", null) + assertNull(request.prompt) + } + + // --- AtomaticAIResponse --- + + @Test + fun `AtomaticAIResponse constructor stores reply and remainingAtoms`() { + val response = AtomaticAIResponse("Auto reply text", 42) + assertEquals("Auto reply text", response.reply) + assertEquals(42, response.remainingAtoms) + } + + @Test + fun `AtomaticAIResponse no-arg constructor defaults to null and zero`() { + val response = AtomaticAIResponse() + assertNull(response.reply) + assertEquals(0, response.remainingAtoms) + } + + @Test + fun `AtomaticAIResponse setters update fields`() { + val response = AtomaticAIResponse() + response.reply = "new reply" + response.remainingAtoms = 99 + assertEquals("new reply", response.reply) + assertEquals(99, response.remainingAtoms) + } + + // --- AtomaticAIErrorResponse --- + + @Test + fun `AtomaticAIErrorResponse no-arg constructor defaults correctly`() { + val err = AtomaticAIErrorResponse() + assertNull(err.error) + assertNull(err.message) + assertEquals(0, err.statusCode) + } + + @Test + fun `AtomaticAIErrorResponse full constructor stores all fields`() { + val err = AtomaticAIErrorResponse("Unauthorized", "Token expired", 401) + assertEquals("Unauthorized", err.error) + assertEquals("Token expired", err.message) + assertEquals(401, err.statusCode) + } + + @Test + fun `AtomaticAIErrorResponse setters update fields`() { + val err = AtomaticAIErrorResponse() + err.error = "Forbidden" + err.message = "Access denied" + err.statusCode = 403 + assertEquals("Forbidden", err.error) + assertEquals("Access denied", err.message) + assertEquals(403, err.statusCode) + } + + // --- AtomaticAIErrorResponse.isAuthError --- + + @Test + fun `isAuthError returns true for status 401`() { + val err = AtomaticAIErrorResponse("Unauthorized", "Invalid credentials", 401) + assertTrue(err.isAuthError) + } + + @Test + fun `isAuthError returns true for status 403`() { + val err = AtomaticAIErrorResponse("Forbidden", "Access denied", 403) + assertTrue(err.isAuthError) + } + + @Test + fun `isAuthError returns false for status 200`() { + val err = AtomaticAIErrorResponse(null, null, 200) + assertFalse(err.isAuthError) + } + + @Test + fun `isAuthError returns false for status 500`() { + val err = AtomaticAIErrorResponse("Server Error", "Internal error", 500) + assertFalse(err.isAuthError) + } + + @Test + fun `isAuthError returns true when message contains expired token`() { + val err = AtomaticAIErrorResponse("Error", "Your token has expired", 200) + assertTrue(err.isAuthError) + } + + @Test + fun `isAuthError returns true when message contains invalid token`() { + val err = AtomaticAIErrorResponse("Error", "Token is invalid", 200) + assertTrue(err.isAuthError) + } + + @Test + fun `isAuthError returns false when message is about token but not expired or invalid`() { + val err = AtomaticAIErrorResponse("Error", "Token created successfully", 200) + assertFalse(err.isAuthError) + } + + @Test + fun `isAuthError returns false when message is null and status is not 401 or 403`() { + val err = AtomaticAIErrorResponse("Not Found", null, 404) + assertFalse(err.isAuthError) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 41e0a0e5f..237840c70 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ loggingInterceptor = "4.7.2" preferenceKtx = "1.2.1" material = "1.12.0" mockito = "5.18.0" +mockito-kotlin = "5.4.0" robolectric = "4.15.1" retrofit = "2.9.0" roomCompiler = "2.6.1" @@ -31,6 +32,7 @@ googleid = "1.1.1" [libraries] mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito-kotlin" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } activity = { module = "androidx.activity:activity", version.ref = "activity" }