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

@@ -57,6 +57,7 @@ class ChatNotificationService : Service() {
internal const val NOTIFICATION_ID = 1
internal const val PENDING_NOTIFICATION_ID = 2
@Volatile
private var isAppInForeground = false
fun setAppForeground(foreground: Boolean) {
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.notification
import android.app.Notification
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
@@ -10,12 +11,34 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.launch
class NotificationReplyReceiver : BroadcastReceiver() {
open class NotificationReplyReceiver : BroadcastReceiver() {
companion object {
const val KEY_TEXT_REPLY = "key_text_reply"
const val EXTRA_SESSION_ID = "extra_session_id"
}
/**
* Test-friendly wrapper for [BroadcastReceiver.goAsync] which is `final`
* (Java) and cannot be mocked or overridden directly. Tests override this
* via anonymous subclass to inject a fake [PendingResult].
*/
internal open fun goAsyncCompat(): BroadcastReceiver.PendingResult = goAsync()
/**
* Test-friendly wrapper for notification creation. Override in tests to
* avoid [NotificationCompat.Builder.build()] calling Android framework
* methods that throw "not mocked" in unit tests.
*/
internal open fun buildReplyNotification(context: Context): Notification =
NotificationCompat
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Hermes")
.setContentText("Replied")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
override fun onReceive(
context: Context,
intent: Intent,
@@ -27,7 +50,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (!replyText.isNullOrBlank() && !sessionId.isNullOrBlank()) {
HermesWsClient.sendMessage(sessionId, replyText)
val pendingResult = goAsync()
val pendingResult = goAsyncCompat()
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
try {
val db =
@@ -53,15 +76,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
}
val repliedNotification =
NotificationCompat
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Hermes")
.setContentText("Replied")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
val repliedNotification = buildReplyNotification(context)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification)