test(monitor): Cover foreground re-promotion and start-rejection logging

This commit is contained in:
darken
2026-07-28 23:51:46 +02:00
committed by Matthias Urhahn
parent de287fcb66
commit 9f322f0a3f
2 changed files with 213 additions and 0 deletions
@@ -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<Triple<Logging.Priority, String, String>>()
private val testLogger = object : Logging.Logger {
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
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<String> = 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<Context>().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<Context>().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<Context>().startServiceCompat(any()) } throws failure
MonitorControl(context).startMonitor()
val warning = warnings().single { it.contains("Failed to start monitor service") }
warning shouldContain SecurityException::class.java.name
}
}
@@ -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
}
}