diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt index 87cc7d7b0369..5872166e0c81 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt @@ -45,7 +45,7 @@ import javax.inject.Named import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker import org.wordpress.android.ui.utils.UiString -import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.ui.mysite.cards.connectivity.SiteConnectivityBannerViewModelSlice @Suppress("LargeClass", "LongMethod", "LongParameterList") class MySiteViewModel @Inject constructor( @@ -66,8 +66,8 @@ class MySiteViewModel @Inject constructor( private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice, private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice, private val siteCapabilityChecker: SiteCapabilityChecker, - private val editorSettingsRepository: EditorSettingsRepository, private val gutenbergEditorPreloader: GutenbergEditorPreloader, + private val siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() private val _onNavigation = MutableLiveData>() @@ -78,12 +78,6 @@ class MySiteViewModel @Inject constructor( as they're already built on site select. */ private var isSiteSelected = false - /* Editor capabilities rarely change, so once we've successfully fetched them for a site we - skip subsequent non-user-initiated fetches in this ViewModel session. Failed fetches do - not populate this set, so a transient network failure recovers on the next onResume. - User-initiated refreshes (e.g. pull-to-refresh) always bypass this gate. */ - private val fetchedCapabilitiesForSite = mutableSetOf() - val onScrollTo: MutableLiveData> = MutableLiveData() val onSnackbarMessage = merge( @@ -132,15 +126,17 @@ class MySiteViewModel @Inject constructor( applicationPasswordViewModelSlice.uiModel, accountDataViewModelSlice.uiModel, dashboardCardsViewModelSlice.uiModel, - dashboardItemsViewModelSlice.uiModel + dashboardItemsViewModelSlice.uiModel, + siteConnectivityBannerViewModelSlice.uiModel, ) { siteInfoHeaderCard, applicationPAsswordModel, accountData, dashboardCards, - siteItems -> + siteItems, + connectivityBanner -> val nonNullSiteInfoHeaderCard = siteInfoHeaderCard ?: return@merge buildNoSiteState(accountData?.url, accountData?.name) - val headerList = listOfNotNull(nonNullSiteInfoHeaderCard, applicationPAsswordModel) + val headerList = listOfNotNull(nonNullSiteInfoHeaderCard, applicationPAsswordModel, connectivityBanner) return@merge if (!dashboardCards.isNullOrEmpty()) SiteSelected(dashboardData = headerList + dashboardCards) else if (!siteItems.isNullOrEmpty()) @@ -156,6 +152,7 @@ class MySiteViewModel @Inject constructor( dashboardCardsViewModelSlice.initialize(viewModelScope) dashboardItemsViewModelSlice.initialize(viewModelScope) accountDataViewModelSlice.initialize(viewModelScope) + siteConnectivityBannerViewModelSlice.initialize(viewModelScope) } private fun shouldShowDashboard(site: SiteModel): Boolean { @@ -176,12 +173,10 @@ class MySiteViewModel @Inject constructor( siteCapabilityChecker.clearCacheForSite(site.siteId) } buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh) - launch { - fetchEditorCapabilitiesWithSnackbar( - site, - isUserInitiated = isPullToRefresh - ) - } + siteConnectivityBannerViewModelSlice.fetchCapabilities( + site, + isUserInitiated = isPullToRefresh + ) } ?: run { accountDataViewModelSlice.onRefresh() } @@ -193,45 +188,15 @@ class MySiteViewModel @Inject constructor( selectedSiteRepository.updateSiteSettingsIfNecessary() selectedSiteRepository.getSelectedSite()?.let { buildDashboardOrSiteItems(it) - launch { - fetchEditorCapabilitiesWithSnackbar( - it, - isUserInitiated = false - ) - } + siteConnectivityBannerViewModelSlice.fetchCapabilities( + it, + isUserInitiated = false + ) } ?: run { accountDataViewModelSlice.onResume() } } - private suspend fun fetchEditorCapabilitiesWithSnackbar( - site: SiteModel, - isUserInitiated: Boolean - ) { - if (site.id in fetchedCapabilitiesForSite && !isUserInitiated) { - return - } - val ok = editorSettingsRepository - .fetchEditorCapabilitiesForSite(site) - if (ok) { - fetchedCapabilitiesForSite.add(site.id) - } - val hasCache = editorSettingsRepository - .hasCachedCapabilities(site) - if (!ok && (isUserInitiated || !hasCache)) { - _onSnackbarMessage.postValue( - Event( - SnackbarMessageHolder( - UiString.UiStringRes( - R.string - .site_settings_fetch_failed - ) - ) - ) - ) - } - } - private fun checkAndShowJetpackFullPluginInstallOnboarding() { selectedSiteRepository.getSelectedSite()?.let { selectedSite -> if (getShowJetpackFullPluginInstallOnboardingUseCase.execute(selectedSite)) { @@ -352,6 +317,7 @@ class MySiteViewModel @Inject constructor( private fun onSitePicked(site: SiteModel) { siteInfoHeaderCardViewModelSlice.buildCard(site) applicationPasswordViewModelSlice.buildCard(site) + siteConnectivityBannerViewModelSlice.clearBanner() dashboardItemsViewModelSlice.clearValue() dashboardCardsViewModelSlice.clearValue() dashboardCardsViewModelSlice.resetShownTracker() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt new file mode 100644 index 000000000000..4ea71a4ac1a8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt @@ -0,0 +1,78 @@ +package org.wordpress.android.ui.mysite.cards.connectivity + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.ui.mysite.MySiteCardAndItem +import org.wordpress.android.util.NetworkUtilsWrapper +import javax.inject.Inject + +class SiteConnectivityBannerViewModelSlice @Inject constructor( + private val editorSettingsRepository: EditorSettingsRepository, + private val networkUtilsWrapper: NetworkUtilsWrapper, +) { + private lateinit var scope: CoroutineScope + private var currentJob: Job? = null + private var currentSite: SiteModel? = null + + private val _uiModel = MutableLiveData() + val uiModel: LiveData = _uiModel + + /* Site capabilities rarely change, so once we've successfully fetched them for a site we + skip subsequent non-user-initiated fetches in this slice's lifetime. Failed fetches do + not populate this set, so a transient network failure recovers on the next onResume. + User-initiated calls (PTR, banner retry) always bypass this gate. */ + private val fetchedCapabilitiesForSite = mutableSetOf() + + fun initialize(scope: CoroutineScope) { + this.scope = scope + } + + fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) { + currentJob?.cancel() + currentSite = site + currentJob = scope.launch { + if (site.id in fetchedCapabilitiesForSite && !isUserInitiated) { + return@launch + } + val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) + if (ok) { + fetchedCapabilitiesForSite.add(site.id) + } + val hasCache = editorSettingsRepository.hasCachedCapabilities(site) + // Bail if the user switched sites while we were suspended — postValue + // isn't a suspension point, so cancellation alone won't catch this. + if (currentSite?.id != site.id) return@launch + // Suppress the banner when the device is offline — the global "no + // connection" banner already covers this case, and stacking warnings + // for the same root cause is just noise. + val suppressForOffline = !ok && !networkUtilsWrapper.isNetworkAvailable() + _uiModel.postValue(if (ok || hasCache || suppressForOffline) null else buildBanner()) + } + } + + fun clearBanner() { + currentJob?.cancel() + currentSite = null + _uiModel.postValue(null) + } + + private fun buildBanner(): MySiteCardAndItem.Item.SingleActionCard = + MySiteCardAndItem.Item.SingleActionCard( + textResource = R.string.site_connectivity_banner_text, + imageResource = R.drawable.ic_cloud_off_themed_24dp, + onActionClick = { + val site = currentSite + if (site != null && currentJob?.isActive != true) { + fetchCapabilities(site, isUserInitiated = true) + } + }, + showLearnMore = false, + centerImageVertically = true, + ) +} diff --git a/WordPress/src/main/res/drawable/ic_cloud_off_themed_24dp.xml b/WordPress/src/main/res/drawable/ic_cloud_off_themed_24dp.xml new file mode 100644 index 000000000000..66687d97baf3 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_cloud_off_themed_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 71931f0c758f..d40fae768691 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -705,7 +705,7 @@ Use Third-Party Blocks (Beta) Load third-party blocks from plugins installed on your site. Your site doesn\'t support loading third-party blocks in the editor. - Failed to fetch site settings – some editor functionality may be limited. + Unable to connect to your site. Some functionality might be limited. Password updated To reconnect the app to your self-hosted site, enter the site\'s new password here. Homepage Settings diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt index 9003170f0006..123f9f3bfad1 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt @@ -14,7 +14,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -40,7 +39,7 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewModelSlice import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker -import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.ui.mysite.cards.connectivity.SiteConnectivityBannerViewModelSlice import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource @@ -106,7 +105,7 @@ class MySiteViewModelTest : BaseUnitTest() { lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader @Mock - lateinit var editorSettingsRepository: EditorSettingsRepository + lateinit var siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice private lateinit var viewModel: MySiteViewModel private lateinit var uiModels: MutableList @@ -143,7 +142,7 @@ class MySiteViewModelTest : BaseUnitTest() { whenever(dashboardCardsViewModelSlice.uiModel).thenReturn(MutableLiveData()) whenever(dashboardItemsViewModelSlice.uiModel).thenReturn(MutableLiveData()) whenever(applicationPasswordViewModelSlice.uiModel).thenReturn(MutableLiveData()) - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenReturn(true) + whenever(siteConnectivityBannerViewModelSlice.uiModel).thenReturn(MutableLiveData()) viewModel = MySiteViewModel( testDispatcher(), @@ -163,8 +162,8 @@ class MySiteViewModelTest : BaseUnitTest() { dashboardItemsViewModelSlice, applicationPasswordViewModelSlice, siteCapabilityChecker, - editorSettingsRepository, gutenbergEditorPreloader, + siteConnectivityBannerViewModelSlice, ) uiModels = mutableListOf() snackbars = mutableListOf() @@ -362,91 +361,6 @@ class MySiteViewModelTest : BaseUnitTest() { verify(dashboardCardsViewModelSlice).clearValue() } - @Test - fun `given selected site, when onResume invoked twice, then editor capabilities are fetched once`() = test { - initSelectedSite() - - viewModel.onResume() - advanceUntilIdle() - viewModel.onResume() - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) - } - - @Test - fun `given selected site, when onResume then non-PTR refresh, then editor capabilities are fetched once`() = - test { - initSelectedSite() - - viewModel.onResume() - advanceUntilIdle() - viewModel.refresh(isPullToRefresh = false) - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) - } - - @Test - fun `given selected site, when onResume then PTR refresh, then editor capabilities are fetched twice`() = test { - initSelectedSite() - - viewModel.onResume() - advanceUntilIdle() - viewModel.refresh(isPullToRefresh = true) - advanceUntilIdle() - - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) - } - - @Test - fun `given PTR refresh, when onResume invoked after, then editor capabilities are not re-fetched`() = test { - initSelectedSite() - - viewModel.refresh(isPullToRefresh = true) - advanceUntilIdle() - viewModel.onResume() - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) - } - - @Test - fun `given fetch failed, when onResume invoked again, then editor capabilities are re-fetched`() = test { - initSelectedSite() - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false, true) - - viewModel.onResume() - advanceUntilIdle() - viewModel.onResume() - advanceUntilIdle() - - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) - } - - @Test - fun `given site switched, when onResume invoked, then editor capabilities are fetched for the new site`() = - test { - initSelectedSite() - val otherSite = SiteModel().apply { - id = TEST_SITE_ID + 1 - url = TEST_URL - name = TEST_SITE_NAME - siteId = (TEST_SITE_ID + 1).toLong() - } - - viewModel.onResume() - advanceUntilIdle() - whenever(selectedSiteRepository.getSelectedSite()).thenReturn(otherSite) - viewModel.onResume() - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(otherSite) - } - - - /* LAND ON THE EDITOR A/B EXPERIMENT */ @Test fun `when performFirstStepAfterSiteCreation called, then home page editor is shown`() = test { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt new file mode 100644 index 000000000000..84cd2448fa42 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt @@ -0,0 +1,259 @@ +package org.wordpress.android.ui.mysite.cards.connectivity + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.lenient +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +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.fluxc.model.SiteModel +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.ui.mysite.MySiteCardAndItem +import org.wordpress.android.util.NetworkUtilsWrapper + +private const val TEST_SITE_LOCAL_ID = 42 + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + + @Mock + lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + private lateinit var siteTest: SiteModel + private lateinit var slice: SiteConnectivityBannerViewModelSlice + private val emittedBanners = mutableListOf() + + @Before + fun setUp() { + siteTest = SiteModel().apply { id = TEST_SITE_LOCAL_ID } + // Default network state is available; tests that need offline override per-test. Lenient + // because tests where the fetch succeeds never reach the network check. + lenient().`when`(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + slice = SiteConnectivityBannerViewModelSlice(editorSettingsRepository, networkUtilsWrapper) + slice.initialize(testScope()) + slice.uiModel.observeForever { emittedBanners.add(it) } + } + + @Test + fun `given fetch succeeds, when fetchCapabilities invoked, then banner is null`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given fetch fails with no cache, when fetchCapabilities invoked, then banner is shown`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard + assertThat(banner.textResource).isEqualTo(R.string.site_connectivity_banner_text) + assertThat(banner.showLearnMore).isFalse + } + + @Test + fun `given fetch fails with no cache but device offline, when fetchCapabilities invoked, then banner is null`() = + test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + // Global offline indicator covers this case — suppress to avoid stacked warnings. + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given fetch fails but cache exists, when fetchCapabilities invoked, then banner is null`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(true) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given prior successful fetch, when fetchCapabilities invoked again non-user-initiated, then fetch skipped`() = + test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) + } + + @Test + fun `given prior failed fetch, when fetchCapabilities invoked again non-user-initiated, then fetch retries`() = + test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false, true) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + assertThat(emittedBanners.last()).isNotNull + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given prior successful fetch, when user-initiated fetchCapabilities invoked, then fetch runs again`() = + test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + slice.fetchCapabilities(siteTest, isUserInitiated = true) + advanceUntilIdle() + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) + } + + @Test + fun `given banner showing, when retry tapped, then fetch runs and bypasses session dedup`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard + + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + banner.onActionClick() + advanceUntilIdle() + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `when clearBanner invoked, then banner is null`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + assertThat(emittedBanners.last()).isNotNull + slice.clearBanner() + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given two different sites, when fetched in sequence, then both fetches run`() = test { + val otherSite = SiteModel().apply { id = TEST_SITE_LOCAL_ID + 1 } + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(otherSite)).thenReturn(true) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + slice.fetchCapabilities(otherSite, isUserInitiated = false) + advanceUntilIdle() + + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(eq(siteTest)) + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(eq(otherSite)) + } + + @Test + fun `given fetch in flight, when clearBanner invoked, then banner stays null after fetch completes`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + slice.clearBanner() + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given retry in flight, when banner tapped again, then second tap is a no-op`() = test { + val gate = CompletableDeferred() + var callCount = 0 + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).doSuspendableAnswer { + callCount++ + if (callCount == 1) false else gate.await() + } + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard + + banner.onActionClick() // first tap — retry suspends on gate + banner.onActionClick() // second tap — should be ignored + gate.complete(true) + advanceUntilIdle() + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) + } + + @Test + fun `given banner cleared, when retry tapped, then no fetch runs`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard + slice.clearBanner() + advanceUntilIdle() + + // Simulate a tap that landed before LiveData propagated the null clear. + banner.onActionClick() + advanceUntilIdle() + + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) + } + + @Test + fun `given fetch in flight for site A, when fetch starts for site B, then site A result is discarded`() = test { + val siteB = SiteModel().apply { id = TEST_SITE_LOCAL_ID + 1 } + val gateA = CompletableDeferred() + // Site A's fetch suspends on a gate so we can interleave site B's call before A completes. + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).doSuspendableAnswer { + gateA.await() + } + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteB)).thenReturn(true) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() // siteA suspended in fetch + slice.fetchCapabilities(siteB, isUserInitiated = false) + advanceUntilIdle() // siteB completes; currentSite is now siteB + gateA.complete(false) // release siteA — its result must NOT post a banner + advanceUntilIdle() + + // No banner card should ever have been emitted for site A. + assertThat(emittedBanners.filterIsInstance()).isEmpty() + } +}