diff --git a/feature-deep-linking/build.gradle b/feature-deep-linking/build.gradle index f105e177..c5f6b3b9 100644 --- a/feature-deep-linking/build.gradle +++ b/feature-deep-linking/build.gradle @@ -31,6 +31,8 @@ dependencies { implementation daggerDep ksp daggerCompiler + testImplementation project(':test-shared') + androidTestImplementation androidTestRunnerDep androidTestImplementation androidTestRulesDep androidTestImplementation androidJunitDep diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt index 30f58e1c..7fa5446b 100644 --- a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt @@ -2,6 +2,7 @@ package io.novafoundation.nova.feature_deep_linking.presentation.handling import android.net.Uri import io.novafoundation.nova.common.utils.onFailureInstance +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.merge @@ -23,12 +24,18 @@ class RootDeepLinkHandler( return handleDeepLinkInternal(pendingDeepLink) .onSuccess { pendingDeepLinkProvider.clear() } + // A pending link that reaches a handler but fails with a terminal error (malformed/unresolvable), + // or matches no handler, will never succeed on retry - clear it so it does not replay on every launch. + .onFailureInstance { pendingDeepLinkProvider.clear() } + .onFailureInstance { pendingDeepLinkProvider.clear() } } override suspend fun handleDeepLink(data: Uri): Result { pendingDeepLinkProvider.save(data) return handleDeepLinkInternal(data) .onSuccess { pendingDeepLinkProvider.clear() } + // Same as above: a terminal handling failure must not be persisted, otherwise it bricks every launch. + .onFailureInstance { pendingDeepLinkProvider.clear() } .onFailureInstance { pendingDeepLinkProvider.clear() } // If we haven't find any handler - no need to save deep link } diff --git a/feature-deep-linking/src/test/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandlerTest.kt b/feature-deep-linking/src/test/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandlerTest.kt new file mode 100644 index 00000000..44d38338 --- /dev/null +++ b/feature-deep-linking/src/test/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandlerTest.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling + +import android.net.Uri +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.ReferendumHandlingException +import io.novafoundation.nova.test_shared.whenever +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class RootDeepLinkHandlerTest { + + private val link = mock(Uri::class.java) + private val pendingDeepLinkProvider = mock(PendingDeepLinkProvider::class.java) + + private class FailingHandler(private val error: Throwable) : DeepLinkHandler { + override val callbackFlow: Flow = emptyFlow() + override suspend fun matches(data: Uri) = true + override suspend fun handleDeepLink(data: Uri): Result = Result.failure(error) + } + + private fun rootWith(error: Throwable) = RootDeepLinkHandler(pendingDeepLinkProvider, listOf(FailingHandler(error))) + + // A link that reaches a handler and fails for good was kept as pending and replayed on every + // launch, showing "Referendum not found" each time the app opened. + @Test + fun `a fresh link that fails for good is not kept as pending`() = runBlocking { + rootWith(ReferendumHandlingException.ReferendumIsNotSpecified).handleDeepLink(link) + + verify(pendingDeepLinkProvider).clear() + } + + @Test + fun `a pending link that fails for good is dropped`() = runBlocking { + whenever(pendingDeepLinkProvider.get()).thenReturn(link) + + rootWith(ReferendumHandlingException.ReferendumIsNotSpecified).checkAndHandlePendingDeepLink() + + verify(pendingDeepLinkProvider).clear() + } + + @Test + fun `a link that fails for another reason stays pending for a retry`() = runBlocking { + rootWith(IllegalStateException("not ready yet")).handleDeepLink(link) + + verify(pendingDeepLinkProvider, never()).clear() + } +} diff --git a/feature-governance-impl/build.gradle b/feature-governance-impl/build.gradle index 2e2cdf69..39d0a308 100644 --- a/feature-governance-impl/build.gradle +++ b/feature-governance-impl/build.gradle @@ -65,4 +65,5 @@ dependencies { testImplementation jUnitDep testImplementation mockitoDep + testImplementation project(':test-shared') } \ No newline at end of file diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt index 1c13551b..fb832850 100644 --- a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt @@ -31,7 +31,9 @@ class ReferendumDeepLinkHandler( override suspend fun matches(data: Uri): Boolean { val path = data.path ?: return false - return path.startsWith(ReferendumDetailsDeepLinkConfigurator.PREFIX) + // Exact match, not startsWith: otherwise "/open/governance" (and any "/open/gov*") would wrongly + // match the referendum prefix "/open/gov" and fail with ReferendumIsNotSpecified. + return path == ReferendumDetailsDeepLinkConfigurator.PREFIX } override suspend fun handleDeepLink(data: Uri) = runCatching { diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandlerTest.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandlerTest.kt new file mode 100644 index 00000000..4b4c56d0 --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandlerTest.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.test_shared.whenever +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class ReferendumDeepLinkHandlerTest { + + private val handler = ReferendumDeepLinkHandler( + router = mock(GovernanceRouter::class.java), + chainRegistry = mock(ChainRegistry::class.java), + mutableGovernanceState = mock(MutableGovernanceState::class.java), + automaticInteractionGate = mock(AutomaticInteractionGate::class.java) + ) + + private fun linkWithPath(path: String): Uri = mock(Uri::class.java).also { + whenever(it.path).thenReturn(path) + } + + @Test + fun `matches a referendum link`() = runBlocking { + assertTrue(handler.matches(linkWithPath("/open/gov"))) + } + + // "/open/governance" starts with the referendum prefix "/open/gov"; matching it sent the + // governance banner link to the referendum screen, which failed without a referendum id. + @Test + fun `does not match a link that only starts with the referendum path`() = runBlocking { + assertFalse(handler.matches(linkWithPath("/open/governance"))) + } +}