// // Copyright 2025 Element Creations Ltd. // Copyright 2022-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. // import Combine @testable import ElementX import Testing @MainActor final class InviteUsersScreenViewModelTests { var viewModel: InviteUsersScreenViewModelProtocol! var userDiscoveryService: UserDiscoveryServiceMock! var clientProxy: ClientProxyMock! var context: InviteUsersScreenViewModel.Context { viewModel.context } @Test func selectUser() { let roomProxy = JoinedRoomProxyMock(.init(name: "newroom", members: [])) roomProxy.inviteUserIDReturnValue = .success(()) setupViewModel(roomProxy: roomProxy, isSkippable: true) #expect(context.viewState.selectedUsers.isEmpty) context.send(viewAction: .toggleUser(.mockAlice)) #expect(context.viewState.selectedUsers.count == 1) #expect(context.viewState.selectedUsers.first?.userID == UserProfileProxy.mockAlice.userID) } @Test func reselectUser() { let roomProxy = JoinedRoomProxyMock(.init(name: "newroom", members: [])) roomProxy.inviteUserIDReturnValue = .success(()) setupViewModel(roomProxy: roomProxy, isSkippable: true) #expect(context.viewState.selectedUsers.isEmpty) context.send(viewAction: .toggleUser(.mockAlice)) #expect(context.viewState.selectedUsers.count == 1) #expect(context.viewState.selectedUsers.first?.userID == UserProfileProxy.mockAlice.userID) context.send(viewAction: .toggleUser(.mockAlice)) #expect(context.viewState.selectedUsers.isEmpty) } @Test func deselectUser() { let roomProxy = JoinedRoomProxyMock(.init(name: "newroom", members: [])) roomProxy.inviteUserIDReturnValue = .success(()) setupViewModel(roomProxy: roomProxy, isSkippable: true) #expect(context.viewState.selectedUsers.isEmpty) context.send(viewAction: .toggleUser(.mockAlice)) #expect(context.viewState.selectedUsers.count == 1) #expect(context.viewState.selectedUsers.first?.userID == UserProfileProxy.mockAlice.userID) context.send(viewAction: .toggleUser(.mockAlice)) #expect(context.viewState.selectedUsers.isEmpty) } @Test func inviteButton() async throws { let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob] let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers)) roomProxy.inviteUserIDReturnValue = .success(()) setupViewModel(roomProxy: roomProxy, isSkippable: false) let deferredState = deferFulfillment(viewModel.context.$viewState) { state in state.isUserSelected(.mockAlice) } context.send(viewAction: .toggleUser(.mockAlice)) try await deferredState.fulfill() let deferredAction = deferFulfillment(viewModel.actions) { action in switch action { case .dismiss: return true case .openRoom: return false } } context.send(viewAction: .proceed) try await deferredAction.fulfill() #expect(roomProxy.inviteUserIDReceivedInvocations == [RoomMemberProxyMock.mockAlice.userID]) } // MARK: - History Sharing @Test func invitingUnknownUsersOpensConfirmationDialog() async throws { let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob] let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers)) roomProxy.inviteUserIDReturnValue = .success(()) setupViewModel(roomProxy: roomProxy, isSkippable: false) // Mock the lack of cached user identity clientProxy.userIdentityForFallBackToServerReturnValue = .success(nil) let deferredState = deferFulfillment(viewModel.context.$viewState) { state in state.isUserSelected(.mockAlice) && state.usersToConfirm.contains(.mockAlice) } context.send(viewAction: .toggleUser(.mockAlice)) try await deferredState.fulfill() context.send(viewAction: .proceed) #expect(context.presentConfirmationDialog) let deferredAction = deferFulfillment(viewModel.actions) { action in switch action { case .dismiss: return true case .openRoom: return false } } context.send(viewAction: .confirmUnknownUsers) try await deferredAction.fulfill() #expect(roomProxy.inviteUserIDReceivedInvocations == [RoomMemberProxyMock.mockAlice.userID]) } @Test func removeButtonRemovesUnknownUsers() async throws { let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob] let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers)) roomProxy.inviteUserIDReturnValue = .success(()) setupViewModel(roomProxy: roomProxy, isSkippable: false) // Mock the lack of cached user identity clientProxy.userIdentityForFallBackToServerReturnValue = .success(nil) var deferredState = deferFulfillment(viewModel.context.$viewState) { state in state.isUserSelected(.mockAlice) && state.usersToConfirm.contains(.mockAlice) } context.send(viewAction: .toggleUser(.mockAlice)) try await deferredState.fulfill() context.send(viewAction: .proceed) #expect(context.presentConfirmationDialog) deferredState = deferFulfillment(viewModel.context.$viewState) { state in !state.usersToConfirm.contains(.mockAlice) && !state.selectedUsers.contains(.mockAlice) } context.send(viewAction: .removeUnknownUsers) try await deferredState.fulfill() } // MARK: - Draft (new room) @Test func createsNewRoomInDraftMode() async throws { userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.searchProfilesWithReturnValue = .success([]) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com")) let newRoomID = "!newroom:example.com" clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReturnValue = .success(newRoomID) viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), roomType: .draft(mandatoryInvitees: [.mockAlice]), isSkippable: false, userDiscoveryService: userDiscoveryService, userIndicatorController: UserIndicatorControllerMock(), appSettings: .volatile()) // The locked invitee starts pre-selected and locked. #expect(context.viewState.selectedUsers.map(\.userID) == [UserProfileProxy.mockAlice.userID]) #expect(context.viewState.isInviteeMandatory(.mockAlice)) // The proceed button is disabled while the only selected user is the locked invitee. #expect(!context.viewState.hasInvitableSelectedUsers) // Selecting a non-locked user enables the proceed button. var deferredState = deferFulfillment(viewModel.context.$viewState) { state in state.isUserSelected(.mockBob) } context.send(viewAction: .toggleUser(.mockBob)) try await deferredState.fulfill() #expect(context.viewState.hasInvitableSelectedUsers) // Deselecting the non-locked user disables the proceed button again. deferredState = deferFulfillment(viewModel.context.$viewState) { state in !state.isUserSelected(.mockBob) } context.send(viewAction: .toggleUser(.mockBob)) try await deferredState.fulfill() #expect(!context.viewState.hasInvitableSelectedUsers) // Re-select the non-locked user before proceeding. deferredState = deferFulfillment(viewModel.context.$viewState) { state in state.isUserSelected(.mockBob) } context.send(viewAction: .toggleUser(.mockBob)) try await deferredState.fulfill() let deferredAction = deferFulfillment(viewModel.actions) { action in if case .openRoom(let roomID) = action, roomID == newRoomID { return true } return false } context.send(viewAction: .proceed) try await deferredAction.fulfill() let args = try #require(clientProxy.createRoomNameTopicAccessTypeIsSpaceUserIDsAvatarURLAliasLocalPartReceivedArguments) #expect(args.name == nil) #expect(args.topic == nil) #expect(args.accessType == .private) #expect(args.isSpace == false) #expect(args.userIDs == [UserProfileProxy.mockAlice.userID, UserProfileProxy.mockBob.userID]) #expect(args.avatarURL == nil) #expect(args.aliasLocalPart == nil) } // MARK: - Helpers private func setupViewModel(roomProxy: JoinedRoomProxyProtocol, isSkippable: Bool) { userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.searchProfilesWithReturnValue = .success([]) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com")) let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), roomType: .existingRoom(roomProxy: roomProxy), isSkippable: isSkippable, userDiscoveryService: userDiscoveryService, userIndicatorController: UserIndicatorControllerMock(), appSettings: .volatile()) viewModel.state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) self.viewModel = viewModel } }