diff --git a/logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScopeExtensions.kt b/logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScopeExtensions.kt new file mode 100644 index 00000000000..86834de9f89 --- /dev/null +++ b/logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScopeExtensions.kt @@ -0,0 +1,21 @@ +/* + * 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.conversation + +val ConversationScope.getPaginatedFlowOfConversationDetailsWithEventsBySearchQuery + get() = GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase(dispatcher, conversationRepository) diff --git a/logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/conversation/GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase.kt b/logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/conversation/GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase.kt new file mode 100644 index 00000000000..61bf99999e3 --- /dev/null +++ b/logic/src/androidMain/kotlin/com/wire/kalium/logic/feature/conversation/GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase.kt @@ -0,0 +1,44 @@ +/* + * 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.conversation + +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents +import com.wire.kalium.logic.data.conversation.ConversationRepository +import com.wire.kalium.logic.data.conversation.ConversationQueryConfig +import com.wire.kalium.util.KaliumDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +/** + * This use case will observe and return a flow of paginated searched conversation details with last message and unread events counts. + * @see PagingData + * @see ConversationDetailsWithEvents + */ +class GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase internal constructor( + private val dispatcher: KaliumDispatcher, + private val conversationRepository: ConversationRepository, +) { + suspend operator fun invoke( + queryConfig: ConversationQueryConfig, + pagingConfig: PagingConfig, + startingOffset: Long, + ): Flow> = conversationRepository.extensions + .getPaginatedConversationDetailsWithEventsBySearchQuery(queryConfig, pagingConfig, startingOffset).flowOn(dispatcher.io) +} diff --git a/logic/src/androidUnitTest/kotlin/com/wire/kalium/logic/feature/conversation/GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCaseTest.kt b/logic/src/androidUnitTest/kotlin/com/wire/kalium/logic/feature/conversation/GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCaseTest.kt new file mode 100644 index 00000000000..31ace8e2c65 --- /dev/null +++ b/logic/src/androidUnitTest/kotlin/com/wire/kalium/logic/feature/conversation/GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCaseTest.kt @@ -0,0 +1,82 @@ +/* + * 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.conversation + +import androidx.paging.PagingData +import app.cash.paging.PagingConfig +import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents +import com.wire.kalium.logic.data.conversation.ConversationQueryConfig +import com.wire.kalium.logic.data.conversation.ConversationRepository +import com.wire.kalium.logic.data.conversation.ConversationRepositoryExtensions +import com.wire.kalium.logic.test_util.TestKaliumDispatcher +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.every +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCaseTest { + private val dispatcher = TestKaliumDispatcher + + @Test + fun givenSearchQuery_whenGettingPaginatedList_thenCallUseCaseWithProperParams() = runTest(dispatcher.default) { + // Given + val (arrangement, useCase) = Arrangement().withPaginatedConversationResult(emptyFlow()).arrange() + with(arrangement) { + // When + useCase(queryConfig = queryConfig, pagingConfig = pagingConfig, startingOffset = startingOffset) + // Then + coVerify { + conversationRepository.extensions + .getPaginatedConversationDetailsWithEventsBySearchQuery(queryConfig, pagingConfig, startingOffset) + }.wasInvoked(exactly = once) + } + } + + inner class Arrangement { + @Mock + val conversationRepository = mock(ConversationRepository::class) + + @Mock + val conversationRepositoryExtensions = mock(ConversationRepositoryExtensions::class) + + val queryConfig = ConversationQueryConfig("search") + val pagingConfig = PagingConfig(20) + val startingOffset = 0L + + init { + every { + conversationRepository.extensions + }.returns(conversationRepositoryExtensions) + } + + suspend fun withPaginatedConversationResult(result: Flow>) = apply { + coEvery { + conversationRepositoryExtensions.getPaginatedConversationDetailsWithEventsBySearchQuery(any(), any(), any()) + }.returns(result) + } + + fun arrange() = this to GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase(dispatcher, conversationRepository) + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt index 50215cc9f03..1e86404efe9 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepository.kt @@ -84,6 +84,8 @@ import kotlinx.datetime.Instant @Suppress("TooManyFunctions") interface ConversationRepository { + val extensions: ConversationRepositoryExtensions + // region Get/Observe by id suspend fun observeConversationById(conversationId: ConversationId): Flow> @@ -327,6 +329,8 @@ internal class ConversationDataSource internal constructor( private val messageMapper: MessageMapper = MapperProvider.messageMapper(selfUserId), private val receiptModeMapper: ReceiptModeMapper = MapperProvider.receiptModeMapper() ) : ConversationRepository { + override val extensions: ConversationRepositoryExtensions = + ConversationRepositoryExtensionsImpl(conversationDAO, conversationMapper, messageMapper) // region Get/Observe by id diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepositoryExtensions.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepositoryExtensions.kt new file mode 100644 index 00000000000..549b365abeb --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepositoryExtensions.kt @@ -0,0 +1,88 @@ +/* + * 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.data.conversation + +import app.cash.paging.PagingConfig +import app.cash.paging.PagingData +import app.cash.paging.map +import com.wire.kalium.logic.data.message.MessageMapper +import com.wire.kalium.logic.data.message.UnreadEventType +import com.wire.kalium.persistence.dao.conversation.ConversationDAO +import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsEntity +import com.wire.kalium.persistence.dao.conversation.ConversationExtensions.QueryConfig +import com.wire.kalium.persistence.dao.message.KaliumPager +import com.wire.kalium.persistence.dao.unread.UnreadEventTypeEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ConversationRepositoryExtensions { + suspend fun getPaginatedConversationDetailsWithEventsBySearchQuery( + queryConfig: ConversationQueryConfig, + pagingConfig: PagingConfig, + startingOffset: Long, + ): Flow> +} + +class ConversationRepositoryExtensionsImpl internal constructor( + private val conversationDAO: ConversationDAO, + private val conversationMapper: ConversationMapper, + private val messageMapper: MessageMapper, +) : ConversationRepositoryExtensions { + override suspend fun getPaginatedConversationDetailsWithEventsBySearchQuery( + queryConfig: ConversationQueryConfig, + pagingConfig: PagingConfig, + startingOffset: Long + ): Flow> { + val pager: KaliumPager = with(queryConfig) { + conversationDAO.platformExtensions.getPagerForConversationDetailsWithEventsSearch( + queryConfig = QueryConfig(searchQuery, fromArchive, onlyInteractionEnabled, newActivitiesOnTop), + pagingConfig = pagingConfig + ) + } + + return pager.pagingDataFlow.map { + it.map { + ConversationDetailsWithEvents( + conversationDetails = conversationMapper.fromDaoModelToDetails(it.conversationViewEntity), + lastMessage = when { + it.messageDraft != null -> messageMapper.fromDraftToMessagePreview(it.messageDraft!!) + it.lastMessage != null -> messageMapper.fromEntityToMessagePreview(it.lastMessage!!) + else -> null + }, + unreadEventCount = it.unreadEvents.unreadEvents.mapKeys { + when (it.key) { + UnreadEventTypeEntity.KNOCK -> UnreadEventType.KNOCK + UnreadEventTypeEntity.MISSED_CALL -> UnreadEventType.MISSED_CALL + UnreadEventTypeEntity.MENTION -> UnreadEventType.MENTION + UnreadEventTypeEntity.REPLY -> UnreadEventType.REPLY + UnreadEventTypeEntity.MESSAGE -> UnreadEventType.MESSAGE + } + }, + hasNewActivitiesToShow = it.hasNewActivitiesToShow, + ) + } + } + } +} + +data class ConversationQueryConfig( + val searchQuery: String = "", + val fromArchive: Boolean = false, + val onlyInteractionEnabled: Boolean = false, + val newActivitiesOnTop: Boolean = false, +) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt index bb3b7c29645..b471f6c742c 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt @@ -71,12 +71,13 @@ import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCaseImpl import com.wire.kalium.logic.sync.SyncManager import com.wire.kalium.logic.sync.receiver.conversation.RenamedConversationEventHandler import com.wire.kalium.logic.sync.receiver.handler.CodeUpdateHandlerImpl +import com.wire.kalium.util.KaliumDispatcher import com.wire.kalium.util.KaliumDispatcherImpl import kotlinx.coroutines.CoroutineScope @Suppress("LongParameterList") class ConversationScope internal constructor( - private val conversationRepository: ConversationRepository, + internal val conversationRepository: ConversationRepository, private val conversationGroupRepository: ConversationGroupRepository, private val connectionRepository: ConnectionRepository, private val userRepository: UserRepository, @@ -101,7 +102,8 @@ class ConversationScope internal constructor( private val scope: CoroutineScope, private val kaliumLogger: KaliumLogger, private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, - private val serverConfigLinks: ServerConfig.Links + private val serverConfigLinks: ServerConfig.Links, + internal val dispatcher: KaliumDispatcher = KaliumDispatcherImpl, ) { val getConversations: GetConversationsUseCase diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepositoryExtensionsTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepositoryExtensionsTest.kt new file mode 100644 index 00000000000..82ee1f9d0aa --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/ConversationRepositoryExtensionsTest.kt @@ -0,0 +1,119 @@ +/* + * 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.data.conversation + +import app.cash.paging.Pager +import app.cash.paging.PagingConfig +import app.cash.paging.PagingSource +import app.cash.paging.PagingState +import com.wire.kalium.logic.data.message.MessageMapper +import com.wire.kalium.logic.framework.TestConversationDetails +import com.wire.kalium.logic.framework.TestMessage +import com.wire.kalium.persistence.dao.conversation.ConversationDAO +import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsEntity +import com.wire.kalium.persistence.dao.conversation.ConversationExtensions +import com.wire.kalium.persistence.dao.message.KaliumPager +import io.mockative.Mock +import io.mockative.any +import io.mockative.eq +import io.mockative.every +import io.mockative.matches +import io.mockative.mock +import io.mockative.once +import io.mockative.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class ConversationRepositoryExtensionsTest { + private val fakePagingSource = object : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null + + override suspend fun load(params: LoadParams): LoadResult = + LoadResult.Error(NotImplementedError("STUB for tests. Not implemented.")) + } + + @Test + fun givenParameters_whenPaginatedConversationDetailsWithEvents_thenShouldCallDaoExtensionsWithRightParameters() = runTest { + val pagingConfig = PagingConfig(20) + val pager = Pager(pagingConfig) { fakePagingSource } + val kaliumPager = KaliumPager(pager, fakePagingSource, StandardTestDispatcher()) + val (arrangement, conversationRepositoryExtensions) = Arrangement() + .withConversationExtensionsReturningPager(kaliumPager) + .arrange() + val searchQuery = "search" + conversationRepositoryExtensions.getPaginatedConversationDetailsWithEventsBySearchQuery( + queryConfig = ConversationQueryConfig( + searchQuery = searchQuery, + fromArchive = false, + onlyInteractionEnabled = false, + newActivitiesOnTop = false, + ), + pagingConfig = pagingConfig, + startingOffset = 0L + ) + verify { + arrangement.conversationDaoExtensions + .getPagerForConversationDetailsWithEventsSearch( + queryConfig = matches { + it.searchQuery == searchQuery && !it.fromArchive && !it.onlyInteractionEnabled && !it.newActivitiesOnTop + }, + pagingConfig = eq(pagingConfig), + startingOffset = any() + ) + }.wasInvoked(exactly = once) + } + + private class Arrangement { + @Mock + val conversationDaoExtensions: ConversationExtensions = mock(ConversationExtensions::class) + + @Mock + private val conversationDAO: ConversationDAO = mock(ConversationDAO::class) + + @Mock + private val conversationMapper: ConversationMapper = mock(ConversationMapper::class) + + @Mock + private val messageMapper: MessageMapper = mock(MessageMapper::class) + private val conversationRepositoryExtensions: ConversationRepositoryExtensions by lazy { + ConversationRepositoryExtensionsImpl(conversationDAO, conversationMapper, messageMapper) + } + + init { + every { + messageMapper.fromEntityToMessage(any()) + }.returns(TestMessage.TEXT_MESSAGE) + every { + conversationMapper.fromDaoModelToDetails(any()) + }.returns(TestConversationDetails.CONVERSATION_GROUP) + every { + conversationDAO.platformExtensions + }.returns(conversationDaoExtensions) + } + + fun withConversationExtensionsReturningPager(kaliumPager: KaliumPager) = apply { + every { + conversationDaoExtensions.getPagerForConversationDetailsWithEventsSearch(any(), any(), any()) + }.returns(kaliumPager) + } + + fun arrange() = this to conversationRepositoryExtensions + } +} diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAO.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAO.kt index 734524cb16d..084dd6a8617 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAO.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAO.kt @@ -31,6 +31,7 @@ data class ProposalTimerEntity( @Suppress("TooManyFunctions") interface ConversationDAO { + val platformExtensions: ConversationExtensions //region Get/Observe by ID suspend fun observeConversationById(qualifiedID: QualifiedIDEntity): Flow diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAOImpl.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAOImpl.kt index 0a38f14f230..e75b781aec0 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAOImpl.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationDAOImpl.kt @@ -60,6 +60,8 @@ internal class ConversationDAOImpl internal constructor( ) : ConversationDAO { private val conversationMapper = ConversationMapper private val conversationDetailsWithEventsMapper = ConversationDetailsWithEventsMapper + override val platformExtensions: ConversationExtensions = + ConversationExtensionsImpl(conversationQueries, conversationDetailsWithEventsMapper, coroutineContext) // region Get/Observe by ID diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationExtensions.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationExtensions.kt new file mode 100644 index 00000000000..cda01b14727 --- /dev/null +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/ConversationExtensions.kt @@ -0,0 +1,81 @@ +/* + * 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.persistence.dao.conversation + +import app.cash.paging.Pager +import app.cash.paging.PagingConfig +import app.cash.sqldelight.paging3.QueryPagingSource +import com.wire.kalium.persistence.ConversationsQueries +import com.wire.kalium.persistence.dao.conversation.ConversationExtensions.QueryConfig +import com.wire.kalium.persistence.dao.message.KaliumPager +import kotlin.coroutines.CoroutineContext + +interface ConversationExtensions { + fun getPagerForConversationDetailsWithEventsSearch( + queryConfig: QueryConfig, + pagingConfig: PagingConfig, + startingOffset: Long = 0, + ): KaliumPager + + data class QueryConfig( + val searchQuery: String = "", + val fromArchive: Boolean = false, + val onlyInteractionEnabled: Boolean = false, + val newActivitiesOnTop: Boolean = false, + ) +} + +internal class ConversationExtensionsImpl internal constructor( + private val queries: ConversationsQueries, + private val mapper: ConversationDetailsWithEventsMapper, + private val coroutineContext: CoroutineContext, +) : ConversationExtensions { + override fun getPagerForConversationDetailsWithEventsSearch( + queryConfig: QueryConfig, + pagingConfig: PagingConfig, + startingOffset: Long + ): KaliumPager = + KaliumPager( + // We could return a Flow directly, but having the PagingSource is the only way to test this + pager = Pager(pagingConfig) { + pagingSource(queryConfig, startingOffset) + }, + pagingSource = pagingSource(queryConfig, startingOffset), + coroutineContext = coroutineContext, + ) + + private fun pagingSource(queryConfig: QueryConfig, initialOffset: Long) = with(queryConfig) { + QueryPagingSource( + countQuery = queries.countConversationDetailsWithEventsFromSearch(fromArchive, onlyInteractionEnabled, searchQuery), + transacter = queries, + context = coroutineContext, + initialOffset = initialOffset, + queryProvider = { limit, offset -> + queries.selectConversationDetailsWithEventsFromSearch( + fromArchive = fromArchive, + onlyInteractionsEnabled = onlyInteractionEnabled, + searchQuery = searchQuery, + newActivitiesOnTop = newActivitiesOnTop, + limit = limit, + offset = offset, + mapper = mapper::fromViewToModel, + ) + } + ) + } +} diff --git a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/conversation/ConversationExtensionsTest.kt b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/conversation/ConversationExtensionsTest.kt new file mode 100644 index 00000000000..95610f7f3f6 --- /dev/null +++ b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/conversation/ConversationExtensionsTest.kt @@ -0,0 +1,200 @@ +/* + * 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.persistence.conversation + +import app.cash.paging.PagingConfig +import app.cash.paging.PagingSource +import app.cash.paging.PagingSourceLoadParamsAppend +import app.cash.paging.PagingSourceLoadParamsRefresh +import app.cash.paging.PagingSourceLoadResultPage +import com.wire.kalium.persistence.BaseDatabaseTest +import com.wire.kalium.persistence.dao.ConnectionDAO +import com.wire.kalium.persistence.dao.ConversationIDEntity +import com.wire.kalium.persistence.dao.UserDAO +import com.wire.kalium.persistence.dao.UserIDEntity +import com.wire.kalium.persistence.dao.conversation.ConversationDAO +import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsMapper +import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsEntity +import com.wire.kalium.persistence.dao.conversation.ConversationEntity +import com.wire.kalium.persistence.dao.conversation.ConversationExtensions +import com.wire.kalium.persistence.dao.conversation.ConversationExtensionsImpl +import com.wire.kalium.persistence.dao.member.MemberDAO +import com.wire.kalium.persistence.dao.message.KaliumPager +import com.wire.kalium.persistence.dao.message.MessageDAO +import com.wire.kalium.persistence.dao.message.draft.MessageDraftDAO +import com.wire.kalium.persistence.utils.stubs.newConversationEntity +import com.wire.kalium.persistence.utils.stubs.newUserEntity +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class ConversationExtensionsTest : BaseDatabaseTest() { + private lateinit var conversationExtensions: ConversationExtensions + private lateinit var messageDAO: MessageDAO + private lateinit var messageDraftDAO: MessageDraftDAO + private lateinit var conversationDAO: ConversationDAO + private lateinit var connectionDAO: ConnectionDAO + private lateinit var memberDAO: MemberDAO + private lateinit var userDAO: UserDAO + private val selfUserId = UserIDEntity("selfValue", "selfDomain") + + @BeforeTest + fun setUp() { + deleteDatabase(selfUserId) + val db = createDatabase(selfUserId, encryptedDBSecret, true) + val queries = db.database.conversationsQueries + messageDAO = db.messageDAO + messageDraftDAO = db.messageDraftDAO + conversationDAO = db.conversationDAO + connectionDAO = db.connectionDAO + memberDAO = db.memberDAO + userDAO = db.userDAO + conversationExtensions = ConversationExtensionsImpl(queries, ConversationDetailsWithEventsMapper, dispatcher) + } + + @AfterTest + fun tearDown() { + deleteDatabase(selfUserId) + } + + @Test + fun givenInsertedConversations_whenGettingFirstPage_thenItShouldContainTheCorrectCountBeforeAndAfter() = runTest(dispatcher) { + populateData() + val result = getPager().pagingSource.refresh() + assertIs>(result) + // Assuming the first page was fetched, itemsAfter should be the remaining ones + assertEquals(CONVERSATION_COUNT - PAGE_SIZE, result.itemsAfter) + assertEquals(0, result.itemsBefore) // No items before the first page + } + + @Test + fun givenInsertedConversations_whenGettingFirstSearchedPage_thenItShouldContainTheCorrectCountBeforeAndAfter() = runTest(dispatcher) { + populateData() + val searchQuery = "conversation 1" + val result = getPager(searchQuery = searchQuery).pagingSource.refresh() + assertIs>(result) + // Assuming the first page was fetched containing only 11 results ("conversation 1" and "conversation 10" to "conversation 19") + assertEquals(0, result.itemsAfter) // Since the page has fewer elements than PAGE_SIZE, there should be no items after this page + assertEquals(0, result.itemsBefore) // No items before the first page + } + + @Test + fun givenInsertedConversations_whenGettingFirstPage_thenTheNextKeyShouldBeTheFirstItemOfTheNextPage() = runTest(dispatcher) { + populateData() + val result = getPager().pagingSource.refresh() + assertIs>(result) + assertEquals(PAGE_SIZE, result.nextKey) // First page fetched, second page starts at the end of the first one + } + + @Test + fun givenInsertedConversations_whenGettingFirstPage_thenItShouldContainTheFirstPageOfItems() = runTest(dispatcher) { + populateData() + val result = getPager().pagingSource.refresh() + assertIs>(result) + result.data.forEachIndexed { index, conversation -> + assertEquals("$CONVERSATION_ID_PREFIX$index", conversation.conversationViewEntity.id.value) + assertEquals(false, conversation.conversationViewEntity.archived) + } + } + + @Test + fun givenInsertedConversations_whenGettingSecondPage_thenShouldContainTheCorrectItems() = runTest(dispatcher) { + populateData() + val pagingSource = getPager().pagingSource + val secondPageResult = pagingSource.nextPageForOffset(PAGE_SIZE) + assertIs>(secondPageResult) + assertFalse { secondPageResult.data.isEmpty() } + assertTrue { secondPageResult.data.size <= PAGE_SIZE } + secondPageResult.data.forEachIndexed { index, conversation -> + assertEquals("$CONVERSATION_ID_PREFIX${index + PAGE_SIZE}", conversation.conversationViewEntity.id.value) + } + } + + @Test + fun givenInsertedConversations_whenGettingFirstPageOfArchivedConversations_thenItShouldContainArchivedItems() = runTest(dispatcher) { + populateData(archived = false, count = CONVERSATION_COUNT, conversationIdPrefix = CONVERSATION_ID_PREFIX) + populateData(archived = true, count = CONVERSATION_COUNT, conversationIdPrefix = ARCHIVED_CONVERSATION_ID_PREFIX) + val result = getPager(fromArchive = true).pagingSource.refresh() + assertIs>(result) + result.data.forEachIndexed { index, conversation -> + assertEquals("$ARCHIVED_CONVERSATION_ID_PREFIX$index", conversation.conversationViewEntity.id.value) + assertEquals(true, conversation.conversationViewEntity.archived) + } + } + + @Test + fun givenInsertedConversations_whenGettingFirstSearchedPage_thenShouldContainTheCorrectItems() = runTest(dispatcher) { + populateData() + val searchQuery = "conversation 1" + val result = getPager(searchQuery = searchQuery).pagingSource.refresh() + assertIs>(result) + // Assuming the first page was fetched containing only 11 results ["conversation 1" and "conversation 10" to "conversation 19"] + assertEquals(11, result.data.size) + result.data.forEachIndexed { index, conversation -> + assertEquals(true, conversation.conversationViewEntity.name?.contains(searchQuery) ?: false) + } + } + + private fun getPager(searchQuery: String = "", fromArchive: Boolean = false): KaliumPager = + conversationExtensions.getPagerForConversationDetailsWithEventsSearch( + pagingConfig = PagingConfig(PAGE_SIZE), + queryConfig = ConversationExtensions.QueryConfig(searchQuery = searchQuery, fromArchive = fromArchive), + ) + + private suspend fun PagingSource.refresh() = + load(PagingSourceLoadParamsRefresh(null, PAGE_SIZE, false)) + + private suspend fun PagingSource.nextPageForOffset(key: Int) = + load(PagingSourceLoadParamsAppend(key, PAGE_SIZE, true)) + + private suspend fun populateData( + archived: Boolean = false, + count: Int = CONVERSATION_COUNT, + conversationIdPrefix: String = CONVERSATION_ID_PREFIX, + ) { + userDAO.upsertUser(newUserEntity(qualifiedID = UserIDEntity("user", "domain"))) + repeat(count) { + // Ordered by date - Inserting with decreasing date is important to assert pagination + val lastModified = Instant.fromEpochSeconds(CONVERSATION_COUNT - it.toLong()) + val lastRead = lastModified.minus(1.seconds) // if message needs to be unread, then lastRead should be before lastModified + val conversation = newConversationEntity(ConversationIDEntity("$conversationIdPrefix$it", "domain")).copy( + name = "conversation $it", + type = ConversationEntity.Type.GROUP, + lastModifiedDate = lastModified, + lastReadDate = lastRead, + archived = archived, + ) + conversationDAO.insertConversation(conversation) + } + } + + private companion object { + const val CONVERSATION_COUNT = 100 + const val CONVERSATION_ID_PREFIX = "conversation_" + const val ARCHIVED_CONVERSATION_ID_PREFIX = "archived_conversation_" + const val PAGE_SIZE = 20 + } +}