test(sync): instrumented cache-lock deferral for PruneWorker/BackfillWorker #272

Merged
JMR-dev merged 1 commits from test-226-worker-cachelock-deferral into main 2026-07-04 01:30:29 +00:00
JMR-dev commented 2026-07-04 01:15:49 +00:00 (Migrated from github.com)

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

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)
Sign in to join this conversation.