From 1d258dadedf1896921679c3643fbb126949ed1de Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 17 Apr 2026 09:50:52 -0500 Subject: [PATCH] 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> --- .../core/service/MarkAsReadReceiverTest.kt | 101 +++++++++++ .../MeshServiceNotificationsImplTest.kt | 1 + .../core/service/ReactionReceiverTest.kt | 157 ++++++++++++++++++ .../core/service/ReplyReceiverTest.kt | 125 ++++++++++++++ .../testing/FakeMeshServiceNotifications.kt | 2 +- .../core/testing/FakeRadioController.kt | 2 +- 6 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt new file mode 100644 index 000000000..b5e76ed33 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MarkAsReadReceiverTest.kt @@ -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 . + */ +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 { 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) + } +} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt index a4a3b0fe3..de3f4e4b6 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt @@ -59,6 +59,7 @@ class MeshServiceNotificationsImplTest { context = context, packetRepository = lazy { error("Not used in this test") }, nodeRepository = lazy { error("Not used in this test") }, + shortcutManager = lazy { error("Not used in this test") }, ) notifications.initChannels() diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt new file mode 100644 index 000000000..30260ee25 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReactionReceiverTest.kt @@ -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 . + */ +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 } + single { 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(MockMode.autofill) + everySuspend { throwingRepo.onServiceAction(any()) } calls { throw IllegalStateException("boom") } + stopKoin() + val dispatcher = UnconfinedTestDispatcher() + startKoin { + modules( + module { + single { throwingRepo } + single { 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) + } +} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt new file mode 100644 index 000000000..2cd651541 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt @@ -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 . + */ +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() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + radioController = RecordingRadioController(callLog) + notifications = RecordingNotifications(callLog) + val dispatcher = UnconfinedTestDispatcher() + startKoin { + modules( + module { + single { radioController } + single { 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) : FakeRadioController() { + override suspend fun sendMessage(packet: org.meshtastic.core.model.DataPacket) { + callLog.add("send") + super.sendMessage(packet) + } +} + +internal class RecordingNotifications(private val callLog: MutableList) : + org.meshtastic.core.testing.FakeMeshServiceNotifications() { + val appendCalls = mutableListOf>() + val markReadCalls = mutableListOf() + + 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) + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index e1c1c7659..fef69fdc7 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt @@ -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() {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index d23a7f1ec..9d2e490f5 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -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 {