diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index f7a98c1..08b75e9 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -54,33 +54,43 @@ class DefaultMessagesRepository @Inject constructor( override fun observeScheduled(): Flow> = messageDao.observeScheduled().map { rows -> rows.map { it.toDomain() } } - override suspend fun refreshFeed(): ApiResult = withContext(dispatchers.io) { - when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { + /** + * Loads the head of the feed (no cursor) and replaces the cached feed with it, + * restarting keyset pagination. Returns the next page's opaque cursor. + */ + override suspend fun refreshFeed(): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT) }) { is ApiResult.Success -> { val page = result.data - val entities = page.data.mapIndexed { index, dto -> + val entities = page.rows.mapIndexed { index, dto -> dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong()) } messageDao.clearFeed() messageDao.insertAll(entities) - ApiResult.Success(page.pagination.hasMore) + ApiResult.Success(page.nextCursor) } is ApiResult.Failure -> result } } - override suspend fun loadMoreFeed(currentCount: Int): ApiResult = withContext(dispatchers.io) { + /** + * Appends the page following [cursor] to the tail of the cached feed. The + * cursor is opaque: it goes back to the API exactly as it arrived. Rows are + * keyed by id, so a row the server happens to repeat updates in place rather + * than duplicating. + */ + override suspend fun loadMoreFeed(cursor: String): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { - api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = currentCount) + api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, cursor = cursor) }) { is ApiResult.Success -> { val page = result.data val base = (messageDao.maxFeedOrder() ?: -1L) + 1L - val entities = page.data.mapIndexed { index, dto -> + val entities = page.rows.mapIndexed { index, dto -> dto.toDomain(currentUserId()).toEntity(feedOrder = base + index) } messageDao.insertAll(entities) - ApiResult.Success(page.pagination.hasMore) + ApiResult.Success(page.nextCursor) } is ApiResult.Failure -> result } @@ -172,7 +182,7 @@ class DefaultMessagesRepository @Inject constructor( override suspend fun refreshReplies(messageId: String): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getReplies(messageId) }) { is ApiResult.Success -> { - val entities = result.data.data.mapIndexed { index, dto -> + val entities = result.data.rows.mapIndexed { index, dto -> dto.toDomain(currentUserId()) .copy(parentId = messageId) .toEntity(feedOrder = index.toLong()) @@ -374,7 +384,7 @@ class DefaultMessagesRepository @Inject constructor( api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { is ApiResult.Success -> - ApiResult.Success(result.data.data.map { it.toDomain(currentUserId()) }) + ApiResult.Success(result.data.rows.map { it.toDomain(currentUserId()) }) is ApiResult.Failure -> result } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt index c9014ab..6ce4cd2 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -30,15 +30,18 @@ interface MessagesRepository { /** * Refreshes the first page of the feed from the API and replaces the cached - * feed. Returns whether more pages are available. + * feed, restarting keyset pagination from the top. Returns the opaque cursor + * for the next page, or null when the feed ends here. */ - suspend fun refreshFeed(): ApiResult + suspend fun refreshFeed(): ApiResult /** - * Fetches and appends the next feed page after [currentCount] items. - * Returns whether still more pages remain. + * Fetches the page that follows [cursor] and appends it to the cached feed. + * [cursor] is the opaque token a previous [refreshFeed]/[loadMoreFeed] + * returned and is handed to the API verbatim — never construct or parse one. + * Returns the cursor for the page after this one, or null at the end. */ - suspend fun loadMoreFeed(currentCount: Int): ApiResult + suspend fun loadMoreFeed(cursor: String): ApiResult /** * Creates a new top-level message and caches it. Optionally attaches already diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt index fe84d22..bc22521 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -29,11 +29,18 @@ import retrofit2.http.Query */ interface MessagesApi { - /** Feed of top-level messages, newest first, offset/limit paginated. */ + /** + * Feed of top-level messages, newest first, **keyset (cursor) paginated**. + * + * [cursor] is the previous response's `nextCursor`, passed back verbatim; it + * is opaque and must never be constructed, parsed or modified. A null + * [cursor] loads the first page (Retrofit omits the query parameter). The + * endpoint has no `offset` parameter. + */ @GET("api/messages") suspend fun getMessages( @Query("limit") limit: Int, - @Query("offset") offset: Int, + @Query("cursor") cursor: String? = null, ): MessagesResponse /** Creates a new message (or a reply when `parentId` is set). The created diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt index a5c9c09..dc1e81c 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt @@ -4,22 +4,43 @@ import com.interlinedlist.android.feature.messages.domain.CrossPostStatus import kotlinx.serialization.Serializable /** - * Paginated list envelope shared by the feed, replies, and search endpoints: - * `{ data: [...], pagination: { total, limit, offset, hasMore } }`. + * Paginated list envelope shared by the feed, replies, and search endpoints. + * + * The feed is documented as `{ messages: [...], pagination: {...} }` while the + * sibling list endpoints wrap their rows in `data`; both keys are accepted and + * read through [rows]. */ @Serializable data class MessagesResponse( val data: List = emptyList(), + val messages: List = emptyList(), val pagination: PaginationDto = PaginationDto(), -) +) { + /** The message rows under whichever key the endpoint used. */ + val rows: List get() = data.ifEmpty { messages } + + /** + * The opaque cursor to request the next page with, or null at the end of the + * list. Hand it straight back as `cursor`: never parse or modify it. + */ + val nextCursor: String? get() = pagination.nextCursor?.takeIf { it.isNotBlank() } +} -/** Pagination cursor returned alongside a list of messages. */ +/** + * Pagination envelope returned alongside a list of messages. + * + * The feed uses keyset pagination: [nextCursor] is an **opaque** token that is + * passed back verbatim as the next request's `cursor`, and `null` means the end + * of the list. [total] and [offset] only appear on the legacy offset path and are + * omitted once a cursor is in play. + */ @Serializable data class PaginationDto( val total: Int = 0, val limit: Int = DEFAULT_LIMIT, val offset: Int = 0, val hasMore: Boolean = false, + val nextCursor: String? = null, ) { companion object { const val DEFAULT_LIMIT = 20 diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt index c679eb8..80e1e12 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt @@ -105,7 +105,11 @@ enum class ModerationAction { BLOCK, MUTE, REPORT } private data class FeedTransientState( val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, - val canLoadMore: Boolean = false, + /** + * Opaque keyset cursor for the next feed page, or null at the end of the feed + * (and until the first refresh returns). Held verbatim: never parsed or built. + */ + val nextCursor: String? = null, val errorMessage: String? = null, val subscriptionRequired: Boolean = false, val isComposeOpen: Boolean = false, @@ -130,6 +134,9 @@ private data class FeedTransientState( ) { /** A per-message override always wins over the account default. */ val composeVisibility: MessageVisibility get() = visibilityOverride ?: defaultVisibility + + /** More pages remain exactly while the server handed back a cursor. */ + val canLoadMore: Boolean get() = nextCursor != null } @HiltViewModel @@ -217,12 +224,21 @@ class MessagesFeedViewModel @Inject constructor( } } + /** Reloads the head of the feed, discarding the stored paging cursor. */ fun refresh() { - transient.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) } + transient.update { + it.copy( + isRefreshing = true, + // Paging restarts from the top; the old cursor no longer applies. + nextCursor = null, + errorMessage = null, + subscriptionRequired = false, + ) + } viewModelScope.launch { when (val result = repository.refreshFeed()) { is ApiResult.Success -> transient.update { - it.copy(isRefreshing = false, canLoadMore = result.data) + it.copy(isRefreshing = false, nextCursor = result.data) } is ApiResult.Failure -> transient.update { it.copy(isRefreshing = false).withError(result.error) @@ -231,14 +247,16 @@ class MessagesFeedViewModel @Inject constructor( } } + /** Appends the page after the stored cursor; a no-op at the end of the feed. */ fun loadMore() { - val current = uiState.value - if (current.isLoadingMore || !current.canLoadMore) return + val current = transient.value + val cursor = current.nextCursor ?: return + if (current.isLoadingMore || current.isRefreshing) return transient.update { it.copy(isLoadingMore = true) } viewModelScope.launch { - when (val result = repository.loadMoreFeed(currentCount = current.messages.size)) { + when (val result = repository.loadMoreFeed(cursor)) { is ApiResult.Success -> transient.update { - it.copy(isLoadingMore = false, canLoadMore = result.data) + it.copy(isLoadingMore = false, nextCursor = result.data) } is ApiResult.Failure -> transient.update { it.copy(isLoadingMore = false).withError(result.error) diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt index d6e6d98..248a939 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt @@ -71,7 +71,7 @@ class DefaultMessagesRepositoryTest { } @Test - fun `refreshFeed caches messages and reports hasMore`() = runTest(dispatcher) { + fun `refreshFeed caches messages and reports the next cursor`() = runTest(dispatcher) { enqueueJson( 200, """ @@ -81,7 +81,7 @@ class DefaultMessagesRepositoryTest { "digCount": 2, "replyCount": 1 }, { "id": "2", "content": "second", "author": { "id": "me", "username": "me" } } ], - "pagination": { "total": 5, "limit": 20, "offset": 0, "hasMore": true } + "pagination": { "limit": 20, "hasMore": true, "nextCursor": "page-2" } } """.trimIndent(), ) @@ -90,7 +90,7 @@ class DefaultMessagesRepositoryTest { val result = repo.refreshFeed() assertThat(result).isInstanceOf(ApiResult.Success::class.java) - assertThat((result as ApiResult.Success).data).isTrue() // hasMore + assertThat((result as ApiResult.Success).data).isEqualTo("page-2") val cached = repo.observeFeed().first() assertThat(cached.map { it.id }).containsExactly("1", "2").inOrder() // Author id "me" matches the session user -> flagged mine. @@ -114,26 +114,27 @@ class DefaultMessagesRepositoryTest { enqueueJson( 200, """{ "data": [ { "id": "1", "content": "a" } ], - "pagination": { "total": 3, "limit": 20, "offset": 0, "hasMore": true } }""", + "pagination": { "limit": 20, "hasMore": true, "nextCursor": "after-1" } }""", ) enqueueJson( 200, """{ "data": [ { "id": "2", "content": "b" } ], - "pagination": { "total": 3, "limit": 20, "offset": 1, "hasMore": false } }""", + "pagination": { "limit": 20, "hasMore": false, "nextCursor": null } }""", ) val repo = repository() - repo.refreshFeed() + val cursor = (repo.refreshFeed() as ApiResult.Success).data!! - val more = repo.loadMoreFeed(currentCount = 1) + val more = repo.loadMoreFeed(cursor) - assertThat((more as ApiResult.Success).data).isFalse() // no more pages + assertThat((more as ApiResult.Success).data).isNull() // end of the feed val ids = repo.observeFeed().first().map { it.id } assertThat(ids).containsExactly("1", "2").inOrder() - // Second request carried the offset from the current feed size. + // Second request carried the previous page's cursor, verbatim. server.takeRequest() - val secondPath = server.takeRequest().path - assertThat(secondPath).contains("offset=1") + val second = server.takeRequest().requestUrl!! + assertThat(second.queryParameter("cursor")).isEqualTo("after-1") + assertThat(second.queryParameter("offset")).isNull() } @Test diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt new file mode 100644 index 0000000..f9b8f2f --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt @@ -0,0 +1,200 @@ +package com.interlinedlist.android.feature.messages.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * `GET /api/messages` paginates by an **opaque** keyset `cursor`: the caller hands + * back the previous response's `nextCursor` verbatim and never constructs, parses + * or modifies one. The endpoint has no `offset` parameter at all. + * + * These tests pin that contract, and in particular the regression it exists for: + * a post arriving at the head of the feed between two pages must not duplicate or + * skip rows, which offset paging cannot guarantee. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MessagesFeedCursorPagingTest { + + private val dispatcher = StandardTestDispatcher() + + // Mirrors the production Json (see core:network NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private lateinit var server: MockWebServer + private lateinit var api: MessagesApi + private lateinit var userApi: InterlinedListApi + private lateinit var dao: FakeMessageDao + + /** An opaque, base64-padded token: nothing in the app may interpret it. */ + private val cursorAfterM3 = "MjAyNi0wOS0xM1QxMjowMDowMC4wMDBafG0z=" + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val contentType = "application/json".toMediaType() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + api = retrofit.create(MessagesApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + dao = FakeMessageDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultMessagesRepository( + api = api, + userApi = userApi, + messageDao = dao, + sessionStore = fakeSessionStore("me"), + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun page(ids: List, nextCursor: String?): String { + val rows = ids.joinToString(",") { """{ "id": "$it", "content": "$it" }""" } + val cursor = nextCursor?.let { "\"$it\"" } ?: "null" + return """ + { "messages": [$rows], + "pagination": { "limit": 3, "hasMore": ${nextCursor != null}, "nextCursor": $cursor } } + """.trimIndent() + } + + private fun enqueue(body: String) { + server.enqueue(MockResponse().setResponseCode(200).setBody(body)) + } + + @Test + fun `the first page asks for no cursor and never sends an offset`() = runTest(dispatcher) { + enqueue(page(listOf("m5", "m4", "m3"), cursorAfterM3)) + + val result = repository().refreshFeed() + + assertThat((result as ApiResult.Success).data).isEqualTo(cursorAfterM3) + val url = server.takeRequest().requestUrl!! + assertThat(url.queryParameter("cursor")).isNull() + assertThat(url.queryParameter("offset")).isNull() + assertThat(url.queryParameter("limit")).isEqualTo("20") + } + + @Test + fun `page two hands the cursor back verbatim and sends no offset`() = runTest(dispatcher) { + enqueue(page(listOf("m5", "m4", "m3"), cursorAfterM3)) + enqueue(page(listOf("m2", "m1"), nextCursor = null)) + val repo = repository() + + val cursor = (repo.refreshFeed() as ApiResult.Success).data!! + repo.loadMoreFeed(cursor) + + server.takeRequest() // page 1 + val url = server.takeRequest().requestUrl!! + // Verbatim: the padded token survives the round trip untouched. + assertThat(url.queryParameter("cursor")).isEqualTo(cursorAfterM3) + assertThat(url.queryParameter("offset")).isNull() + } + + @Test + fun `a null nextCursor marks the end of the feed`() = runTest(dispatcher) { + enqueue(page(listOf("m5", "m4", "m3"), cursorAfterM3)) + enqueue(page(listOf("m2", "m1"), nextCursor = null)) + val repo = repository() + + val cursor = (repo.refreshFeed() as ApiResult.Success).data!! + val next = repo.loadMoreFeed(cursor) + + assertThat((next as ApiResult.Success).data).isNull() + } + + @Test + fun `appending page two adds no duplicates and skips no rows`() = runTest(dispatcher) { + enqueue(page(listOf("m5", "m4", "m3"), cursorAfterM3)) + enqueue(page(listOf("m2", "m1"), nextCursor = null)) + val repo = repository() + + val cursor = (repo.refreshFeed() as ApiResult.Success).data!! + repo.loadMoreFeed(cursor) + + val ids = repo.observeFeed().first().map { it.id } + assertThat(ids).containsExactly("m5", "m4", "m3", "m2", "m1").inOrder() + assertThat(ids).containsNoDuplicates() + } + + @Test + fun `a post arriving at the head between pages neither duplicates nor skips rows`() = + runTest(dispatcher) { + // Server truth at page 1: m5 m4 m3 m2 m1. Between the two page loads a + // new post (m6) lands at the head, shifting every offset by one. + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val url: HttpUrl = request.requestUrl!! + val body = when { + // Offset paging against the drifted feed: it would re-serve + // m3 and skip m1. Reaching this branch means the app + // regressed to offset paging. + url.queryParameter("offset") != null -> page(listOf("m3", "m2"), null) + url.queryParameter("cursor") == cursorAfterM3 -> + page(listOf("m2", "m1"), null) + else -> page(listOf("m5", "m4", "m3"), cursorAfterM3) + } + return MockResponse().setResponseCode(200).setBody(body) + } + } + val repo = repository() + + val cursor = (repo.refreshFeed() as ApiResult.Success).data!! + repo.loadMoreFeed(cursor) + + val ids = repo.observeFeed().first().map { it.id } + assertThat(ids).containsNoDuplicates() + assertThat(ids).containsExactly("m5", "m4", "m3", "m2", "m1").inOrder() + server.takeRequest() + assertThat(server.takeRequest().requestUrl!!.queryParameter("offset")).isNull() + } + + @Test + fun `refreshing from the top resets the cursor and replaces the head`() = runTest(dispatcher) { + enqueue(page(listOf("m5", "m4", "m3"), cursorAfterM3)) + enqueue(page(listOf("m2", "m1"), nextCursor = null)) + // The refresh sees the new head post and a fresh cursor. + enqueue(page(listOf("m6", "m5", "m4"), "cursor-after-m4")) + val repo = repository() + + val cursor = (repo.refreshFeed() as ApiResult.Success).data!! + repo.loadMoreFeed(cursor) + val refreshed = repo.refreshFeed() + + // A refresh restarts paging from the top: no cursor on the wire, the cached + // head is replaced by the fresh page, and a new cursor is handed back. + assertThat((refreshed as ApiResult.Success).data).isEqualTo("cursor-after-m4") + assertThat(repo.observeFeed().first().map { it.id }) + .containsExactly("m6", "m5", "m4").inOrder() + server.takeRequest() + server.takeRequest() + assertThat(server.takeRequest().requestUrl!!.queryParameter("cursor")).isNull() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponseTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponseTest.kt new file mode 100644 index 0000000..285f91d --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponseTest.kt @@ -0,0 +1,64 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * The feed envelope is documented as `{ messages, pagination }` while the sibling + * list endpoints wrap their rows in `data`. These tests pin the tolerant parsing + * the DTO promises, plus the keyset `nextCursor` the feed pages on. + */ +class MessagesResponseTest { + + // Mirrors NetworkModule's configuration. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private fun decode(body: String) = json.decodeFromString(MessagesResponse.serializer(), body) + + @Test + fun `reads the documented feed envelope and its next cursor`() { + val response = decode( + """ + { + "messages": [ { "id": "m1", "content": "hello" } ], + "pagination": { "limit": 20, "hasMore": true, "nextCursor": "b64|token=" } + } + """.trimIndent(), + ) + + assertThat(response.rows.map { it.id }).containsExactly("m1") + assertThat(response.nextCursor).isEqualTo("b64|token=") + } + + @Test + fun `reads rows wrapped in the data envelope`() { + val response = decode( + """ + { "data": [ { "id": "m2", "content": "hi" } ], + "pagination": { "limit": 20, "hasMore": false, "nextCursor": null } } + """.trimIndent(), + ) + + assertThat(response.rows.map { it.id }).containsExactly("m2") + assertThat(response.nextCursor).isNull() + } + + @Test + fun `a blank next cursor counts as the end of the feed`() { + val response = decode( + """{ "messages": [], "pagination": { "limit": 20, "hasMore": false, "nextCursor": "" } }""", + ) + + assertThat(response.rows).isEmpty() + assertThat(response.nextCursor).isNull() + } + + @Test + fun `an empty body decodes to an empty page with no cursor`() { + val response = decode("{}") + + assertThat(response.rows).isEmpty() + assertThat(response.nextCursor).isNull() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt index 01a2680..bf5ad5d 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -26,8 +26,10 @@ class FakeMessagesRepository : MessagesRepository { private val single = MutableStateFlow>(emptyMap()) private val scheduled = MutableStateFlow>(emptyList()) - var refreshResult: ApiResult = ApiResult.Success(false) - var loadMoreResult: ApiResult = ApiResult.Success(false) + /** The cursor a refresh hands back; null means "end of the feed". */ + var refreshResult: ApiResult = ApiResult.Success(null) + /** The cursor a page-append hands back; null means "end of the feed". */ + var loadMoreResult: ApiResult = ApiResult.Success(null) var createResult: ApiResult? = null /** Cross-post statuses returned alongside a successful [createResult]. */ var createCrossPosts: List = emptyList() @@ -53,6 +55,8 @@ class FakeMessagesRepository : MessagesRepository { var refreshCount = 0 var loadMoreCount = 0 + /** Every cursor handed to [loadMoreFeed], in order. */ + val loadMoreCursors = mutableListOf() var lastSetDug: Pair? = null var deletedIds = mutableListOf() var lastCreate: CreateArgs? = null @@ -100,13 +104,14 @@ class FakeMessagesRepository : MessagesRepository { override fun observeScheduled(): Flow> = scheduled - override suspend fun refreshFeed(): ApiResult { + override suspend fun refreshFeed(): ApiResult { refreshCount++ return refreshResult } - override suspend fun loadMoreFeed(currentCount: Int): ApiResult { + override suspend fun loadMoreFeed(cursor: String): ApiResult { loadMoreCount++ + loadMoreCursors += cursor return loadMoreResult } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt index 08d868b..37a0c47 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt @@ -48,7 +48,7 @@ class MessagesFeedViewModelTest { @Test fun `refresh runs on init and toggles the refreshing flag`() = runTest(dispatcher) { - val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success(true) } + val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success("page-2") } val vm = MessagesFeedViewModel(repo) vm.uiState.test { @@ -92,7 +92,7 @@ class MessagesFeedViewModelTest { @Test fun `loadMore is a no-op when there are no more pages`() = runTest(dispatcher) { - val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success(false) } + val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success(null) } val vm = MessagesFeedViewModel(repo) backgroundScope.launch { vm.uiState.collect {} } advanceUntilIdle() @@ -106,8 +106,8 @@ class MessagesFeedViewModelTest { @Test fun `loadMore fetches the next page when more are available`() = runTest(dispatcher) { val repo = FakeMessagesRepository().apply { - refreshResult = ApiResult.Success(true) - loadMoreResult = ApiResult.Success(false) + refreshResult = ApiResult.Success("page-2") + loadMoreResult = ApiResult.Success(null) } val vm = MessagesFeedViewModel(repo) backgroundScope.launch { vm.uiState.collect {} } @@ -120,6 +120,62 @@ class MessagesFeedViewModelTest { assertThat(vm.uiState.value.canLoadMore).isFalse() } + @Test + fun `loadMore hands back the cursor from the previous page`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Success("page-2") + loadMoreResult = ApiResult.Success("page-3") + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + vm.loadMore() + advanceUntilIdle() + + // Each page is requested with the cursor the page before it returned. + assertThat(repo.loadMoreCursors).containsExactly("page-2", "page-3").inOrder() + } + + @Test + fun `refresh resets the stored cursor`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Success("page-2") + loadMoreResult = ApiResult.Success("page-3") + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + vm.loadMore() + advanceUntilIdle() + + // Refreshing from the top restarts paging: the stale "page-3" is dropped. + repo.refreshResult = ApiResult.Success("fresh-page-2") + vm.refresh() + advanceUntilIdle() + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCursors).containsExactly("page-2", "fresh-page-2").inOrder() + } + + @Test + fun `a failed refresh leaves the feed at the end so loadMore stays idle`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(0) + } + @Test fun `post creates a message and closes the compose sheet`() = runTest(dispatcher) { val repo = FakeMessagesRepository().apply {