diff --git a/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorControlTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorControlTest.kt new file mode 100644 index 00000000..0090674d --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorControlTest.kt @@ -0,0 +1,92 @@ +package eu.darken.capod.monitor.core.worker + +import android.Manifest +import android.app.Application +import android.app.ForegroundServiceStartNotAllowedException +import android.content.Context +import eu.darken.capod.common.debug.logging.Logging +import eu.darken.capod.common.startServiceCompat +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * A start rejection must never escape, but it also must not disappear into a message-only log line + * — the stack tells us which caller tried to start the service from the background. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34], application = Application::class) +class MonitorControlTest { + + private val context: Application get() = RuntimeEnvironment.getApplication() + + private val logLines = mutableListOf>() + + private val testLogger = object : Logging.Logger { + override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { + logLines.add(Triple(priority, tag, message)) + } + } + + @Before + fun setup() { + shadowOf(context).grantPermissions(Manifest.permission.BLUETOOTH) + logLines.clear() + Logging.install(testLogger) + mockkStatic("eu.darken.capod.common.ContextExtensionsKt") + } + + @After + fun teardown() { + Logging.remove(testLogger) + unmockkAll() + } + + private fun warnings(): List = logLines + .filter { it.first == Logging.Priority.WARN } + .map { it.third } + + @Test + fun `background start rejections are logged as such`() { + val failure = ForegroundServiceStartNotAllowedException("not allowed from background") + every { any().startServiceCompat(any()) } throws failure + + MonitorControl(context).startMonitor() + + val warning = warnings().single { it.contains("Start rejected") } + warning shouldContain ForegroundServiceStartNotAllowedException::class.java.name + } + + @Test + fun `other illegal states are logged generically`() { + val failure = IllegalStateException("something else") + every { any().startServiceCompat(any()) } throws failure + + MonitorControl(context).startMonitor() + + val warning = warnings().single { it.contains("Failed to start monitor service") } + warning shouldContain IllegalStateException::class.java.name + warning shouldNotContain "Start rejected" + } + + @Test + fun `security exceptions are logged with their stack`() { + val failure = SecurityException("missing permission") + every { any().startServiceCompat(any()) } throws failure + + MonitorControl(context).startMonitor() + + val warning = warnings().single { it.contains("Failed to start monitor service") } + warning shouldContain SecurityException::class.java.name + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorServiceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorServiceTest.kt new file mode 100644 index 00000000..63cc34a0 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorServiceTest.kt @@ -0,0 +1,121 @@ +package eu.darken.capod.monitor.core.worker + +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.app.Service +import androidx.core.app.NotificationCompat +import eu.darken.capod.monitor.ui.MonitorNotifications +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.matchers.types.shouldNotBeSameInstanceAs +import kotlinx.coroutines.Job +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Every `startForegroundService()` re-arms the `startForeground()` obligation, so each + * `onStartCommand()` has to satisfy it again — on every exit path. + * + * NOTE: `create()` already runs the real `onCreate()` (early promotion + failing Hilt injection), + * so shadow state is non-trivial before a test acts. Assertions are deltas against the state + * captured right before the call under test, never absolutes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34], application = Application::class) +class MonitorServiceTest { + + private val context: Application get() = RuntimeEnvironment.getApplication() + + private fun MonitorService.setField(name: String, value: Any?) { + MonitorService::class.java.getDeclaredField(name).apply { isAccessible = true }.set(this, value) + } + + private fun notification(title: String): Notification = + NotificationCompat.Builder(context, MonitorNotifications.NOTIFICATION_CHANNEL_ID) + .setContentTitle(title) + .build() + + private fun createService(): MonitorService { + val service = Robolectric.buildService(MonitorService::class.java).create().get() + // Hilt injection fails on a plain Application, so wire what the tests need by hand and + // reset the flags onCreate() left behind. + service.notificationManager = context.getSystemService(NotificationManager::class.java) + service.setField("foregroundStartFailed", false) + service.setField("injectionComplete", false) + service.setField("monitoringJob", null) + service.setField("lastNotification", null) + return service + } + + private fun MonitorService.readyForMonitoring() { + setField("injectionComplete", true) + setField("monitoringJob", Job()) + setField("foregroundStartFailed", false) + } + + @Test + fun `repeated non-force starts promote to foreground every time`() { + val service = createService() + service.readyForMonitoring() + + val first = notification("first") + service.setField("lastNotification", first) + + service.onStartCommand(MonitorService.intent(context), 0, 1) shouldBe Service.START_STICKY + shadowOf(service).lastForegroundNotification shouldBeSameInstanceAs first + shadowOf(service).lastForegroundNotificationId shouldBe MonitorNotifications.NOTIFICATION_ID + + val second = notification("second") + service.setField("lastNotification", second) + + service.onStartCommand(MonitorService.intent(context), 0, 2) shouldBe Service.START_STICKY + shadowOf(service).lastForegroundNotification shouldBeSameInstanceAs second + shadowOf(service).lastForegroundNotificationId shouldBe MonitorNotifications.NOTIFICATION_ID + } + + @Test + fun `foreground-denied start still satisfies the obligation before stopping`() { + val service = createService() + service.setField("foregroundStartFailed", true) + service.setField("lastNotification", null) + val beforeCall = shadowOf(service).lastForegroundNotification + + service.onStartCommand(MonitorService.intent(context), 0, 1) shouldBe Service.START_NOT_STICKY + + shadowOf(service).lastForegroundNotification shouldNotBeSameInstanceAs beforeCall + shadowOf(service).lastForegroundNotificationId shouldBe MonitorNotifications.NOTIFICATION_ID + } + + @Test + fun `re-promotion falls back to the early notification when nothing was posted`() { + val service = createService() + service.readyForMonitoring() + service.setField("lastNotification", null) + val beforeCall = shadowOf(service).lastForegroundNotification + + service.onStartCommand(MonitorService.intent(context), 0, 1) shouldBe Service.START_STICKY + + shadowOf(service).lastForegroundNotification shouldNotBeSameInstanceAs beforeCall + shadowOf(service).lastForegroundNotificationId shouldBe MonitorNotifications.NOTIFICATION_ID + } + + @Test + fun `re-promotion uses the notification posted by the monitor flow`() { + val service = createService() + service.readyForMonitoring() + + val dynamic = notification("dynamic") + service.postPrimaryNotification(dynamic) + + service.onStartCommand(MonitorService.intent(context), 0, 1) shouldBe Service.START_STICKY + + shadowOf(service).lastForegroundNotification shouldBeSameInstanceAs dynamic + shadowOf(service).lastForegroundNotificationId shouldBe MonitorNotifications.NOTIFICATION_ID + } +}