test(#291): add coverage for 3 critical untested components (#300)

* test(#291): add coverage for 3 critical untested components

- NavigationController dedup guard (line 45): 14 pure unit tests for
  navigateTo, resetTo, goBack, primary-screen clearing, and null guards.
- NotificationReplyReceiver: 11 MockK-based tests covering the full
  WS+Room+notification flow: valid reply sends WS message, persists to
  Room with correct entity shape, shows replied confirmation notification,
  finishes PendingResult, and gracefully handles null/blank inputs and
  Room failures.
- ChatNotificationService isAppInForeground flag: 5 tests verifying
  companion object toggle, volatile modifier (thread safety), default
  false state, and idempotent calls.
- Fix: add @Volatile to isAppInForeground (was a plain var read from
  background coroutine and written from UI thread)

Closes #291

* fix(#291): fix MockK type errors in test compilation

* fix(#291): fix test assertions and goAsync mock for CI

* fix(#291): wrap final BroadcastReceiver.goAsync() with open Kotlin wrapper

* chore: trigger CI on latest fix commit

* fix(#291): make NotificationReplyReceiver open so tests can subclass it

* fix(#291): extract buildReplyNotification() to avoid 'not mocked' framework error

NotificationCompat.Builder.build() internally calls android.app.Notification.Builder
methods (e.g. setWhen) that throw RuntimeException with 'Method setWhen in
android.app.Notification.Builder not mocked' in unit tests — the standard Android
unit test environment does not provide real framework implementations.

Extracting notification creation into an open method (same pattern as goAsyncCompat())
lets tests override it to return a mock Notification, avoiding the framework call
entirely.

* style: fix ktlint blank-line-before-declaration in test

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-22 06:49:19 +03:00
1 parent bbe3b00ccb
commit c44f4c3068
5 files changed
+632 -11

No files matched your search

@@ -0,0 +1,99 @@
package com.m57.hermescontrol.notification
import org.junit.Assert.assertEquals
import org.junit.Test
import java.lang.reflect.Modifier
/**
* Unit tests for [ChatNotificationService] companion object — specifically
* the `isAppInForeground` flag that gates notification display.
*
* Issue #291 (Critical Test Coverage): Verifies that `setAppForeground`
* correctly toggles the flag and that the field is `@Volatile` for thread
* safety (it is read from the event-collection coroutine on a background
* dispatcher while `setAppForeground` is called from the UI thread).
*
* These are pure unit tests with no Android dependencies — the companion
* object can be exercised directly via reflection for the private flag
* or through the public `setAppForeground` API.
*/
class ChatNotificationServiceTest {
@Test
fun `setAppForeground true sets the flag`() {
val field =
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
field.isAccessible = true
// Reset to known state
field.setBoolean(null, false)
ChatNotificationService.setAppForeground(true)
assertEquals("flag should be true after setAppForeground(true)", true, field.getBoolean(null))
}
@Test
fun `setAppForeground false clears the flag`() {
val field =
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
field.isAccessible = true
// Set to known state
field.setBoolean(null, true)
ChatNotificationService.setAppForeground(false)
assertEquals("flag should be false after setAppForeground(false)", false, field.getBoolean(null))
}
@Test
fun `isAppInForeground defaults to false`() {
val field =
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
field.isAccessible = true
// Ensure it's in a clean state
field.setBoolean(null, false)
assertEquals("initial value should be false", false, field.getBoolean(null))
}
@Test
fun `isAppInForeground field is volatile for thread safety`() {
val field =
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
val modifiers = field.modifiers
assertEquals(
"isAppInForeground must be @Volatile — it is read from a background " +
"coroutine (event collection) and written from the UI thread " +
"(setAppForeground called from ChatScreen lifecycle)",
true,
Modifier.isVolatile(modifiers),
)
}
@Test
fun `setAppForeground is idempotent when called multiple times`() {
val field =
ChatNotificationService::class.java
.getDeclaredField("isAppInForeground")
field.isAccessible = true
// Set true twice
ChatNotificationService.setAppForeground(true)
ChatNotificationService.setAppForeground(true)
assertEquals("flag should remain true after consecutive setAppForeground(true)", true, field.getBoolean(null))
// Set false twice
ChatNotificationService.setAppForeground(false)
ChatNotificationService.setAppForeground(false)
assertEquals(
"flag should remain false after consecutive setAppForeground(false)",
false,
field.getBoolean(null),
)
}
}
@@ -0,0 +1,277 @@
package com.m57.hermescontrol.notification
import android.app.Notification
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.ChatMessageEntity
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.ws.HermesWsClient
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
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
/**
* Unit tests for [NotificationReplyReceiver] — the BroadcastReceiver that
* handles inline reply actions on chat notifications.
*
* Issue #291 (Critical Test Coverage): Verifies the full flow —
* (a) WS message is sent via [HermesWsClient.sendMessage]
* (b) Reply is persisted to Room via [ChatMessageDao.upsert]
* (c) A "Replied" confirmation notification is posted
* (d) Invalid/missing input is handled gracefully (no crash)
* (e) Room failures are logged and the pending result is still finished
*
* Uses [HermesDatabase.setForTest] for deterministic Room mocking and
* [spyk] for [BroadcastReceiver.goAsync] interception.
*
* NOTE: This does NOT use coVerify for suspend functions (incompatible with
* MockK 1.13.12). Instead, call counting via coEvery + answers is used.
*/
class NotificationReplyReceiverTest {
private lateinit var mockContext: Context
private lateinit var mockIntent: Intent
private lateinit var mockNotificationManager: NotificationManager
private lateinit var mockPendingResult: BroadcastReceiver.PendingResult
private lateinit var mockDb: HermesDatabase
private lateinit var mockDao: ChatMessageDao
private lateinit var receiver: NotificationReplyReceiver
private lateinit var mockNotification: Notification
// Call counters for suspend function tracking
private var upsertCallCount = 0
@Before
fun setUp() {
upsertCallCount = 0
// Mock Android framework statics (same pattern as HermesWsClientTest)
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any<String>(), any<String>()) } returns 0
every { android.util.Log.i(any<String>(), any<String>()) } returns 0
every { android.util.Log.e(any<String>(), any<String>(), any()) } returns 0
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
// Mock RemoteInput static method
mockkStatic(RemoteInput::class)
// Mock Context
mockContext = mockk(relaxed = true)
mockNotificationManager = mockk(relaxed = true)
every { mockContext.getSystemService(Context.NOTIFICATION_SERVICE) } returns mockNotificationManager
// Mock Intent
mockIntent = mockk(relaxed = true)
// Mock Room DB via its test injection point
mockDao = mockk(relaxed = true)
coEvery { mockDao.upsert(any<ChatMessageEntity>()) } answers {
upsertCallCount++
Unit
}
mockDb = mockk(relaxed = true)
every { mockDb.chatMessageDao() } returns mockDao
HermesDatabase.setForTest(mockDb)
// Mock PendingResult
mockPendingResult = mockk(relaxed = true)
// Mock Notification
mockNotification = mockk(relaxed = true)
// Mock HermesWsClient singleton
mockkObject(HermesWsClient)
every { HermesWsClient.sendMessage(any(), any()) } returns "mock-req-id"
// Create receiver with overridden goAsyncCompat() and buildReplyNotification()
// — BroadcastReceiver.goAsync() is final (Java) and cannot be mocked via spyk,
// and NotificationCompat.Builder.build() calls framework methods that throw
// "not mocked" in unit tests.
receiver =
object : NotificationReplyReceiver() {
override fun goAsyncCompat(): BroadcastReceiver.PendingResult = mockPendingResult
override fun buildReplyNotification(context: Context): Notification = mockNotification
}
}
@After
fun tearDown() {
HermesDatabase.setForTest(null)
unmockkAll()
}
// ── Happy path: valid reply ────────────────────────────────────────────
@Test
fun `valid reply sends message via WebSocket`() {
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
verify { HermesWsClient.sendMessage("session-abc", "Hello") }
}
@Test
fun `valid reply persists message to Room`() {
val entitySlot = slot<ChatMessageEntity>()
coEvery { mockDao.upsert(capture(entitySlot)) } answers {
upsertCallCount++
Unit
}
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
assertEquals("upsert should have been called", 1, upsertCallCount)
assertEquals("session-abc", entitySlot.captured.sessionId)
assertEquals("Hello", entitySlot.captured.content)
assertEquals("USER", entitySlot.captured.role)
assertNotNull("entity must have a UUID id", entitySlot.captured.id)
assertTrue("id should be a non-empty UUID", entitySlot.captured.id.isNotBlank())
assertTrue("timestamp should be positive", entitySlot.captured.timestamp > 0)
}
@Test
fun `valid reply posts replied notification`() {
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
verify {
mockNotificationManager.notify(
ChatNotificationService.PENDING_NOTIFICATION_ID,
any(),
)
}
}
@Test
fun `valid reply finishes pending result after Room save`() {
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
verify { mockPendingResult.finish() }
}
// ── Boundary / invalid input ──────────────────────────────────────────
@Test
fun `reply with empty text does nothing`() {
givenValidReply("session-abc", " ")
receiver.onReceive(mockContext, mockIntent)
verify(inverse = true) { HermesWsClient.sendMessage(any(), any()) }
assertFalse("upsert should NOT be called for empty reply", upsertCallCount > 0)
}
@Test
fun `reply with null text does nothing`() {
givenEmptyRemoteInput("session-abc")
receiver.onReceive(mockContext, mockIntent)
verify(inverse = true) { HermesWsClient.sendMessage(any(), any()) }
assertFalse("upsert should NOT be called for null reply", upsertCallCount > 0)
}
@Test
fun `reply with null sessionId does nothing`() {
givenValidReply(null, "Hello")
receiver.onReceive(mockContext, mockIntent)
verify(inverse = true) { HermesWsClient.sendMessage(any(), any()) }
assertFalse("upsert should NOT be called for null sessionId", upsertCallCount > 0)
}
@Test
fun `reply with empty sessionId does nothing`() {
givenValidReply("", "Hello")
receiver.onReceive(mockContext, mockIntent)
verify(inverse = true) { HermesWsClient.sendMessage(any(), any()) }
assertFalse("upsert should NOT be called for empty sessionId", upsertCallCount > 0)
}
@Test
fun `reply with blank sessionId does nothing`() {
givenValidReply(" ", "Hello")
receiver.onReceive(mockContext, mockIntent)
verify(inverse = true) { HermesWsClient.sendMessage(any(), any()) }
assertFalse("upsert should NOT be called for blank sessionId", upsertCallCount > 0)
}
@Test
fun `null remote input does nothing`() {
every { RemoteInput.getResultsFromIntent(mockIntent) } returns null
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns "session-abc"
receiver.onReceive(mockContext, mockIntent)
verify(inverse = true) { HermesWsClient.sendMessage(any(), any()) }
assertFalse("upsert should NOT be called for null remote input", upsertCallCount > 0)
}
@Test
fun `Room failure logs error and finishes pending result`() {
givenValidReply("session-abc", "Hello")
// Make upsert throw
coEvery { mockDao.upsert(any<ChatMessageEntity>()) } throws RuntimeException("DB full")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
// Error should be logged
verify { android.util.Log.e("NotificationReply", any<String>(), any()) }
// Pending result must still finish even on Room failure
verify { mockPendingResult.finish() }
}
// ── Helpers ────────────────────────────────────────────────────────────
private fun givenValidReply(
sessionId: String?,
replyText: String,
) {
val mockRemoteInputResult = mockk<android.os.Bundle>(relaxed = true)
every {
mockRemoteInputResult.getCharSequence(NotificationReplyReceiver.KEY_TEXT_REPLY)
} returns replyText
every { RemoteInput.getResultsFromIntent(mockIntent) } returns mockRemoteInputResult
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
}
private fun givenEmptyRemoteInput(sessionId: String?) {
val mockRemoteInputResult = mockk<android.os.Bundle>(relaxed = true)
every {
mockRemoteInputResult.getCharSequence(NotificationReplyReceiver.KEY_TEXT_REPLY)
} returns null
every { RemoteInput.getResultsFromIntent(mockIntent) } returns mockRemoteInputResult
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
}
}