// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // @testable import ElementX import MatrixRustSDK import Testing @MainActor struct NotificationSettingsScreenViewModelTests { private var viewModel: NotificationSettingsScreenViewModelProtocol private var context: NotificationSettingsScreenViewModelType.Context private var appSettings: AppSettings private var userNotificationCenter: UserNotificationCenterMock private var notificationSettingsProxy: NotificationSettingsProxyMock init() throws { AppSettings.resetAllSettings() userNotificationCenter = UserNotificationCenterMock() userNotificationCenter.authorizationStatusReturnValue = .authorized appSettings = AppSettings() notificationSettingsProxy = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .allMessages notificationSettingsProxy.isRoomMentionEnabledReturnValue = true notificationSettingsProxy.isCallEnabledReturnValue = true viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings, userNotificationCenter: userNotificationCenter, notificationSettingsProxy: notificationSettingsProxy, isModallyPresented: false) context = viewModel.context } @Test func enableNotifications() { appSettings.enableNotifications = false context.send(viewAction: .changedEnableNotifications) #expect(appSettings.enableNotifications) } @Test func disableNotifications() { appSettings.enableNotifications = true context.send(viewAction: .changedEnableNotifications) #expect(!appSettings.enableNotifications) } @Test func fetchSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (_, true): return .allMessages case (_, _): return .mentionsAndKeywordsOnly } } let deferred = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } notificationSettingsProxy.callbacks.send(.settingsDidChange) try await deferred.fulfill() #expect(notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneCallsCount == 4) #expect(notificationSettingsProxy.isRoomMentionEnabledCalled) #expect(notificationSettingsProxy.isCallEnabledCalled) #expect(context.viewState.settings?.groupChatsMode == .mentionsAndKeywordsOnly) #expect(context.viewState.settings?.directChatsMode == .allMessages) #expect(context.viewState.settings?.inconsistentSettings == []) #expect(context.viewState.bindings.alertInfo == nil) } @Test func inconsistentGroupChatsSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (true, false): return .allMessages case (false, false): return .mentionsAndKeywordsOnly default: return .allMessages } } let deferred = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } notificationSettingsProxy.callbacks.send(.settingsDidChange) try await deferred.fulfill() #expect(context.viewState.settings?.groupChatsMode == .allMessages) #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .groupChat, isEncrypted: false)]) } @Test func inconsistentDirectChatsSettings() async throws { notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (true, true): return .allMessages case (false, true): return .mentionsAndKeywordsOnly default: return .allMessages } } let deferred = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } notificationSettingsProxy.callbacks.send(.settingsDidChange) try await deferred.fulfill() #expect(context.viewState.settings?.directChatsMode == .allMessages) #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .oneToOneChat, isEncrypted: false)]) } @Test func fixInconsistentSettings() async throws { // Initialize with a configuration mismatch where encrypted one-to-one chats is `.allMessages` and unencrypted one-to-one chats is `.mentionsAndKeywordsOnly` notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (true, true): return .allMessages case (false, true): return .mentionsAndKeywordsOnly default: return .allMessages } } let deferredSettings = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } notificationSettingsProxy.callbacks.send(.settingsDidChange) try await deferredSettings.fulfill() #expect(context.viewState.settings?.directChatsMode == .allMessages) #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .oneToOneChat, isEncrypted: false)]) let deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch), transitionValues: [false, true, false]) context.send(viewAction: .fixConfigurationMismatchTapped) try await deferredMismatch.fulfill() // Ensure we only fix the invalid setting: unencrypted one-to-one chats should be set to `.allMessages` (to match encrypted one-to-one chats) #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 1) let callArguments = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedArguments #expect(callArguments?.isEncrypted == false) #expect(callArguments?.isOneToOne == true) #expect(callArguments?.mode == .allMessages) } @Test func fixAllInconsistentSettings() async throws { // Initialize with a configuration mismatch where // - encrypted one-to-one chats is `.allMessages` and unencrypted one-to-one chats is `.mentionsAndKeywordsOnly` // - encrypted group chats is `.allMessages` and unencrypted group chats is `.mentionsAndKeywordsOnly` notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { case (true, _): return .allMessages case (false, _): return .mentionsAndKeywordsOnly } } let deferredSettings = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } notificationSettingsProxy.callbacks.send(.settingsDidChange) try await deferredSettings.fulfill() #expect(context.viewState.settings?.directChatsMode == .allMessages) #expect(context.viewState.settings?.inconsistentSettings == [.init(chatType: .groupChat, isEncrypted: false), .init(chatType: .oneToOneChat, isEncrypted: false)]) var deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch)) { $0 } context.send(viewAction: .fixConfigurationMismatchTapped) try await deferredMismatch.fulfill() deferredMismatch = deferFulfillment(viewModel.context.observe(\.viewState.fixingConfigurationMismatch)) { !$0 } try await deferredMismatch.fulfill() // All problems should be fixed #expect(notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeCallsCount == 2) let callArguments = notificationSettingsProxy.setDefaultRoomNotificationModeIsEncryptedIsOneToOneModeReceivedInvocations // Ensure we fix the invalid unencrypted group chats setting (it should be set to `.allMessages` to match encrypted group chats) #expect(callArguments[0].isEncrypted == false) #expect(callArguments[0].isOneToOne == false) #expect(callArguments[0].mode == .allMessages) // Ensure we fix the invalid unencrypted one-to-one chats setting (it should be set to `.allMessages` to match encrypted one-to-one chats) #expect(callArguments[1].isEncrypted == false) #expect(callArguments[1].isOneToOne == true) #expect(callArguments[1].mode == .allMessages) } @Test func toggleRoomMentionOff() async throws { notificationSettingsProxy.isRoomMentionEnabledReturnValue = true let deferredState = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } notificationSettingsProxy.callbacks.send(.settingsDidChange) try await deferredState.fulfill() context.roomMentionsEnabled = false let deferred = deferFulfillment(notificationSettingsProxy.callbacks) { callback in callback == .settingsDidChange } context.send(viewAction: .roomMentionChanged) try await deferred.fulfill() #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled == false) } @Test func toggleRoomMentionOn() async throws { notificationSettingsProxy.isRoomMentionEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.roomMentionsEnabled = true let deferred = deferFulfillment(notificationSettingsProxy.callbacks) { callback in callback == .settingsDidChange } context.send(viewAction: .roomMentionChanged) try await deferred.fulfill() #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledCalled) #expect(notificationSettingsProxy.setRoomMentionEnabledEnabledReceivedEnabled == true) } @Test func toggleRoomMentionFailure() async throws { notificationSettingsProxy.setRoomMentionEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error") notificationSettingsProxy.isRoomMentionEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.roomMentionsEnabled = true var deferred = deferFulfillment(context.observe(\.viewState.applyingChange)) { $0 } context.send(viewAction: .roomMentionChanged) try await deferred.fulfill() deferred = deferFulfillment(context.observe(\.viewState.applyingChange)) { !$0 } try await deferred.fulfill() #expect(context.alertInfo != nil) } @Test func toggleCallsOff() async throws { notificationSettingsProxy.isCallEnabledReturnValue = true let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.callsEnabled = false let deferred = deferFulfillment(notificationSettingsProxy.callbacks) { callback in callback == .settingsDidChange } context.send(viewAction: .callsChanged) try await deferred.fulfill() #expect(notificationSettingsProxy.setCallEnabledEnabledCalled) #expect(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled == false) } @Test func toggleCallsOn() async throws { notificationSettingsProxy.isCallEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.callsEnabled = true let deferred = deferFulfillment(notificationSettingsProxy.callbacks) { callback in callback == .settingsDidChange } context.send(viewAction: .callsChanged) try await deferred.fulfill() #expect(notificationSettingsProxy.setCallEnabledEnabledCalled) #expect(notificationSettingsProxy.setCallEnabledEnabledReceivedEnabled == true) } @Test func toggleCallsFailure() async throws { notificationSettingsProxy.setCallEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error") notificationSettingsProxy.isCallEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.callsEnabled = true var deferred = deferFulfillment(context.observe(\.viewState.applyingChange)) { $0 } context.send(viewAction: .callsChanged) try await deferred.fulfill() deferred = deferFulfillment(context.observe(\.viewState.applyingChange)) { !$0 } try await deferred.fulfill() #expect(context.alertInfo != nil) } @Test func toggleInvitationsOff() async throws { notificationSettingsProxy.isInviteForMeEnabledReturnValue = true let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.invitationsEnabled = false let deferred = deferFulfillment(notificationSettingsProxy.callbacks) { callback in callback == .settingsDidChange } context.send(viewAction: .invitationsChanged) try await deferred.fulfill() #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled) #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == false) } @Test func toggleInvitationsOn() async throws { notificationSettingsProxy.isInviteForMeEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.invitationsEnabled = true let deferred = deferFulfillment(notificationSettingsProxy.callbacks) { callback in callback == .settingsDidChange } context.send(viewAction: .invitationsChanged) try await deferred.fulfill() #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledCalled) #expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == true) } @Test func toggleInvitesFailure() async throws { notificationSettingsProxy.setInviteForMeEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error") notificationSettingsProxy.isInviteForMeEnabledReturnValue = false let deferredInitialFetch = deferFulfillment(viewModel.context.observe(\.viewState.settings)) { $0 != nil } viewModel.fetchInitialContent() try await deferredInitialFetch.fulfill() context.invitationsEnabled = true var deferred = deferFulfillment(context.observe(\.viewState.applyingChange)) { $0 } context.send(viewAction: .invitationsChanged) try await deferred.fulfill() deferred = deferFulfillment(context.observe(\.viewState.applyingChange)) { !$0 } try await deferred.fulfill() #expect(context.alertInfo != nil) } }