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 @@
+[](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)
+ }
+}