From 68ab67ead9ed449c119315339a98b59a2a6ae56c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:32:30 +0200 Subject: [PATCH] Add tests and CI workflow with coverage --- .github/workflows/ci.yml | 65 ++-------- README.md | 2 + app/build.gradle.kts | 7 ++ .../com/cashpilot/android/AppStateTest.kt | 116 +++++++++++++++++ .../cashpilot/android/HeartbeatModelTest.kt | 119 ++++++++++++++++++ .../com/cashpilot/android/KnownAppsTest.kt | 83 ++++++++++++ .../com/cashpilot/android/SettingsTest.kt | 69 ++++++++++ 7 files changed, 405 insertions(+), 56 deletions(-) create mode 100644 app/src/test/java/com/cashpilot/android/AppStateTest.kt create mode 100644 app/src/test/java/com/cashpilot/android/HeartbeatModelTest.kt create mode 100644 app/src/test/java/com/cashpilot/android/KnownAppsTest.kt create mode 100644 app/src/test/java/com/cashpilot/android/SettingsTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7557d70..94fb4c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,68 +1,21 @@ name: CI - on: push: branches: [main] pull_request: branches: [main] - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - jobs: - build: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/setup-java@v4 with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Build debug APK - run: ./gradlew assembleDebug - - - name: Run lint - run: ./gradlew lintDebug - - - name: Check signing secrets - id: signing - run: echo "available=true" >> "$GITHUB_OUTPUT" - if: env.KEYSTORE_B64 != '' - env: - KEYSTORE_B64: ${{ secrets.KEYSTORE_BASE64 }} - - - name: Decode keystore - if: steps.signing.outputs.available == 'true' - run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore - - - name: Build release APK - if: steps.signing.outputs.available == 'true' - run: ./gradlew assembleRelease - env: - CASHPILOT_KEYSTORE_PATH: release.keystore - CASHPILOT_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - CASHPILOT_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - CASHPILOT_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - - - name: Remove keystore - if: always() - run: rm -f app/release.keystore - - - name: Upload debug APK - uses: actions/upload-artifact@v4 + distribution: temurin + java-version: 17 + - run: chmod +x gradlew + - run: ./gradlew testDebugUnitTest jacocoTestReport || ./gradlew testDebugUnitTest + - uses: codecov/codecov-action@v5 + if: github.event_name == 'push' with: - name: app-debug - path: app/build/outputs/apk/debug/app-debug.apk - - - name: Upload release APK - if: steps.signing.outputs.available == 'true' && github.event_name == 'push' - uses: actions/upload-artifact@v4 - with: - name: app-release - path: app/build/outputs/apk/release/app-release.apk + fail_ci_if_error: false diff --git a/README.md b/README.md index 95c3cff..d3efe6f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ CashPilot Android banner

