diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewEvent.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewEvent.kt index 14d86d7ba7..adfc17f827 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewEvent.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewEvent.kt @@ -19,6 +19,19 @@ package im.vector.app.features.settings.notifications import im.vector.app.core.platform.VectorViewEvents sealed interface VectorSettingsPushRuleNotificationViewEvent : VectorViewEvents { - data class PushRuleUpdated(val ruleId: String, val checked: Boolean) : VectorSettingsPushRuleNotificationViewEvent - data class Failure(val throwable: Throwable) : VectorSettingsPushRuleNotificationViewEvent + /** + * A global push rule checked state has changed. + * + * @property ruleId the global rule id which has been updated. + * @property checked whether the global rule is checked. + * @property failure whether there has been a failure when updating the global rule (ie. a sub rule has not been updated). + */ + data class PushRuleUpdated(val ruleId: String, val checked: Boolean, val failure: Throwable? = null) : VectorSettingsPushRuleNotificationViewEvent + + /** + * A failure has occurred. + * + * @property throwable the related exception, if any. + */ + data class Failure(val throwable: Throwable?) : VectorSettingsPushRuleNotificationViewEvent } diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModel.kt index c681576ce0..39969ec13e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModel.kt @@ -83,9 +83,19 @@ class VectorSettingsPushRuleNotificationViewModel @AssistedInject constructor( } } setState { copy(isLoading = false) } - val failure = results.firstNotNullOfOrNull { it.exceptionOrNull() } - if (failure == null) { - _viewEvents.post(PushRuleUpdated(ruleId, checked)) + val failure = results.firstNotNullOfOrNull { result -> + // If the failure is a rule not found error, do not consider it + result.exceptionOrNull()?.takeUnless { it is ServerError && it.error.code == MatrixError.M_NOT_FOUND } + } + val newChecked = if (checked) { + // If any rule is checked, the global rule is checked + results.any { it.isSuccess } + } else { + // If any rule has not been unchecked, the global rule remains checked + failure != null + } + if (results.any { it.isSuccess }) { + _viewEvents.post(PushRuleUpdated(ruleId, newChecked, failure)) } else { _viewEvents.post(Failure(failure)) } @@ -93,18 +103,11 @@ class VectorSettingsPushRuleNotificationViewModel @AssistedInject constructor( } private suspend fun updatePushRule(kind: RuleKind, ruleId: String, enable: Boolean, newActions: List<Action>?) { - try { - activeSessionHolder.getSafeActiveSession()?.pushRuleService()?.updatePushRuleActions( - kind = kind, - ruleId = ruleId, - enable = enable, - actions = newActions - ) - } catch (failure: ServerError) { - // Ignore the error if the rule id is not known from the server - if (failure.error.code != MatrixError.M_NOT_FOUND) { - throw failure - } - } + activeSessionHolder.getSafeActiveSession()?.pushRuleService()?.updatePushRuleActions( + kind = kind, + ruleId = ruleId, + enable = enable, + actions = newActions + ) } } diff --git a/vector/src/test/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModelTest.kt new file mode 100644 index 0000000000..ff1cedee86 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/notifications/VectorSettingsPushRuleNotificationViewModelTest.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.notifications + +import com.airbnb.mvrx.test.MavericksTestRule +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.test +import im.vector.app.test.testDispatcher +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.matrix.android.sdk.api.session.pushrules.RuleIds +import org.matrix.android.sdk.api.session.pushrules.rest.PushRuleAndKind + +internal class VectorSettingsPushRuleNotificationViewModelTest { + + @get:Rule + val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakePushRuleService = fakeActiveSessionHolder.fakeSession.fakePushRuleService + + private val initialState = VectorSettingsPushRuleNotificationViewState() + private fun createViewModel() = VectorSettingsPushRuleNotificationViewModel( + initialState = initialState, + activeSessionHolder = fakeActiveSessionHolder.instance, + ) + + @Before + fun setup() { + mockkStatic("im.vector.app.features.settings.notifications.NotificationIndexKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a ruleId, when the rule is checked or unchecked, then the related rules are also updated and a view event is posted`() = runTest { + // Given + val viewModel = createViewModel() + + val firstRuleId = RuleIds.RULE_ID_ONE_TO_ONE_ROOM + val secondRuleId = RuleIds.RULE_ID_ALL_OTHER_MESSAGES_ROOMS + fakePushRuleService.givenUpdatePushRuleActionsSucceed() + + // When + val viewModelTest = viewModel.test() + viewModel.handle(VectorSettingsPushRuleNotificationViewAction.UpdatePushRule(givenARuleId(firstRuleId), true)) + viewModel.handle(VectorSettingsPushRuleNotificationViewAction.UpdatePushRule(givenARuleId(secondRuleId), false)) + + // Then + coVerifyOrder { + // first rule id + fakePushRuleService.updatePushRuleActions(any(), firstRuleId, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_ONE_TO_ONE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_ONE_TO_ONE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_ONE_TO_ONE_UNSTABLE, any(), any()) + + // second rule id + fakePushRuleService.updatePushRuleActions(any(), secondRuleId, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_UNSTABLE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_UNSTABLE, any(), any()) + } + + viewModelTest + .assertStatesChanges( + initialState, + { copy(isLoading = true) }, + { copy(isLoading = false) }, + { copy(isLoading = true) }, + { copy(isLoading = false) }, + ) + .assertEvents( + VectorSettingsPushRuleNotificationViewEvent.PushRuleUpdated(RuleIds.RULE_ID_ONE_TO_ONE_ROOM, true), + VectorSettingsPushRuleNotificationViewEvent.PushRuleUpdated(RuleIds.RULE_ID_ALL_OTHER_MESSAGES_ROOMS, false), + ) + .finish() + } + + @Test + fun `given a ruleId, when the rule is checked and an error is thrown, then all the related rules are updated and an event is posted with the failure`() = runTest { + // Given + val viewModel = createViewModel() + val failure = mockk<Throwable>() + + val firstRuleId = RuleIds.RULE_ID_ONE_TO_ONE_ROOM + fakePushRuleService.givenUpdatePushRuleActionsSucceed() + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE, failure) + + val secondRuleId = RuleIds.RULE_ID_ALL_OTHER_MESSAGES_ROOMS + fakePushRuleService.givenUpdatePushRuleActionsFail(secondRuleId, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_START, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_START_UNSTABLE, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_END, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_END_UNSTABLE, failure) + + // When + val viewModelTest = viewModel.test() + // One rule failed to update + viewModel.handle(VectorSettingsPushRuleNotificationViewAction.UpdatePushRule(givenARuleId(firstRuleId), true)) + // All the rules failed to update + viewModel.handle(VectorSettingsPushRuleNotificationViewAction.UpdatePushRule(givenARuleId(secondRuleId), true)) + + // Then + coVerifyOrder { + // first rule id + fakePushRuleService.updatePushRuleActions(any(), firstRuleId, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_ONE_TO_ONE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_ONE_TO_ONE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_ONE_TO_ONE_UNSTABLE, any(), any()) + + // second rule id + fakePushRuleService.updatePushRuleActions(any(), secondRuleId, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_UNSTABLE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_UNSTABLE, any(), any()) + } + + viewModelTest + .assertStatesChanges( + initialState, + { copy(isLoading = true) }, + { copy(isLoading = false) }, + { copy(isLoading = true) }, + { copy(isLoading = false) }, + ) + .assertEvents( + VectorSettingsPushRuleNotificationViewEvent.PushRuleUpdated(RuleIds.RULE_ID_ONE_TO_ONE_ROOM, true, failure), + VectorSettingsPushRuleNotificationViewEvent.Failure(failure), + ) + .finish() + } + + @Test + fun `given a ruleId, when the rule is unchecked and an error is thrown, then all the related rules are updated and an event is posted with the failure`() = runTest { + // Given + val viewModel = createViewModel() + val failure = mockk<Throwable>() + + val firstRuleId = RuleIds.RULE_ID_ONE_TO_ONE_ROOM + fakePushRuleService.givenUpdatePushRuleActionsSucceed() + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE, failure) + + val secondRuleId = RuleIds.RULE_ID_ALL_OTHER_MESSAGES_ROOMS + fakePushRuleService.givenUpdatePushRuleActionsFail(secondRuleId, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_START, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_START_UNSTABLE, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_END, failure) + fakePushRuleService.givenUpdatePushRuleActionsFail(RuleIds.RULE_ID_POLL_END_UNSTABLE, failure) + + // When + val viewModelTest = viewModel.test() + // One rule failed to update + viewModel.handle(VectorSettingsPushRuleNotificationViewAction.UpdatePushRule(givenARuleId(firstRuleId), false)) + // All the rules failed to update + viewModel.handle(VectorSettingsPushRuleNotificationViewAction.UpdatePushRule(givenARuleId(secondRuleId), false)) + + // Then + coVerifyOrder { + // first rule id + fakePushRuleService.updatePushRuleActions(any(), firstRuleId, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_ONE_TO_ONE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_ONE_TO_ONE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_ONE_TO_ONE_UNSTABLE, any(), any()) + + // second rule id + fakePushRuleService.updatePushRuleActions(any(), secondRuleId, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_START_UNSTABLE, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END, any(), any()) + fakePushRuleService.updatePushRuleActions(any(), RuleIds.RULE_ID_POLL_END_UNSTABLE, any(), any()) + } + + viewModelTest + .assertStatesChanges( + initialState, + { copy(isLoading = true) }, + { copy(isLoading = false) }, + { copy(isLoading = true) }, + { copy(isLoading = false) }, + ) + .assertEvents( + // The global rule remains checked if all the rules are not unchecked + VectorSettingsPushRuleNotificationViewEvent.PushRuleUpdated(RuleIds.RULE_ID_ONE_TO_ONE_ROOM, true, failure), + VectorSettingsPushRuleNotificationViewEvent.Failure(failure), + ) + .finish() + } + + @Test + fun `given a rule id, when requesting the check state, returns the expected value according to the related rules`() { + // Given + val viewModel = createViewModel() + val firstRuleId = RuleIds.RULE_ID_ONE_TO_ONE_ROOM + givenARuleId(firstRuleId, NotificationIndex.OFF) + givenARuleId(RuleIds.RULE_ID_POLL_START_ONE_TO_ONE, NotificationIndex.OFF) + givenARuleId(RuleIds.RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE, NotificationIndex.SILENT) + givenARuleId(RuleIds.RULE_ID_POLL_END_ONE_TO_ONE, NotificationIndex.NOISY) + givenARuleId(RuleIds.RULE_ID_POLL_END_ONE_TO_ONE_UNSTABLE, NotificationIndex.OFF) + + val secondRuleId = RuleIds.RULE_ID_ALL_OTHER_MESSAGES_ROOMS + givenARuleId(secondRuleId, NotificationIndex.OFF) + givenARuleId(RuleIds.RULE_ID_POLL_START, NotificationIndex.OFF) + givenARuleId(RuleIds.RULE_ID_POLL_START_UNSTABLE, NotificationIndex.OFF) + givenARuleId(RuleIds.RULE_ID_POLL_END, NotificationIndex.OFF) + givenARuleId(RuleIds.RULE_ID_POLL_END_UNSTABLE, NotificationIndex.OFF) + + // When + val firstResult = viewModel.isPushRuleChecked(firstRuleId) + val secondResult = viewModel.isPushRuleChecked(secondRuleId) + + // Then + firstResult shouldBe true + secondResult shouldBe false + } + + private fun givenARuleId(ruleId: String, notificationIndex: NotificationIndex = NotificationIndex.NOISY): PushRuleAndKind { + val ruleAndKind = mockk<PushRuleAndKind> { + every { pushRule.ruleId } returns ruleId + every { pushRule.notificationIndex } returns notificationIndex + every { kind } returns mockk() + } + + every { fakePushRuleService.getPushRules().findDefaultRule(ruleId) } returns ruleAndKind + + return ruleAndKind + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePushRuleService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePushRuleService.kt new file mode 100644 index 0000000000..4560f58978 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePushRuleService.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.mockk +import org.matrix.android.sdk.api.session.pushrules.PushRuleService + +class FakePushRuleService : PushRuleService by mockk(relaxed = true) { + + fun givenUpdatePushRuleActionsSucceed(ruleId: String? = null) { + coJustRun { updatePushRuleActions(any(), ruleId ?: any(), any(), any()) } + } + + fun givenUpdatePushRuleActionsFail(ruleId: String? = null, failure: Throwable = mockk()) { + coEvery { updatePushRuleActions(any(), ruleId ?: any(), any(), any()) }.throws(failure) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt index a05dce9c54..1b6d3e2729 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt @@ -41,10 +41,11 @@ class FakeSession( val fakeHomeServerCapabilitiesService: FakeHomeServerCapabilitiesService = FakeHomeServerCapabilitiesService(), val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService(), val fakeRoomService: FakeRoomService = FakeRoomService(), + val fakePushRuleService: FakePushRuleService = FakePushRuleService(), val fakePushersService: FakePushersService = FakePushersService(), val fakeUserService: FakeUserService = FakeUserService(), private val fakeEventService: FakeEventService = FakeEventService(), - val fakeSessionAccountDataService: FakeSessionAccountDataService = FakeSessionAccountDataService() + val fakeSessionAccountDataService: FakeSessionAccountDataService = FakeSessionAccountDataService(), ) : Session by mockk(relaxed = true) { init { @@ -61,6 +62,7 @@ class FakeSession( override fun sharedSecretStorageService() = fakeSharedSecretStorageService override fun roomService() = fakeRoomService override fun eventService() = fakeEventService + override fun pushRuleService() = fakePushRuleService override fun pushersService() = fakePushersService override fun accountDataService() = fakeSessionAccountDataService override fun userService() = fakeUserService