Merge main into test-cache-lock-gate-coverage
This commit is contained in:
@@ -5,9 +5,11 @@ import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.Lazy
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
|
||||
/**
|
||||
* Runs one bounded slice of the full-history backfill (issue #12). Cancellable (WorkManager stops it
|
||||
@@ -19,16 +21,26 @@ import kotlinx.coroutines.CancellationException
|
||||
class BackfillWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val backfiller: MailBackfiller,
|
||||
// Lazy: resolving MailBackfiller builds the Room DB graph, whose first query blocks while the
|
||||
// encrypted cache is locked. Resolve it only after the cache-lock check passes, so a locked run
|
||||
// fails fast instead of parking this thread on an unsatisfiable passphrase await (mirrors SyncWorker).
|
||||
private val backfiller: Lazy<MailBackfiller>,
|
||||
private val cacheGuard: EncryptedCacheGuard,
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result = runCatching {
|
||||
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited to
|
||||
// one slice per periodic run. runBackfill() returns true while any folder still has pages left;
|
||||
// isStopped lets WorkManager end a long run gracefully (the periodic schedule resumes it).
|
||||
while (backfiller.runBackfill() && !isStopped) { /* page the next slice */ }
|
||||
}.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { error -> if (error is CancellationException) throw error else Result.retry() },
|
||||
)
|
||||
override suspend fun doWork(): Result {
|
||||
// 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()) return Result.retry()
|
||||
return runCatching {
|
||||
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited
|
||||
// to one slice per periodic run. runBackfill() returns true while any folder still has pages
|
||||
// left; isStopped lets WorkManager end a long run gracefully (the periodic schedule resumes).
|
||||
val mailBackfiller = backfiller.get()
|
||||
while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ }
|
||||
}.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { error -> if (error is CancellationException) throw error else Result.retry() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.Lazy
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
|
||||
/**
|
||||
* Enforces device-only retention (issue #13) by running [MailPruner]. Purely local — it never
|
||||
@@ -16,11 +18,20 @@ import dagger.assisted.AssistedInject
|
||||
class PruneWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val pruner: MailPruner,
|
||||
// Lazy: resolving MailPruner builds the Room DB graph, whose first query blocks while the encrypted
|
||||
// cache is locked. Resolve it only after the cache-lock check passes, so a locked run fails fast
|
||||
// instead of parking this thread on an unsatisfiable passphrase await (mirrors SyncWorker/SendWorker).
|
||||
private val pruner: Lazy<MailPruner>,
|
||||
private val cacheGuard: EncryptedCacheGuard,
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result = runCatching { pruner.prune() }.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { Result.retry() },
|
||||
)
|
||||
override suspend fun doWork(): Result {
|
||||
// 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()) return Result.retry()
|
||||
return runCatching { pruner.get().prune() }.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { Result.retry() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import dagger.Lazy
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* [BackfillWorker] must defer while the encrypted cache is locked rather than park this WorkManager
|
||||
* thread opening the DB. It gates on [EncryptedCacheGuard], resolving the (`Lazy`) [MailBackfiller]
|
||||
* only once unlocked — the same pre-auth invariant `SyncWorker`/`SendWorker` enforce.
|
||||
*/
|
||||
class BackfillWorkerTest {
|
||||
|
||||
private val backfiller = mockk<MailBackfiller>()
|
||||
private val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
|
||||
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
||||
|
||||
private fun worker() = BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard)
|
||||
|
||||
@Test
|
||||
fun `retries without resolving the backfiller when the cache is locked`() = runTest {
|
||||
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||
|
||||
assertEquals(Result.retry(), worker().doWork())
|
||||
|
||||
verify(exactly = 0) { lazyBackfiller.get() }
|
||||
coVerify(exactly = 0) { backfiller.runBackfill(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chains slices to completion and succeeds when the cache is unlocked`() = runTest {
|
||||
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||
// true then false: one slice still has pages, the next reports done — the worker loops until false.
|
||||
coEvery { backfiller.runBackfill(any()) } returnsMany listOf(true, false)
|
||||
|
||||
assertEquals(Result.success(), worker().doWork())
|
||||
|
||||
coVerify(exactly = 2) { backfiller.runBackfill(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries when backfilling throws`() = runTest {
|
||||
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||
coEvery { backfiller.runBackfill(any()) } throws IllegalStateException("boom")
|
||||
|
||||
assertEquals(Result.retry(), worker().doWork())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import dagger.Lazy
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* [PruneWorker] must not touch the database while the encrypted cache is locked: opening it would park
|
||||
* this WorkManager thread on an unsatisfiable passphrase await (and wedge the shared serial executor).
|
||||
* It gates on [EncryptedCacheGuard] and only resolves the (`Lazy`) [MailPruner] once unlocked — the
|
||||
* invariant every pre-auth DB entry point shares with `SyncWorker`/`SendWorker`.
|
||||
*/
|
||||
class PruneWorkerTest {
|
||||
|
||||
private val pruner = mockk<MailPruner>()
|
||||
private val lazyPruner = mockk<Lazy<MailPruner>> { every { get() } returns pruner }
|
||||
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
||||
|
||||
private fun worker() = PruneWorker(mockk(relaxed = true), mockk(relaxed = true), lazyPruner, cacheGuard)
|
||||
|
||||
@Test
|
||||
fun `retries without resolving the pruner when the cache is locked`() = runTest {
|
||||
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||
|
||||
assertEquals(Result.retry(), worker().doWork())
|
||||
|
||||
// The whole point of the guard: the DB-backed dependency is never even resolved while locked.
|
||||
verify(exactly = 0) { lazyPruner.get() }
|
||||
coVerify(exactly = 0) { pruner.prune(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prunes and succeeds when the cache is unlocked`() = runTest {
|
||||
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||
coEvery { pruner.prune(any()) } returns 0
|
||||
|
||||
assertEquals(Result.success(), worker().doWork())
|
||||
|
||||
coVerify(exactly = 1) { pruner.prune(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries when pruning throws`() = runTest {
|
||||
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||
coEvery { pruner.prune(any()) } throws IllegalStateException("boom")
|
||||
|
||||
assertEquals(Result.retry(), worker().doWork())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user