+[![codecov](https://codecov.io/gh/GeiserX/CashPilot-android/graph/badge.svg)](https://codecov.io/gh/GeiserX/CashPilot-android) + # CashPilot Android Agent Lightweight Android companion for [CashPilot](https://github.com/GeiserX/CashPilot) — monitors passive income apps running on your phone and reports their status to the CashPilot fleet dashboard. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f8fe2f0..26277f5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,4 +97,11 @@ dependencies { implementation(libs.kotlinx.serialization.json) debugImplementation(libs.androidx.ui.tooling) + + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType { + useJUnitPlatform() } diff --git a/app/src/test/java/com/cashpilot/android/AppStateTest.kt b/app/src/test/java/com/cashpilot/android/AppStateTest.kt new file mode 100644 index 0000000..3f2d481 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/AppStateTest.kt @@ -0,0 +1,116 @@ +package com.cashpilot.android + +import com.cashpilot.android.model.AppStatus +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.MonitoredApp +import com.cashpilot.android.ui.AppDisplayInfo +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.FleetSummary +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AppStateTest { + + @Test + fun `app state ordinal ordering`() { + // Sorting by ordinal puts RUNNING first, NOT_INSTALLED last + assertTrue(AppState.RUNNING.ordinal < AppState.STOPPED.ordinal) + assertTrue(AppState.STOPPED.ordinal < AppState.NOT_INSTALLED.ordinal) + assertTrue(AppState.NOT_INSTALLED.ordinal < AppState.DISABLED.ordinal) + } + + @Test + fun `fleet summary defaults`() { + val summary = FleetSummary() + assertEquals(0, summary.running) + assertEquals(0, summary.stopped) + assertEquals(0, summary.notInstalled) + assertEquals(0, summary.disabled) + assertEquals(0, summary.totalTx) + assertEquals(0, summary.totalRx) + } + + @Test + fun `fleet summary with values`() { + val summary = FleetSummary( + running = 3, + stopped = 2, + notInstalled = 5, + disabled = 1, + totalTx = 1000000, + totalRx = 5000000, + ) + assertEquals(3, summary.running) + assertEquals(5000000, summary.totalRx) + } + + @Test + fun `app display info with running state`() { + val app = MonitoredApp("test", "com.test", "Test App") + val status = AppStatus(slug = "test", running = true, netTx24h = 100) + val info = AppDisplayInfo(app = app, state = AppState.RUNNING, status = status) + assertEquals(AppState.RUNNING, info.state) + assertEquals(100, info.status?.netTx24h) + } + + @Test + fun `app display info with null status`() { + val app = MonitoredApp("test", "com.test", "Test App") + val info = AppDisplayInfo(app = app, state = AppState.NOT_INSTALLED) + assertNull(info.status) + } + + @Test + fun `compute fleet summary from display list`() { + val apps = listOf( + AppDisplayInfo( + app = MonitoredApp("a", "com.a", "A"), + state = AppState.RUNNING, + status = AppStatus(slug = "a", running = true, netTx24h = 500, netRx24h = 1000), + ), + AppDisplayInfo( + app = MonitoredApp("b", "com.b", "B"), + state = AppState.RUNNING, + status = AppStatus(slug = "b", running = true, netTx24h = 300, netRx24h = 700), + ), + AppDisplayInfo( + app = MonitoredApp("c", "com.c", "C"), + state = AppState.STOPPED, + status = AppStatus(slug = "c", running = false, netTx24h = 0, netRx24h = 0), + ), + AppDisplayInfo( + app = MonitoredApp("d", "com.d", "D"), + state = AppState.NOT_INSTALLED, + ), + ) + val summary = FleetSummary( + running = apps.count { it.state == AppState.RUNNING }, + stopped = apps.count { it.state == AppState.STOPPED }, + notInstalled = apps.count { it.state == AppState.NOT_INSTALLED }, + disabled = apps.count { it.state == AppState.DISABLED }, + totalTx = apps.mapNotNull { it.status?.netTx24h }.sum(), + totalRx = apps.mapNotNull { it.status?.netRx24h }.sum(), + ) + assertEquals(2, summary.running) + assertEquals(1, summary.stopped) + assertEquals(1, summary.notInstalled) + assertEquals(0, summary.disabled) + assertEquals(800, summary.totalTx) + assertEquals(1700, summary.totalRx) + } + + @Test + fun `sorting display list by state ordinal`() { + val list = listOf( + AppDisplayInfo(app = MonitoredApp("d", "com.d", "D"), state = AppState.DISABLED), + AppDisplayInfo(app = MonitoredApp("r", "com.r", "R"), state = AppState.RUNNING), + AppDisplayInfo(app = MonitoredApp("n", "com.n", "N"), state = AppState.NOT_INSTALLED), + AppDisplayInfo(app = MonitoredApp("s", "com.s", "S"), state = AppState.STOPPED), + ) + val sorted = list.sortedBy { it.state.ordinal } + assertEquals(AppState.RUNNING, sorted[0].state) + assertEquals(AppState.STOPPED, sorted[1].state) + assertEquals(AppState.NOT_INSTALLED, sorted[2].state) + assertEquals(AppState.DISABLED, sorted[3].state) + } +} diff --git a/app/src/test/java/com/cashpilot/android/HeartbeatModelTest.kt b/app/src/test/java/com/cashpilot/android/HeartbeatModelTest.kt new file mode 100644 index 0000000..db9af8c --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/HeartbeatModelTest.kt @@ -0,0 +1,119 @@ +package com.cashpilot.android + +import com.cashpilot.android.model.AppContainer +import com.cashpilot.android.model.AppStatus +import com.cashpilot.android.model.SystemInfo +import com.cashpilot.android.model.WorkerHeartbeat +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class HeartbeatModelTest { + + @Test + fun `worker heartbeat defaults`() { + val hb = WorkerHeartbeat(name = "test-worker") + assertEquals("test-worker", hb.name) + assertEquals("", hb.url) + assertTrue(hb.containers.isEmpty()) + assertTrue(hb.apps.isEmpty()) + assertEquals("android", hb.systemInfo.deviceType) + } + + @Test + fun `app container with running status`() { + val container = AppContainer( + slug = "earnapp", + name = "cashpilot-earnapp", + status = "running", + ) + assertEquals("earnapp", container.slug) + assertEquals("running", container.status) + assertEquals("", container.image) + assertTrue(container.labels.isEmpty()) + } + + @Test + fun `app container with labels`() { + val container = AppContainer( + slug = "iproyal", + name = "cashpilot-iproyal", + status = "stopped", + labels = mapOf("cashpilot.managed" to "true", "cashpilot.service" to "iproyal"), + ) + assertEquals("true", container.labels["cashpilot.managed"]) + assertEquals("iproyal", container.labels["cashpilot.service"]) + } + + @Test + fun `system info defaults`() { + val info = SystemInfo() + assertEquals("", info.os) + assertEquals("", info.arch) + assertEquals("", info.osVersion) + assertEquals("android", info.deviceType) + assertTrue(info.apps.isEmpty()) + } + + @Test + fun `system info with values`() { + val info = SystemInfo( + os = "Android", + arch = "arm64-v8a", + osVersion = "Android 14 (API 34)", + ) + assertEquals("Android", info.os) + assertEquals("arm64-v8a", info.arch) + } + + @Test + fun `app status running`() { + val status = AppStatus( + slug = "earnapp", + running = true, + notificationActive = true, + netTx24h = 1024000, + netRx24h = 5120000, + ) + assertTrue(status.running) + assertTrue(status.notificationActive) + assertEquals(1024000, status.netTx24h) + } + + @Test + fun `app status stopped`() { + val status = AppStatus( + slug = "traffmonetizer", + running = false, + ) + assertFalse(status.running) + assertFalse(status.notificationActive) + assertEquals(0, status.netTx24h) + assertEquals(0, status.netRx24h) + assertNull(status.lastActive) + } + + @Test + fun `heartbeat with full payload`() { + val apps = listOf( + AppStatus(slug = "earnapp", running = true), + AppStatus(slug = "iproyal", running = false), + ) + val containers = apps.map { + AppContainer( + slug = it.slug, + name = "cashpilot-${it.slug}", + status = if (it.running) "running" else "stopped", + ) + } + val hb = WorkerHeartbeat( + name = "Pixel 8", + containers = containers, + apps = apps, + systemInfo = SystemInfo(os = "Android", arch = "arm64-v8a"), + ) + assertEquals(2, hb.containers.size) + assertEquals(2, hb.apps.size) + assertEquals("running", hb.containers[0].status) + assertEquals("stopped", hb.containers[1].status) + } +} diff --git a/app/src/test/java/com/cashpilot/android/KnownAppsTest.kt b/app/src/test/java/com/cashpilot/android/KnownAppsTest.kt new file mode 100644 index 0000000..fe991f3 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/KnownAppsTest.kt @@ -0,0 +1,83 @@ +package com.cashpilot.android + +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.MonitoredApp +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class KnownAppsTest { + + @Test + fun `all apps have unique slugs`() { + val slugs = KnownApps.all.map { it.slug } + assertEquals(slugs.size, slugs.toSet().size, "Duplicate slugs found") + } + + @Test + fun `all apps have unique package names`() { + val packages = KnownApps.all.map { it.packageName } + assertEquals(packages.size, packages.toSet().size, "Duplicate package names found") + } + + @Test + fun `byPackage map contains all entries`() { + assertEquals(KnownApps.all.size, KnownApps.byPackage.size) + } + + @Test + fun `bySlug map contains all entries`() { + assertEquals(KnownApps.all.size, KnownApps.bySlug.size) + } + + @Test + fun `byPackage lookup returns correct app`() { + val app = KnownApps.byPackage["com.brd.earnapp.play"] + assertNotNull(app) + assertEquals("earnapp", app!!.slug) + assertEquals("EarnApp", app.displayName) + } + + @Test + fun `bySlug lookup returns correct app`() { + val app = KnownApps.bySlug["honeygain"] + assertNull(app, "honeygain is not in the current known apps list") + } + + @Test + fun `all apps have non-empty slug and packageName`() { + KnownApps.all.forEach { app -> + assertTrue(app.slug.isNotBlank(), "App has blank slug: $app") + assertTrue(app.packageName.isNotBlank(), "App has blank packageName: $app") + assertTrue(app.displayName.isNotBlank(), "App has blank displayName: $app") + } + } + + @Test + fun `iproyal has referral url`() { + val app = KnownApps.bySlug["iproyal"] + assertNotNull(app) + assertNotNull(app!!.referralUrl) + assertTrue(app.referralUrl!!.startsWith("https://")) + } + + @Test + fun `mysterium has no referral url`() { + val app = KnownApps.bySlug["mysterium"] + assertNotNull(app) + assertNull(app!!.referralUrl) + } + + @Test + fun `monitored app data class equality`() { + val a = MonitoredApp("test", "com.test", "Test App") + val b = MonitoredApp("test", "com.test", "Test App") + assertEquals(a, b) + } + + @Test + fun `monitored app with different referral urls are not equal`() { + val a = MonitoredApp("test", "com.test", "Test", referralUrl = "https://a.com") + val b = MonitoredApp("test", "com.test", "Test", referralUrl = "https://b.com") + assertNotEquals(a, b) + } +} diff --git a/app/src/test/java/com/cashpilot/android/SettingsTest.kt b/app/src/test/java/com/cashpilot/android/SettingsTest.kt new file mode 100644 index 0000000..ff4226a --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/SettingsTest.kt @@ -0,0 +1,69 @@ +package com.cashpilot.android + +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.Settings +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SettingsTest { + + @Test + fun `default settings has empty server url`() { + val settings = Settings() + assertEquals("", settings.serverUrl) + } + + @Test + fun `default settings has empty api key`() { + val settings = Settings() + assertEquals("", settings.apiKey) + } + + @Test + fun `default heartbeat interval is 30 seconds`() { + val settings = Settings() + assertEquals(30, settings.heartbeatIntervalSeconds) + } + + @Test + fun `default enabled slugs contains all known apps`() { + val settings = Settings() + val allSlugs = KnownApps.all.map { it.slug }.toSet() + assertEquals(allSlugs, settings.enabledSlugs) + } + + @Test + fun `default setup is not completed`() { + val settings = Settings() + assertFalse(settings.setupCompleted) + } + + @Test + fun `copy preserves values`() { + val original = Settings( + serverUrl = "https://example.com", + apiKey = "test-key", + heartbeatIntervalSeconds = 60, + setupCompleted = true, + ) + val copy = original.copy(heartbeatIntervalSeconds = 120) + assertEquals("https://example.com", copy.serverUrl) + assertEquals("test-key", copy.apiKey) + assertEquals(120, copy.heartbeatIntervalSeconds) + assertTrue(copy.setupCompleted) + } + + @Test + fun `settings equality`() { + val a = Settings(serverUrl = "https://a.com", apiKey = "key1") + val b = Settings(serverUrl = "https://a.com", apiKey = "key1") + assertEquals(a, b) + } + + @Test + fun `settings inequality on different api key`() { + val a = Settings(apiKey = "key1") + val b = Settings(apiKey = "key2") + assertNotEquals(a, b) + } +}