diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt index 3a8145fed7f..3040ad5bdb9 100644 --- a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import app.k9mail.core.ui.compose.common.koin.koinPreview import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.flowOf import net.thunderbird.core.common.resources.StringsResourceManager import net.thunderbird.core.outcome.Outcome @@ -16,6 +17,8 @@ import net.thunderbird.feature.mail.account.api.BaseAccount import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure import net.thunderbird.feature.notification.api.command.NotificationCommand.Success import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver import net.thunderbird.feature.notification.api.sender.NotificationSender @PreviewLightDark @@ -47,6 +50,10 @@ private fun SecretDebugSettingsScreenPreview() { ): Flow, Failure>> = error("not implemented") }, + notificationReceiver = object : InAppNotificationReceiver { + override val events: SharedFlow + get() = error("not implemented") + }, ) } } WithContent { diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt index d0a1dcbf52a..84da33cb522 100644 --- a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt @@ -13,6 +13,7 @@ val featureDebugSettingsModule = module { stringsResourceManager = get(), accountManager = get(), notificationSender = get(), + notificationReceiver = get(), ) } } diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt index 867d0aeb9b5..b3554185cab 100644 --- a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.thunderbird.core.common.resources.StringsResourceManager @@ -27,12 +28,14 @@ import net.thunderbird.feature.notification.api.content.MailNotification import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.content.PushServiceNotification import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver import net.thunderbird.feature.notification.api.sender.NotificationSender internal class DebugNotificationSectionViewModel( private val stringsResourceManager: StringsResourceManager, private val accountManager: AccountManager, private val notificationSender: NotificationSender, + private val notificationReceiver: InAppNotificationReceiver, private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BaseViewModel(initialState = State()), DebugNotificationSectionContract.ViewModel { @@ -76,6 +79,18 @@ internal class DebugNotificationSectionViewModel( } } } + + viewModelScope.launch { + notificationReceiver + .events + .collectLatest { event -> + updateState { state -> + state.copy( + notificationStatusLog = state.notificationStatusLog + " In-app notification event: $event", + ) + } + } + } } override fun event(event: Event) { diff --git a/feature/notification/api/build.gradle.kts b/feature/notification/api/build.gradle.kts index d7aba818af2..01fac014062 100644 --- a/feature/notification/api/build.gradle.kts +++ b/feature/notification/api/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.internal.config.LanguageFeature plugins { id(ThunderbirdPlugins.Library.kmpCompose) + alias(libs.plugins.dev.mokkery) } kotlin { diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt new file mode 100644 index 00000000000..0f021255f7c --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt @@ -0,0 +1,19 @@ +package net.thunderbird.feature.notification.api.receiver + +import kotlinx.coroutines.flow.SharedFlow +import net.thunderbird.feature.notification.api.content.InAppNotification + +/** + * Interface for receiving in-app notification events. + * + * This interface provides a [SharedFlow] of [InAppNotificationEvent]s that can be observed + * by UI components or other parts of the application to react to in-app notifications. + */ +interface InAppNotificationReceiver { + val events: SharedFlow +} + +sealed interface InAppNotificationEvent { + data class Show(val notification: InAppNotification) : InAppNotificationEvent + data class Dismiss(val notification: InAppNotification) : InAppNotificationEvent +} diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompat.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompat.kt new file mode 100644 index 00000000000..52b31d13fca --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompat.kt @@ -0,0 +1,46 @@ +package net.thunderbird.feature.notification.api.receiver.compat + +import androidx.annotation.Discouraged +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver + +/** + * A compatibility class for [InAppNotificationReceiver] that allows Java classes to observe notification events. + * + * This class is discouraged for use in Kotlin code. Use [InAppNotificationReceiver] directly instead. + * + * @param notificationReceiver The [InAppNotificationReceiver] instance to delegate to. + * @param listener A callback function that will be invoked when a new [InAppNotificationEvent] is received. + * @param mainImmediateDispatcher The [CoroutineDispatcher] to use for observing events. + */ +@Discouraged("Only for usage within a Java class. Use InAppNotificationReceiver instead.") +class InAppNotificationReceiverCompat( + private val notificationReceiver: InAppNotificationReceiver, + listener: OnReceiveEventListener, + mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) : InAppNotificationReceiver by notificationReceiver, DisposableHandle { + private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher) + + init { + scope.launch { + events.collect { event -> + listener.onReceiveEvent(event) + } + } + } + + override fun dispose() { + scope.cancel() + } + + fun interface OnReceiveEventListener { + fun onReceiveEvent(event: InAppNotificationEvent) + } +} diff --git a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt new file mode 100644 index 00000000000..d281cfc78d1 --- /dev/null +++ b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt @@ -0,0 +1,86 @@ +package net.thunderbird.feature.notification.api.receiver.compat + +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import dev.mokkery.matcher.any +import dev.mokkery.spy +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.exactly +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import net.thunderbird.feature.notification.api.NotificationSeverity +import net.thunderbird.feature.notification.api.content.AppNotification +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver +import net.thunderbird.feature.notification.api.receiver.compat.InAppNotificationReceiverCompat.OnReceiveEventListener +import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon + +class InAppNotificationReceiverCompatTest { + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `onReceiveEvent should be triggered when an event is received into InAppNotificationReceiver`() = runTest { + // Arrange + val inAppNotificationReceiver = FakeInAppNotificationReceiver() + val eventsTriggered = mutableListOf() + val expectedEvents = List(size = 100) { index -> + val title = "notification $index" + when { + index % 2 == 0 -> { + InAppNotificationEvent.Show(FakeNotification(title = title)) + } + + else -> InAppNotificationEvent.Dismiss(FakeNotification(title = title)) + } + }.toTypedArray() + val onReceiveEventListener = spy( + OnReceiveEventListener { event -> + eventsTriggered += event + }, + ) + InAppNotificationReceiverCompat( + notificationReceiver = inAppNotificationReceiver, + listener = onReceiveEventListener, + mainImmediateDispatcher = UnconfinedTestDispatcher(), + ) + + // Act + expectedEvents.forEach { event -> inAppNotificationReceiver.trigger(event) } + + // Assert + verify(exactly(100)) { + onReceiveEventListener.onReceiveEvent(event = any()) + } + assertThat(eventsTriggered).containsExactlyInAnyOrder(elements = expectedEvents) + } + + private class FakeInAppNotificationReceiver : InAppNotificationReceiver { + private val _events = MutableSharedFlow() + override val events: SharedFlow = _events.asSharedFlow() + + suspend fun trigger(event: InAppNotificationEvent) { + _events.emit(event) + } + } + + data class FakeNotification( + override val title: String = "fake title", + override val contentText: String? = "fake content", + override val severity: NotificationSeverity = NotificationSeverity.Information, + override val icon: NotificationIcon = NotificationIcon( + inAppNotificationIcon = ImageVector.Builder( + defaultWidth = 0.dp, + defaultHeight = 0.dp, + viewportWidth = 0f, + viewportHeight = 0f, + ).build(), + ), + ) : AppNotification(), InAppNotification +} diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt index 1c2d23a5d34..207c19d6f33 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommand.kt @@ -1,11 +1,18 @@ package net.thunderbird.feature.notification.impl.command +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult import net.thunderbird.core.logging.Logger import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationRegistry import net.thunderbird.feature.notification.api.command.NotificationCommand +import net.thunderbird.feature.notification.api.command.NotificationCommandException import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +private const val TAG = "InAppNotificationCommand" + /** * A command that handles in-app notifications. * @@ -16,13 +23,42 @@ import net.thunderbird.feature.notification.api.receiver.NotificationNotifier */ internal class InAppNotificationCommand( private val logger: Logger, + private val featureFlagProvider: FeatureFlagProvider, + private val notificationRegistry: NotificationRegistry, notification: InAppNotification, notifier: NotificationNotifier, ) : NotificationCommand(notification, notifier) { + private val isFeatureFlagEnabled: Boolean + get() = featureFlagProvider + .provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled + override suspend fun execute(): Outcome, Failure> { - logger.debug { - "TODO: Implementation on GitHub Issue #9245. Notification = $notification." + logger.debug(TAG) { "execute() called with: notification = $notification" } + return when { + isFeatureFlagEnabled.not() -> + Outcome.failure( + error = Failure( + command = this, + throwable = NotificationCommandException( + message = "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled", + ), + ), + ) + + canExecuteCommand() -> { + notifier.show(id = notificationRegistry.register(notification), notification = notification) + Outcome.success(Success(command = this)) + } + + else -> { + Outcome.failure(Failure(command = this, throwable = Exception("Can't execute command."))) + } } - return Outcome.success(data = Success(command = this)) } + + // TODO(#9392): Verify if the app is on foreground. IF it isn't, then should fail + // executing the command + // TODO(#9420): If the app is on background and the severity is Fatal or Critical, we should + // let the command execute, but store it in a database instead of triggering the show notification logic. + private fun canExecuteCommand(): Boolean = true } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt index dc0cc830222..e9d43664ef0 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/command/NotificationCommandFactory.kt @@ -8,7 +8,6 @@ import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.content.Notification import net.thunderbird.feature.notification.api.content.SystemNotification import net.thunderbird.feature.notification.api.receiver.NotificationNotifier -import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier /** * A factory for creating a set of notification commands based on a given notification. @@ -18,7 +17,7 @@ internal class NotificationCommandFactory( private val featureFlagProvider: FeatureFlagProvider, private val notificationRegistry: NotificationRegistry, private val systemNotificationNotifier: NotificationNotifier, - private val inAppNotificationNotifier: InAppNotificationNotifier, + private val inAppNotificationNotifier: NotificationNotifier, ) { /** * Creates a set of [NotificationCommand]s for the given [notification]. @@ -47,6 +46,8 @@ internal class NotificationCommandFactory( commands.add( InAppNotificationCommand( logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, notification = notification, notifier = inAppNotificationNotifier, ), diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt index 4c2ed01619d..f40587fca4c 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.kt @@ -1,14 +1,19 @@ package net.thunderbird.feature.notification.impl.inject import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier import net.thunderbird.feature.notification.api.sender.NotificationSender import net.thunderbird.feature.notification.impl.DefaultNotificationRegistry import net.thunderbird.feature.notification.impl.command.NotificationCommandFactory +import net.thunderbird.feature.notification.impl.receiver.InAppNotificationEventBus import net.thunderbird.feature.notification.impl.receiver.InAppNotificationNotifier import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier import net.thunderbird.feature.notification.impl.sender.DefaultNotificationSender import org.koin.core.module.Module import org.koin.core.qualifier.named +import org.koin.dsl.bind import org.koin.dsl.module internal expect val platformFeatureNotificationModule: Module @@ -16,15 +21,26 @@ internal expect val platformFeatureNotificationModule: Module val featureNotificationModule = module { includes(platformFeatureNotificationModule) - factory { InAppNotificationNotifier() } + single { DefaultNotificationRegistry() } + + single { InAppNotificationEventBus() } + .bind(InAppNotificationReceiver::class) + + single>(named()) { + InAppNotificationNotifier( + logger = get(), + notificationRegistry = get(), + inAppNotificationEventBus = get(), + ) + } factory { NotificationCommandFactory( logger = get(), - notificationRegistry = get(), featureFlagProvider = get(), + notificationRegistry = get(), systemNotificationNotifier = get(named()), - inAppNotificationNotifier = get(), + inAppNotificationNotifier = get(named()), ) } @@ -33,6 +49,4 @@ val featureNotificationModule = module { commandFactory = get(), ) } - - single { DefaultNotificationRegistry() } } diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationEventBus.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationEventBus.kt new file mode 100644 index 00000000000..6f11de08f76 --- /dev/null +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationEventBus.kt @@ -0,0 +1,31 @@ +package net.thunderbird.feature.notification.impl.receiver + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver + +/** + * An event bus for in-app notifications. + * + * This interface extends [InAppNotificationReceiver] to allow listening for notification events, + * and adds a [publish] method to send new notification events. + */ +internal interface InAppNotificationEventBus : InAppNotificationReceiver { + /** + * Publishes an in-app notification event to the event bus. + * + * @param event The [InAppNotificationEvent] to be published. + */ + suspend fun publish(event: InAppNotificationEvent) +} + +internal fun InAppNotificationEventBus(): InAppNotificationEventBus = object : InAppNotificationEventBus { + private val _events = MutableSharedFlow(replay = 1) + override val events: SharedFlow = _events.asSharedFlow() + + override suspend fun publish(event: InAppNotificationEvent) { + _events.emit(event) + } +} diff --git a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt index 62185c42b04..41c7f2541f3 100644 --- a/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt +++ b/feature/notification/impl/src/commonMain/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifier.kt @@ -1,22 +1,34 @@ package net.thunderbird.feature.notification.impl.receiver +import net.thunderbird.core.logging.Logger import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationRegistry import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +private const val TAG = "InAppNotificationNotifier" + /** * This notifier is responsible for taking a [InAppNotification] data object and * presenting it to the user in a suitable way. - * - * **Note:** The current implementation is a placeholder and needs to be completed - * as part of GitHub Issue #9245. */ -internal class InAppNotificationNotifier : NotificationNotifier { +internal class InAppNotificationNotifier( + private val logger: Logger, + private val notificationRegistry: NotificationRegistry, + private val inAppNotificationEventBus: InAppNotificationEventBus, +) : NotificationNotifier { + override suspend fun show(id: NotificationId, notification: InAppNotification) { - TODO("Implementation on GitHub Issue #9245") + logger.debug(TAG) { "show() called with: id = $id, notification = $notification" } + if (notificationRegistry.registrar.containsKey(id)) { + inAppNotificationEventBus.publish( + event = InAppNotificationEvent.Show(notification), + ) + } } override fun dispose() { - TODO("Implementation on GitHub Issue #9245") + logger.debug(TAG) { "dispose() called" } } } diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt new file mode 100644 index 00000000000..a98494d4740 --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/command/InAppNotificationCommandTest.kt @@ -0,0 +1,141 @@ +package net.thunderbird.feature.notification.impl.command + +import assertk.all +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import dev.mokkery.matcher.any +import dev.mokkery.spy +import dev.mokkery.verify.VerifyMode.Companion.exactly +import dev.mokkery.verifySuspend +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.NotificationSeverity +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success +import net.thunderbird.feature.notification.api.command.NotificationCommandException +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier +import net.thunderbird.feature.notification.testing.fake.FakeNotification +import net.thunderbird.feature.notification.testing.fake.FakeNotificationRegistry +import net.thunderbird.feature.notification.testing.fake.receiver.FakeInAppNotificationNotifier + +@Suppress("MaxLineLength") +class InAppNotificationCommandTest { + @Test + fun `execute should return Failure when display_in_app_notifications feature flag is Disabled`() = + runTest { + // Arrange + val testSubject = createTestSubject( + featureFlagProvider = { key -> + when (key) { + FeatureFlagKey.DisplayInAppNotifications -> FeatureFlagResult.Disabled + else -> FeatureFlagResult.Enabled + } + }, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled", + ) + } + } + + @Test + fun `execute should return Failure when display_in_app_notifications feature flag is Unavailable`() = + runTest { + // Arrange + val testSubject = createTestSubject( + featureFlagProvider = { key -> + when (key) { + FeatureFlagKey.DisplayInAppNotifications -> FeatureFlagResult.Unavailable + else -> FeatureFlagResult.Enabled + } + }, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("error") { it.error } + .all { + prop(Failure::command) + .isEqualTo(testSubject) + prop(Failure::throwable) + .isInstanceOf() + .hasMessage( + "${FeatureFlagKey.DisplayInAppNotifications.key} feature flag is not enabled", + ) + } + } + + @Test + fun `execute should return Success when display_in_app_notifications feature flag is Enabled`() = + runTest { + // Arrange + val notification = FakeNotification( + severity = NotificationSeverity.Information, + ) + val notifier = spy(FakeInAppNotificationNotifier()) + val testSubject = createTestSubject( + notification = notification, + notifier = notifier, + ) + + // Act + val outcome = testSubject.execute() + + // Assert + assertThat(outcome) + .isInstanceOf>>() + .prop("data") { it.data } + .all { + prop(Success::command) + .isEqualTo(testSubject) + } + + verifySuspend(exactly(1)) { + notifier.show(id = any(), notification) + } + } + + private fun createTestSubject( + notification: InAppNotification = FakeNotification(), + featureFlagProvider: FeatureFlagProvider = FeatureFlagProvider { FeatureFlagResult.Enabled }, + notifier: NotificationNotifier = FakeInAppNotificationNotifier(), + notificationRegistry: NotificationRegistry = FakeNotificationRegistry(), + ): InAppNotificationCommand { + val logger = TestLogger() + return InAppNotificationCommand( + logger = logger, + featureFlagProvider = featureFlagProvider, + notificationRegistry = notificationRegistry, + notification = notification, + notifier = notifier, + ) + } +} diff --git a/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifierTest.kt b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifierTest.kt new file mode 100644 index 00000000000..62448fec9ae --- /dev/null +++ b/feature/notification/impl/src/commonTest/kotlin/net/thunderbird/feature/notification/impl/receiver/InAppNotificationNotifierTest.kt @@ -0,0 +1,73 @@ +package net.thunderbird.feature.notification.impl.receiver + +import dev.mokkery.matcher.any +import dev.mokkery.spy +import dev.mokkery.verify.VerifyMode.Companion.exactly +import dev.mokkery.verifySuspend +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification + +class InAppNotificationNotifierTest { + @Test + fun `show should not publish event when notification is already present in NotificationRegistry`() = runTest { + // Arrange + val notificationId = NotificationId(value = 1) + val notification = FakeInAppOnlyNotification() + val registrar = mapOf(notificationId to notification) + val eventBus = spy(InAppNotificationEventBus()) + val testSubject = createTestSubject(registrar, eventBus) + + // Act + testSubject.show(notificationId, notification) + + // Assert + verifySuspend(exactly(1)) { + eventBus.publish(any()) + } + } + + @Test + fun `show should publish event when notification is not present in NotificationRegistry`() = runTest { + // Arrange + val notificationId = NotificationId(value = Int.MAX_VALUE) + val notification = FakeInAppOnlyNotification() + val registrar = buildMap { + repeat(times = 100) { index -> + put(NotificationId(index), FakeInAppOnlyNotification(title = "fake title $index")) + } + } + val eventBus = spy(InAppNotificationEventBus()) + val testSubject = createTestSubject(registrar, eventBus) + + // Act + testSubject.show(notificationId, notification) + + // Assert + verifySuspend(exactly(0)) { + eventBus.publish(any()) + } + } + + private fun createTestSubject( + registrar: Map, + eventBus: InAppNotificationEventBus, + ): InAppNotificationNotifier { + return InAppNotificationNotifier( + logger = TestLogger(), + notificationRegistry = object : NotificationRegistry { + override val registrar: Map = registrar + override fun get(notificationId: NotificationId): Notification? = error("Not yet implemented") + override fun get(notification: Notification): NotificationId? = error("Not yet implemented") + override suspend fun register(notification: Notification): NotificationId = error("Not yet implemented") + override fun unregister(notificationId: NotificationId) = error("Not yet implemented") + override fun unregister(notification: Notification) = error("Not yet implemented") + }, + inAppNotificationEventBus = eventBus, + ) + } +} diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationRegistry.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationRegistry.kt new file mode 100644 index 00000000000..e2ca648dd9f --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotificationRegistry.kt @@ -0,0 +1,31 @@ +package net.thunderbird.feature.notification.testing.fake + +import kotlin.random.Random +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.NotificationRegistry +import net.thunderbird.feature.notification.api.content.Notification + +open class FakeNotificationRegistry : NotificationRegistry { + override val registrar: Map + get() = TODO("Not yet implemented") + + override fun get(notificationId: NotificationId): Notification? { + TODO("Not yet implemented") + } + + override fun get(notification: Notification): NotificationId? { + TODO("Not yet implemented") + } + + override suspend fun register(notification: Notification): NotificationId { + return NotificationId(value = Random.Default.nextInt()) + } + + override fun unregister(notificationId: NotificationId) { + TODO("Not yet implemented") + } + + override fun unregister(notification: Notification) { + TODO("Not yet implemented") + } +} diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeInAppNotificationNotifier.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeInAppNotificationNotifier.kt new file mode 100644 index 00000000000..adcf427d7db --- /dev/null +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/receiver/FakeInAppNotificationNotifier.kt @@ -0,0 +1,14 @@ +package net.thunderbird.feature.notification.testing.fake.receiver + +import net.thunderbird.feature.notification.api.NotificationId +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.receiver.NotificationNotifier + +open class FakeInAppNotificationNotifier : NotificationNotifier { + override suspend fun show( + id: NotificationId, + notification: InAppNotification, + ) = Unit + + override fun dispose() = Unit +}