diff --git a/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSourceTest.kt new file mode 100644 index 000000000000..2b511b068e9b --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSourceTest.kt @@ -0,0 +1,284 @@ +package org.wordpress.android.ui.comments.unified + +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import rs.wordpress.api.kotlin.WpApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostListParams +import uniffi.wp_api.PostsRequestExecutor +import uniffi.wp_api.PostsRequestFilterListWithViewContextResponse +import uniffi.wp_api.RequestMethod +import uniffi.wp_api.SparseAnyPostWithViewContext +import uniffi.wp_api.SparsePostTitleWithViewContext +import uniffi.wp_api.UniffiWpApiClient +import uniffi.wp_api.WpErrorCode + +/** + * Tests for [CommentsRsDataSource.fetchPostTitles]: the per-site title cache, the posts→pages + * endpoint fallback, negative caching of unresolvable ids, and request chunking. + * + * The [wpApiClient] stub executes each request's builder lambda against a mocked + * [UniffiWpApiClient], recording the endpoint and paging params actually sent — so the tests + * pin the requests made, not just how many there were. + */ +class CommentsRsDataSourceTest { + private data class RecordedRequest( + val endpointType: PostEndpointType, + val includeCount: Int, + val perPage: UInt? + ) + + private val wpApiClientProvider: WpApiClientProvider = mock() + private val wpApiClient: WpApiClient = mock() + private val uniffiClient: UniffiWpApiClient = mock() + private val postsExecutor: PostsRequestExecutor = mock() + private lateinit var dataSource: CommentsRsDataSource + + private val recordedRequests = mutableListOf() + private val cannedResults = ArrayDeque>() + + /** Invoked after each request completes; lets a test simulate concurrent work mid-fetch. */ + private var afterRequest: (() -> Unit)? = null + + private val siteA = SiteModel().apply { id = 1 } + private val siteB = SiteModel().apply { id = 2 } + + @Before + fun setUp() { + dataSource = CommentsRsDataSource(wpApiClientProvider) + whenever(wpApiClientProvider.getWpApiClient(any(), anyOrNull())).thenReturn(wpApiClient) + whenever(uniffiClient.posts()).thenReturn(postsExecutor) + postsExecutor.stub { + on { filterListWithViewContext(any(), any(), any()) } doSuspendableAnswer { invocation -> + val params = invocation.getArgument(1) + recordedRequests += RecordedRequest( + endpointType = invocation.getArgument(0), + includeCount = params.include.size, + perPage = params.perPage + ) + // The payload is decided by the request-level stub below; this value is unused. + PostsRequestFilterListWithViewContextResponse(emptyList(), mock(), null, null) + } + } + wpApiClient.stub { + on { request(any()) } doSuspendableAnswer { invocation -> + // Run the builder lambda so the executor stub above records what was requested. + val executor = invocation.getArgument Any>(0) + executor(uniffiClient) + val result = cannedResults.removeFirst() + afterRequest?.invoke() + result + } + } + } + + @Test + fun `resolved titles are cached and skip the network`() = runTest { + stubRequests(successResponse(sparsePost(5, "Hello"))) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(5))).isEqualTo(mapOf(5L to "Hello")) + assertThat(dataSource.fetchPostTitles(siteA, listOf(5))).isEqualTo(mapOf(5L to "Hello")) + + assertThat(recordedRequests).hasSize(1) + } + + @Test + fun `titles are cached per site, not just per post id`() = runTest { + stubRequests( + successResponse(sparsePost(5, "Site A post")), + successResponse(sparsePost(5, "Site B post")) + ) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(5))).isEqualTo(mapOf(5L to "Site A post")) + assertThat(dataSource.fetchPostTitles(siteB, listOf(5))).isEqualTo(mapOf(5L to "Site B post")) + + assertThat(recordedRequests).hasSize(2) + } + + @Test + fun `a posts endpoint failure still tries the pages endpoint`() = runTest { + stubRequests(wpError(), successResponse(sparsePost(7, "About"))) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(7))).isEqualTo(mapOf(7L to "About")) + + assertThat(recordedRequests.map { it.endpointType }) + .containsExactly(PostEndpointType.Posts, PostEndpointType.Pages) + } + + @Test + fun `ids neither endpoint returns are negative-cached once both succeed`() = runTest { + stubRequests(successResponse(), successResponse()) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEqualTo(mapOf(9L to "")) + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEqualTo(mapOf(9L to "")) + + // Posts + pages for the first call, none for the second. + assertThat(recordedRequests.map { it.endpointType }) + .containsExactly(PostEndpointType.Posts, PostEndpointType.Pages) + } + + @Test + fun `a transient failure is not negative-cached`() = runTest { + stubRequests( + successResponse(), // posts: id not found + wpError(), // pages: transient failure — must NOT negative-cache + successResponse(sparsePost(9, "Resolved later")) + ) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEmpty() + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEqualTo(mapOf(9L to "Resolved later")) + + assertThat(recordedRequests.map { it.endpointType }) + .containsExactly(PostEndpointType.Posts, PostEndpointType.Pages, PostEndpointType.Posts) + } + + @Test + fun `clearPostTitles retries negative-cached ids`() = runTest { + stubRequests( + successResponse(), // posts: not found + successResponse(), // pages: not found → negative-cached + successResponse(sparsePost(9, "Now published")) + ) + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEqualTo(mapOf(9L to "")) + + dataSource.clearPostTitles(siteA) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEqualTo(mapOf(9L to "Now published")) + assertThat(recordedRequests).hasSize(3) + } + + @Test + fun `clearPostTitles evicts resolved titles so a refresh re-fetches them`() = runTest { + stubRequests( + successResponse(sparsePost(5, "Old title")), + successResponse(sparsePost(5, "Renamed title")) + ) + assertThat(dataSource.fetchPostTitles(siteA, listOf(5))).isEqualTo(mapOf(5L to "Old title")) + + dataSource.clearPostTitles(siteA) + + assertThat(dataSource.fetchPostTitles(siteA, listOf(5))).isEqualTo(mapOf(5L to "Renamed title")) + assertThat(recordedRequests).hasSize(2) + } + + @Test + fun `clearPostTitles only evicts the given site's titles`() = runTest { + stubRequests( + successResponse(sparsePost(5, "Site A post")), + successResponse(sparsePost(5, "Site B post")), + successResponse(sparsePost(5, "Site A refetched")) + ) + dataSource.fetchPostTitles(siteA, listOf(5)) + dataSource.fetchPostTitles(siteB, listOf(5)) + + dataSource.clearPostTitles(siteA) + + assertThat(dataSource.fetchPostTitles(siteB, listOf(5))).isEqualTo(mapOf(5L to "Site B post")) + assertThat(dataSource.fetchPostTitles(siteA, listOf(5))).isEqualTo(mapOf(5L to "Site A refetched")) + assertThat(recordedRequests).hasSize(3) + } + + @Test + fun `a clear during an in-flight fetch prevents negative caching`() = runTest { + stubRequests( + successResponse(), // posts: not found + successResponse(), // pages: not found — but a clear lands before the cache write + successResponse(), // retry: posts + successResponse() // retry: pages + ) + afterRequest = { + // Simulates another surface clearing while this fetch is between its network + // responses and its negative-cache write. + if (recordedRequests.size == 2) dataSource.clearPostTitles(siteA) + } + + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEmpty() + + // Not negative-cached, so a later fetch goes back to the network. + afterRequest = null + assertThat(dataSource.fetchPostTitles(siteA, listOf(9))).isEqualTo(mapOf(9L to "")) + assertThat(recordedRequests).hasSize(4) + } + + @Test + fun `batches over 100 ids are chunked to the per_page maximum`() = runTest { + val ids = (1L..101L).toList() + stubRequests( + successResponse(*ids.take(100).map { sparsePost(it, "Post $it") }.toTypedArray()), + successResponse(sparsePost(101, "Post 101")) + ) + + val titles = dataSource.fetchPostTitles(siteA, ids) + + assertThat(titles).hasSize(101) + assertThat(titles[101L]).isEqualTo("Post 101") + // Two posts-endpoint requests with the batch split at 100, each with a matching + // explicit perPage — NOT one oversized request plus a pages fallback. + assertThat(recordedRequests.map { Triple(it.endpointType, it.includeCount, it.perPage) }) + .containsExactly( + Triple(PostEndpointType.Posts, 100, 100u), + Triple(PostEndpointType.Posts, 1, 1u) + ) + } + + // A sparse post as returned by the id+title sparse-field request: everything else null. + private fun sparsePost(id: Long, title: String) = SparseAnyPostWithViewContext( + id = id, + date = null, + dateGmt = null, + guid = null, + link = null, + modified = null, + modifiedGmt = null, + slug = null, + status = null, + postType = null, + title = SparsePostTitleWithViewContext(rendered = title), + content = null, + author = null, + excerpt = null, + featuredMedia = null, + commentStatus = null, + pingStatus = null, + format = null, + meta = null, + sticky = null, + template = null, + categories = null, + tags = null, + parent = null, + menuOrder = null, + additionalFields = null + ) + + private fun successResponse(vararg posts: SparseAnyPostWithViewContext) = WpRequestResult.Success( + response = PostsRequestFilterListWithViewContextResponse(posts.toList(), mock(), null, null) + ) + + private fun wpError() = WpRequestResult.WpError( + errorCode = WpErrorCode.Forbidden(), + errorMessage = "server said no", + statusCode = 403u, + response = "", + requestUrl = "https://example.com", + requestMethod = RequestMethod.GET + ) + + @Suppress("UNCHECKED_CAST") + private fun stubRequests(vararg responses: WpRequestResult<*>) { + recordedRequests.clear() + cannedResults.clear() + responses.forEach { cannedResults.add(it as WpRequestResult) } + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsListMappingTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsListMappingTest.kt new file mode 100644 index 000000000000..1005ac5df870 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/CommentsRsListMappingTest.kt @@ -0,0 +1,103 @@ +package org.wordpress.android.ui.comments.unified + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.mock +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import uniffi.wp_api.CommentContentWithViewContext +import uniffi.wp_api.CommentType +import uniffi.wp_api.CommentWithViewContext +import uniffi.wp_api.UserAvatarSize +import uniffi.wp_api.WpAdditionalFields +import uniffi.wp_api.WpApiParamCommentsOrderBy +import uniffi.wp_api.WpApiParamOrder +import java.util.Date +import uniffi.wp_api.CommentStatus as RsCommentStatus + +class CommentsRsListMappingTest { + @Test + fun `toRsComment maps the fields the detail and list screens need`() { + val item = rsComment().toRsComment() + + assertThat(item.remoteCommentId).isEqualTo(COMMENT_ID) + assertThat(item.authorName).isEqualTo("Jane") + assertThat(item.authorAvatarUrl).isEqualTo("https://example.com/avatar96.png") + assertThat(item.dateGmt).isEqualTo(DATE_GMT) + assertThat(item.contentHtml).isEqualTo("

hello

") + assertThat(item.url).isEqualTo("https://example.com/post/#comment-42") + assertThat(item.postId).isEqualTo(POST_ID) + assertThat(item.status).isEqualTo(APPROVED) + } + + @Test + fun `pickAvatarUrl prefers size 96`() { + val comment = rsComment( + avatarUrls = mapOf( + UserAvatarSize.Size24 to "https://example.com/avatar24.png", + UserAvatarSize.Size96 to "https://example.com/avatar96.png" + ) + ) + + assertThat(comment.pickAvatarUrl()).isEqualTo("https://example.com/avatar96.png") + } + + @Test + fun `pickAvatarUrl falls back to the first non-empty url`() { + val comment = rsComment( + avatarUrls = mapOf( + UserAvatarSize.Size24 to "", + UserAvatarSize.Size48 to "https://example.com/avatar48.png" + ) + ) + + assertThat(comment.pickAvatarUrl()).isEqualTo("https://example.com/avatar48.png") + } + + @Test + fun `pickAvatarUrl returns empty when there are no avatar urls`() { + assertThat(rsComment(avatarUrls = emptyMap()).pickAvatarUrl()).isEmpty() + } + + @Test + fun `firstPageParams requests newest comments with the given status and search`() { + val dataSource = CommentsRsDataSource(mock()) + + val params = dataSource.firstPageParams(RsCommentStatus.Spam, search = "query") + + assertThat(params.perPage).isEqualTo(CommentsRsDataSource.COMMENTS_PAGE_SIZE) + assertThat(params.status).isEqualTo(RsCommentStatus.Spam) + assertThat(params.search).isEqualTo("query") + assertThat(params.orderby).isEqualTo(WpApiParamCommentsOrderBy.DATE_GMT) + assertThat(params.order).isEqualTo(WpApiParamOrder.DESC) + } + + private fun rsComment( + status: RsCommentStatus = RsCommentStatus.Approved, + avatarUrls: Map = mapOf( + UserAvatarSize.Size96 to "https://example.com/avatar96.png" + ) + ) = CommentWithViewContext( + id = COMMENT_ID, + author = 7L, + authorName = "Jane", + authorUrl = "https://example.com", + content = CommentContentWithViewContext(rendered = "

hello

"), + date = "2026-07-01T12:00:00", + dateGmt = DATE_GMT, + link = "https://example.com/post/#comment-42", + parent = 0L, + post = POST_ID, + status = status, + commentType = CommentType.Comment, + authorAvatarUrls = avatarUrls, + // Mocked: the real WpAdditionalFields constructor loads the uniffi native library, + // which isn't available in local unit tests. + additionalFields = mock() + ) + + companion object { + private const val COMMENT_ID = 42L + private const val POST_ID = 99L + private val DATE_GMT = Date(1_700_000_000_000) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/compose/CommentHtmlBodyTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/compose/CommentHtmlBodyTest.kt new file mode 100644 index 000000000000..54d69fe79d06 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/comments/unified/compose/CommentHtmlBodyTest.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.ui.comments.unified.compose + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric tests for the [CommentHtmlBody] parsing helpers. Robolectric is required because + * both helpers ultimately reach [android.text.Html]/HtmlCompat, which aren't stubbed on the plain + * JVM. These pin the segment-splitting boundaries and the link/whitespace handling that the + * composable relies on. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = android.app.Application::class) +class CommentHtmlBodyTest { + @Test + fun `plain html with no image is a single html segment`() { + val segments = splitCommentHtml("Hello world") + + assertThat(segments).containsExactly(CommentBodySegment.Html("Hello world")) + } + + @Test + fun `an image at the start yields no leading html segment`() { + val segments = splitCommentHtml("""tail""") + + assertThat(segments).containsExactly( + CommentBodySegment.Image("https://example.com/a.png"), + CommentBodySegment.Html("tail") + ) + } + + @Test + fun `text around an image splits into html, image, html in order`() { + val segments = splitCommentHtml("beforeafter") + + assertThat(segments).containsExactly( + CommentBodySegment.Html("before"), + CommentBodySegment.Image("https://example.com/b.jpg"), + CommentBodySegment.Html("after") + ) + } + + @Test + fun `multiple images are split with the text between them`() { + val segments = splitCommentHtml("abc") + + assertThat(segments).containsExactly( + CommentBodySegment.Html("a"), + CommentBodySegment.Image("1.png"), + CommentBodySegment.Html("b"), + CommentBodySegment.Image("2.png"), + CommentBodySegment.Html("c") + ) + } + + @Test + fun `blank text around an image is dropped`() { + val segments = splitCommentHtml(" ") + + assertThat(segments).containsExactly(CommentBodySegment.Image("1.png")) + } + + @Test + fun `an img tag without a src is left inline in the html`() { + val segments = splitCommentHtml("xyz") + + assertThat(segments).containsExactly(CommentBodySegment.Html("xyz")) + } + + @Test + fun `plain html renders its text content`() { + val result = commentHtmlToAnnotatedString("Nice post", LINK_COLOR) + + assertThat(result.text).isEqualTo("Nice post") + } + + @Test + fun `trailing block whitespace is trimmed`() { + val result = commentHtmlToAnnotatedString("

hello

", LINK_COLOR) + + assertThat(result.text).isEqualTo("hello") + } + + @Test + fun `a link becomes a tappable url annotation`() { + val result = commentHtmlToAnnotatedString( + """see this""", + LINK_COLOR + ) + + assertThat(result.text).isEqualTo("see this") + val links = result.getLinkAnnotations(0, result.length) + assertThat(links).hasSize(1) + val link = links.first().item + assertThat(link).isInstanceOf(LinkAnnotation.Url::class.java) + assertThat((link as LinkAnnotation.Url).url).isEqualTo("https://example.com") + } + + @Test + fun `whitespace-only html yields an empty annotated string`() { + val result = commentHtmlToAnnotatedString("

", LINK_COLOR) + + assertThat(result.text).isEmpty() + } + + companion object { + private val LINK_COLOR = Color.Blue + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListTabTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListTabTest.kt new file mode 100644 index 000000000000..023bff617408 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListTabTest.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.commentsrs + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import uniffi.wp_api.CommentStatus as RsCommentStatus + +class CommentsRsListTabTest { + @Test + fun `all tab queries status all which the server treats as approved plus hold`() { + assertThat(CommentsRsListTab.ALL.queryStatus).isEqualTo(RsCommentStatus.Custom("all")) + } + + @Test + fun `approved tab queries the literal approve status`() { + // WP_Comment_Query only recognises "approve"; the RsCommentStatus.Approved enum + // serialises to "approved", which the server treats as unknown and returns nothing for. + assertThat(CommentsRsListTab.APPROVED.queryStatus).isEqualTo(RsCommentStatus.Custom("approve")) + } + + @Test + fun `remaining tabs use the built-in statuses`() { + assertThat(CommentsRsListTab.PENDING.queryStatus).isEqualTo(RsCommentStatus.Hold) + assertThat(CommentsRsListTab.SPAM.queryStatus).isEqualTo(RsCommentStatus.Spam) + assertThat(CommentsRsListTab.TRASHED.queryStatus).isEqualTo(RsCommentStatus.Trash) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModelTest.kt new file mode 100644 index 000000000000..cda2cbbc8598 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModelTest.kt @@ -0,0 +1,497 @@ +package org.wordpress.android.ui.commentsrs + +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsCommentsPageResult +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.util.DateTimeUtilsWrapper +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.util.WPAvatarUtilsWrapper +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.viewmodel.ResourceProvider +import uniffi.wp_api.CommentListParams +import java.util.Date + +@ExperimentalCoroutinesApi +class CommentsRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) { + @Mock lateinit var selectedSiteRepository: SelectedSiteRepository + @Mock lateinit var commentsRsDataSource: CommentsRsDataSource + @Mock lateinit var resourceProvider: ResourceProvider + @Mock lateinit var networkUtilsWrapper: NetworkUtilsWrapper + @Mock lateinit var dateTimeUtilsWrapper: DateTimeUtilsWrapper + @Mock lateinit var avatarUtilsWrapper: WPAvatarUtilsWrapper + @Mock lateinit var analyticsTracker: AnalyticsTrackerWrapper + + private lateinit var site: SiteModel + private var activeViewModel: CommentsRsListViewModel? = null + + @Before + fun setUp() = test { + site = SiteModel().apply { + id = 1 + siteId = 123L + } + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(site) + whenever(resourceProvider.getString(any())).thenReturn("string") + whenever(dateTimeUtilsWrapper.javaDateToTimeSpan(any())).thenReturn("2 hours ago") + whenever(avatarUtilsWrapper.rewriteAvatarUrlWithResource(any(), any())).thenAnswer { it.arguments[0] } + whenever(commentsRsDataSource.firstPageParams(any(), anyOrNull())).thenReturn(FIRST_PAGE) + whenever(commentsRsDataSource.fetchPostTitles(any(), any())).thenReturn(emptyMap()) + } + + @After + fun tearDown() { + activeViewModel?.viewModelScope?.cancel() + activeViewModel = null + } + + private fun createViewModel() = CommentsRsListViewModel( + selectedSiteRepository = selectedSiteRepository, + commentsRsDataSource = commentsRsDataSource, + resourceProvider = resourceProvider, + networkUtilsWrapper = networkUtilsWrapper, + dateTimeUtilsWrapper = dateTimeUtilsWrapper, + avatarUtilsWrapper = avatarUtilsWrapper, + analyticsTracker = analyticsTracker, + bgDispatcher = testDispatcher() + ).also { activeViewModel = it } + + private suspend fun givenPage( + comments: List, + nextPageParams: CommentListParams? = null + ) { + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .thenReturn(RsCommentsPageResult.Success(comments, nextPageParams)) + } + + @Test + fun `when no site selected, emits ShowToast and Finish`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + val viewModel = createViewModel() + + viewModel.events.test { + val first = awaitItem() + assertThat(first).isInstanceOf(CommentsRsListEvent.ShowToast::class.java) + assertThat((first as CommentsRsListEvent.ShowToast).messageResId).isEqualTo(R.string.blog_not_found) + assertThat(awaitItem()).isEqualTo(CommentsRsListEvent.Finish) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `initTab loads the first page and maps rows`() = test { + givenPage(listOf(rsItem(id = 1), rsItem(id = 2)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.isLoading).isFalse() + assertThat(state.comments).hasSize(2) + assertThat(state.comments.first().remoteCommentId).isEqualTo(1) + assertThat(state.comments.first().authorName).isEqualTo("Jane") + assertThat(state.comments.first().snippet).isEqualTo("hello") + assertThat(state.canLoadMore).isTrue() + } + + @Test + fun `initTab passes the tab's query status to the data source`() = test { + givenPage(emptyList()) + val viewModel = createViewModel() + + viewModel.initTab(CommentsRsListTab.APPROVED) + advanceUntilIdle() + + verify(commentsRsDataSource).firstPageParams(eq(CommentsRsListTab.APPROVED.queryStatus), anyOrNull()) + } + + @Test + fun `initTab is a no-op when the tab is already initialized`() = test { + givenPage(emptyList()) + val viewModel = createViewModel() + + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + verify(commentsRsDataSource, times(1)).fetchCommentsPage(eq(site), any()) + } + + @Test + fun `initTab failure with no content shows the error state`() = test { + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .thenReturn(RsCommentsPageResult.Error("server said no")) + val viewModel = createViewModel() + + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.error).isEqualTo("server said no") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `loadMore appends the next page and dedupes by comment id`() = test { + givenPage(listOf(rsItem(id = 1), rsItem(id = 2)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + givenPage(listOf(rsItem(id = 2), rsItem(id = 3)), nextPageParams = null) + + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments.map { it.remoteCommentId }).containsExactly(1L, 2L, 3L) + assertThat(state.canLoadMore).isFalse() + } + + @Test + fun `stale loadMore result arriving after a silent refresh is discarded`() = test { + givenPage(listOf(rsItem(id = 1), rsItem(id = 2)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + // Next two fetches: the silent refresh gets a fresh first page; the concurrently + // launched loadMore gets the (now stale) old second page. + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())).thenReturn( + RsCommentsPageResult.Success(listOf(rsItem(id = 10), rsItem(id = 11)), NEXT_PAGE), + RsCommentsPageResult.Success(listOf(rsItem(id = 3), rsItem(id = 4)), null) + ) + + viewModel.refreshTab(CommentsRsListTab.ALL) // silent: sets no busy flag + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments.map { it.remoteCommentId }).containsExactly(10L, 11L) + assertThat(state.isLoadingMore).isFalse() + assertThat(state.canLoadMore).isTrue() + } + + @Test + fun `loadMore failure offers a retry snackbar`() = test { + givenPage(listOf(rsItem(id = 1)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .thenReturn(RsCommentsPageResult.Error("boom")) + + viewModel.snackbarMessages.test { + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + val snackbar = awaitItem() + assertThat(snackbar.message).isEqualTo("boom") + assertThat(snackbar.onAction != null).isTrue() + cancelAndIgnoreRemainingEvents() + } + assertThat(viewModel.tabStates.value.getValue(CommentsRsListTab.ALL).isLoadingMore).isFalse() + } + + @Test + fun `fully deduplicated page auto-advances to the next page`() = test { + givenPage(listOf(rsItem(id = 1), rsItem(id = 2)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + // The next page duplicates the current rows entirely (server-side shift), then the + // page after that has the genuinely new comment. + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())).thenReturn( + RsCommentsPageResult.Success(listOf(rsItem(id = 1), rsItem(id = 2)), THIRD_PAGE), + RsCommentsPageResult.Success(listOf(rsItem(id = 3)), null) + ) + + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments.map { it.remoteCommentId }).containsExactly(1L, 2L, 3L) + assertThat(state.canLoadMore).isFalse() + } + + @Test + fun `an empty page with a remaining cursor auto-advances to the next page`() = test { + givenPage(listOf(rsItem(id = 1), rsItem(id = 2)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + // A middle page can come back empty (comments deleted server-side) while the + // headers still advertise more pages. + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())).thenReturn( + RsCommentsPageResult.Success(emptyList(), THIRD_PAGE), + RsCommentsPageResult.Success(listOf(rsItem(id = 3)), null) + ) + + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments.map { it.remoteCommentId }).containsExactly(1L, 2L, 3L) + assertThat(state.canLoadMore).isFalse() + } + + @Test + fun `auto-advance is capped when pages keep adding nothing`() = test { + givenPage(listOf(rsItem(id = 1)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + // Pathological cursor chain: every page dedupes away with a cursor still present. + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .thenReturn(RsCommentsPageResult.Success(listOf(rsItem(id = 1)), NEXT_PAGE)) + + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + // 1 init + 1 user loadMore + at most 3 auto-advances, then the chain stops. + verify(commentsRsDataSource, times(5)).fetchCommentsPage(eq(site), any()) + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.isLoadingMore).isFalse() + } + + @Test + fun `a user refresh retries cached post titles, a silent refresh does not`() = test { + givenPage(listOf(rsItem(id = 1))) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + viewModel.refreshTab(CommentsRsListTab.ALL) // silent + advanceUntilIdle() + verify(commentsRsDataSource, never()).clearPostTitles(any()) + + viewModel.refreshTab(CommentsRsListTab.ALL, isUserRefresh = true) + advanceUntilIdle() + verify(commentsRsDataSource).clearPostTitles(site) + } + + @Test + fun `a user refresh cancels an in-flight title resolve and fetches fresh titles`() = test { + givenPage(listOf(rsItem(id = 1, postId = 10))) + // The first resolve hangs mid-flight (holding pre-refresh results); the retry after + // the user refresh returns the fresh title. + whenever(commentsRsDataSource.fetchPostTitles(eq(site), any())) + .doSuspendableAnswer { delay(60_000); emptyMap() } + .thenReturn(mapOf(10L to "Fresh title")) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + runCurrent() // page applied; resolve job now suspended mid-flight + + viewModel.refreshTab(CommentsRsListTab.ALL, isUserRefresh = true) + advanceUntilIdle() + + // Without the cancel, the identical-ids guard defers to the hung pre-refresh job and + // the fresh title is never fetched. + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments.first().postTitle).isEqualTo("Fresh title") + verify(commentsRsDataSource, times(2)).fetchPostTitles(eq(site), any()) + } + + @Test + fun `loadMore is a no-op when there is no next page`() = test { + givenPage(listOf(rsItem(id = 1)), nextPageParams = null) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + viewModel.loadMore(CommentsRsListTab.ALL) + advanceUntilIdle() + + verify(commentsRsDataSource, times(1)).fetchCommentsPage(eq(site), any()) + } + + @Test + fun `refresh failure keeps the current comments and offers retry`() = test { + givenPage(listOf(rsItem(id = 1))) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .thenReturn(RsCommentsPageResult.Error("boom")) + + viewModel.snackbarMessages.test { + viewModel.refreshTab(CommentsRsListTab.ALL, isUserRefresh = true) + advanceUntilIdle() + + val snackbar = awaitItem() + assertThat(snackbar.message).isEqualTo("boom") + assertThat(snackbar.onAction != null).isTrue() + cancelAndIgnoreRemainingEvents() + } + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments).hasSize(1) + assertThat(state.error).isNull() + } + + @Test + fun `refreshTab is a no-op while another first-page fetch is in flight`() = test { + givenPage(listOf(rsItem(id = 1))) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .doSuspendableAnswer { delay(60_000); RsCommentsPageResult.Success(emptyList(), null) } + + viewModel.refreshTab(CommentsRsListTab.ALL) // suspends mid-flight + runCurrent() + viewModel.refreshTab(CommentsRsListTab.ALL, isUserRefresh = true) // overlaps: skipped + runCurrent() + + // 1 init + 1 first refresh; the overlapping refresh didn't fire a duplicate request + verify(commentsRsDataSource, times(2)).fetchCommentsPage(eq(site), any()) + } + + @Test + fun `a silent refresh failure on a populated tab shows no snackbar`() = test { + givenPage(listOf(rsItem(id = 1))) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .thenReturn(RsCommentsPageResult.Error("boom")) + + viewModel.snackbarMessages.test { + viewModel.refreshTab(CommentsRsListTab.ALL) // silent + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments).hasSize(1) + assertThat(state.error).isNull() + } + + @Test + fun `a refresh landing while a loadMore is in flight clears isLoadingMore`() = test { + givenPage(listOf(rsItem(id = 1)), nextPageParams = NEXT_PAGE) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + // The loadMore hangs mid-flight; the silent refresh completes immediately. + whenever(commentsRsDataSource.fetchCommentsPage(eq(site), any())) + .doSuspendableAnswer { delay(60_000); RsCommentsPageResult.Success(emptyList(), null) } + .thenReturn(RsCommentsPageResult.Success(listOf(rsItem(id = 10)), null)) + + viewModel.loadMore(CommentsRsListTab.ALL) + runCurrent() // loadMore suspended, isLoadingMore = true + viewModel.refreshTab(CommentsRsListTab.ALL) + runCurrent() // refresh applied while the loadMore is still in flight + + // The replacing page must clear the flag itself — the stale loadMore result is + // discarded by the generation check, so nothing else resets it promptly. + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.isLoadingMore).isFalse() + assertThat(state.comments.map { it.remoteCommentId }).containsExactly(10L) + } + + @Test + fun `tab changes are tracked once per change with the filter property`() = test { + whenever(resourceProvider.getString(CommentsRsListTab.ALL.trackingLabelResId)).thenReturn("All") + whenever(resourceProvider.getString(CommentsRsListTab.SPAM.trackingLabelResId)).thenReturn("Spam") + val viewModel = createViewModel() + + viewModel.onTabChanged(CommentsRsListTab.ALL) + viewModel.onTabChanged(CommentsRsListTab.ALL) // repeat: not tracked again + viewModel.onTabChanged(CommentsRsListTab.SPAM) + + verify(analyticsTracker, times(1)) + .track(Stat.COMMENT_FILTER_CHANGED, mapOf("selected_filter" to "All")) + verify(analyticsTracker, times(1)) + .track(Stat.COMMENT_FILTER_CHANGED, mapOf("selected_filter" to "Spam")) + } + + @Test + fun `refreshAllTabs refreshes only initialized tabs`() = test { + givenPage(listOf(rsItem(id = 1))) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + viewModel.initTab(CommentsRsListTab.SPAM) + advanceUntilIdle() + + viewModel.refreshAllTabs() + advanceUntilIdle() + + // 2 init fetches + 2 refresh fetches, nothing for the 3 uninitialized tabs + verify(commentsRsDataSource, times(4)).fetchCommentsPage(eq(site), any()) + } + + @Test + fun `onCommentClick emits OpenCommentDetail`() = test { + givenPage(listOf(rsItem(id = 42))) + val viewModel = createViewModel() + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + viewModel.events.test { + viewModel.onCommentClick(42L) + val event = awaitItem() + assertThat(event).isInstanceOf(CommentsRsListEvent.OpenCommentDetail::class.java) + assertThat((event as CommentsRsListEvent.OpenCommentDetail).remoteCommentId).isEqualTo(42L) + assertThat(event.site).isEqualTo(site) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `post titles are resolved in a batch and applied to rows`() = test { + givenPage(listOf(rsItem(id = 1, postId = 10), rsItem(id = 2, postId = 20))) + whenever(commentsRsDataSource.fetchPostTitles(site, listOf(10L, 20L))) + .thenReturn(mapOf(10L to "First post", 20L to "Second post")) + val viewModel = createViewModel() + + viewModel.initTab(CommentsRsListTab.ALL) + advanceUntilIdle() + + val state = viewModel.tabStates.value.getValue(CommentsRsListTab.ALL) + assertThat(state.comments.map { it.postTitle }).containsExactly("First post", "Second post") + } + + private fun rsItem(id: Long, postId: Long = 99L) = RsComment( + remoteCommentId = id, + authorName = "Jane", + authorAvatarUrl = "https://example.com/avatar.png", + dateGmt = Date(0), + contentHtml = "

hello

", + url = "https://example.com/post/#comment-$id", + postId = postId, + status = APPROVED + ) + + companion object { + private val FIRST_PAGE = CommentListParams() + private val NEXT_PAGE = CommentListParams(page = 2u) + private val THIRD_PAGE = CommentListParams(page = 3u) + } +}