diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepository.kt index 7fbef86d732..912d1391bdc 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepository.kt @@ -24,7 +24,6 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.id.SelfTeamIdProvider import com.wire.kalium.logic.data.id.toDao -import com.wire.kalium.logic.data.id.toModel import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails import com.wire.kalium.logic.data.publicuser.model.UserSearchResult import com.wire.kalium.logic.data.user.ConnectionStateMapper @@ -62,6 +61,11 @@ internal interface SearchUserRepository { excludeMembersOfConversation: ConversationId? ): Either> + suspend fun searchLocalByHandle( + handle: String, + excludeMembersOfConversation: ConversationId? + ): Either> + } data class SearchUsersOptions( @@ -131,18 +135,8 @@ internal class SearchUserRepositoryImpl( } else { searchDAO.getKnownContactsExcludingAConversation(excludeConversation.toDao()) } - }.map { searchEntityList -> - searchEntityList.map { - UserSearchDetails( - id = it.id.toModel(), - name = it.name, - completeAssetId = it.completeAssetId?.toModel(), - type = userTypeMapper.fromUserTypeEntity(it.type), - previewAssetId = it.previewAssetId?.toModel(), - connectionStatus = connectionStateMapper.fromDaoConnectionStateToUser(it.connectionStatus), - handle = it.handle - ) - } + }.map { + it.map(userMapper::fromSearchEntityToUserSearchDetails) } override suspend fun searchLocalByName( @@ -155,16 +149,23 @@ internal class SearchUserRepositoryImpl( searchDAO.searchListExcludingAConversation(excludeMembersOfConversation.toDao(), name) } }.map { - it.map { searchEntity -> - UserSearchDetails( - id = searchEntity.id.toModel(), - name = searchEntity.name, - completeAssetId = searchEntity.completeAssetId?.toModel(), - previewAssetId = searchEntity.previewAssetId?.toModel(), - type = userTypeMapper.fromUserTypeEntity(searchEntity.type), - connectionStatus = connectionStateMapper.fromDaoConnectionStateToUser(searchEntity.connectionStatus), - handle = searchEntity.handle - ) + it.map(userMapper::fromSearchEntityToUserSearchDetails) + } + + override suspend fun searchLocalByHandle( + handle: String, + excludeMembersOfConversation: ConversationId? + ): Either> = if (excludeMembersOfConversation == null) { + wrapStorageRequest { + searchDAO.handleSearch(handle) + }.map { + it.map(userMapper::fromSearchEntityToUserSearchDetails) + } + } else { + wrapStorageRequest { + searchDAO.handleSearchExcludingAConversation(handle, excludeMembersOfConversation.toDao()) + }.map { + it.map(userMapper::fromSearchEntityToUserSearchDetails) } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserMapper.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserMapper.kt index 41d35db8f20..870d033e9b0 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserMapper.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/user/UserMapper.kt @@ -25,6 +25,7 @@ import com.wire.kalium.logic.data.id.TeamId import com.wire.kalium.logic.data.id.toDao import com.wire.kalium.logic.data.id.toModel import com.wire.kalium.logic.data.message.UserSummary +import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails import com.wire.kalium.logic.data.user.type.DomainUserTypeMapper import com.wire.kalium.logic.data.user.type.UserEntityTypeMapper import com.wire.kalium.logic.di.MapperProvider @@ -46,6 +47,7 @@ import com.wire.kalium.persistence.dao.UserAvailabilityStatusEntity import com.wire.kalium.persistence.dao.UserDetailsEntity import com.wire.kalium.persistence.dao.UserEntity import com.wire.kalium.persistence.dao.UserEntityMinimized +import com.wire.kalium.persistence.dao.UserSearchEntity import com.wire.kalium.persistence.dao.UserTypeEntity import kotlinx.datetime.toInstant @@ -90,6 +92,7 @@ interface UserMapper { fun fromUserProfileDtoToOtherUser(userProfile: UserProfileDTO, selfUserId: UserId, selfTeamId: TeamId?): OtherUser fun fromFailedUserToEntity(userId: NetworkQualifiedId): UserEntity + fun fromSearchEntityToUserSearchDetails(searchEntity: UserSearchEntity): UserSearchDetails } @Suppress("TooManyFunctions") @@ -397,6 +400,16 @@ internal class UserMapperImpl( activeOneOnOneConversationId = null ) } + + override fun fromSearchEntityToUserSearchDetails(searchEntity: UserSearchEntity) = UserSearchDetails( + id = searchEntity.id.toModel(), + name = searchEntity.name, + completeAssetId = searchEntity.completeAssetId?.toModel(), + previewAssetId = searchEntity.previewAssetId?.toModel(), + type = domainUserTypeMapper.fromUserTypeEntity(searchEntity.type), + connectionStatus = connectionStateMapper.fromDaoConnectionStateToUser(searchEntity.connectionStatus), + handle = searchEntity.handle + ) } fun SupportedProtocol.toApi() = when (this) { diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt index 948d275037b..c8c7815f720 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt @@ -1760,7 +1760,8 @@ class UserSessionScope internal constructor( get() = SearchScope( searchUserRepository = searchUserRepository, selfUserId = userId, - sessionRepository = globalScope.sessionRepository + sessionRepository = globalScope.sessionRepository, + kaliumConfigs = kaliumConfigs ) private val clearUserData: ClearUserDataUseCase get() = ClearUserDataUseCaseImpl(userStorage) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchByHandleUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchByHandleUseCase.kt new file mode 100644 index 00000000000..0dc1aaad030 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchByHandleUseCase.kt @@ -0,0 +1,94 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * 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 http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.search + +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.publicuser.ConversationMemberExcludedOptions +import com.wire.kalium.logic.data.publicuser.SearchUserRepository +import com.wire.kalium.logic.data.publicuser.SearchUsersOptions +import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.functional.getOrElse +import com.wire.kalium.logic.functional.map +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** + * Result of a search by handle. + */ +class SearchByHandleUseCase internal constructor( + private val searchUserRepository: SearchUserRepository, + private val selfUserId: UserId, + private val maxRemoteSearchResultCount: Int +) { + suspend operator fun invoke( + searchHandle: String, + excludingConversation: ConversationId?, + customDomain: String? + ): SearchUserResult = coroutineScope { + val cleanSearchQuery = searchHandle + .trim() + .removePrefix("@") + .lowercase() + + if (cleanSearchQuery.isBlank()) { + return@coroutineScope SearchUserResult(emptyList(), emptyList()) + } + + val remoteResultsDeferred = async { + searchUserRepository.searchUserRemoteDirectory( + cleanSearchQuery, + customDomain ?: selfUserId.domain, + maxRemoteSearchResultCount, + SearchUsersOptions( + conversationExcluded = excludingConversation?.let { ConversationMemberExcludedOptions.ConversationExcluded(it) } + ?: ConversationMemberExcludedOptions.None, + selfUserIncluded = false + ) + ).map { userSearchResult -> + userSearchResult.result.map { + UserSearchDetails( + id = it.id, + name = it.name, + completeAssetId = it.completePicture, + previewAssetId = it.previewPicture, + type = it.userType, + connectionStatus = it.connectionStatus, + handle = it.handle + ) + } + }.getOrElse(emptyList()) + .associateBy { it.id } + .toMutableMap() + } + + val localSearchResultDeferred = async { + searchUserRepository.searchLocalByHandle( + cleanSearchQuery, + excludingConversation + ).getOrElse(emptyList()) + .associateBy { it.id } + .toMutableMap() + } + + val remoteResults = remoteResultsDeferred.await() + val localSearchResult = localSearchResultDeferred.await() + + SearchUserResult.resolveLocalAndRemoteResult(localSearchResult, remoteResults) + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchScope.kt index 595cf20aedb..662514c9447 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchScope.kt @@ -20,13 +20,26 @@ package com.wire.kalium.logic.feature.search import com.wire.kalium.logic.data.publicuser.SearchUserRepository import com.wire.kalium.logic.data.session.SessionRepository import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.featureFlags.KaliumConfigs class SearchScope internal constructor( private val searchUserRepository: SearchUserRepository, private val sessionRepository: SessionRepository, - private val selfUserId: UserId + private val selfUserId: UserId, + private val kaliumConfigs: KaliumConfigs ) { - val searchUsersUseCase: SearchUsersUseCase get() = SearchUsersUseCase(searchUserRepository, selfUserId) + val searchUsers: SearchUsersUseCase + get() = SearchUsersUseCase( + searchUserRepository, + selfUserId, + kaliumConfigs.maxRemoteSearchResultCount + ) + val searchByHandle: SearchByHandleUseCase + get() = SearchByHandleUseCase( + searchUserRepository, + selfUserId, + kaliumConfigs.maxRemoteSearchResultCount + ) val federatedSearchParser: FederatedSearchParser get() = FederatedSearchParser(sessionRepository, selfUserId) } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUserResult.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUserResult.kt new file mode 100644 index 00000000000..430a8b53325 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUserResult.kt @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * 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 http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.search + +import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails +import com.wire.kalium.logic.data.user.ConnectionState +import com.wire.kalium.logic.data.user.UserId + +data class SearchUserResult( + val connected: List, + val notConnected: List +) { + internal companion object { + inline fun resolveLocalAndRemoteResult( + localResult: MutableMap, + remoteSearch: MutableMap + ): SearchUserResult { + val updatedUser = mutableListOf() + remoteSearch.forEach { (userId, remoteUser) -> + if (localResult.contains(userId) || (remoteUser.connectionStatus == ConnectionState.ACCEPTED)) { + localResult[userId] = remoteUser + updatedUser.add(userId) + } + } + + updatedUser.forEach { userId -> + remoteSearch.remove(userId) + } + + return SearchUserResult( + connected = localResult.values.toList(), + notConnected = remoteSearch.values.toList() + ) + } + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUsersUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUsersUseCase.kt index 80b7c633c42..74b27af6a65 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUsersUseCase.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/search/SearchUsersUseCase.kt @@ -22,7 +22,6 @@ import com.wire.kalium.logic.data.publicuser.ConversationMemberExcludedOptions import com.wire.kalium.logic.data.publicuser.SearchUserRepository import com.wire.kalium.logic.data.publicuser.SearchUsersOptions import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails -import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.functional.getOrElse import com.wire.kalium.logic.functional.map @@ -37,15 +36,16 @@ import kotlinx.coroutines.coroutineScope */ class SearchUsersUseCase internal constructor( private val searchUserRepository: SearchUserRepository, - private val selfUserId: UserId + private val selfUserId: UserId, + private val maxRemoteSearchResultCount: Int ) { suspend operator fun invoke( searchQuery: String, excludingMembersOfConversation: ConversationId?, customDomain: String? - ): Result { + ): SearchUserResult { return if (searchQuery.isBlank()) { - Result( + SearchUserResult( connected = searchUserRepository.getKnownContacts(excludingMembersOfConversation).getOrElse(emptyList()), notConnected = emptyList() ) @@ -58,14 +58,14 @@ class SearchUsersUseCase internal constructor( searchQuery: String, excludingConversation: ConversationId?, customDomain: String? - ): Result = coroutineScope { + ): SearchUserResult = coroutineScope { val cleanSearchQuery = searchQuery.trim().lowercase() val remoteResultsDeferred = async { searchUserRepository.searchUserRemoteDirectory( cleanSearchQuery, customDomain ?: selfUserId.domain, - MAX_SEARCH_RESULTS, + maxRemoteSearchResultCount, SearchUsersOptions( conversationExcluded = excludingConversation?.let { ConversationMemberExcludedOptions.ConversationExcluded(it) } ?: ConversationMemberExcludedOptions.None, @@ -98,37 +98,6 @@ class SearchUsersUseCase internal constructor( val remoteResults = remoteResultsDeferred.await() val localSearchResult = localSearchResultDeferred.await() - resolveLocalAndRemoteResult(localSearchResult, remoteResults) - } - - private inline fun resolveLocalAndRemoteResult( - localResult: MutableMap, - remoteSearch: MutableMap - ): Result { - val updatedUser = mutableListOf() - remoteSearch.forEach { (userId, remoteUser) -> - if (localResult.contains(userId) || (remoteUser.connectionStatus == ConnectionState.ACCEPTED)) { - localResult[userId] = remoteUser - updatedUser.add(userId) - } - } - - updatedUser.forEach { userId -> - remoteSearch.remove(userId) - } - - return Result( - connected = localResult.values.toList(), - notConnected = remoteSearch.values.toList() - ) - } - - data class Result( - val connected: List, - val notConnected: List - ) - - private companion object { - private const val MAX_SEARCH_RESULTS = 30 + SearchUserResult.resolveLocalAndRemoteResult(localSearchResult, remoteResults) } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/featureFlags/KaliumConfigs.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/featureFlags/KaliumConfigs.kt index 2014077d9de..c8ea258b58f 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/featureFlags/KaliumConfigs.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/featureFlags/KaliumConfigs.kt @@ -46,6 +46,7 @@ data class KaliumConfigs( // Interval between attempts to advance the proteus to MLS migration val mlsMigrationInterval: Duration = 24.hours, val fetchAllTeamMembersEagerly: Boolean = false, + val maxRemoteSearchResultCount: Int = 30 ) sealed interface BuildFileRestrictionState { diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepositoryTest.kt index 84d7c22b417..a66949ee857 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepositoryTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/publicuser/SearchUserRepositoryTest.kt @@ -466,127 +466,158 @@ class SearchUserRepositoryTest { .wasInvoked(exactly = once) } - internal class Arrangement : SelfTeamIdProviderArrangement by SelfTeamIdProviderArrangementImpl(), - SearchDAOArrangement by SearchDAOArrangementImpl() { - - @Mock - internal val userDetailsApi: UserDetailsApi = mock(classOf()) - - @Mock - internal val userSearchApiWrapper: UserSearchApiWrapper = mock(classOf()) - - @Mock - internal val userDAO: UserDAO = mock(classOf()) - - private val searchUserRepository: SearchUserRepository by lazy { - SearchUserRepositoryImpl( - userDAO, - searchDAO, - userDetailsApi, - userSearchApiWrapper, - selfUserId = TestUser.SELF.id, - selfTeamIdProvider - ) - } + @Test + fun givenSearchQueryAndNullForConversation_thenSearchingByHandle_thenCorrectDaoFunctionIsCalled() = runTest { + val (arrangement, searchUserRepository) = Arrangement() + .arrange { + withSearchByHandle(emptyList()) + } - fun arrange(block: Arrangement.() -> Unit = { }) = apply(block).run { - this to searchUserRepository - } + searchUserRepository.searchLocalByHandle("handle", null).shouldSucceed() - fun withSearchResult(result: Either) = apply { - given(userSearchApiWrapper) - .suspendFunction(userSearchApiWrapper::search) - .whenInvokedWith(anything(), anything(), anything(), anything()) - .thenReturn(result) - } + verify(arrangement.searchDAO) + .suspendFunction(arrangement.searchDAO::handleSearch) + .with(eq("handle")) + .wasInvoked(exactly = once) + } - fun withGetMultipleUsersResult(result: NetworkResponse) = apply { - given(userDetailsApi) - .suspendFunction(userDetailsApi::getMultipleUsers) - .whenInvokedWith(any()) - .thenReturn(result) - } + @Test + fun givenSearchQueryAndConversation_thenSearchingByHandle_thenCorrectDaoFunctionIsCalled() = runTest { + val conversationId = ConversationId("conversationId", "domain") + val (arrangement, searchUserRepository) = Arrangement() + .arrange { + withSearchByHandleExcludingConversation(emptyList()) + } - fun withObserveUserDetailsByQualifiedIdResult(result: Flow) = apply { - given(userDAO) - .suspendFunction(userDAO::observeUserDetailsByQualifiedID) - .whenInvokedWith(any()) - .thenReturn(result) - } + searchUserRepository.searchLocalByHandle("handle", conversationId).shouldSucceed() - fun withGetUsersDetailsNotInConversationByNameOrHandleOrEmailResult(result: Flow>) = apply { - given(userDAO) - .suspendFunction(userDAO::getUsersDetailsNotInConversationByNameOrHandleOrEmail) - .whenInvokedWith(anything(), anything()) - .thenReturn(result) - } + verify(arrangement.searchDAO) + .suspendFunction(arrangement.searchDAO::handleSearchExcludingAConversation) + .with(eq("handle"), eq(conversationId.toDao())) + .wasInvoked(exactly = once) + } - fun withGetUserDetailsByNameOrHandleOrEmailAndConnectionStatesResult(result: Flow>) = apply { - given(userDAO) - .suspendFunction(userDAO::getUserDetailsByNameOrHandleOrEmailAndConnectionStates) - .whenInvokedWith(anything(), anything()) - .thenReturn(result) - } + internal class Arrangement : SelfTeamIdProviderArrangement by SelfTeamIdProviderArrangementImpl(), + SearchDAOArrangement by SearchDAOArrangementImpl() { - fun withGetUserDetailsByHandleAndConnectionStatesResult(result: Flow>) = apply { - given(userDAO) - .suspendFunction(userDAO::getUserDetailsByHandleAndConnectionStates) - .whenInvokedWith(anything(), anything()) - .thenReturn(result) - } + @Mock + internal val userDetailsApi: UserDetailsApi = mock(classOf()) - fun withGetUsersDetailsNotInConversationByHandleResult(result: Flow>) = apply { - given(userDAO) - .suspendFunction(userDAO::getUsersDetailsNotInConversationByHandle) - .whenInvokedWith(anything(), anything()) - .thenReturn(result) - } + @Mock + internal val userSearchApiWrapper: UserSearchApiWrapper = mock(classOf()) - fun withUpsertUsersSuccess() = apply { - given(userDAO) - .suspendFunction(userDAO::upsertUsers) - .whenInvokedWith(any()) - .thenReturn(Unit) - } - } + @Mock + internal val userDAO: UserDAO = mock(classOf()) - private companion object { - const val TEST_QUERY = "testQuery" - const val TEST_DOMAIN = "testDomain" - val CONTACTS = buildList { - for (i in 1..5) { - add( - ContactDTO( - accentId = i, - handle = "handle$i", - name = "name$i", - qualifiedID = UserIdDTO( - value = "value$i", - domain = "domain$i" - ), - team = "team$i" - ) + private val searchUserRepository: SearchUserRepository by lazy { + SearchUserRepositoryImpl( + userDAO, + searchDAO, + userDetailsApi, + userSearchApiWrapper, + selfUserId = TestUser.SELF.id, + selfTeamIdProvider ) } + + fun arrange(block: Arrangement.() -> Unit = { }) = apply(block).run { + this to searchUserRepository + } + + fun withSearchResult(result: Either) = apply { + given(userSearchApiWrapper) + .suspendFunction(userSearchApiWrapper::search) + .whenInvokedWith(anything(), anything(), anything(), anything()) + .thenReturn(result) + } + + fun withGetMultipleUsersResult(result: NetworkResponse) = apply { + given(userDetailsApi) + .suspendFunction(userDetailsApi::getMultipleUsers) + .whenInvokedWith(any()) + .thenReturn(result) + } + + fun withObserveUserDetailsByQualifiedIdResult(result: Flow) = apply { + given(userDAO) + .suspendFunction(userDAO::observeUserDetailsByQualifiedID) + .whenInvokedWith(any()) + .thenReturn(result) + } + + fun withGetUsersDetailsNotInConversationByNameOrHandleOrEmailResult(result: Flow>) = apply { + given(userDAO) + .suspendFunction(userDAO::getUsersDetailsNotInConversationByNameOrHandleOrEmail) + .whenInvokedWith(anything(), anything()) + .thenReturn(result) + } + + fun withGetUserDetailsByNameOrHandleOrEmailAndConnectionStatesResult(result: Flow>) = apply { + given(userDAO) + .suspendFunction(userDAO::getUserDetailsByNameOrHandleOrEmailAndConnectionStates) + .whenInvokedWith(anything(), anything()) + .thenReturn(result) + } + + fun withGetUserDetailsByHandleAndConnectionStatesResult(result: Flow>) = apply { + given(userDAO) + .suspendFunction(userDAO::getUserDetailsByHandleAndConnectionStates) + .whenInvokedWith(anything(), anything()) + .thenReturn(result) + } + + fun withGetUsersDetailsNotInConversationByHandleResult(result: Flow>) = apply { + given(userDAO) + .suspendFunction(userDAO::getUsersDetailsNotInConversationByHandle) + .whenInvokedWith(anything(), anything()) + .thenReturn(result) + } + + fun withUpsertUsersSuccess() = apply { + given(userDAO) + .suspendFunction(userDAO::upsertUsers) + .whenInvokedWith(any()) + .thenReturn(Unit) + } } - val CONTACT_SEARCH_RESPONSE = UserSearchResponse( - documents = CONTACTS, - found = CONTACTS.size, - returned = 5, - searchPolicy = SearchPolicyDTO.FULL_SEARCH, - took = 100, - ) + private companion object { + const val TEST_QUERY = "testQuery" + const val TEST_DOMAIN = "testDomain" + val CONTACTS = buildList { + for (i in 1..5) { + add( + ContactDTO( + accentId = i, + handle = "handle$i", + name = "name$i", + qualifiedID = UserIdDTO( + value = "value$i", + domain = "domain$i" + ), + team = "team$i" + ) + ) + } + } - val CONTACT_SEARCH_RESPONSE_EMPTY = UserSearchResponse( - documents = emptyList(), - found = 0, - returned = 0, - searchPolicy = SearchPolicyDTO.FULL_SEARCH, - took = 0, - ) + val CONTACT_SEARCH_RESPONSE = UserSearchResponse( + documents = CONTACTS, + found = CONTACTS.size, + returned = 5, + searchPolicy = SearchPolicyDTO.FULL_SEARCH, + took = 100, + ) - val USER_RESPONSE = ListUsersDTO(usersFailed = emptyList(), usersFound = listOf(USER_PROFILE_DTO)) - } + val CONTACT_SEARCH_RESPONSE_EMPTY = UserSearchResponse( + documents = emptyList(), + found = 0, + returned = 0, + searchPolicy = SearchPolicyDTO.FULL_SEARCH, + took = 0, + ) -} + val USER_RESPONSE = ListUsersDTO(usersFailed = emptyList(), usersFound = listOf(USER_PROFILE_DTO)) + } + + } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchByHandleUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchByHandleUseCaseTest.kt new file mode 100644 index 00000000000..fd25a166378 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchByHandleUseCaseTest.kt @@ -0,0 +1,282 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * 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 http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.search + +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails +import com.wire.kalium.logic.data.publicuser.model.UserSearchResult +import com.wire.kalium.logic.data.user.ConnectionState +import com.wire.kalium.logic.data.user.OtherUser +import com.wire.kalium.logic.data.user.SupportedProtocol +import com.wire.kalium.logic.data.user.UserAssetId +import com.wire.kalium.logic.data.user.UserAvailabilityStatus +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserType +import com.wire.kalium.logic.functional.right +import com.wire.kalium.logic.util.arrangement.repository.SearchRepositoryArrangement +import com.wire.kalium.logic.util.arrangement.repository.SearchRepositoryArrangementImpl +import io.mockative.any +import io.mockative.anything +import io.mockative.eq +import io.mockative.once +import io.mockative.verify +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SearchByHandleUseCaseTest { + + @Test + fun givenEmptySearchQueryAndNoExcludedConversation_whenInvokingSearch_thenReturnEmptySearchResult() = runTest { + + val (arrangement, searchUseCase) = Arrangement().arrange { + withGetKnownContacts( + result = emptyList().right(), + excludeConversation = eq(null) + ) + } + + val result = searchUseCase( + searchHandle = "", + excludingConversation = null, + customDomain = null + ) + + assertEquals( + expected = emptyList(), + actual = result.connected + ) + + + assertEquals( + expected = emptyList(), + actual = result.notConnected + ) + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::getKnownContacts) + .with(eq(null)) + .wasNotInvoked() + + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchUserRemoteDirectory) + .with(any(), anything()) + .wasNotInvoked() + } + + @Test + fun givenEmptySearchQueryWithExcludedConversation_whenInvokingSearch_thenReturnEmptySearchResult() = runTest { + + val conversationId = ConversationId("conversationId", "conversationDomain") + val (arrangement, searchUseCase) = Arrangement().arrange { } + + val result = searchUseCase( + searchHandle = "", + excludingConversation = conversationId, + customDomain = null + ) + + assertEquals( + expected = emptyList(), + actual = result.connected + ) + + assertEquals( + expected = emptyList(), + actual = result.notConnected + ) + + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::getKnownContacts) + .with(any()) + .wasNotInvoked() + + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchUserRemoteDirectory) + .with(any(), anything()) + .wasNotInvoked() + } + + @Test + fun givenNonEmptySearchQueryAndNoExcludedConversation_whenInvokingSearch_thenRespondWithAllKnownContacts() = runTest { + + val (arrangement, searchUseCase) = Arrangement().arrange { + withSearchByHandle( + result = emptyList().right(), + ) + withSearchUserRemoteDirectory( + result = UserSearchResult(emptyList()).right(), + ) + } + + val result = searchUseCase( + searchHandle = "searchQuery", + excludingConversation = null, + customDomain = null + ) + + assertEquals( + expected = emptyList(), + actual = result.connected + ) + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchUserRemoteDirectory) + .with(eq("searchquery"), any(), any(), any()) + .wasInvoked(exactly = once) + } + + @Test + fun givenLocalAndRemoteResult_whenInvokingSearch_thenThereAreNoDuplicatedResult() = runTest { + + val remoteSearchResult = listOf( + newOtherUser("remoteAndLocalUser1").copy(name = "updatedNewName"), + newOtherUser("remoteUser2").copy( + teamId = TeamId("otherTeamId"), + connectionStatus = ConnectionState.PENDING + ), + ) + + val localSearchResult = listOf( + newUserSearchDetails("remoteAndLocalUser1").copy(name = "oldName"), + newUserSearchDetails("localUser2") + ) + + val expected = SearchUserResult( + connected = listOf( + newUserSearchDetails("remoteAndLocalUser1").copy(name = "updatedNewName"), + newUserSearchDetails("localUser2") + ), + notConnected = listOf( + newUserSearchDetails("remoteUser2").copy(connectionStatus = ConnectionState.PENDING), + ) + ) + + val (arrangement, searchUseCase) = Arrangement().arrange { + withSearchUserRemoteDirectory( + result = UserSearchResult(remoteSearchResult).right(), + searchQuery = eq("searchquery"), + ) + withSearchByHandle( + result = localSearchResult.right(), + searchQuery = eq("searchquery"), + ) + } + + val result = searchUseCase( + searchHandle = "searchQuery", + excludingConversation = null, + customDomain = null + ) + + assertEquals( + expected = expected, + actual = result + ) + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchUserRemoteDirectory) + .with(eq("searchquery"), any(), any(), any()) + .wasInvoked(exactly = once) + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchLocalByHandle) + .with(eq("searchquery"), anything()) + .wasInvoked(exactly = once) + } + + @Test + fun givenSearchQuery_whenDoingSearch_thenCallTheSearchFunctionsWithCleanQuery() = runTest { + val searchQuery = " @search Query " + val cleanQuery = "search query" + val (arrangement, searchUseCase) = Arrangement().arrange { + withSearchUserRemoteDirectory( + result = UserSearchResult(emptyList()).right(), + searchQuery = eq(cleanQuery), + ) + withSearchByHandle( + result = emptyList().right(), + searchQuery = eq(cleanQuery), + ) + } + + val result = searchUseCase( + searchHandle = searchQuery, + excludingConversation = null, + customDomain = null + ) + + assertEquals( + expected = emptyList(), + actual = result.connected + ) + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchUserRemoteDirectory) + .with(eq(cleanQuery), any(), any(), any()) + .wasInvoked(exactly = once) + verify(arrangement.searchUserRepository) + .suspendFunction(arrangement.searchUserRepository::searchLocalByHandle) + .with(eq(cleanQuery), anything()) + .wasInvoked(exactly = once) + } + + private companion object { + val selfUserId = UserId("self", "domain") + + fun newOtherUser(id: String) = OtherUser( + UserId(id, "otherDomain"), + name = "otherUsername", + handle = "handle", + email = "otherEmail", + phone = "otherPhone", + accentId = 0, + teamId = TeamId("otherTeamId"), + connectionStatus = ConnectionState.ACCEPTED, + previewPicture = UserAssetId("value", "domain"), + completePicture = UserAssetId("value", "domain"), + availabilityStatus = UserAvailabilityStatus.AVAILABLE, + userType = UserType.INTERNAL, + botService = null, + deleted = false, + defederated = false, + isProteusVerified = false, + supportedProtocols = setOf(SupportedProtocol.PROTEUS) + ) + + fun newUserSearchDetails(id: String) = UserSearchDetails( + id = UserId(id, "otherDomain"), + name = "otherUsername", + previewAssetId = UserAssetId("value", "domain"), + completeAssetId = UserAssetId("value", "domain"), + type = UserType.INTERNAL, + connectionStatus = ConnectionState.ACCEPTED, + handle = "handle" + ) + } + + private class Arrangement : SearchRepositoryArrangement by SearchRepositoryArrangementImpl() { + private val useCase: SearchByHandleUseCase by lazy { + SearchByHandleUseCase( + searchUserRepository = searchUserRepository, + selfUserId = selfUserId, + maxRemoteSearchResultCount = 30 + ) + } + + suspend fun arrange(block: Arrangement.() -> Unit) = apply(block).let { + this to useCase + } + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchUseCaseTest.kt index 6f263880082..0ebaf10c1bc 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchUseCaseTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/search/SearchUseCaseTest.kt @@ -138,7 +138,7 @@ class SearchUseCaseTest { newUserSearchDetails("localUser2") ) - val expected = SearchUsersUseCase.Result( + val expected = SearchUserResult( connected = listOf( newUserSearchDetails("remoteAndLocalUser1").copy(name = "updatedNewName"), newUserSearchDetails("localUser2") @@ -253,7 +253,8 @@ class SearchUseCaseTest { private val searchUseCase: SearchUsersUseCase = SearchUsersUseCase( searchUserRepository = searchUserRepository, - selfUserId = selfUserID + selfUserId = selfUserID, + maxRemoteSearchResultCount = 30 ) fun arrange(block: Arrangement.() -> Unit) = apply(block) diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/dao/SearchDAOArrangement.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/dao/SearchDAOArrangement.kt index 470565533cf..c9f2e6064b5 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/dao/SearchDAOArrangement.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/dao/SearchDAOArrangement.kt @@ -49,6 +49,17 @@ internal interface SearchDAOArrangement { conversationId: Matcher = any(), query: Matcher = any() ) + + fun withSearchByHandle( + result: List, + handle: Matcher = any() + ) + + fun withSearchByHandleExcludingConversation( + result: List, + conversationId: Matcher = any(), + handle: Matcher = any() + ) } internal class SearchDAOArrangementImpl : SearchDAOArrangement { @@ -92,4 +103,22 @@ internal class SearchDAOArrangementImpl : SearchDAOArrangement { .whenInvokedWith(conversationId, query) .thenReturn(result) } + + override fun withSearchByHandle(result: List, handle: Matcher) { + given(searchDAO) + .suspendFunction(searchDAO::handleSearch) + .whenInvokedWith(handle) + .thenReturn(result) + } + + override fun withSearchByHandleExcludingConversation( + result: List, + conversationId: Matcher, + handle: Matcher + ) { + given(searchDAO) + .suspendFunction(searchDAO::handleSearchExcludingAConversation) + .whenInvokedWith(handle, conversationId) + .thenReturn(result) + } } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/SearchRepositoryArrangement.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/SearchRepositoryArrangement.kt index 6a2913a8049..02d3a16e4c4 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/SearchRepositoryArrangement.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/util/arrangement/repository/SearchRepositoryArrangement.kt @@ -54,6 +54,12 @@ internal interface SearchRepositoryArrangement { searchQuery: Matcher = any(), excludeConversation: Matcher = anything() ) + + fun withSearchByHandle( + result: Either>, + searchQuery: Matcher = any(), + excludeConversation: Matcher = anything() + ) } internal class SearchRepositoryArrangementImpl : SearchRepositoryArrangement { @@ -94,4 +100,14 @@ internal class SearchRepositoryArrangementImpl : SearchRepositoryArrangement { .thenReturn(result) } + override fun withSearchByHandle( + result: Either>, + searchQuery: Matcher, + excludeConversation: Matcher + ) { + given(searchUserRepository) + .suspendFunction(searchUserRepository::searchLocalByHandle) + .whenInvokedWith(searchQuery, excludeConversation) + .thenReturn(result) + } } diff --git a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Search.sq b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Search.sq index 991c6b57c49..8ae38d585b1 100644 --- a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Search.sq +++ b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/Search.sq @@ -37,3 +37,24 @@ SELECT qualified_id, name, complete_asset_id, preview_asset_id, user_type, conne FROM Member WHERE conversation = :conversationId ); + +searchByHandle: +SELECT qualified_id, name, complete_asset_id, preview_asset_id, user_type, connection_status, handle + FROM User + WHERE connection_status = 'ACCEPTED' AND + qualified_id != (SELECT id FROM SelfUser LIMIT 1) AND + deleted = 0 AND + handle LIKE ('%' || :searchQuery || '%'); + +searchByHandleExcludingAConversation: +SELECT qualified_id, name, complete_asset_id, preview_asset_id, user_type, connection_status, handle + FROM User + WHERE connection_status = 'ACCEPTED' AND + qualified_id != (SELECT id FROM SelfUser LIMIT 1) AND + deleted = 0 AND + handle LIKE ('%' || :searchQuery || '%') AND + qualified_id NOT IN ( + SELECT user + FROM Member + WHERE conversation = :conversationId + ); diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/SearchDAO.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/SearchDAO.kt index ed7a7a13850..85a758f54f4 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/SearchDAO.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/SearchDAO.kt @@ -59,6 +59,11 @@ interface SearchDAO { suspend fun searchList(query: String): List suspend fun getKnownContactsExcludingAConversation(conversationId: ConversationIDEntity): List suspend fun searchListExcludingAConversation(conversationId: ConversationIDEntity, query: String): List + suspend fun handleSearch(searchQuery: String): List + suspend fun handleSearchExcludingAConversation( + searchQuery: String, + conversationId: ConversationIDEntity + ): List } internal class SearchDAOImpl internal constructor( @@ -92,4 +97,22 @@ internal class SearchDAOImpl internal constructor( mapper = UserSearchEntityMapper::map ).executeAsList() } + + override suspend fun handleSearch(searchQuery: String): List = withContext(coroutineContext) { + searchQueries.searchByHandle( + searchQuery, + mapper = UserSearchEntityMapper::map + ).executeAsList() + } + + override suspend fun handleSearchExcludingAConversation( + searchQuery: String, + conversationId: ConversationIDEntity + ): List = withContext(coroutineContext) { + searchQueries.searchByHandleExcludingAConversation( + searchQuery, + conversationId, + mapper = UserSearchEntityMapper::map + ).executeAsList() + } } diff --git a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/SearchDAOTest.kt b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/SearchDAOTest.kt index 4d9a719b59a..0eef7deb77a 100644 --- a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/SearchDAOTest.kt +++ b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/SearchDAOTest.kt @@ -207,4 +207,77 @@ class SearchDAOTest : BaseDatabaseTest() { assertEquals(connectedUser2.id, it[0].id) } } + + @Test + fun givenConnectedUser_whenSearchingByHandle_thenReturnOnlyConnectedUsers() = runTest { + val searchQuery = "searchQuery" + val connectedUser1 = newUserEntity(id = "1").copy(handle = "searchQuery", connectionStatus = ConnectionEntity.State.ACCEPTED) + val connectedUser2 = newUserEntity(id = "2").copy(handle = "qwerty", connectionStatus = ConnectionEntity.State.ACCEPTED) + val pendingUser = newUserEntity(id = "pendingUser").copy(connectionStatus = ConnectionEntity.State.PENDING) + val blockedUser = newUserEntity(id = "blockedUser").copy(connectionStatus = ConnectionEntity.State.BLOCKED) + val notConnectedUser = newUserEntity(id = "notConnectedUser").copy(connectionStatus = ConnectionEntity.State.NOT_CONNECTED) + val ignoredUser = newUserEntity(id = "ignoredUser").copy(connectionStatus = ConnectionEntity.State.IGNORED) + val missingLeaseholdConsentUser = + newUserEntity(id = "missingLeaseholdConsentUser").copy(connectionStatus = ConnectionEntity.State.MISSING_LEGALHOLD_CONSENT) + val deletedUser = newUserEntity(id = "deletedUser").copy(connectionStatus = ConnectionEntity.State.ACCEPTED, deleted = true) + + userDAO.insertOrIgnoreUsers( + listOf( + connectedUser1, + connectedUser2, + pendingUser, + blockedUser, + notConnectedUser, + ignoredUser, + missingLeaseholdConsentUser, + deletedUser + ) + ) + + searchDAO.handleSearch(searchQuery).also { + assertEquals(1, it.size) + assertEquals(connectedUser1.id, it[0].id) + } + } + + @Test + fun givenUsers_whenSearchingVByHandleAndExcludingAConversation_thenOnlyReturnConnectedUsersThatAreNotMembers() = runTest { + val searchQuery = "searchQuery" + val connectedUser1 = newUserEntity(id = "1").copy(handle = searchQuery, connectionStatus = ConnectionEntity.State.ACCEPTED) + val connectedUser2 = newUserEntity(id = "2").copy(handle = searchQuery, connectionStatus = ConnectionEntity.State.ACCEPTED) + val pendingUser = newUserEntity(id = "pendingUser").copy(connectionStatus = ConnectionEntity.State.PENDING) + val blockedUser = newUserEntity(id = "blockedUser").copy(connectionStatus = ConnectionEntity.State.BLOCKED) + val notConnectedUser = newUserEntity(id = "notConnectedUser").copy(connectionStatus = ConnectionEntity.State.NOT_CONNECTED) + val ignoredUser = newUserEntity(id = "ignoredUser").copy(connectionStatus = ConnectionEntity.State.IGNORED) + val missingLeaseholdConsentUser = + newUserEntity(id = "missingLeaseholdConsentUser").copy(connectionStatus = ConnectionEntity.State.MISSING_LEGALHOLD_CONSENT) + val deletedUser = newUserEntity(id = "deletedUser").copy(connectionStatus = ConnectionEntity.State.ACCEPTED, deleted = true) + + val conversation = newConversationEntity(id = "1") + + userDAO.insertOrIgnoreUsers( + listOf( + connectedUser1, + connectedUser2, + pendingUser, + blockedUser, + notConnectedUser, + ignoredUser, + missingLeaseholdConsentUser, + deletedUser + ) + ) + conversationDAO.insertConversation(conversation) + memberDAO.insertMember( + MemberEntity( + connectedUser1.id, + MemberEntity.Role.Member + ), conversation.id + ) + + searchDAO.handleSearchExcludingAConversation(searchQuery, conversation.id).also { + assertEquals(1, it.size) + assertEquals(connectedUser2.id, it[0].id) + } + } }