From 5ce963c1654e6f144447b0f2b8d44e48cce49fbe Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Fri, 7 Aug 2026 12:32:22 -0600
Subject: [PATCH 1/3] Reader: resolve the post-detail load to content or an
error
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening a Reader post could hang on a spinner (or a blank body) indefinitely. `getOrFetchReaderPost()` decided whether to show cached content — and whether to surface a fetch error — based on `post != null`. A post cached from the feed is often header-only (its body hasn't been fetched), so a failed or stalled body fetch was silently swallowed and the screen was left spinning with no error.
Gate the optimistic render on `post?.hasText()` (a renderable body) so a header-only post shows the managed loading spinner instead of the unmanaged WebView progress bar, and surface an error on every non-success outcome — including a `Success` that yields no post. The load now always terminates in content or an error.
CMM-2254
---
.../viewmodels/ReaderPostDetailViewModel.kt | 26 +++++++-----
.../ReaderPostDetailViewModelTest.kt | 40 +++++++++++++++++++
2 files changed, 56 insertions(+), 10 deletions(-)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
index b8cc6d50dd03..15285d1a1864 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
@@ -391,9 +391,11 @@ class ReaderPostDetailViewModel @Inject constructor(
private suspend fun getOrFetchReaderPost(blogId: Long, postId: Long) {
getReaderPostFromDb(blogId = blogId, postId = postId)
- // Show cached content immediately if available, otherwise show loading
- val hasCachedPost = post != null
- if (hasCachedPost) {
+ // Only render the cached copy immediately if it actually has body content. A
+ // header-only post (whose body hasn't been fetched yet) would otherwise leave the
+ // user staring at a blank article, so keep the loading state until the fetch lands.
+ val hasRenderableContent = post?.hasText() == true
+ if (hasRenderableContent) {
updatePostDetailsUi()
} else {
_uiState.value = LoadingUiState
@@ -404,39 +406,43 @@ class ReaderPostDetailViewModel @Inject constructor(
when (readerFetchPostUseCase.fetchPost(blogId = blogId, postId = postId, isFeed = isFeed)) {
FetchReaderPostState.Success -> {
getReaderPostFromDb(blogId, postId)
- // Update UI if content changed, or we didn't have cached content
- if (!hasCachedPost || post?.text != oldPostText) {
+ if (post == null) {
+ // The fetch reported success but produced no post to render; surface an
+ // error instead of leaving the loading spinner up forever.
+ _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic))
+ } else if (!hasRenderableContent || post?.text != oldPostText) {
+ // Update UI if content changed, or we didn't already have renderable content
updatePostDetailsUi()
}
}
FetchReaderPostState.AlreadyRunning -> {
- if (!hasCachedPost) {
+ if (!hasRenderableContent) {
AppLog.i(T.READER, "reader post detail > fetch post already running")
_uiState.value = ErrorUiState(null)
}
}
FetchReaderPostState.Failed.NoNetwork -> {
- if (!hasCachedPost) {
+ if (!hasRenderableContent) {
_uiState.value = ErrorUiState(UiStringRes(R.string.no_network_message))
}
}
FetchReaderPostState.Failed.RequestFailed -> {
- if (!hasCachedPost) {
+ if (!hasRenderableContent) {
_uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic))
}
}
FetchReaderPostState.Failed.NotAuthorised -> {
- if (!hasCachedPost) {
+ if (!hasRenderableContent) {
trackAndUpdateNotAuthorisedErrorState()
}
}
FetchReaderPostState.Failed.PostNotFound -> {
- if (!hasCachedPost) {
+ if (!hasRenderableContent) {
_uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_not_found))
}
}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
index e470d5703b46..a340cd4f57a4 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
@@ -481,6 +481,45 @@ class ReaderPostDetailViewModelTest : BaseUnitTest() {
.isEqualTo(ErrorUiState(UiStringRes(R.string.reader_err_get_post_not_found)))
}
+ /* SHOW POST - LOAD ALWAYS TERMINATES (CMM-2254) */
+ @Test
+ fun `given cached post has no body, when show post is triggered, then loading state is shown`() = test {
+ val bodylessPost = createDummyReaderPost(readerPost.postId).apply { text = null }
+ whenever(readerGetPostUseCase.get(anyLong(), anyLong(), anyBoolean())).thenReturn(Pair(bodylessPost, false))
+ val observers = init(showPost = false)
+
+ viewModel.onShowPost(blogId = bodylessPost.blogId, postId = bodylessPost.postId)
+
+ assertThat(observers.uiStates.first()).isEqualTo(LoadingUiState)
+ }
+
+ @Test
+ fun `given cached post has no body and fetch fails, when show post is triggered, then error is shown`() = test {
+ val bodylessPost = createDummyReaderPost(readerPost.postId).apply { text = null }
+ whenever(readerGetPostUseCase.get(anyLong(), anyLong(), anyBoolean())).thenReturn(Pair(bodylessPost, false))
+ whenever(readerFetchPostUseCase.fetchPost(anyLong(), anyLong(), anyBoolean()))
+ .thenReturn(Failed.RequestFailed)
+ val observers = init(showPost = false)
+
+ viewModel.onShowPost(blogId = bodylessPost.blogId, postId = bodylessPost.postId)
+
+ assertThat(observers.uiStates.last())
+ .isEqualTo(ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic)))
+ }
+
+ @Test
+ fun `given fetch succeeds but post is still missing, when show post is triggered, then error is shown`() =
+ testWithoutLocalPost {
+ whenever(readerFetchPostUseCase.fetchPost(anyLong(), anyLong(), anyBoolean()))
+ .thenReturn(FetchReaderPostState.Success)
+ val observers = init(showPost = false)
+
+ viewModel.onShowPost(blogId = readerPost.blogId, postId = readerPost.postId)
+
+ assertThat(observers.uiStates.last())
+ .isEqualTo(ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic)))
+ }
+
@Test
fun `given unauthorised, when post is fetched, then error ui is shown`() = testWithoutLocalPost {
whenever(readerFetchPostUseCase.fetchPost(readerPost.blogId, readerPost.postId, viewModel.isFeed))
@@ -1241,6 +1280,7 @@ class ReaderPostDetailViewModelTest : BaseUnitTest() {
this.blogId = id * 100
this.feedId = id * 1000
this.title = "DummyPost"
+ this.text = "
Dummy post content
"
this.featuredVideo = id.toString()
this.featuredImage = "/featured_image/$id/url"
this.isExternal = !isWpComPost
From 9cf4ac6574ec0334e671a9609b1e963f56ad459e Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Fri, 7 Aug 2026 12:32:30 -0600
Subject: [PATCH 2/3] Reader: bound the post fetch with a timeout and leak-free
dedup
Back the detail-load fix with a fetch that can't hang: wrap the request in `withTimeoutOrNull` (30s) so a stalled connection resolves to RequestFailed instead of suspending forever.
Track in-flight requests in a Set released in a `finally` so a request that is cancelled mid-flight (user backs out) or times out no longer leaves a sticky `AlreadyRunning`. The request callback now resumes its own continuation guarded by `isActive`, so a late callback can't resume a later retry of the same post.
CMM-2254
---
.../reader/usecases/ReaderFetchPostUseCase.kt | 74 +++++++++++--------
.../usecases/ReaderFetchPostUseCaseTest.kt | 22 ++++++
2 files changed, 66 insertions(+), 30 deletions(-)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt
index aec69120a459..dcbf390cecbd 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt
@@ -1,6 +1,7 @@
package org.wordpress.android.ui.reader.usecases
import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeoutOrNull
import org.wordpress.android.ui.reader.actions.ReaderActions
import org.wordpress.android.ui.reader.actions.ReaderPostActionsWrapper
import org.wordpress.android.ui.reader.usecases.ReaderFetchPostUseCase.FetchReaderPostState.AlreadyRunning
@@ -11,50 +12,60 @@ import org.wordpress.android.ui.reader.usecases.ReaderFetchPostUseCase.FetchRead
import org.wordpress.android.ui.reader.usecases.ReaderFetchPostUseCase.FetchReaderPostState.Success
import org.wordpress.android.util.NetworkUtilsWrapper
import java.net.HttpURLConnection
+import java.util.Collections
import javax.inject.Inject
-import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class ReaderFetchPostUseCase @Inject constructor(
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val readerPostActionsWrapper: ReaderPostActionsWrapper
) {
- private val continuations: MutableMap?> = mutableMapOf()
+ // Tracks in-flight fetches so a second request for the same post short-circuits to
+ // AlreadyRunning. Synchronized because the request callbacks fire on a background thread.
+ private val inFlightRequests = Collections.synchronizedSet(mutableSetOf())
suspend fun fetchPost(blogId: Long, postId: Long, isFeed: Boolean): FetchReaderPostState {
- return if (!networkUtilsWrapper.isNetworkAvailable()) {
- NoNetwork
- } else {
- val requestParams = FetchPostRequestParams(blogId, postId, isFeed)
- // There is already an action running for this request
- if (continuations[requestParams] != null) {
- AlreadyRunning
- } else {
- when (fetchPostAndWaitForResult(requestParams)) {
- HttpURLConnection.HTTP_OK -> Success
- HttpURLConnection.HTTP_UNAUTHORIZED, HttpURLConnection.HTTP_FORBIDDEN -> NotAuthorised
- HttpURLConnection.HTTP_NOT_FOUND -> PostNotFound
- else -> RequestFailed
- }
- }
+ if (!networkUtilsWrapper.isNetworkAvailable()) {
+ return NoNetwork
}
- }
- private suspend fun fetchPostAndWaitForResult(requestParams: FetchPostRequestParams): Int {
- val listener = object : ReaderActions.OnRequestListener {
- override fun onSuccess(result: String?) {
- continuations[requestParams]?.resume(HttpURLConnection.HTTP_OK)
- continuations[requestParams] = null
- }
+ val requestParams = FetchPostRequestParams(blogId, postId, isFeed)
+ // add() returns false when an identical request is already in flight
+ if (!inFlightRequests.add(requestParams)) {
+ return AlreadyRunning
+ }
- override fun onFailure(statusCode: Int) {
- continuations[requestParams]?.resume(statusCode)
- continuations[requestParams] = null
+ return try {
+ // Never wait forever: a request that neither succeeds nor fails (e.g. a stalled
+ // connection) resolves to RequestFailed so the caller can leave the loading state.
+ val statusCode = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
+ fetchPostAndWaitForResult(requestParams)
}
+ when (statusCode) {
+ HttpURLConnection.HTTP_OK -> Success
+ HttpURLConnection.HTTP_UNAUTHORIZED, HttpURLConnection.HTTP_FORBIDDEN -> NotAuthorised
+ HttpURLConnection.HTTP_NOT_FOUND -> PostNotFound
+ else -> RequestFailed
+ }
+ } finally {
+ // Always release the slot, including on timeout or coroutine cancellation, so a
+ // later retry of the same post isn't wrongly rejected as AlreadyRunning.
+ inFlightRequests.remove(requestParams)
}
+ }
- return suspendCancellableCoroutine { cont ->
- continuations[requestParams] = cont
+ private suspend fun fetchPostAndWaitForResult(requestParams: FetchPostRequestParams): Int =
+ suspendCancellableCoroutine { cont ->
+ val listener = object : ReaderActions.OnRequestListener {
+ override fun onSuccess(result: String?) {
+ // Guard against resuming an already-cancelled (e.g. timed-out) request
+ if (cont.isActive) cont.resume(HttpURLConnection.HTTP_OK)
+ }
+
+ override fun onFailure(statusCode: Int) {
+ if (cont.isActive) cont.resume(statusCode)
+ }
+ }
if (requestParams.isFeed) {
readerPostActionsWrapper.requestFeedPost(
@@ -70,7 +81,6 @@ class ReaderFetchPostUseCase @Inject constructor(
)
}
}
- }
sealed class FetchReaderPostState {
object Success : FetchReaderPostState()
@@ -88,4 +98,8 @@ class ReaderFetchPostUseCase @Inject constructor(
val postId: Long,
val isFeed: Boolean
)
+
+ companion object {
+ private const val FETCH_TIMEOUT_MS = 30_000L
+ }
}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCaseTest.kt
index d03add7c7650..a64a8b36e5e3 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCaseTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCaseTest.kt
@@ -1,6 +1,8 @@
package org.wordpress.android.ui.reader.usecases
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
@@ -13,6 +15,7 @@ import org.mockito.kotlin.whenever
import org.wordpress.android.BaseUnitTest
import org.wordpress.android.ui.reader.actions.ReaderActions
import org.wordpress.android.ui.reader.actions.ReaderPostActionsWrapper
+import org.wordpress.android.ui.reader.usecases.ReaderFetchPostUseCase.FetchReaderPostState.AlreadyRunning
import org.wordpress.android.ui.reader.usecases.ReaderFetchPostUseCase.FetchReaderPostState.Failed
import org.wordpress.android.ui.reader.usecases.ReaderFetchPostUseCase.FetchReaderPostState.Success
import org.wordpress.android.util.NetworkUtilsWrapper
@@ -132,4 +135,23 @@ class ReaderFetchPostUseCaseTest : BaseUnitTest() {
assertThat(result).isEqualTo(Failed.RequestFailed)
}
+
+ @Test
+ fun `given the request never responds, when reader post is fetched, then request failed is returned`() = test {
+ // The request mock never invokes its listener, so resolution relies on the timeout
+ val result = useCase.fetchPost(postId = postId, blogId = blogId, isFeed = false)
+
+ assertThat(result).isEqualTo(Failed.RequestFailed)
+ }
+
+ @Test
+ fun `given a request for the same post is running, when fetched again, then already running is returned`() = test {
+ // The first request stays in flight because its listener is never invoked
+ val firstRequest = launch { useCase.fetchPost(postId = postId, blogId = blogId, isFeed = false) }
+
+ val secondResult = useCase.fetchPost(postId = postId, blogId = blogId, isFeed = false)
+
+ assertThat(secondResult).isEqualTo(AlreadyRunning)
+ firstRequest.cancelAndJoin()
+ }
}
From 4092cc69797fcb2d4f482f38cb68429b5ab6dad1 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Fri, 7 Aug 2026 15:03:08 -0600
Subject: [PATCH 3/3] Reader: guard empty-body loads and hold loading on
concurrent fetch
Follow-ups from reviewing the CMM-2254 fetch changes:
- The `Success` path now applies the same `hasText()` renderability test
as the loading gate. A fetch that succeeds but leaves no post, or a
post with an empty body, surfaces a retryable error instead of a blank
article; content already on screen is kept, not clobbered.
- `AlreadyRunning` with nothing rendered stays on `LoadingUiState`
instead of `ErrorUiState(null)`, which blanked the screen entirely on a
concurrent re-entry (e.g. rotation mid-fetch).
Folds the per-branch `!hasRenderableContent` guard into
`renderFetchedPost` / `showFetchOutcomeWithoutContent` (dropping the
`CyclomaticComplexMethod` suppress) and suppresses `ReturnCount` on the
`fetchPost` guard clauses.
---
.../reader/usecases/ReaderFetchPostUseCase.kt | 1 +
.../viewmodels/ReaderPostDetailViewModel.kt | 85 ++++++++++---------
.../ReaderPostDetailViewModelTest.kt | 36 +++++++-
3 files changed, 80 insertions(+), 42 deletions(-)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt
index dcbf390cecbd..7047cee8bd4b 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/ReaderFetchPostUseCase.kt
@@ -24,6 +24,7 @@ class ReaderFetchPostUseCase @Inject constructor(
// AlreadyRunning. Synchronized because the request callbacks fire on a background thread.
private val inFlightRequests = Collections.synchronizedSet(mutableSetOf())
+ @Suppress("ReturnCount") // early guard clauses (no network / already in flight) read cleaner than nesting
suspend fun fetchPost(blogId: Long, postId: Long, isFeed: Boolean): FetchReaderPostState {
if (!networkUtilsWrapper.isNetworkAvailable()) {
return NoNetwork
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
index 15285d1a1864..1f5776e6675e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt
@@ -387,7 +387,6 @@ class ReaderPostDetailViewModel @Inject constructor(
launch { getOrFetchReaderPost(blogId = blogId, postId = postId) }
}
- @Suppress("CyclomaticComplexMethod")
private suspend fun getOrFetchReaderPost(blogId: Long, postId: Long) {
getReaderPostFromDb(blogId = blogId, postId = postId)
@@ -403,49 +402,55 @@ class ReaderPostDetailViewModel @Inject constructor(
// Always fetch fresh content from the server
val oldPostText = post?.text
- when (readerFetchPostUseCase.fetchPost(blogId = blogId, postId = postId, isFeed = isFeed)) {
- FetchReaderPostState.Success -> {
- getReaderPostFromDb(blogId, postId)
- if (post == null) {
- // The fetch reported success but produced no post to render; surface an
- // error instead of leaving the loading spinner up forever.
- _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic))
- } else if (!hasRenderableContent || post?.text != oldPostText) {
- // Update UI if content changed, or we didn't already have renderable content
- updatePostDetailsUi()
- }
- }
-
- FetchReaderPostState.AlreadyRunning -> {
- if (!hasRenderableContent) {
- AppLog.i(T.READER, "reader post detail > fetch post already running")
- _uiState.value = ErrorUiState(null)
- }
- }
-
- FetchReaderPostState.Failed.NoNetwork -> {
- if (!hasRenderableContent) {
- _uiState.value = ErrorUiState(UiStringRes(R.string.no_network_message))
- }
- }
-
- FetchReaderPostState.Failed.RequestFailed -> {
- if (!hasRenderableContent) {
- _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic))
- }
- }
+ val fetchState = readerFetchPostUseCase.fetchPost(blogId = blogId, postId = postId, isFeed = isFeed)
+ if (fetchState is FetchReaderPostState.Success) {
+ renderFetchedPost(blogId, postId, hadRenderableContent = hasRenderableContent, oldPostText = oldPostText)
+ } else if (!hasRenderableContent) {
+ // A non-success outcome only changes the UI when there's nothing on screen yet; a
+ // rendered post is kept rather than torn down by a failed background refresh.
+ showFetchOutcomeWithoutContent(fetchState)
+ }
+ }
- FetchReaderPostState.Failed.NotAuthorised -> {
- if (!hasRenderableContent) {
- trackAndUpdateNotAuthorisedErrorState()
- }
+ private suspend fun renderFetchedPost(
+ blogId: Long,
+ postId: Long,
+ hadRenderableContent: Boolean,
+ oldPostText: String?
+ ) {
+ getReaderPostFromDb(blogId, postId)
+ // Apply the same renderability test as the loading gate: a fetch that "succeeds" but
+ // leaves us with no post, or a post whose body is still empty, must not fall through
+ // to a blank article — that's the CMM-2254 symptom.
+ if (post?.hasText() == true) {
+ // Render when the body changed, or we didn't already have renderable content
+ if (!hadRenderableContent || post?.text != oldPostText) {
+ updatePostDetailsUi()
}
+ } else if (!hadRenderableContent) {
+ // Success but nothing renderable (a missing post or an empty body). Surface an
+ // error rather than a blank article or an endless spinner.
+ _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic))
+ }
+ }
- FetchReaderPostState.Failed.PostNotFound -> {
- if (!hasRenderableContent) {
- _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_not_found))
- }
+ private fun showFetchOutcomeWithoutContent(fetchState: FetchReaderPostState) {
+ when (fetchState) {
+ FetchReaderPostState.AlreadyRunning -> {
+ // A sibling fetch for this post is already in flight (e.g. a re-entry after a
+ // config change); stay on loading and let it resolve the UI.
+ AppLog.i(T.READER, "reader post detail > fetch post already running")
+ _uiState.value = LoadingUiState
}
+ FetchReaderPostState.Failed.NoNetwork ->
+ _uiState.value = ErrorUiState(UiStringRes(R.string.no_network_message))
+ FetchReaderPostState.Failed.RequestFailed ->
+ _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic))
+ FetchReaderPostState.Failed.NotAuthorised ->
+ trackAndUpdateNotAuthorisedErrorState()
+ FetchReaderPostState.Failed.PostNotFound ->
+ _uiState.value = ErrorUiState(UiStringRes(R.string.reader_err_get_post_not_found))
+ FetchReaderPostState.Success -> Unit // handled by renderFetchedPost
}
}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
index a340cd4f57a4..47071097af03 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModelTest.kt
@@ -454,7 +454,7 @@ class ReaderPostDetailViewModelTest : BaseUnitTest() {
}
@Test
- fun `given request already running, when post is fetched, then no error is shown`() =
+ fun `given request already running, when post is fetched, then loading is kept and no error is shown`() =
testWithoutLocalPost {
whenever(readerFetchPostUseCase.fetchPost(readerPost.blogId, readerPost.postId, viewModel.isFeed))
.thenReturn(FetchReaderPostState.Success)
@@ -464,7 +464,9 @@ class ReaderPostDetailViewModelTest : BaseUnitTest() {
viewModel.onShowPost(blogId = readerPost.blogId, postId = readerPost.postId)
- assertThat(observers.uiStates.filterIsInstance().last().message).isNull()
+ // A sibling fetch is still in flight, so we stay on loading rather than showing
+ // a (message-less) error that would blank the screen mid-load.
+ assertThat(observers.uiStates.last()).isEqualTo(LoadingUiState)
}
@Test
@@ -520,6 +522,36 @@ class ReaderPostDetailViewModelTest : BaseUnitTest() {
.isEqualTo(ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic)))
}
+ @Test
+ fun `given fetch succeeds but body is empty, when show post is triggered, then error is shown`() = test {
+ val bodylessPost = createDummyReaderPost(readerPost.postId).apply { text = null }
+ whenever(readerGetPostUseCase.get(anyLong(), anyLong(), anyBoolean())).thenReturn(Pair(bodylessPost, false))
+ whenever(readerFetchPostUseCase.fetchPost(anyLong(), anyLong(), anyBoolean()))
+ .thenReturn(FetchReaderPostState.Success)
+ val observers = init(showPost = false)
+
+ viewModel.onShowPost(blogId = bodylessPost.blogId, postId = bodylessPost.postId)
+
+ assertThat(observers.uiStates.last())
+ .isEqualTo(ErrorUiState(UiStringRes(R.string.reader_err_get_post_generic)))
+ }
+
+ @Test
+ fun `given rendered post and empty refetch, when show post is triggered, then content is kept`() = test {
+ val bodylessPost = createDummyReaderPost(readerPost.postId).apply { text = null }
+ whenever(readerGetPostUseCase.get(anyLong(), anyLong(), anyBoolean()))
+ .thenReturn(Pair(readerPost, false))
+ .thenReturn(Pair(bodylessPost, false))
+ whenever(readerFetchPostUseCase.fetchPost(anyLong(), anyLong(), anyBoolean()))
+ .thenReturn(FetchReaderPostState.Success)
+ val observers = init(showPost = false)
+
+ viewModel.onShowPost(blogId = readerPost.blogId, postId = readerPost.postId)
+
+ assertThat(observers.uiStates.last()).isInstanceOf(ReaderPostDetailsUiState::class.java)
+ assertThat(observers.uiStates.none { it is ErrorUiState }).isTrue
+ }
+
@Test
fun `given unauthorised, when post is fetched, then error ui is shown`() = testWithoutLocalPost {
whenever(readerFetchPostUseCase.fetchPost(readerPost.blogId, readerPost.postId, viewModel.isFeed))