test(notifications): add unit tests for reply/markAsRead/reaction receivers

Adds Robolectric-based androidHostTest coverage for the three notification
BroadcastReceivers. Verifies:

- ReplyReceiver sends a DataPacket derived from the contactKey then calls
  appendOutgoingMessage followed by markConversationRead in that order.
- MarkAsReadReceiver invokes markConversationRead, ignores wrong actions,
  and drops intents missing the contact key.
- ReactionReceiver dispatches a ServiceAction.Reaction and, on success,
  calls markConversationRead. Failures in dispatch short-circuit markRead.

Uses the existing FakeRadioController and FakeMeshServiceNotifications
(marked open so tests can record calls) plus mokkery for ServiceRepository,
mirroring the pattern in SendMessageWorkerTest. Fakes are wired through a
per-test Koin graph to match each receiver's KoinComponent injection.

Also fixes a pre-existing compile break in MeshServiceNotificationsImplTest
that was missing the shortcutManager constructor argument.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-17 09:50:52 -05:00
parent dac4880e0f
commit 1d258daded
6 changed files with 386 additions and 2 deletions

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MarkAsReadReceiverTest {
private lateinit var context: Context
private lateinit var notifications: RecordingNotifications
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
notifications = RecordingNotifications(mutableListOf())
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `markAsRead action invokes markConversationRead`() {
val contactKey = "0!deadbeef"
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = MarkAsReadReceiver.MARK_AS_READ_ACTION
putExtra(MarkAsReadReceiver.CONTACT_KEY, contactKey)
}
MarkAsReadReceiver().onReceive(context, intent)
assertEquals(listOf(contactKey), notifications.markReadCalls)
}
@Test
fun `missing contactKey does not invoke markConversationRead`() {
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply { action = MarkAsReadReceiver.MARK_AS_READ_ACTION }
MarkAsReadReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
@Test
fun `wrong action is ignored`() {
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = "some.other.ACTION"
putExtra(MarkAsReadReceiver.CONTACT_KEY, "0!abcd")
}
MarkAsReadReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
}

View file

@ -59,6 +59,7 @@ class MeshServiceNotificationsImplTest {
context = context,
packetRepository = lazy<PacketRepository> { error("Not used in this test") },
nodeRepository = lazy<NodeRepository> { error("Not used in this test") },
shortcutManager = lazy<ConversationShortcutManager> { error("Not used in this test") },
)
notifications.initChannels()

View file

@ -0,0 +1,157 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ReactionReceiverTest {
private lateinit var context: Context
private lateinit var notifications: RecordingNotifications
private lateinit var serviceRepository: ServiceRepository
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
notifications = RecordingNotifications(mutableListOf())
serviceRepository = mock(MockMode.autofill)
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<ServiceRepository> { serviceRepository }
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `reaction dispatches ServiceAction and marks conversation read`() {
val contactKey = "0!cafebabe"
val emoji = "👍"
val replyId = 42
everySuspend { serviceRepository.onServiceAction(any()) } returns Unit
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = ReactionReceiver.REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_EMOJI, emoji)
putExtra(ReactionReceiver.EXTRA_REPLY_ID, replyId)
}
ReactionReceiver().onReceive(context, intent)
verifySuspend(VerifyMode.exactly(1)) {
serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
}
assertEquals(listOf(contactKey), notifications.markReadCalls)
}
@Test
fun `reaction does not markRead when ServiceAction dispatch throws`() {
val contactKey = "0!feedface"
val throwingRepo = mock<ServiceRepository>(MockMode.autofill)
everySuspend { throwingRepo.onServiceAction(any()) } calls { throw IllegalStateException("boom") }
stopKoin()
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<ServiceRepository> { throwingRepo }
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = ReactionReceiver.REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_REACTION, "🎉")
putExtra(ReactionReceiver.EXTRA_PACKET_ID, 7)
}
ReactionReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
@Test
fun `reaction without contactKey is dropped`() {
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = ReactionReceiver.REACT_ACTION
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
}
ReactionReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
@Test
fun `wrong action is ignored`() {
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = "other.ACTION"
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, "0!abcd")
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
}
ReactionReceiver().onReceive(context, intent)
assertEquals(emptyList(), notifications.markReadCalls)
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.app.RemoteInput
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.testing.FakeRadioController
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ReplyReceiverTest {
private lateinit var context: Context
private lateinit var radioController: FakeRadioController
private lateinit var notifications: RecordingNotifications
private val callLog = mutableListOf<String>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
radioController = RecordingRadioController(callLog)
notifications = RecordingNotifications(callLog)
val dispatcher = UnconfinedTestDispatcher()
startKoin {
modules(
module {
single<RadioController> { radioController }
single<MeshServiceNotifications> { notifications }
single { CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) }
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `reply sends DataPacket and marks conversation read in order`() {
val contactKey = "2!abcd1234"
val replyText = "hello world"
val intent =
Intent(context, ReplyReceiver::class.java).apply {
action = ReplyReceiver.REPLY_ACTION
putExtra(ReplyReceiver.CONTACT_KEY, contactKey)
}
val results = Bundle().apply { putCharSequence(ReplyReceiver.KEY_TEXT_REPLY, replyText) }
RemoteInput.addResultsToIntent(
arrayOf(RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY).build()),
intent,
results,
)
ReplyReceiver().onReceive(context, intent)
assertEquals(1, radioController.sentPackets.size)
val sent = radioController.sentPackets.first()
assertEquals("!abcd1234", sent.to)
assertEquals(2, sent.channel)
assertEquals(replyText, sent.text)
assertEquals(listOf(contactKey to replyText), notifications.appendCalls)
assertEquals(listOf(contactKey), notifications.markReadCalls)
assertEquals(listOf("send", "append", "markRead"), callLog)
}
}
private class RecordingRadioController(private val callLog: MutableList<String>) : FakeRadioController() {
override suspend fun sendMessage(packet: org.meshtastic.core.model.DataPacket) {
callLog.add("send")
super.sendMessage(packet)
}
}
internal class RecordingNotifications(private val callLog: MutableList<String>) :
org.meshtastic.core.testing.FakeMeshServiceNotifications() {
val appendCalls = mutableListOf<Pair<String, String>>()
val markReadCalls = mutableListOf<String>()
override suspend fun appendOutgoingMessage(contactKey: String, text: String) {
callLog.add("append")
appendCalls.add(contactKey to text)
}
override suspend fun markConversationRead(contactKey: String) {
callLog.add("markRead")
markReadCalls.add(contactKey)
}
}

View file

@ -24,7 +24,7 @@ import org.meshtastic.proto.Telemetry
/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeMeshServiceNotifications : MeshServiceNotifications {
open class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun clearNotifications() {}
override fun initChannels() {}

View file

@ -31,7 +31,7 @@ import org.meshtastic.proto.User
* A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests.
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeRadioController :
open class FakeRadioController :
BaseFake(),
RadioController {