From 987f92245d133bcb4ec8018195a25ae7542e9beb Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 17 Jun 2026 14:47:10 -0400 Subject: [PATCH] Fix instrumentation tests relying on mocked SignalStore and RemoteConfig. --- ...SignalInstrumentationApplicationContext.kt | 9 +++ .../securesms/jobs/BackupDeleteJobTest.kt | 35 ++++---- .../jobs/BackupSubscriptionCheckJobTest.kt | 30 +++---- .../securesms/testing/RemoteConfigForTest.kt | 81 +++++++++++++++++++ .../securesms/testing/SignalTestRunner.kt | 47 +++++++++++ 5 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/RemoteConfigForTest.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt index 2fd05f01bb..2c6ec745bc 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt @@ -9,9 +9,11 @@ import org.thoughtcrime.securesms.database.LogDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger import org.thoughtcrime.securesms.logging.PersistentLogger import org.thoughtcrime.securesms.testing.InMemoryLogger +import org.thoughtcrime.securesms.testing.TestRemoteConfig import org.thoughtcrime.securesms.util.Environment /** @@ -30,6 +32,13 @@ class SignalInstrumentationApplicationContext : ApplicationContext() { val default = ApplicationDependencyProvider(this) AppDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default)) AppDependencies.deadlockDetector.start() + + // Stage any test-declared remote config into the store to be read in RemoteConfig.init(). + if (TestRemoteConfig.pending.isNotEmpty()) { + val json = TestRemoteConfig.json + SignalStore.remoteConfig.currentConfig = json + SignalStore.remoteConfig.pendingConfig = json + } } override fun initializeLogging() { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt index e418d38ea7..78032bde9b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt @@ -32,10 +32,18 @@ import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.testing.Flag +import org.thoughtcrime.securesms.testing.RemoteConfigForTest import org.thoughtcrime.securesms.testing.SignalActivityRule -import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag import java.util.UUID +@RemoteConfigForTest( + flags = [ + Flag(TestRemoteConfigFlag.INTERNAL_USER, "true"), + Flag(TestRemoteConfigFlag.DEFAULT_MAX_BACKOFF, "1") + ] +) class BackupDeleteJobTest { @get:Rule @@ -43,10 +51,6 @@ class BackupDeleteJobTest { @Before fun setUp() { - mockkObject(RemoteConfig) - every { RemoteConfig.internalUser } returns true - every { RemoteConfig.defaultMaxBackoff } returns 1000L - mockkObject(BackupRepository) every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID) every { BackupRepository.deleteBackup() } returns NetworkResult.Success(Unit) @@ -60,29 +64,24 @@ class BackupDeleteJobTest { @Test fun givenUserNotRegistered_whenIRun_thenIExpectFailure() { - mockkObject(SignalStore) { - every { SignalStore.account.isRegistered } returns false + SignalStore.account.setRegistered(false) - val job = BackupDeleteJob() + val job = BackupDeleteJob() - val result = job.run() + val result = job.run() - assertThat(result.isFailure).isTrue() - } + assertThat(result.isFailure).isTrue() } @Test fun givenLinkedDevice_whenIRun_thenIExpectFailure() { - mockkObject(SignalStore) { - every { SignalStore.account.isRegistered } returns true - every { SignalStore.account.isLinkedDevice } returns true + SignalStore.account.deviceId = 2 - val job = BackupDeleteJob() + val job = BackupDeleteJob() - val result = job.run() + val result = job.run() - assertThat(result.isFailure).isTrue() - } + assertThat(result.isFailure).isTrue() } @Test diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt index e5a1fb8aa5..1429f39b73 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt @@ -42,8 +42,10 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.net.SignalNetwork +import org.thoughtcrime.securesms.testing.Flag +import org.thoughtcrime.securesms.testing.RemoteConfigForTest import org.thoughtcrime.securesms.testing.SignalActivityRule -import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure @@ -55,6 +57,7 @@ import java.util.Currency import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds +@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.INTERNAL_USER, "true") ]) @RunWith(AndroidJUnit4::class) class BackupSubscriptionCheckJobTest { @@ -67,9 +70,6 @@ class BackupSubscriptionCheckJobTest { @Before fun setUp() { - mockkObject(RemoteConfig) - every { RemoteConfig.internalUser } returns true - coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success( @@ -142,28 +142,22 @@ class BackupSubscriptionCheckJobTest { @Test fun givenUserIsNotRegistered_whenIRun_thenIExpectSuccessAndEarlyExit() { - mockkObject(SignalStore.account) { - every { SignalStore.account.e164 } returns "+15555550101" - every { SignalStore.account.isRegistered } returns false + SignalStore.account.setRegistered(false) - val job = BackupSubscriptionCheckJob.create() - val result = job.run() + val job = BackupSubscriptionCheckJob.create() + val result = job.run() - assertEarlyExit(result) - } + assertEarlyExit(result) } @Test fun givenIsLinkedDevice_whenIRun_thenIExpectSuccessAndEarlyExit() { - mockkObject(SignalStore.account) { - every { SignalStore.account.e164 } returns "+15555550101" - every { SignalStore.account.isLinkedDevice } returns true + SignalStore.account.deviceId = 2 - val job = BackupSubscriptionCheckJob.create() - val result = job.run() + val job = BackupSubscriptionCheckJob.create() + val result = job.run() - assertEarlyExit(result) - } + assertEarlyExit(result) } @Test diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/RemoteConfigForTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/RemoteConfigForTest.kt new file mode 100644 index 0000000000..f670d85b6a --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/RemoteConfigForTest.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.testing + +import org.json.JSONObject +import org.thoughtcrime.securesms.util.RemoteConfig +import kotlin.reflect.KProperty0 +import kotlin.reflect.jvm.isAccessible + +/** + * Declares remote config values a test needs. The [SignalTestRunner] reads this off the + * about-to-run test (class and/or method) and stages the values into [TestRemoteConfig], which + * [org.thoughtcrime.securesms.SignalInstrumentationApplicationContext] seeds into the real + * [RemoteConfig] before the startup `init()` runs. + * + * Method-level annotations override class-level ones for the same key. Values are strings, matching + * how the service delivers config; `"true"`/`"false"` are decoded into real booleans on the way into + * the store (same as [org.signal.network.api.RemoteConfigApi]), other values stay strings. + * + * Prefer the typed [Flag] (which resolves its key from the actual [RemoteConfig] property); use + * [RawFlag] for keys that don't have a [TestRemoteConfigFlag] entry. + * + * ``` + * @RemoteConfigForTest( + * flags = [Flag(TestRemoteConfigFlag.INTERNAL_USER, "true")], + * rawFlags = [RawFlag("android.someOtherKey", "1")] + * ) + * class MyTest { ... } + * ``` + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class RemoteConfigForTest( + val flags: Array = [], + val rawFlags: Array = [] +) + +/** A flag whose key is resolved from the referenced [RemoteConfig] property at runtime. */ +@Retention(AnnotationRetention.RUNTIME) +annotation class Flag(val flag: TestRemoteConfigFlag, val value: String) + +/** A flag identified by its raw remote config key, for keys without a [TestRemoteConfigFlag] entry. */ +@Retention(AnnotationRetention.RUNTIME) +annotation class RawFlag(val key: String, val value: String) + +/** + * Typed handles for remote config flags referenced by tests. + */ +enum class TestRemoteConfigFlag(private val property: KProperty0<*>) { + INTERNAL_USER(RemoteConfig::internalUser), + DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff); + + val key: String + get() { + property.isAccessible = true + val delegate = property.getDelegate() ?: error("RemoteConfig.${property.name} has no delegate; only `by remoteX(...)` configs can be referenced by ${TestRemoteConfigFlag::class.simpleName}.") + check(delegate is RemoteConfig.Config<*>) { + "RemoteConfig.${property.name} delegate is ${delegate::class.simpleName}, not RemoteConfig.Config; cannot resolve its remote config key." + } + return delegate.key + } +} + +/** + * Process-static bridge between [SignalTestRunner] (which knows the running test) and + * [org.thoughtcrime.securesms.SignalInstrumentationApplicationContext] (which seeds the config). + * Safe because Orchestrator runs each test in a fresh process. + */ +object TestRemoteConfig { + @Volatile + var pending: Map = emptyMap() + + /** + * The staged config as a JSON string ready to write into `SignalStore.remoteConfig`. Mirrors + * [org.signal.network.api.RemoteConfigApi]'s decode so `"true"`/`"false"` land as real booleans + * (like the server path) while other values stay strings. + */ + val json: String + get() { + val decoded = pending.mapValues { (_, value) -> (value as? String)?.lowercase()?.toBooleanStrictOrNull() ?: value } + return JSONObject(decoded).toString() + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalTestRunner.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalTestRunner.kt index 0c879c2281..d0e9a64936 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalTestRunner.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalTestRunner.kt @@ -2,15 +2,62 @@ package org.thoughtcrime.securesms.testing import android.app.Application import android.content.Context +import android.os.Bundle import androidx.test.runner.AndroidJUnitRunner import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext /** * Custom runner that replaces application with [SignalInstrumentationApplicationContext]. + * + * Before the application is created, it reads any [RemoteConfigForTest] declared on the + * about-to-run test (passed by the Orchestrator as the `class` argument, `pkg.Class#method`) and + * stages the values in [TestRemoteConfig] so the app can seed them into `RemoteConfig` at startup. */ @Suppress("unused") class SignalTestRunner : AndroidJUnitRunner() { + override fun onCreate(arguments: Bundle?) { + TestRemoteConfig.pending = parseRemoteConfig(arguments?.getString("class")) + super.onCreate(arguments) + } + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { return super.newApplication(cl, SignalInstrumentationApplicationContext::class.java.name, context) } + + /** + * Resolves [RemoteConfigForTest] annotations from the targeted test(s). [classArg] is the + * instrumentation `class` argument: a comma-separated list of `pkg.Class` or `pkg.Class#method`. + * Method-level flags override class-level flags for the same key. Reflection failures (e.g. a + * whole-suite run with no `class` arg) fall back to no overrides. + */ + private fun parseRemoteConfig(classArg: String?): Map { + if (classArg.isNullOrBlank()) { + return emptyMap() + } + + val flags = mutableMapOf() + + for (entry in classArg.split(",")) { + val (className, methodName) = entry.trim().split("#", limit = 2).let { it[0] to it.getOrNull(1) } + + try { + // initialize = false: only read annotations, don't run the test class's static init this early. + val testClass = Class.forName(className, false, javaClass.classLoader) + val method = methodName?.let { name -> testClass.declaredMethods.firstOrNull { it.name == name } } + + // Class annotation first, then method annotation so method-level flags override class-level ones. + listOfNotNull( + testClass.getAnnotation(RemoteConfigForTest::class.java), + method?.getAnnotation(RemoteConfigForTest::class.java) + ).forEach { annotation -> + annotation.flags.forEach { flags[it.flag.key] = it.value } + annotation.rawFlags.forEach { flags[it.key] = it.value } + } + } catch (_: ReflectiveOperationException) { + // Class/method not resolvable in this run; leave overrides as-is. + } + } + + return flags + } }