diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index fe22f7131e8..60e2f87fedc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -367,14 +367,14 @@ public class ChannelListViewModel( currentState: SearchMessageState, channelFilter: FilterObject, ): SearchMessageState { - val offset = currentState.messages.size val limit = channelLimit - logger.v { "[searchMessages] #$src; query: '${currentState.query}', offset: $offset, limit: $limit" } + val next = currentState.next + logger.v { "[searchMessages] #$src; query: '${currentState.query}', next: $next, limit: $limit" } val result = chatClient.searchMessages( channelFilter = channelFilter, messageFilter = Filters.autocomplete("text", currentState.query), - offset = offset, limit = limit, + next = next, ).await() return when (result) { is io.getstream.result.Result.Success -> { @@ -383,7 +383,8 @@ public class ChannelListViewModel( messages = currentState.messages + result.value.messages, isLoading = false, isLoadingMore = false, - canLoadMore = result.value.messages.size >= limit, + canLoadMore = !result.value.next.isNullOrEmpty(), + next = result.value.next, ) } is io.getstream.result.Result.Failure -> { @@ -391,7 +392,6 @@ public class ChannelListViewModel( currentState.copy( isLoading = false, isLoadingMore = false, - canLoadMore = true, ) } } @@ -773,6 +773,7 @@ public class ChannelListViewModel( private data class SearchMessageState( val query: String = "", val canLoadMore: Boolean = true, + val next: String? = null, val messages: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, @@ -784,7 +785,8 @@ public class ChannelListViewModel( "messages.size=${messages.size}, " + "isLoading=$isLoading, " + "isLoadingMore=$isLoadingMore, " + - "canLoadMore=$canLoadMore)" + "canLoadMore=$canLoadMore, " + + "next=$next)" } } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index ef8f80bb926..883bfec1c27 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.channels import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery @@ -30,10 +31,12 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.OrFilterObject +import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.randomMessage import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -48,10 +51,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.amshove.kluent.`should be equal to` +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -76,8 +84,8 @@ internal class ChannelListViewModelTest { .get(this) val channelsState = viewModel.channelsState - channelsState.channelItems.size `should be equal to` 0 - channelsState.isLoading `should be equal to` true + assertEquals(0, channelsState.channelItems.size) + assertTrue(channelsState.isLoading) } @Test @@ -95,8 +103,8 @@ internal class ChannelListViewModelTest { .get(this) val channelsState = viewModel.channelsState - channelsState.channelItems.size `should be equal to` 2 - channelsState.isLoading `should be equal to` false + assertEquals(2, channelsState.channelItems.size) + assertFalse(channelsState.isLoading) } @Test @@ -118,8 +126,8 @@ internal class ChannelListViewModelTest { viewModel.performChannelAction(DeleteConversation(channel1)) viewModel.deleteConversation(channel1) - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(channelClient).delete() } @@ -140,8 +148,8 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.muteChannel(channel1) - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(chatClient).muteChannel("messaging", "channel1", null) } @@ -171,9 +179,9 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.unmuteChannel(channel1) - (viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted `should be equal to` true - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertTrue((viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted) + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(chatClient).unmuteChannel("messaging", "channel1") } @@ -193,8 +201,8 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.dismissChannelAction() - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) } @Test @@ -223,8 +231,8 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(2)).queryChannels(captor.capture()) - captor.firstValue.offset `should be equal to` 0 - captor.secondValue.offset `should be equal to` 30 + assertEquals(0, captor.firstValue.offset) + assertEquals(30, captor.secondValue.offset) } @Test @@ -246,7 +254,7 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(1)).queryChannels(captor.capture()) - captor.firstValue.offset `should be equal to` 0 + assertEquals(0, captor.firstValue.offset) } @Test @@ -271,8 +279,8 @@ internal class ChannelListViewModelTest { val andFilterObject = captor.secondValue.filter as AndFilterObject val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject - autoCompleteFilterObject.fieldName `should be equal to` "name" - autoCompleteFilterObject.value `should be equal to` "Search query" + assertEquals("name", autoCompleteFilterObject.fieldName) + assertEquals("Search query", autoCompleteFilterObject.value) } @Test @@ -301,8 +309,8 @@ internal class ChannelListViewModelTest { val andFilterObject = captor.secondValue.filter as AndFilterObject val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject - autoCompleteFilterObject.fieldName `should be equal to` "name" - autoCompleteFilterObject.value `should be equal to` "Search query" + assertEquals("name", autoCompleteFilterObject.fieldName) + assertEquals("Search query", autoCompleteFilterObject.value) } @Test @@ -333,9 +341,125 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(2)).queryChannels(captor.capture()) - captor.allValues.size `should be equal to` 2 - captor.firstValue.offset `should be equal to` 0 - captor.secondValue.offset `should be equal to` 30 + assertEquals(2, captor.allValues.size) + assertEquals(0, captor.firstValue.offset) + assertEquals(30, captor.secondValue.offset) + } + + @Test + fun `Given channel list When setting message search query Should search messages without offset or cursor`() = + runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:channel1")) + val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = eq(null), + limit = any(), + next = eq(null), + sort = eq(null), + ) + val items = viewModel.channelsState.channelItems + assertEquals(1, items.size) + assertInstanceOf(ItemState.SearchResultItemState::class.java, items.first()) + } + + @Test + fun `Given message search results with next cursor When loading more Should pass the cursor`() = + runTest { + val chatClient: ChatClient = mock() + val firstPageMessages = listOf(randomMessage(cid = "messaging:channel1")) + val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2") + val secondPageMessages = listOf(randomMessage(cid = "messaging:channel1")) + val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null) + + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn( + firstPageResult.asCall(), + secondPageResult.asCall(), + ) + + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = captor.capture(), + sort = anyOrNull(), + ) + assertNull(captor.firstValue) + assertEquals("cursor_page2", captor.secondValue) + } + + @Test + fun `Given message search results without next cursor When loading more Should not load more`() = + runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:channel1")) + val searchResult = SearchMessagesResult(messages = messages, next = null) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + assertTrue(viewModel.channelsState.endOfChannels) + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient, times(1)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) } private class Fixture( @@ -347,6 +471,7 @@ internal class ChannelListViewModelTest { private val clientState: ClientState = mock() private val stateRegistry: StateRegistry = mock() private val globalState: GlobalState = mock() + private val repositoryFacade: RepositoryFacade = mock() init { val statePlugin: StatePlugin = mock() @@ -357,6 +482,7 @@ internal class ChannelListViewModelTest { whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient whenever(chatClient.clientState) doReturn clientState + whenever(chatClient.repositoryFacade) doReturn repositoryFacade whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap()) } @@ -394,6 +520,16 @@ internal class ChannelListViewModelTest { whenever(chatClient.unmuteChannel(any(), any())) doReturn Unit.asCall() } + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ) doReturn result.asCall() + } + + suspend fun givenRepositorySelectChannels(channels: List = emptyList()) = apply { + whenever(repositoryFacade.selectChannels(any>())) doReturn channels + } + fun givenChannelsState( channelsStateData: ChannelsStateData = ChannelsStateData.Loading, channels: List? = null, diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index e432f6630a5..77c098e8f27 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5209,15 +5209,15 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State { public fun ()V - public fun (Ljava/lang/String;ZLjava/util/List;ZZ)V - public synthetic fun (Ljava/lang/String;ZLjava/util/List;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)V + public synthetic fun (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Z public final fun component3 ()Ljava/util/List; public final fun component4 ()Z public final fun component5 ()Z - public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZ)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;Ljava/lang/String;ZLjava/util/List;ZZILjava/lang/Object;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; + public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; public fun equals (Ljava/lang/Object;)Z public final fun getCanLoadMore ()Z public final fun getQuery ()Ljava/lang/String; diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 350bd4d5d1b..f699a39925a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.ViewModel import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.state.utils.Event import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.log.taggedLogger @@ -124,7 +124,7 @@ public class SearchViewModel( val currentState = _state.value!! val result = searchMessages( query = currentState.query, - offset = currentState.results.size, + next = currentState.next, ) when (result) { is Result.Success -> handleSearchMessageSuccess(result.value) @@ -136,12 +136,12 @@ public class SearchViewModel( /** * Notifies the UI about the search results and enables the pagination. */ - private suspend fun handleSearchMessageSuccess(messages: List) { - logger.d { "Found messages: ${messages.size}" } + private suspend fun handleSearchMessageSuccess(searchResult: SearchMessagesResult) { + logger.d { "Found messages: ${searchResult.messages.size}" } val currentState = _state.value!! - val channels = chatClient.repositoryFacade.selectChannels(messages.map { it.cid }) + val channels = chatClient.repositoryFacade.selectChannels(searchResult.messages.map { it.cid }) _state.value = currentState.copy( - results = currentState.results + messages.map { + results = currentState.results + searchResult.messages.map { MessageResult( message = it, channel = channels.firstOrNull { channel -> channel.cid == it.cid }, @@ -149,7 +149,8 @@ public class SearchViewModel( }, isLoading = false, isLoadingMore = false, - canLoadMore = messages.size == QUERY_LIMIT, + canLoadMore = !searchResult.next.isNullOrEmpty(), + next = searchResult.next, ) } @@ -161,7 +162,6 @@ public class SearchViewModel( _state.value = _state.value!!.copy( isLoading = false, isLoadingMore = false, - canLoadMore = true, ) _errorEvents.value = Event(Unit) } @@ -170,21 +170,19 @@ public class SearchViewModel( * Searches messages containing [query] text across channels where the current user is a member. * * @param query The search query. - * @param offset The pagination offset offset. + * @param next The cursor for the next page of results. */ - private suspend fun searchMessages(query: String, offset: Int): Result> { - logger.d { "Searching for \"$query\" with offset: $offset" } + private suspend fun searchMessages(query: String, next: String?): Result { + logger.d { "Searching for \"$query\" with next: $next" } val currentUser = requireNotNull(chatClient.clientState.user.value) - // TODO: use the pagination based on "limit" nad "next" params here return chatClient .searchMessages( channelFilter = Filters.`in`("members", listOf(currentUser.id)), messageFilter = Filters.autocomplete("text", query), - offset = offset, limit = QUERY_LIMIT, + next = next, ) .await() - .map { it.messages } } /** @@ -195,6 +193,7 @@ public class SearchViewModel( * @param canLoadMore If we've reached the end of messages, to stop triggering pagination. * @param isLoading If we're currently loading data (initial load). * @param isLoadingMore If we're loading more items (pagination). + * @param next The cursor for the next page of results. */ public data class State( val query: String = "", @@ -202,6 +201,7 @@ public class SearchViewModel( val results: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, + internal val next: String? = null, ) private companion object { diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt new file mode 100644 index 00000000000..2a78f15a05e --- /dev/null +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.viewmodels.search + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.SearchMessagesResult +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.test.InstantTaskExecutorExtension +import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.test.asCall +import io.getstream.chat.android.test.observeAll +import io.getstream.chat.android.ui.viewmodel.search.SearchViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class SearchViewModelTest { + + @Test + fun `Given empty query When setting query Should clear results`() = runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + viewModel.setQuery("") + + val states = viewModel.state.observeAll() + val lastState = states.last() + assertEquals(0, lastState.results.size) + assertFalse(lastState.isLoading) + assertFalse(lastState.canLoadMore) + assertNull(lastState.next) + } + + @Test + fun `Given search query When searching Should call searchMessages without offset or cursor`() = runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:ch1")) + val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = eq(null), + limit = any(), + next = eq(null), + sort = eq(null), + ) + } + + @Test + fun `Given search results with next cursor When loading more Should pass the cursor`() = runTest { + val chatClient: ChatClient = mock() + val firstPageMessages = listOf(randomMessage(cid = "messaging:ch1")) + val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2") + val secondPageMessages = listOf(randomMessage(cid = "messaging:ch1")) + val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null) + + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn( + firstPageResult.asCall(), + secondPageResult.asCall(), + ) + + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + viewModel.loadMore() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = captor.capture(), + sort = anyOrNull(), + ) + assertNull(captor.firstValue) + assertEquals("cursor_page2", captor.secondValue) + } + + @Test + fun `Given search results without next cursor When loading more Should not load more`() = runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:ch1")) + val searchResult = SearchMessagesResult(messages = messages, next = null) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + + val states = viewModel.state.observeAll() + val lastState = states.last() + assertFalse(lastState.canLoadMore) + + viewModel.loadMore() + + verify(chatClient, times(1)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + } + + private class Fixture( + private val chatClient: ChatClient = mock(), + ) { + private val clientState: ClientState = mock() + private val repositoryFacade: RepositoryFacade = mock() + + init { + whenever(chatClient.clientState) doReturn clientState + whenever(chatClient.repositoryFacade) doReturn repositoryFacade + } + + fun givenCurrentUser(currentUser: User = User(id = "Jc")) = apply { + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + } + + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ) doReturn result.asCall() + } + + suspend fun givenRepositorySelectChannels(channels: List = emptyList()) = apply { + whenever(repositoryFacade.selectChannels(any>())) doReturn channels + } + + fun get(): SearchViewModel = SearchViewModel(chatClient = chatClient) + } + + companion object { + @JvmField + @RegisterExtension + val testCoroutines: TestCoroutineExtension = TestCoroutineExtension() + + @JvmField + @RegisterExtension + val instantExecutorExtension: InstantTaskExecutorExtension = InstantTaskExecutorExtension() + } +}