diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 829b32c..c006c39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -343,6 +343,10 @@ val jacocoNonJvmTestableSurface = listOf( "**/di/**", // --- src/debug cold-open probe (issue #221) --- "**/data/local/coldopen/**", + // --- src/debug fetch-gate receiver (issue #393): a BroadcastReceiver that only runs on-device + // --- (adb-driven), covered by an instrumented test, never packaged into a release build. Its + // --- pure collaborators DebugFetchGate/FetchScope stay IN scope (unit-tested by DebugFetchGateTest). + "**/debug/FetchGateReceiver*", ) // Classes = the debug variant's compiled Kotlin (AGP 9 built-in Kotlin output), with the generated diff --git a/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt new file mode 100644 index 0000000..4d7430d --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.debug + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker.Result +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import dagger.Lazy +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.security.EncryptedCacheGuard +import org.libremail.data.security.PassphraseSession +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.data.sync.BackfillWorker +import org.libremail.data.sync.DebugFetchGate +import org.libremail.data.sync.FetchScope +import org.libremail.data.sync.MailBackfiller +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * On-device proof of the debug-only fetch gate (issue #393): the adb-reachable [FetchGateReceiver] + * updates [DebugFetchGate] and returns the resulting state as ordered-broadcast result data (exactly + * what `adb shell am broadcast ... FETCH_GATE` prints back to the harness), and a gated proactive path + * ([BackfillWorker]) genuinely defers while an un-gated path keeps running. The broadcast is sent + * ordered — the same delivery mode `am broadcast` uses — so [BroadcastReceiver.getResultData] on the + * final receiver reads back what the gate set, with no logcat race. + * + * The worker-deferral cases reuse `WorkerCacheLockDeferralInstrumentedTest`'s approach: build a + * [BackfillWorker] with a real, never-unlocked-or-off [EncryptedCacheGuard] and a `Lazy` [MailBackfiller] + * whose resolution is observable, so "the gate deferred before touching the DB" is proven by the `Lazy` + * never being resolved. + */ +@RunWith(AndroidJUnit4::class) +class FetchGateReceiverInstrumentedTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + // A fresh, real, never-unlocked session per test — so the real guard reports UNLOCKED only because + // app-lock is off (see [unlockedGuard]), never because of leftover auth state. + private val session = PassphraseSession() + + @Before + @After + fun resetGate() { + DebugFetchGate.reset() + unmockkAll() + } + + @Test + fun pauseUpdatesTheGateAndReturnsTheReadBack() { + val data = sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill,prefetch") + + assertEquals("paused=[backfill,prefetch]", data) + assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL)) + assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH)) + } + + @Test + fun resumeAllClearsTheGateAndReturnsAnEmptyReadBack() { + sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "all") + + val data = sendGateBroadcast(FetchGateReceiver.ACTION_RESUME, "all") + + assertEquals("paused=[]", data) + assertFalse(DebugFetchGate.isPaused(FetchScope.BACKFILL)) + assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH)) + } + + @Test + fun queryReadsBackTheStateWithoutMutatingIt() { + sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill") + + val data = sendGateBroadcast(FetchGateReceiver.ACTION_QUERY, scope = null) + + assertEquals("paused=[backfill]", data) + assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL)) + assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH)) + } + + @Test + fun aBackfillPausedGateDefersTheBackfillWorkerWithoutResolvingTheBackfiller() = runBlocking { + sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill") + val lazyBackfiller = mockk>() + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard())) + .build() + + val result = withTimeout(TIMEOUT_MS) { worker.doWork() } + + assertEquals(Result.retry(), result) + // The gate deferred BEFORE any DB-backed work — the Lazy was never resolved. + verify(exactly = 0) { lazyBackfiller.get() } + } + + @Test + fun aPrefetchOnlyPauseLeavesTheBackfillWorkerRunning() = runBlocking { + // The worker gate honours BACKFILL only; pausing PREFETCH must NOT defer history paging — the + // on-device analogue of "on-demand open and header sync stay live while prefetch is paused". + sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "prefetch") + val backfiller = mockk { coEvery { runBackfill(any()) } returns false } + val lazyBackfiller = mockk> { every { get() } returns backfiller } + val worker = TestListenableWorkerBuilder(context) + .setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard())) + .build() + + val result = withTimeout(TIMEOUT_MS) { worker.doWork() } + + assertEquals(Result.success(), result) + verify { lazyBackfiller.get() } + } + + /** + * Sends the [FetchGateReceiver.ACTION] broadcast to the receiver by explicit component (mirroring + * `am broadcast -n`), ordered, and returns the result data the receiver set (the harness read-back). + */ + private fun sendGateBroadcast(action: String, scope: String?): String { + val latch = CountDownLatch(1) + val readBack = arrayOfNulls(1) + val intent = Intent(FetchGateReceiver.ACTION).apply { + component = ComponentName(context, FetchGateReceiver::class.java) + putExtra(FetchGateReceiver.EXTRA_ACTION, action) + if (scope != null) putExtra(FetchGateReceiver.EXTRA_SCOPE, scope) + } + context.sendOrderedBroadcast( + intent, + null, + object : BroadcastReceiver() { + override fun onReceive(c: Context, i: Intent) { + readBack[0] = resultData + latch.countDown() + } + }, + null, + 0, + null, + null, + ) + assertTrue("gate broadcast timed out", latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) + return requireNotNull(readBack[0]) { "receiver set no result data" } + } + + /** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off) — so only the gate can defer. */ + private fun unlockedGuard(): EncryptedCacheGuard { + val settingsRepository = mockk() + every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true)) + return EncryptedCacheGuard(settingsRepository, session) + } + + private fun backfillWorkerFactory(lazyBackfiller: Lazy, cacheGuard: EncryptedCacheGuard) = + object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard) + } + + private companion object { + const val TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index bf9bfc3..6e7cf5c 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + diff --git a/app/src/debug/kotlin/org/libremail/debug/FetchGateReceiver.kt b/app/src/debug/kotlin/org/libremail/debug/FetchGateReceiver.kt new file mode 100644 index 0000000..92ca955 --- /dev/null +++ b/app/src/debug/kotlin/org/libremail/debug/FetchGateReceiver.kt @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.debug + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.libremail.data.sync.DebugFetchGate +import org.libremail.data.sync.FetchScope +import org.libremail.reporting.AppLog + +/** + * Debug-only [BroadcastReceiver] (issue #393) that lets an adb-driven perf harness pause/resume the + * proactive-fetch activities tracked by [DebugFetchGate], so a genuinely uncached message-open can be + * measured (add an account, let headers sync, then open a message that must hit the network instead of a + * warmed cache). Declared **only** in `app/src/debug/AndroidManifest.xml`, so it is physically absent + * from every release APK — the same source-set guarantee `ColdOpenCacheProbe` (#221) relies on. + * + * Driven by (component targeted with `-n`, so it needs no ``): + * ``` + * adb shell am broadcast -a org.libremail.debug.FETCH_GATE \ + * -n org.libremail.app/org.libremail.debug.FetchGateReceiver \ + * --es action --es scope + * ``` + * `am broadcast` delivers this **ordered**, so the receiver returns the resulting state as result data + * (e.g. `data=paused=[backfill,prefetch]`) which `am` prints — a synchronous, race-free read-back for + * the harness. A `query` reports the current state without changing it. The new state is logged via the + * PII-free [AppLog] (scope names only — never an email, host, or message content). + */ +class FetchGateReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.getStringExtra(EXTRA_ACTION)?.trim()?.lowercase() + val scopes = FetchScope.parse(intent.getStringExtra(EXTRA_SCOPE)) + when (action) { + ACTION_PAUSE -> { + DebugFetchGate.pause(scopes) + AppLog.i(TAG, "fetch gate pause -> ${DebugFetchGate.pausedResult()}") + } + ACTION_RESUME -> { + DebugFetchGate.resume(scopes) + AppLog.i(TAG, "fetch gate resume -> ${DebugFetchGate.pausedResult()}") + } + ACTION_QUERY -> AppLog.i(TAG, "fetch gate query -> ${DebugFetchGate.pausedResult()}") + else -> AppLog.w(TAG, "fetch gate: unknown action") + } + // Return the gate state as ordered-broadcast result data for a synchronous read-back. Guarded so + // a non-ordered send (which has no result receiver) can't crash the receiver. + if (isOrderedBroadcast) { + resultCode = RESULT_CODE + resultData = DebugFetchGate.pausedResult() + } + } + + companion object { + /** The broadcast action the harness sends (kept for parity with the adb command; delivery is by `-n`). */ + const val ACTION = "org.libremail.debug.FETCH_GATE" + + /** `--es action `. */ + const val EXTRA_ACTION = "action" + + /** `--es scope ` (see [FetchScope.parse]). */ + const val EXTRA_SCOPE = "scope" + + const val ACTION_PAUSE = "pause" + const val ACTION_RESUME = "resume" + const val ACTION_QUERY = "query" + + private const val TAG = "FetchGateReceiver" + private const val RESULT_CODE = 0 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt index d340512..d35402a 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/BackfillWorker.kt @@ -9,6 +9,7 @@ import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException +import org.libremail.BuildConfig import org.libremail.data.security.EncryptedCacheGuard import org.libremail.reporting.AppLog @@ -30,6 +31,16 @@ class BackfillWorker @AssistedInject constructor( ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { + // Debug-only fetch gate (issue #393): a test harness can pause backfill via an adb broadcast so a + // genuinely uncached message-open can be measured (proactive backfill would otherwise warm the + // cache first). Skip-and-reschedule exactly like the cache-lock deferral below; WorkManager + // retries and picks up from the persisted per-folder boundary once the gate resumes. The whole + // branch is compiled out of release: BuildConfig.DEBUG is a compile-time `false` there, so R8 + // strips it (and DebugFetchGate with it). + if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) { + AppLog.i(TAG, "backfill deferred: fetch-gate paused") + return Result.retry() + } // Can't open the encrypted DB without the user present — retry later rather than parking a // WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await. if (cacheGuard.isCacheLocked()) { diff --git a/app/src/main/kotlin/org/libremail/data/sync/DebugFetchGate.kt b/app/src/main/kotlin/org/libremail/data/sync/DebugFetchGate.kt new file mode 100644 index 0000000..07fa983 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/DebugFetchGate.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +/** + * The proactive-fetch activities a debug harness can pause via [DebugFetchGate] (issue #393). Only the + * two *proactive* activities are gateable: full-history [BACKFILL] paging ([BackfillWorker]) and the + * post-sync body [PREFETCH] ([MailSyncer]/[MailBackfiller] `prefetchIfEnabled`). Header sync and the + * on-demand message open are deliberately absent — they are **never** gated, so a paused gate can defer + * background caching without ever blocking new mail arriving or a user-triggered (uncached) open. The + * `all` wire alias ([ALL_ALIAS]) expands to every entry here. + */ +enum class FetchScope(val wireName: String) { + BACKFILL("backfill"), + PREFETCH("prefetch"), + ; + + companion object { + /** The `all` scope alias accepted on the adb wire — expands to every [FetchScope]. */ + const val ALL_ALIAS = "all" + + /** + * Parses the comma-separated `scope` extra of the debug broadcast (e.g. `"backfill,prefetch"` + * or `"all"`) into the set of scopes it names. Case- and whitespace-insensitive; the [ALL_ALIAS] + * expands to every scope; unrecognised or blank tokens are ignored; a null/blank input yields + * the empty set. Declaration order is preserved so the read-back string is stable. + */ + fun parse(raw: String?): Set { + if (raw.isNullOrBlank()) return emptySet() + val tokens = raw.split(',').map { it.trim().lowercase() }.filter { it.isNotEmpty() } + if (tokens.contains(ALL_ALIAS)) return entries.toSet() + return entries.filterTo(LinkedHashSet()) { it.wireName in tokens } + } + } +} + +/** + * In-memory, thread-safe holder of the currently-paused proactive-fetch [FetchScope]s — a **debug-only** + * test hook (issue #393) that lets an adb-driven perf harness pause background body caching so a genuine + * uncached message-open can be measured (the harness adds an account, lets headers sync, then opens a + * message that must hit the network rather than a warmed cache). + * + * Lives in `src/main` so the workers/syncer/backfiller can reference it, but **every read is wrapped in + * `if (BuildConfig.DEBUG && ...)`**. `BuildConfig.DEBUG` is a compile-time `false` in release, so R8 + * dead-code-eliminates each such branch, leaving this object unreferenced and stripping it (and + * [FetchScope]) from the release APK entirely — verified by issue #393's release-exclusion check. The + * writer, `FetchGateReceiver`, lives wholly in `src/debug` and is never packaged into release either; + * this is the same source-set guarantee `ColdOpenCacheProbe` (#221) relies on. + * + * Defaults to **nothing paused**, so the gate is inert until a debug broadcast pauses a scope. Reads are + * lock-free (a `@Volatile` snapshot of an immutable set, cheap enough for the fetch hot path); the rare + * writes swap the reference under a lock. + */ +object DebugFetchGate { + private val writeLock = Any() + + @Volatile + private var paused: Set = emptySet() + + /** Whether [scope]'s proactive fetch is currently paused. */ + fun isPaused(scope: FetchScope): Boolean = scope in paused + + /** The scopes currently paused, in [FetchScope] declaration order. */ + fun pausedScopes(): Set { + val snapshot = paused + return FetchScope.entries.filterTo(LinkedHashSet()) { it in snapshot } + } + + /** Pauses [scopes] (union with whatever is already paused). No-op for an empty set. */ + fun pause(scopes: Set) { + if (scopes.isEmpty()) return + synchronized(writeLock) { paused = paused + scopes } + } + + /** Resumes [scopes] (removes them from the paused set). No-op for an empty set. */ + fun resume(scopes: Set) { + if (scopes.isEmpty()) return + synchronized(writeLock) { paused = paused - scopes } + } + + /** Clears every pause, restoring the default not-paused state. Used to isolate tests. */ + fun reset() { + synchronized(writeLock) { paused = emptySet() } + } + + /** + * The synchronous read-back string the debug receiver returns as ordered-broadcast result data — + * e.g. `"paused=[backfill,prefetch]"` (declaration order) or `"paused=[]"` when nothing is paused. + */ + fun pausedResult(): String { + val snapshot = paused + return FetchScope.entries.filter { it in snapshot }.joinToString( + separator = ",", + prefix = "paused=[", + postfix = "]", + ) { it.wireName } + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt b/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt index 6ee52b0..d0f12b2 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.libremail.BuildConfig import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.BackfillProgressDao import org.libremail.data.local.dao.MessageDao @@ -224,6 +225,14 @@ class MailBackfiller @Inject constructor( * fetched is filled in lazily when the message is opened. */ private suspend fun prefetchIfEnabled(ids: List) { + // Debug-only fetch gate (issue #393): pause proactive body prefetch so a later open is a genuine + // uncached fetch. Header paging above is untouched (its own gate is the BackfillWorker entry), so + // history still lands; a skipped body is filled in lazily on open. Compiled out of release + // (BuildConfig.DEBUG is a compile-time false, so R8 drops the branch). + if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.PREFETCH)) { + AppLog.i(TAG, "prefetch skipped: fetch-gate paused") + return + } val shouldPrefetch = SyncResourcePolicy.shouldPrefetchContent( policy = settingsRepository.fetchPolicy(), unmetered = { context.isActiveNetworkUnmetered() }, diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt index 1b649e6..1d8df94 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.libremail.BuildConfig import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain @@ -166,6 +167,14 @@ class MailSyncer @Inject constructor( * and is cancellable between messages so an IDLE renewal stops it promptly. */ private suspend fun prefetchIfEnabled(account: Account, folder: String) { + // Debug-only fetch gate (issue #393): a test harness pauses proactive body prefetch so a later + // open does a genuine uncached fetch. Header sync above already ran, so mail still arrives; the + // skipped prefetch is filled in lazily on open, exactly as the low-battery pause behaves. Compiled + // out of release (BuildConfig.DEBUG is a compile-time false, so R8 drops the branch). + if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.PREFETCH)) { + AppLog.i(TAG, "prefetch skipped: fetch-gate paused") + return + } val shouldPrefetch = SyncResourcePolicy.shouldPrefetchContent( policy = settingsRepository.fetchPolicy(), unmetered = { context.isActiveNetworkUnmetered() }, diff --git a/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt index 66a59ef..0550f6c 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/BackfillWorkerTest.kt @@ -52,7 +52,10 @@ class BackfillWorkerTest { } @After - fun tearDown() = unmockkAll() + fun tearDown() { + unmockkAll() + DebugFetchGate.reset() // the gate is a process-global object; don't leak a pause to other tests + } @Test fun `retries without resolving the backfiller when the cache is locked`() = runTest { @@ -122,4 +125,44 @@ class BackfillWorkerTest { assertTrue(entry.message.contains("IllegalStateException"), entry.message) assertFalse(entry.message.contains("a@example.org"), entry.message) } + + // --- issue #393: debug-only fetch gate ------------------------------------------------------ + + @Test + fun `defers without resolving the backfiller when the fetch gate pauses backfill`() = runTest { + // Cache unlocked, so the ONLY reason to defer is the gate. (BuildConfig.DEBUG is true under + // testDebugUnitTest, so the gate branch is live.) + coEvery { cacheGuard.isCacheLocked() } returns false + DebugFetchGate.pause(setOf(FetchScope.BACKFILL)) + + assertEquals(Result.retry(), worker().doWork()) + + // Same invariant as the cache-lock deferral: never resolve the DB-backed collaborator. + verify(exactly = 0) { lazyBackfiller.get() } + coVerify(exactly = 0) { backfiller.runBackfill(any()) } + } + + @Test + fun `pausing only prefetch does NOT defer the backfill worker`() = runTest { + // The worker gate honours BACKFILL only — a PREFETCH pause must leave history paging running. + coEvery { cacheGuard.isCacheLocked() } returns false + coEvery { backfiller.runBackfill(any()) } returns false + DebugFetchGate.pause(setOf(FetchScope.PREFETCH)) + + assertEquals(Result.success(), worker().doWork()) + + coVerify(exactly = 1) { backfiller.runBackfill(any()) } + } + + @Test + fun `logs a deferred breadcrumb when the fetch gate pauses backfill`() = runTest { + coEvery { cacheGuard.isCacheLocked() } returns false + DebugFetchGate.pause(setOf(FetchScope.BACKFILL)) + + worker().doWork() + + val entry = logBuffer.snapshot().single() + assertEquals('I', entry.level) + assertEquals("backfill deferred: fetch-gate paused", entry.message) + } } diff --git a/app/src/test/kotlin/org/libremail/data/sync/DebugFetchGateTest.kt b/app/src/test/kotlin/org/libremail/data/sync/DebugFetchGateTest.kt new file mode 100644 index 0000000..f0191ad --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/sync/DebugFetchGateTest.kt @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Unit tests for the debug-only fetch gate (issue #393): its default not-paused state, pause/resume/query + * per [FetchScope], the `all` alias + scope-string parsing the receiver relies on, the ordered-broadcast + * read-back string, and thread-safety of the in-memory holder. [DebugFetchGate] is a process-global + * object, so each test resets it to isolate from the others. + */ +class DebugFetchGateTest { + + @Before + @After + fun resetGate() = DebugFetchGate.reset() + + @Test + fun `defaults to nothing paused for every scope`() { + FetchScope.entries.forEach { scope -> + assertFalse(DebugFetchGate.isPaused(scope), "$scope must default to not-paused") + } + assertTrue(DebugFetchGate.pausedScopes().isEmpty()) + assertEquals("paused=[]", DebugFetchGate.pausedResult()) + } + + @Test + fun `pausing one scope leaves the other live`() { + DebugFetchGate.pause(setOf(FetchScope.BACKFILL)) + + assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL)) + assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH)) + assertEquals(setOf(FetchScope.BACKFILL), DebugFetchGate.pausedScopes()) + assertEquals("paused=[backfill]", DebugFetchGate.pausedResult()) + } + + @Test + fun `pausing both scopes reports both, in declaration order`() { + DebugFetchGate.pause(setOf(FetchScope.PREFETCH, FetchScope.BACKFILL)) + + assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL)) + assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH)) + // Declaration order (BACKFILL before PREFETCH), regardless of the set's insertion order. + assertEquals("paused=[backfill,prefetch]", DebugFetchGate.pausedResult()) + } + + @Test + fun `pause is additive across calls`() { + DebugFetchGate.pause(setOf(FetchScope.BACKFILL)) + DebugFetchGate.pause(setOf(FetchScope.PREFETCH)) + + assertEquals(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH), DebugFetchGate.pausedScopes()) + } + + @Test + fun `resume clears only the named scope`() { + DebugFetchGate.pause(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH)) + + DebugFetchGate.resume(setOf(FetchScope.BACKFILL)) + + assertFalse(DebugFetchGate.isPaused(FetchScope.BACKFILL)) + assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH)) + assertEquals("paused=[prefetch]", DebugFetchGate.pausedResult()) + } + + @Test + fun `resume of an unpaused scope is a no-op`() { + DebugFetchGate.resume(setOf(FetchScope.BACKFILL)) + + assertEquals("paused=[]", DebugFetchGate.pausedResult()) + } + + @Test + fun `empty pause and resume are no-ops`() { + DebugFetchGate.pause(emptySet()) + assertEquals("paused=[]", DebugFetchGate.pausedResult()) + + DebugFetchGate.pause(setOf(FetchScope.PREFETCH)) + DebugFetchGate.resume(emptySet()) + assertEquals("paused=[prefetch]", DebugFetchGate.pausedResult()) + } + + @Test + fun `reset clears every pause`() { + DebugFetchGate.pause(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH)) + + DebugFetchGate.reset() + + assertTrue(DebugFetchGate.pausedScopes().isEmpty()) + } + + // --- FetchScope.parse (the scope-string contract the receiver depends on) ------------------------- + + @Test + fun `parse maps a comma list of known scopes`() { + assertEquals(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH), FetchScope.parse("backfill,prefetch")) + assertEquals(setOf(FetchScope.BACKFILL), FetchScope.parse("backfill")) + } + + @Test + fun `parse expands the all alias to every scope`() { + assertEquals(FetchScope.entries.toSet(), FetchScope.parse("all")) + // The alias wins even mixed with other tokens. + assertEquals(FetchScope.entries.toSet(), FetchScope.parse("backfill,all")) + } + + @Test + fun `parse is case- and whitespace-insensitive`() { + assertEquals(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH), FetchScope.parse(" BACKFILL , Prefetch ")) + } + + @Test + fun `parse ignores unknown and blank tokens`() { + assertEquals(setOf(FetchScope.PREFETCH), FetchScope.parse("prefetch,bogus,,")) + assertTrue(FetchScope.parse("nope").isEmpty()) + } + + @Test + fun `parse of null or blank is the empty set`() { + assertTrue(FetchScope.parse(null).isEmpty()) + assertTrue(FetchScope.parse("").isEmpty()) + assertTrue(FetchScope.parse(" ").isEmpty()) + } + + // --- thread-safety -------------------------------------------------------------------------------- + + @Test + fun `concurrent pause and resume never corrupts the holder`() { + val threads = 8 + val iterations = 2_000 + val pool = Executors.newFixedThreadPool(threads) + val start = CountDownLatch(1) + try { + val futures = (0 until threads).map { index -> + pool.submit { + start.await() + val scope = FetchScope.entries[index % FetchScope.entries.size] + repeat(iterations) { + DebugFetchGate.pause(setOf(scope)) + DebugFetchGate.isPaused(scope) + DebugFetchGate.pausedResult() + DebugFetchGate.resume(setOf(scope)) + } + } + } + start.countDown() + futures.forEach { it.get(30, TimeUnit.SECONDS) } + } finally { + pool.shutdownNow() + } + + // No exception above (a data race on a plain HashSet would throw), and the holder is consistent: + // pausedScopes() only ever reports declared scopes. + assertTrue(DebugFetchGate.pausedScopes().all { it in FetchScope.entries }) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt index 695d9c5..0c1ef44 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt @@ -107,6 +107,7 @@ class MailBackfillerTest { fun tearDown() { greenMail.stop() unmockkAll() + DebugFetchGate.reset() // the gate is a process-global object; don't leak a pause to other tests } private fun params() = ImapConnectionParams( @@ -234,6 +235,23 @@ class MailBackfillerTest { coVerify(exactly = 0) { requireNotNull(lastMailRepository).prefetchMessage(any()) } } + // --- issue #393: debug-only fetch gate ------------------------------------------------------ + + @Test + fun `a paused prefetch gate skips body prefetch but still pages history headers`() = runTest { + appendMessages(60) + seedForegroundWindow() + DebugFetchGate.pause(setOf(FetchScope.PREFETCH)) + + // ALWAYS + healthy battery: without the gate this WOULD prefetch, so the gate is the only cause. + backfiller(AccountSettings("acct"), fetchPolicy = FetchPolicy.ALWAYS).runBackfill() + + // The gate stops only the body/attachment prefetch; the history headers still page in. + assertEquals(60, distinctCachedUids().size, "header paging itself is not gated") + coVerify(exactly = 0) { requireNotNull(lastMailRepository).prefetchMessage(any()) } + assertTrue(logBuffer.snapshot().any { it.message == "prefetch skipped: fetch-gate paused" }) + } + @Test fun `charging at low percent keeps the backfill content prefetch running`() = runTest { appendMessages(60) diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt index 0e7a0b2..5e5ec17 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt @@ -73,7 +73,10 @@ class MailSyncerTest { } @After - fun tearDown() = unmockkAll() + fun tearDown() { + unmockkAll() + DebugFetchGate.reset() // the gate is a process-global object; don't leak a pause to other tests + } /** The IMAP client of the most recently built [syncer], for verifying the fetch window size. */ private lateinit var lastImapClient: ImapClient @@ -228,6 +231,22 @@ class MailSyncerTest { coVerify(exactly = 0) { repo.prefetchMessage(any()) } } + // --- issue #393: debug-only fetch gate ------------------------------------------------------ + + @Test + fun `a paused prefetch gate skips prefetch while the header sync still runs`() = runTest { + val repo = mockk(relaxed = true) + DebugFetchGate.pause(setOf(FetchScope.PREFETCH)) + + // ALWAYS + healthy battery: without the gate this WOULD prefetch, so the gate is the only cause. + val result = syncer(FetchPolicy.ALWAYS, repo).syncFolder("acct", "INBOX") + + assertEquals(0, result.getOrNull()) // header sync still ran and succeeded + coVerify { lastImapClient.fetchRecent(any(), "INBOX", any()) } // headers still fetched + coVerify(exactly = 0) { repo.prefetchMessage(any()) } + assertTrue(logBuffer.snapshot().any { it.message == "prefetch skipped: fetch-gate paused" }) + } + @Test fun `at exactly the 20 percent threshold prefetch is paused`() = runTest { val repo = mockk(relaxed = true)