An on-device (androidTest) test proving PruneWorker and BackfillWorkerdefer (Result.retry()) while the encrypted cache is locked, using the realEncryptedCacheGuard — not the mocked guard the JVM PruneWorkerTest/BackfillWorkerTest use (#225).
EncryptedCacheGuard (app/src/main/kotlin/org/libremail/data/security/EncryptedCacheGuard.kt:27-30) depends only on SettingsRepository + PassphraseSession — never Keystore/BiometricPrompt — so the locked state is reproduced with no device auth: SettingsRepository is mocked (the same pattern DatabaseProvisionerInstrumentedTest already uses for its security/settings collaborators) to report appLock=true, encryptCache=true, and a real, never-unlock()ed PassphraseSession is passed alongside it into a genuine EncryptedCacheGuard instance.
PruneWorker/BackfillWorker (PruneWorker.kt:31, BackfillWorker.kt:34) are built via TestListenableWorkerBuilder with a custom WorkerFactory (their extra Hilt-@AssistedInject constructor args — Lazy<MailPruner>/Lazy<MailBackfiller> + EncryptedCacheGuard — aren't supported by the default factory), then doWork() is run under a bounded withTimeout and asserted to return Result.retry().
"No libremail.db connection is opened" is proven by construction: both workers can only reach the database through the Lazy pruner/backfiller, and MockK's verify(exactly = 0) { lazy.get() } proves that Lazy is never resolved — i.e. no DAO method, and so no Room/SQLCipher open, ever ran. (Inspecting the real on-disk libremail.db file directly would couple the test to whatever else the shared instrumentation process happens to do, so this is the deterministic alternative called out in the ticket's "e.g." framing.)
Two contrast tests exercise the real guard's own branch logic directly (app-lock off; session unlocked) — EncryptedCacheGuard had no test of its own anywhere in the suite before this, only callers mocking the whole guard — so the locked-path assertions above aren't vacuously true.
New file: app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt. Also adds androidx.work:work-testing (androidTestImplementation, pinned to the existing work version) since no instrumented worker test existed yet to reuse a dependency from.
Why this resolves the ticket's noted blocker
The ticket's follow-up comment flagged a seam problem: no HiltAndroidTest harness and SettingsRepository being a concrete (non-interface) class. Neither turned out to be necessary — DatabaseProvisionerInstrumentedTest.kt already establishes the pattern this PR follows: construct the real class under test directly (bypassing Hilt entirely) with a MockK-mocked SettingsRepository (androidTestImplementation(libs.mockk.android) was already present). No interface extraction or Hilt test infra needed.
A targeted single-class cold-boot emulator run was not performed: adb devices showed a peer agent's emulator already attached when I reached that step, and the instructions for this task are to never boot a second one concurrently. CI's full instrumented matrix is the validation path for the actual on-device run.
Closes #226
## What this adds
An on-device (androidTest) test proving `PruneWorker` and `BackfillWorker` **defer** (`Result.retry()`) while the encrypted cache is locked, using the **real** `EncryptedCacheGuard` — not the mocked guard the JVM `PruneWorkerTest`/`BackfillWorkerTest` use (#225).
- `EncryptedCacheGuard` (`app/src/main/kotlin/org/libremail/data/security/EncryptedCacheGuard.kt:27-30`) depends only on `SettingsRepository` + `PassphraseSession` — never Keystore/BiometricPrompt — so the locked state is reproduced with no device auth: `SettingsRepository` is mocked (the same pattern `DatabaseProvisionerInstrumentedTest` already uses for its security/settings collaborators) to report `appLock=true, encryptCache=true`, and a real, never-`unlock()`ed `PassphraseSession` is passed alongside it into a genuine `EncryptedCacheGuard` instance.
- `PruneWorker`/`BackfillWorker` (`PruneWorker.kt:31`, `BackfillWorker.kt:34`) are built via `TestListenableWorkerBuilder` with a custom `WorkerFactory` (their extra Hilt-`@AssistedInject` constructor args — `Lazy<MailPruner>`/`Lazy<MailBackfiller>` + `EncryptedCacheGuard` — aren't supported by the default factory), then `doWork()` is run under a bounded `withTimeout` and asserted to return `Result.retry()`.
- "No `libremail.db` connection is opened" is proven by construction: both workers can only reach the database through the `Lazy` pruner/backfiller, and MockK's `verify(exactly = 0) { lazy.get() }` proves that `Lazy` is never resolved — i.e. no DAO method, and so no Room/SQLCipher open, ever ran. (Inspecting the real on-disk `libremail.db` file directly would couple the test to whatever else the shared instrumentation process happens to do, so this is the deterministic alternative called out in the ticket's "e.g." framing.)
- Two contrast tests exercise the real guard's own branch logic directly (app-lock off; session unlocked) — `EncryptedCacheGuard` had no test of its own anywhere in the suite before this, only callers mocking the whole guard — so the locked-path assertions above aren't vacuously true.
New file: `app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt`. Also adds `androidx.work:work-testing` (`androidTestImplementation`, pinned to the existing `work` version) since no instrumented worker test existed yet to reuse a dependency from.
## Why this resolves the ticket's noted blocker
The ticket's follow-up comment flagged a seam problem: no `HiltAndroidTest` harness and `SettingsRepository` being a concrete (non-interface) class. Neither turned out to be necessary — `DatabaseProvisionerInstrumentedTest.kt` already establishes the pattern this PR follows: construct the real class under test directly (bypassing Hilt entirely) with a MockK-mocked `SettingsRepository` (`androidTestImplementation(libs.mockk.android)` was already present). No interface extraction or Hilt test infra needed.
## Validation
- `:app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt` — green.
- A targeted single-class cold-boot emulator run was **not** performed: `adb devices` showed a peer agent's emulator already attached when I reached that step, and the instructions for this task are to never boot a second one concurrently. CI's full instrumented matrix is the validation path for the actual on-device run.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #226
What this adds
An on-device (androidTest) test proving
PruneWorkerandBackfillWorkerdefer (Result.retry()) while the encrypted cache is locked, using the realEncryptedCacheGuard— not the mocked guard the JVMPruneWorkerTest/BackfillWorkerTestuse (#225).EncryptedCacheGuard(app/src/main/kotlin/org/libremail/data/security/EncryptedCacheGuard.kt:27-30) depends only onSettingsRepository+PassphraseSession— never Keystore/BiometricPrompt — so the locked state is reproduced with no device auth:SettingsRepositoryis mocked (the same patternDatabaseProvisionerInstrumentedTestalready uses for its security/settings collaborators) to reportappLock=true, encryptCache=true, and a real, never-unlock()edPassphraseSessionis passed alongside it into a genuineEncryptedCacheGuardinstance.PruneWorker/BackfillWorker(PruneWorker.kt:31,BackfillWorker.kt:34) are built viaTestListenableWorkerBuilderwith a customWorkerFactory(their extra Hilt-@AssistedInjectconstructor args —Lazy<MailPruner>/Lazy<MailBackfiller>+EncryptedCacheGuard— aren't supported by the default factory), thendoWork()is run under a boundedwithTimeoutand asserted to returnResult.retry().libremail.dbconnection is opened" is proven by construction: both workers can only reach the database through theLazypruner/backfiller, and MockK'sverify(exactly = 0) { lazy.get() }proves thatLazyis never resolved — i.e. no DAO method, and so no Room/SQLCipher open, ever ran. (Inspecting the real on-disklibremail.dbfile directly would couple the test to whatever else the shared instrumentation process happens to do, so this is the deterministic alternative called out in the ticket's "e.g." framing.)EncryptedCacheGuardhad no test of its own anywhere in the suite before this, only callers mocking the whole guard — so the locked-path assertions above aren't vacuously true.New file:
app/src/androidTest/kotlin/org/libremail/data/sync/WorkerCacheLockDeferralInstrumentedTest.kt. Also addsandroidx.work:work-testing(androidTestImplementation, pinned to the existingworkversion) since no instrumented worker test existed yet to reuse a dependency from.Why this resolves the ticket's noted blocker
The ticket's follow-up comment flagged a seam problem: no
HiltAndroidTestharness andSettingsRepositorybeing a concrete (non-interface) class. Neither turned out to be necessary —DatabaseProvisionerInstrumentedTest.ktalready establishes the pattern this PR follows: construct the real class under test directly (bypassing Hilt entirely) with a MockK-mockedSettingsRepository(androidTestImplementation(libs.mockk.android)was already present). No interface extraction or Hilt test infra needed.Validation
:app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt— green.adb devicesshowed a peer agent's emulator already attached when I reached that step, and the instructions for this task are to never boot a second one concurrently. CI's full instrumented matrix is the validation path for the actual on-device run.🤖 Generated with Claude Code