Merge main into chore-claudemd-dod-logging
This commit is contained in:
@@ -10,6 +10,7 @@ import dagger.assisted.Assisted
|
|||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import org.libremail.data.security.EncryptedCacheGuard
|
import org.libremail.data.security.EncryptedCacheGuard
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one bounded slice of the full-history backfill (issue #12). Cancellable (WorkManager stops it
|
* Runs one bounded slice of the full-history backfill (issue #12). Cancellable (WorkManager stops it
|
||||||
@@ -31,7 +32,10 @@ class BackfillWorker @AssistedInject constructor(
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
// Can't open the encrypted DB without the user present — retry later rather than parking a
|
// 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.
|
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
|
||||||
if (cacheGuard.isCacheLocked()) return Result.retry()
|
if (cacheGuard.isCacheLocked()) {
|
||||||
|
AppLog.i(TAG, "backfill deferred: cache locked")
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
return runCatching {
|
return runCatching {
|
||||||
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited
|
// 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
|
// to one slice per periodic run. runBackfill() returns true while any folder still has pages
|
||||||
@@ -39,8 +43,19 @@ class BackfillWorker @AssistedInject constructor(
|
|||||||
val mailBackfiller = backfiller.get()
|
val mailBackfiller = backfiller.get()
|
||||||
while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ }
|
while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ }
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { Result.success() },
|
onSuccess = {
|
||||||
onFailure = { error -> if (error is CancellationException) throw error else Result.retry() },
|
AppLog.i(TAG, "backfill worker: success")
|
||||||
|
Result.success()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
AppLog.w(TAG, "backfill worker: retry", error)
|
||||||
|
Result.retry()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "BackfillWorker"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import org.libremail.domain.model.ImapConnectionParams
|
|||||||
import org.libremail.domain.repository.MailRepository
|
import org.libremail.domain.repository.MailRepository
|
||||||
import org.libremail.mail.ImapClient
|
import org.libremail.mail.ImapClient
|
||||||
import org.libremail.power.BatteryStatusProvider
|
import org.libremail.power.BatteryStatusProvider
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.accountLogRef
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -65,13 +67,19 @@ class MailBackfiller @Inject constructor(
|
|||||||
* count as more work — it is retried on a future scheduled run instead of spun on back-to-back.
|
* count as more work — it is retried on a future scheduled run instead of spun on back-to-back.
|
||||||
*/
|
*/
|
||||||
suspend fun runBackfill(maxBatches: Int = DEFAULT_MAX_BATCHES): Boolean = maintenanceGate.mutex.withLock {
|
suspend fun runBackfill(maxBatches: Int = DEFAULT_MAX_BATCHES): Boolean = maintenanceGate.mutex.withLock {
|
||||||
|
AppLog.i(TAG, "backfill slice: maxBatches=$maxBatches")
|
||||||
var remaining = maxBatches
|
var remaining = maxBatches
|
||||||
var moreWork = false
|
var moreWork = false
|
||||||
for (account in accountDao.getAll().map { it.toDomain() }) {
|
accounts@ for (account in accountDao.getAll().map { it.toDomain() }) {
|
||||||
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: continue
|
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: continue
|
||||||
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
|
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
|
||||||
for (folder in messageDao.syncedFolders(account.id)) {
|
for (folder in messageDao.syncedFolders(account.id)) {
|
||||||
if (remaining <= 0) return@withLock true
|
if (remaining <= 0) {
|
||||||
|
// The budget ran out before every folder was visited, so there is very likely more
|
||||||
|
// work left even though nothing here reported it directly.
|
||||||
|
moreWork = true
|
||||||
|
break@accounts
|
||||||
|
}
|
||||||
// Per-folder failures (e.g. a transient server error) must not abort the whole slice.
|
// Per-folder failures (e.g. a transient server error) must not abort the whole slice.
|
||||||
val result = runCatching { backfillFolder(account, params, folder, policy, remaining) }
|
val result = runCatching { backfillFolder(account, params, folder, policy, remaining) }
|
||||||
.getOrElse { FolderResult(batches = 0, moreWork = true) }
|
.getOrElse { FolderResult(batches = 0, moreWork = true) }
|
||||||
@@ -79,6 +87,7 @@ class MailBackfiller @Inject constructor(
|
|||||||
if (result.moreWork) moreWork = true
|
if (result.moreWork) moreWork = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppLog.i(TAG, "backfill slice done: moreWork=$moreWork")
|
||||||
moreWork
|
moreWork
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +162,8 @@ class MailBackfiller @Inject constructor(
|
|||||||
delay(BACKFILL_BATCH_DELAY_MS)
|
delay(BACKFILL_BATCH_DELAY_MS)
|
||||||
}
|
}
|
||||||
if (complete) markComplete(account.id, folder, beforeUid)
|
if (complete) markComplete(account.id, folder, beforeUid)
|
||||||
|
val folderLabel = logSafeFolderLabel(folder)
|
||||||
|
AppLog.d(TAG, "backfill ${accountLogRef(account.id)} folder=$folderLabel pages=$batches complete=$complete")
|
||||||
return FolderResult(batches, moreWork = !complete && !stalled)
|
return FolderResult(batches, moreWork = !complete && !stalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +237,8 @@ class MailBackfiller @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val TAG = "MailBackfiller"
|
||||||
|
|
||||||
/** Headers fetched per server page. */
|
/** Headers fetched per server page. */
|
||||||
const val BACKFILL_BATCH_SIZE = 50
|
const val BACKFILL_BATCH_SIZE = 50
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.libremail.data.settings.AccountSettingsRepository
|
|||||||
import org.libremail.data.settings.RetentionPolicy
|
import org.libremail.data.settings.RetentionPolicy
|
||||||
import org.libremail.data.settings.SettingsRepository
|
import org.libremail.data.settings.SettingsRepository
|
||||||
import org.libremail.data.settings.effectiveRetention
|
import org.libremail.data.settings.effectiveRetention
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ class MailPruner @Inject constructor(
|
|||||||
if (policy.isUnlimited) continue
|
if (policy.isUnlimited) continue
|
||||||
removed += pruneAccount(account.id, policy, nowMillis)
|
removed += pruneAccount(account.id, policy, nowMillis)
|
||||||
}
|
}
|
||||||
|
AppLog.i(TAG, "prune done: removed=$removed")
|
||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ class MailPruner @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val TAG = "MailPruner"
|
||||||
|
|
||||||
/** Ids per DELETE, kept under SQLite's 999-host-parameter limit on older Android. */
|
/** Ids per DELETE, kept under SQLite's 999-host-parameter limit on older Android. */
|
||||||
const val DELETE_CHUNK = 500
|
const val DELETE_CHUNK = 500
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import org.libremail.domain.repository.MailRepository
|
|||||||
import org.libremail.mail.ImapClient
|
import org.libremail.mail.ImapClient
|
||||||
import org.libremail.notifications.MailNotifier
|
import org.libremail.notifications.MailNotifier
|
||||||
import org.libremail.power.BatteryStatusProvider
|
import org.libremail.power.BatteryStatusProvider
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.accountLogRef
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ class MailSyncer @Inject constructor(
|
|||||||
/** Syncs every account's inbox. Succeeds if at least one account synced (or there are none). */
|
/** Syncs every account's inbox. Succeeds if at least one account synced (or there are none). */
|
||||||
override suspend fun syncAll(): Result<Int> {
|
override suspend fun syncAll(): Result<Int> {
|
||||||
val accounts = accountDao.getAll().map { it.toDomain() }
|
val accounts = accountDao.getAll().map { it.toDomain() }
|
||||||
|
AppLog.i(TAG, "sync all: ${accounts.size} accounts")
|
||||||
if (accounts.isEmpty()) return Result.success(0)
|
if (accounts.isEmpty()) return Result.success(0)
|
||||||
|
|
||||||
val result = syncMutex.withLock {
|
val result = syncMutex.withLock {
|
||||||
@@ -64,6 +67,8 @@ class MailSyncer @Inject constructor(
|
|||||||
}
|
}
|
||||||
if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError)
|
if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError)
|
||||||
}
|
}
|
||||||
|
result.onSuccess { total -> AppLog.i(TAG, "sync all done: fetched=$total") }
|
||||||
|
.onFailure { error -> AppLog.w(TAG, "sync all failed", error) }
|
||||||
if (result.isSuccess) accounts.forEach { prefetchIfEnabled(it, INBOX) }
|
if (result.isSuccess) accounts.forEach { prefetchIfEnabled(it, INBOX) }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -147,6 +152,8 @@ class MailSyncer @Inject constructor(
|
|||||||
notifier.notifyNewMail(account, newMessages.sortedByDescending { it.timestampMillis })
|
notifier.notifyNewMail(account, newMessages.sortedByDescending { it.timestampMillis })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val folderLabel = logSafeFolderLabel(folder)
|
||||||
|
AppLog.d(TAG, "sync ${accountLogRef(account.id)} folder=$folderLabel fetched=${fetched.size}")
|
||||||
fetched.size
|
fetched.size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +179,7 @@ class MailSyncer @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val TAG = "MailSyncer"
|
||||||
const val INBOX = "INBOX"
|
const val INBOX = "INBOX"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import dagger.Lazy
|
|||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import org.libremail.data.security.EncryptedCacheGuard
|
import org.libremail.data.security.EncryptedCacheGuard
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enforces device-only retention (issue #13) by running [MailPruner]. Purely local — it never
|
* Enforces device-only retention (issue #13) by running [MailPruner]. Purely local — it never
|
||||||
@@ -28,10 +29,23 @@ class PruneWorker @AssistedInject constructor(
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
// Can't open the encrypted DB without the user present — retry later rather than parking a
|
// 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.
|
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
|
||||||
if (cacheGuard.isCacheLocked()) return Result.retry()
|
if (cacheGuard.isCacheLocked()) {
|
||||||
|
AppLog.i(TAG, "prune deferred: cache locked")
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
return runCatching { pruner.get().prune() }.fold(
|
return runCatching { pruner.get().prune() }.fold(
|
||||||
onSuccess = { Result.success() },
|
onSuccess = {
|
||||||
onFailure = { Result.retry() },
|
AppLog.i(TAG, "prune worker: success")
|
||||||
|
Result.success()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
AppLog.w(TAG, "prune worker: retry", error)
|
||||||
|
Result.retry()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "PruneWorker"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.data.sync
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder name safe to write to an [org.libremail.reporting.AppLog] breadcrumb (and so, in turn, a
|
||||||
|
* submitted [org.libremail.reporting.DebugReport]): the leaf name itself for a known **system** folder
|
||||||
|
* (INBOX, Sent, Drafts, Trash, Spam/Junk, Archive — including the alternate names real IMAP servers use
|
||||||
|
* for them), or a fixed placeholder for anything else. A user-created folder or label (e.g. a client
|
||||||
|
* name or project) can be PII-ish, so only this fixed, closed set of well-known names is ever logged
|
||||||
|
* verbatim; every other folder logs as the placeholder, regardless of nesting or the server's hierarchy
|
||||||
|
* delimiter. Matching is name-only — no server SPECIAL-USE attributes are available down here at the
|
||||||
|
* sync layer — so it is necessarily best-effort in the same way
|
||||||
|
* [org.libremail.domain.model.FolderRole.roleOf]'s display-name fallback is. That is the safe direction:
|
||||||
|
* a false negative just logs the placeholder, never a leaked name.
|
||||||
|
*/
|
||||||
|
internal fun logSafeFolderLabel(folder: String): String {
|
||||||
|
val leaf = folder.substringAfterLast('/').substringAfterLast('.').trim()
|
||||||
|
return if (leaf.lowercase() in SYSTEM_FOLDER_NAMES) leaf else FOLDER_PLACEHOLDER
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FOLDER_PLACEHOLDER = "<folder>"
|
||||||
|
|
||||||
|
/** Case-insensitive leaf names recognized as provider-supplied system folders, never user-created. */
|
||||||
|
private val SYSTEM_FOLDER_NAMES = setOf(
|
||||||
|
"inbox",
|
||||||
|
"sent",
|
||||||
|
"sent mail",
|
||||||
|
"sent items",
|
||||||
|
"sent messages",
|
||||||
|
"drafts",
|
||||||
|
"draft",
|
||||||
|
"junk",
|
||||||
|
"spam",
|
||||||
|
"junk e-mail",
|
||||||
|
"junk email",
|
||||||
|
"bulk mail",
|
||||||
|
"trash",
|
||||||
|
"deleted",
|
||||||
|
"deleted items",
|
||||||
|
"deleted messages",
|
||||||
|
"bin",
|
||||||
|
"archive",
|
||||||
|
"archives",
|
||||||
|
"all mail",
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ import dagger.Lazy
|
|||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import org.libremail.data.security.EncryptedCacheGuard
|
import org.libremail.data.security.EncryptedCacheGuard
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
class SyncWorker @AssistedInject constructor(
|
class SyncWorker @AssistedInject constructor(
|
||||||
@@ -23,10 +24,23 @@ class SyncWorker @AssistedInject constructor(
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
// Can't open the encrypted DB without the user present — retry later rather than parking a
|
// 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.
|
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
|
||||||
if (cacheGuard.isCacheLocked()) return Result.retry()
|
if (cacheGuard.isCacheLocked()) {
|
||||||
|
AppLog.i(TAG, "sync deferred: cache locked")
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
return mailSyncer.get().syncAll().fold(
|
return mailSyncer.get().syncAll().fold(
|
||||||
onSuccess = { Result.success() },
|
onSuccess = {
|
||||||
onFailure = { Result.retry() },
|
AppLog.i(TAG, "sync worker: success")
|
||||||
|
Result.success()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
AppLog.w(TAG, "sync worker: retry", error)
|
||||||
|
Result.retry()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "SyncWorker"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,59 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.work.ListenableWorker.Result
|
import androidx.work.ListenableWorker.Result
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.security.EncryptedCacheGuard
|
import org.libremail.data.security.EncryptedCacheGuard
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [BackfillWorker] must defer while the encrypted cache is locked rather than park this WorkManager
|
* [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]
|
* thread opening the DB. It gates on [EncryptedCacheGuard], resolving the (`Lazy`) [MailBackfiller]
|
||||||
* only once unlocked — the same pre-auth invariant `SyncWorker`/`SendWorker` enforce.
|
* only once unlocked — the same pre-auth invariant `SyncWorker`/`SendWorker` enforce. Also covers issue
|
||||||
|
* #329's AppLog breadcrumbs on the deferred/success/retry outcomes.
|
||||||
*/
|
*/
|
||||||
class BackfillWorkerTest {
|
class BackfillWorkerTest {
|
||||||
|
|
||||||
private val backfiller = mockk<MailBackfiller>()
|
private val backfiller = mockk<MailBackfiller>()
|
||||||
private val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
|
private val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
|
||||||
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
||||||
|
private val logBuffer = RingLogBuffer()
|
||||||
|
|
||||||
private fun worker() = BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard)
|
private fun worker() = BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here,
|
||||||
|
// mirroring org.libremail.reporting.AppLogTest — doWork() now breadcrumbs through AppLog.
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(logBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retries without resolving the backfiller when the cache is locked`() = runTest {
|
fun `retries without resolving the backfiller when the cache is locked`() = runTest {
|
||||||
coEvery { cacheGuard.isCacheLocked() } returns true
|
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||||
@@ -54,4 +82,44 @@ class BackfillWorkerTest {
|
|||||||
|
|
||||||
assertEquals(Result.retry(), worker().doWork())
|
assertEquals(Result.retry(), worker().doWork())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a deferred breadcrumb when the cache is locked, without touching the backfiller`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("backfill deferred: cache locked", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a success breadcrumb once every chained slice completes`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
|
coEvery { backfiller.runBackfill(any()) } returnsMany listOf(true, false)
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("backfill worker: success", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a scrubbed retry breadcrumb when backfilling throws`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
|
coEvery { backfiller.runBackfill(any()) } throws
|
||||||
|
IllegalStateException("auth failed for a@example.org")
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('W', entry.level)
|
||||||
|
assertTrue(entry.message.startsWith("backfill worker: retry"), entry.message)
|
||||||
|
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
|
||||||
|
assertFalse(entry.message.contains("a@example.org"), entry.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import com.icegreen.greenmail.util.GreenMail
|
import com.icegreen.greenmail.util.GreenMail
|
||||||
import com.icegreen.greenmail.util.ServerSetupTest
|
import com.icegreen.greenmail.util.ServerSetupTest
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import jakarta.mail.Folder
|
import jakarta.mail.Folder
|
||||||
import jakarta.mail.Message
|
import jakarta.mail.Message
|
||||||
import jakarta.mail.Session
|
import jakarta.mail.Session
|
||||||
@@ -38,6 +41,8 @@ import org.libremail.mail.FetchedMessage
|
|||||||
import org.libremail.mail.ImapClient
|
import org.libremail.mail.ImapClient
|
||||||
import org.libremail.power.BatteryStatus
|
import org.libremail.power.BatteryStatus
|
||||||
import org.libremail.power.BatteryStatusProvider
|
import org.libremail.power.BatteryStatusProvider
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -74,15 +79,33 @@ class MailBackfillerTest {
|
|||||||
// though `cached` would silently absorb it. Isolates the "no message fetched twice" guarantee.
|
// though `cached` would silently absorb it. Isolates the "no message fetched twice" guarantee.
|
||||||
private var totalOffered = 0
|
private var totalOffered = 0
|
||||||
|
|
||||||
|
// issue #329: AppLog breadcrumbs — a fresh buffer per test (a new instance per @Test, per JUnit4).
|
||||||
|
private val logBuffer = RingLogBuffer()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
|
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
|
||||||
greenMail.start()
|
greenMail.start()
|
||||||
greenMail.setUser("alice@example.org", "secret")
|
greenMail.setUser("alice@example.org", "secret")
|
||||||
|
|
||||||
|
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here
|
||||||
|
// for the whole class, mirroring org.libremail.reporting.AppLogTest — every test now exercises
|
||||||
|
// real backfill code, which breadcrumbs through AppLog.
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(logBuffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() = greenMail.stop()
|
fun tearDown() {
|
||||||
|
greenMail.stop()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
private fun params() = ImapConnectionParams(
|
private fun params() = ImapConnectionParams(
|
||||||
host = "127.0.0.1",
|
host = "127.0.0.1",
|
||||||
@@ -353,6 +376,68 @@ class MailBackfillerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `runBackfill logs a start breadcrumb, a per-folder breadcrumb, and a done breadcrumb`() = runTest {
|
||||||
|
appendMessages(TOTAL)
|
||||||
|
seedForegroundWindow()
|
||||||
|
|
||||||
|
backfiller(AccountSettings("acct")).runBackfill(maxBatches = 3)
|
||||||
|
|
||||||
|
val snapshot = logBuffer.snapshot()
|
||||||
|
val start = snapshot.first { it.message.startsWith("backfill slice:") }
|
||||||
|
val perFolder = snapshot.first { it.message.startsWith("backfill acct:") }
|
||||||
|
val done = snapshot.first { it.message.startsWith("backfill slice done:") }
|
||||||
|
assertEquals('I', start.level)
|
||||||
|
assertEquals("backfill slice: maxBatches=3", start.message)
|
||||||
|
assertEquals('D', perFolder.level)
|
||||||
|
assertTrue(perFolder.message.contains("folder=INBOX"), perFolder.message)
|
||||||
|
assertTrue(perFolder.message.contains("pages="), perFolder.message)
|
||||||
|
assertTrue(perFolder.message.contains("complete="), perFolder.message)
|
||||||
|
assertEquals('I', done.level)
|
||||||
|
assertTrue(done.message.contains("moreWork="), done.message)
|
||||||
|
snapshot.forEach { assertNoPii(it.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an already-complete folder logs no redundant per-folder breadcrumb on the next slice`() = runTest {
|
||||||
|
appendMessages(60)
|
||||||
|
seedForegroundWindow()
|
||||||
|
val backfiller = backfiller(AccountSettings("acct"))
|
||||||
|
var guard = 0
|
||||||
|
while (backfiller.runBackfill() && guard++ < 10) { /* drive to completion */ }
|
||||||
|
logBuffer.clear()
|
||||||
|
|
||||||
|
// The folder is already complete; this slice does no work for it.
|
||||||
|
backfiller.runBackfill()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
logBuffer.snapshot().none { it.message.startsWith("backfill acct:") },
|
||||||
|
"an already-complete folder must not spam a breadcrumb on every subsequent slice",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no slice of a full backfill ever logs the account's email, host, or a message address`() = runTest {
|
||||||
|
appendMessages(TOTAL)
|
||||||
|
seedForegroundWindow()
|
||||||
|
val backfiller = backfiller(AccountSettings("acct"))
|
||||||
|
|
||||||
|
var guard = 0
|
||||||
|
while (backfiller.runBackfill() && guard++ < 10) { /* drive to completion */ }
|
||||||
|
|
||||||
|
val snapshot = logBuffer.snapshot()
|
||||||
|
assertTrue(snapshot.isNotEmpty(), "the run must have logged something to be a meaningful check")
|
||||||
|
snapshot.forEach { assertNoPii(it.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No test fixture's email address or host may ever reach a log line — the hard PII rule. */
|
||||||
|
private fun assertNoPii(message: String) {
|
||||||
|
assertFalse(message.contains("@example.org"), message) // covers the account and every sender
|
||||||
|
assertFalse(message.contains("127.0.0.1"), message) // the GreenMail host
|
||||||
|
}
|
||||||
|
|
||||||
private fun fetchedMessage(uid: String) = FetchedMessage(
|
private fun fetchedMessage(uid: String) = FetchedMessage(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
sender = "Sender",
|
sender = "Sender",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -13,6 +16,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.local.dao.AccountDao
|
import org.libremail.data.local.dao.AccountDao
|
||||||
import org.libremail.data.local.dao.MessageDao
|
import org.libremail.data.local.dao.MessageDao
|
||||||
@@ -28,6 +33,8 @@ import org.libremail.mail.FetchedMessage
|
|||||||
import org.libremail.mail.ImapClient
|
import org.libremail.mail.ImapClient
|
||||||
import org.libremail.power.BatteryStatus
|
import org.libremail.power.BatteryStatus
|
||||||
import org.libremail.power.BatteryStatusProvider
|
import org.libremail.power.BatteryStatusProvider
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -52,6 +59,25 @@ class MailMaintenanceGateTest {
|
|||||||
smtp = ServerConfigEmbedded("127.0.0.1", 465, "NONE"),
|
smtp = ServerConfigEmbedded("127.0.0.1", 465, "NONE"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// issue #329: MailBackfiller/MailPruner now breadcrumb through AppLog, whose Logcat forwarding
|
||||||
|
// (`android.util.Log`) is a no-op stub under plain JVM unit tests — mock it statically, mirroring
|
||||||
|
// org.libremail.reporting.AppLogTest. The breadcrumbs themselves are asserted in
|
||||||
|
// MailBackfillerTest/MailPrunerTest; this class only needs to not crash.
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(RingLogBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gate's contract: everyone who acquires the *same* gate takes the *same* exclusive lock, so no
|
* The gate's contract: everyone who acquires the *same* gate takes the *same* exclusive lock, so no
|
||||||
* two critical sections overlap. Guards against a regression that hands out a fresh lock per access
|
* two critical sections overlap. Guards against a regression that hands out a fresh lock per access
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.just
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.local.dao.AccountDao
|
import org.libremail.data.local.dao.AccountDao
|
||||||
import org.libremail.data.local.dao.MessageDao
|
import org.libremail.data.local.dao.MessageDao
|
||||||
@@ -20,8 +25,11 @@ import org.libremail.data.settings.AccountSettingsRepository
|
|||||||
import org.libremail.data.settings.AppSettings
|
import org.libremail.data.settings.AppSettings
|
||||||
import org.libremail.data.settings.SettingsRepository
|
import org.libremail.data.settings.SettingsRepository
|
||||||
import org.libremail.domain.model.AccountSettings
|
import org.libremail.domain.model.AccountSettings
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +48,27 @@ class MailPrunerTest {
|
|||||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// issue #329: AppLog breadcrumbs — a fresh buffer per test (a new instance per @Test, per JUnit4).
|
||||||
|
private val logBuffer = RingLogBuffer()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here
|
||||||
|
// for the whole class, mirroring org.libremail.reporting.AppLogTest — every test now exercises
|
||||||
|
// real prune code, which breadcrumbs through AppLog.
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(logBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
private fun pruner(global: AppSettings, accountSettings: AccountSettings, messageDao: MessageDao): MailPruner {
|
private fun pruner(global: AppSettings, accountSettings: AccountSettings, messageDao: MessageDao): MailPruner {
|
||||||
val accountDao = mockk<AccountDao>()
|
val accountDao = mockk<AccountDao>()
|
||||||
coEvery { accountDao.getAll() } returns listOf(account)
|
coEvery { accountDao.getAll() } returns listOf(account)
|
||||||
@@ -136,4 +165,61 @@ class MailPrunerTest {
|
|||||||
coVerify(exactly = 0) { messageDao.syncedIdsOlderThan(any(), any()) }
|
coVerify(exactly = 0) { messageDao.syncedIdsOlderThan(any(), any()) }
|
||||||
coVerify(exactly = 0) { messageDao.syncedIdsBeyondCountInFolder(any(), any(), any()) }
|
coVerify(exactly = 0) { messageDao.syncedIdsBeyondCountInFolder(any(), any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prune logs a done breadcrumb with the removed count`() = runTest {
|
||||||
|
val messageDao = mockk<MessageDao>(relaxed = true)
|
||||||
|
coEvery { messageDao.syncedFolders("acct") } returns listOf("INBOX", "Archive")
|
||||||
|
coEvery { messageDao.syncedIdsBeyondCountInFolder("acct", "INBOX", 2) } returns
|
||||||
|
listOf("acct:INBOX:1", "acct:INBOX:2")
|
||||||
|
coEvery { messageDao.syncedIdsBeyondCountInFolder("acct", "Archive", 2) } returns listOf("acct:Archive:9")
|
||||||
|
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||||
|
|
||||||
|
pruner(
|
||||||
|
global = AppSettings(retentionCount = 2, retentionMonths = 0),
|
||||||
|
accountSettings = AccountSettings("acct"),
|
||||||
|
messageDao = messageDao,
|
||||||
|
).prune()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("prune done: removed=3", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prune logs a done breadcrumb even when nothing is removed`() = runTest {
|
||||||
|
val messageDao = mockk<MessageDao>(relaxed = true)
|
||||||
|
|
||||||
|
pruner(
|
||||||
|
global = AppSettings(retentionCount = 0, retentionMonths = 0),
|
||||||
|
accountSettings = AccountSettings("acct"),
|
||||||
|
messageDao = messageDao,
|
||||||
|
).prune()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("prune done: removed=0", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prune never logs the account email or host, even while removing rows by age and count`() = runTest {
|
||||||
|
val messageDao = mockk<MessageDao>(relaxed = true)
|
||||||
|
coEvery { messageDao.syncedFolders("acct") } returns listOf("INBOX")
|
||||||
|
coEvery { messageDao.syncedIdsOlderThan(any(), any()) } returns listOf("acct:INBOX:1")
|
||||||
|
coEvery { messageDao.syncedIdsBeyondCountInFolder("acct", "INBOX", 2) } returns listOf("acct:INBOX:2")
|
||||||
|
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||||
|
|
||||||
|
pruner(
|
||||||
|
global = AppSettings(retentionCount = 2, retentionMonths = 6),
|
||||||
|
accountSettings = AccountSettings("acct"),
|
||||||
|
messageDao = messageDao,
|
||||||
|
).prune()
|
||||||
|
|
||||||
|
logBuffer.snapshot().forEach { entry ->
|
||||||
|
assertFalse(entry.message.contains("a@example.org"), entry.message)
|
||||||
|
assertFalse(entry.message.contains("example.org"), entry.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.local.dao.AccountDao
|
import org.libremail.data.local.dao.AccountDao
|
||||||
import org.libremail.data.local.dao.BackfillProgressDao
|
import org.libremail.data.local.dao.BackfillProgressDao
|
||||||
@@ -28,6 +33,8 @@ import org.libremail.mail.FetchedMessage
|
|||||||
import org.libremail.mail.ImapClient
|
import org.libremail.mail.ImapClient
|
||||||
import org.libremail.power.BatteryStatus
|
import org.libremail.power.BatteryStatus
|
||||||
import org.libremail.power.BatteryStatusProvider
|
import org.libremail.power.BatteryStatusProvider
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -74,6 +81,25 @@ class MailSyncConcurrencyTest {
|
|||||||
useXoauth2 = false,
|
useXoauth2 = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// issue #329: MailSyncer/MailBackfiller/MailPruner now breadcrumb through AppLog, whose Logcat
|
||||||
|
// forwarding (`android.util.Log`) is a no-op stub under plain JVM unit tests — mock it statically,
|
||||||
|
// mirroring org.libremail.reporting.AppLogTest. The breadcrumbs themselves are asserted in
|
||||||
|
// MailSyncerTest/MailBackfillerTest/MailPrunerTest; this class only needs to not crash.
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(RingLogBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
// --- sync ↔ backfill -----------------------------------------------------------------------
|
// --- sync ↔ backfill -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
|
import android.util.Log
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.local.dao.AccountDao
|
import org.libremail.data.local.dao.AccountDao
|
||||||
import org.libremail.data.local.dao.MessageDao
|
import org.libremail.data.local.dao.MessageDao
|
||||||
@@ -28,12 +33,24 @@ import org.libremail.mail.ImapClient
|
|||||||
import org.libremail.notifications.MailNotifier
|
import org.libremail.notifications.MailNotifier
|
||||||
import org.libremail.power.BatteryStatus
|
import org.libremail.power.BatteryStatus
|
||||||
import org.libremail.power.BatteryStatusProvider
|
import org.libremail.power.BatteryStatusProvider
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also covers issue #329's net-new [AppLog] breadcrumbs: every test exercises real sync code, so
|
||||||
|
* `android.util.Log` (a no-op stub under plain JVM unit tests) is statically mocked here for the whole
|
||||||
|
* class, mirroring [org.libremail.reporting.AppLogTest]. [logBuffer] is fresh per test (a new
|
||||||
|
* [MailSyncerTest] instance per `@Test`, per JUnit4) and installed before every test so the dedicated
|
||||||
|
* breadcrumb tests below can assert against it directly.
|
||||||
|
*/
|
||||||
class MailSyncerTest {
|
class MailSyncerTest {
|
||||||
|
|
||||||
|
private val logBuffer = RingLogBuffer()
|
||||||
|
|
||||||
private val account = AccountEntity(
|
private val account = AccountEntity(
|
||||||
id = "acct",
|
id = "acct",
|
||||||
email = "a@example.org",
|
email = "a@example.org",
|
||||||
@@ -43,6 +60,21 @@ class MailSyncerTest {
|
|||||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(logBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
/** The IMAP client of the most recently built [syncer], for verifying the fetch window size. */
|
/** The IMAP client of the most recently built [syncer], for verifying the fetch window size. */
|
||||||
private lateinit var lastImapClient: ImapClient
|
private lateinit var lastImapClient: ImapClient
|
||||||
|
|
||||||
@@ -382,4 +414,61 @@ class MailSyncerTest {
|
|||||||
every { it.getSystemService(ConnectivityManager::class.java) } returns manager
|
every { it.getSystemService(ConnectivityManager::class.java) } returns manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `syncFolder records a debug breadcrumb with the account ref, folder, and fetched count`() = runTest {
|
||||||
|
val repo = mockk<MailRepository>(relaxed = true)
|
||||||
|
val fetched = listOf(
|
||||||
|
FetchedMessage("1", "Ada", "ada@example.org", "Secret subject", 1_000L, isRead = true, isFlagged = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
syncer(FetchPolicy.ON_DEMAND, repo, fetched = fetched).syncFolder("acct", "INBOX")
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('D', entry.level)
|
||||||
|
assertTrue(entry.message.contains("folder=INBOX"), entry.message)
|
||||||
|
assertTrue(entry.message.contains("fetched=1"), entry.message)
|
||||||
|
assertNoPii(entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `syncAll logs a start breadcrumb and a done breadcrumb with the fetched total`() = runTest {
|
||||||
|
val syncer = syncAllSyncer(accounts = listOf(accountEntity("one"), accountEntity("two")))
|
||||||
|
|
||||||
|
syncer.syncAll()
|
||||||
|
|
||||||
|
val snapshot = logBuffer.snapshot()
|
||||||
|
val start = snapshot.first { it.message.startsWith("sync all:") }
|
||||||
|
val done = snapshot.first { it.message.startsWith("sync all done:") }
|
||||||
|
assertEquals('I', start.level)
|
||||||
|
assertEquals("sync all: 2 accounts", start.message)
|
||||||
|
assertEquals('I', done.level)
|
||||||
|
assertEquals("sync all done: fetched=2", done.message)
|
||||||
|
snapshot.forEach { assertNoPii(it.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `syncAll logs a warning breadcrumb with the scrubbed failure when every account fails`() = runTest {
|
||||||
|
val syncer = syncAllSyncer(
|
||||||
|
accounts = listOf(accountEntity("bad1"), accountEntity("bad2")),
|
||||||
|
failingIds = setOf("bad1", "bad2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
syncer.syncAll()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().first { it.message.startsWith("sync all failed") }
|
||||||
|
assertEquals('W', entry.level)
|
||||||
|
// The throwable's class survives the scrub; its free-text message does not (issue #325).
|
||||||
|
assertTrue(entry.message.contains("IOException"), entry.message)
|
||||||
|
assertFalse(entry.message.contains("no network"), entry.message)
|
||||||
|
logBuffer.snapshot().forEach { assertNoPii(it.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No test fixture's email address or host may ever reach a log line — the hard PII rule. */
|
||||||
|
private fun assertNoPii(message: String) {
|
||||||
|
assertFalse(message.contains("@example.org"), message)
|
||||||
|
assertFalse(message.contains("example.org"), message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,60 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.work.ListenableWorker.Result
|
import androidx.work.ListenableWorker.Result
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.security.EncryptedCacheGuard
|
import org.libremail.data.security.EncryptedCacheGuard
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [PruneWorker] must not touch the database while the encrypted cache is locked: opening it would park
|
* [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).
|
* 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
|
* It gates on [EncryptedCacheGuard] and only resolves the (`Lazy`) [MailPruner] once unlocked — the
|
||||||
* invariant every pre-auth DB entry point shares with `SyncWorker`/`SendWorker`.
|
* invariant every pre-auth DB entry point shares with `SyncWorker`/`SendWorker`. Also covers issue
|
||||||
|
* #329's AppLog breadcrumbs on the deferred/success/retry outcomes.
|
||||||
*/
|
*/
|
||||||
class PruneWorkerTest {
|
class PruneWorkerTest {
|
||||||
|
|
||||||
private val pruner = mockk<MailPruner>()
|
private val pruner = mockk<MailPruner>()
|
||||||
private val lazyPruner = mockk<Lazy<MailPruner>> { every { get() } returns pruner }
|
private val lazyPruner = mockk<Lazy<MailPruner>> { every { get() } returns pruner }
|
||||||
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
||||||
|
private val logBuffer = RingLogBuffer()
|
||||||
|
|
||||||
private fun worker() = PruneWorker(mockk(relaxed = true), mockk(relaxed = true), lazyPruner, cacheGuard)
|
private fun worker() = PruneWorker(mockk(relaxed = true), mockk(relaxed = true), lazyPruner, cacheGuard)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here,
|
||||||
|
// mirroring org.libremail.reporting.AppLogTest — doWork() now breadcrumbs through AppLog.
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(logBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retries without resolving the pruner when the cache is locked`() = runTest {
|
fun `retries without resolving the pruner when the cache is locked`() = runTest {
|
||||||
coEvery { cacheGuard.isCacheLocked() } returns true
|
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||||
@@ -55,4 +83,43 @@ class PruneWorkerTest {
|
|||||||
|
|
||||||
assertEquals(Result.retry(), worker().doWork())
|
assertEquals(Result.retry(), worker().doWork())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a deferred breadcrumb when the cache is locked, without touching the pruner`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("prune deferred: cache locked", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a success breadcrumb when pruning succeeds`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
|
coEvery { pruner.prune(any()) } returns 5
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("prune worker: success", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a scrubbed retry breadcrumb when pruning throws`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
|
coEvery { pruner.prune(any()) } throws IllegalStateException("auth failed for a@example.org")
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('W', entry.level)
|
||||||
|
assertTrue(entry.message.startsWith("prune worker: retry"), entry.message)
|
||||||
|
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
|
||||||
|
assertFalse(entry.message.contains("a@example.org"), entry.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.data.sync
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [logSafeFolderLabel] is the PII gate between a raw IMAP folder path and an [org.libremail.reporting.AppLog]
|
||||||
|
* breadcrumb (issue #329's folder-name caveat): a known system folder logs by name, anything else logs
|
||||||
|
* as the fixed placeholder — never the real name, regardless of nesting or delimiter.
|
||||||
|
*/
|
||||||
|
class SyncLoggingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a top-level system folder logs verbatim`() {
|
||||||
|
assertEquals("INBOX", logSafeFolderLabel("INBOX"))
|
||||||
|
assertEquals("Sent", logSafeFolderLabel("Sent"))
|
||||||
|
assertEquals("Drafts", logSafeFolderLabel("Drafts"))
|
||||||
|
assertEquals("Trash", logSafeFolderLabel("Trash"))
|
||||||
|
assertEquals("Archive", logSafeFolderLabel("Archive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matching is case-insensitive but preserves the server's own casing`() {
|
||||||
|
assertEquals("JUNK", logSafeFolderLabel("JUNK"))
|
||||||
|
assertEquals("sent items", logSafeFolderLabel("sent items"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a system folder nested under a Gmail-style slash path logs only its leaf name`() {
|
||||||
|
assertEquals("Sent Mail", logSafeFolderLabel("[Gmail]/Sent Mail"))
|
||||||
|
assertEquals("All Mail", logSafeFolderLabel("[Gmail]/All Mail"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a system folder nested under a Dovecot-style dot path logs only its leaf name`() {
|
||||||
|
assertEquals("Trash", logSafeFolderLabel("INBOX.Trash"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a user-created top-level folder never logs its real name`() {
|
||||||
|
assertEquals("<folder>", logSafeFolderLabel("Projects"))
|
||||||
|
assertEquals("<folder>", logSafeFolderLabel("Invoice from Acme Corp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a user-created nested folder logs the placeholder, never its name or its parent`() {
|
||||||
|
assertEquals("<folder>", logSafeFolderLabel("Work/Reports"))
|
||||||
|
assertEquals("<folder>", logSafeFolderLabel("Clients/Acme Corp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a folder that merely shares a system folder's leaf name still logs only that leaf`() {
|
||||||
|
// Even a custom parent is never exposed — only the recognized leaf is ever logged, by design.
|
||||||
|
assertEquals("Archive", logSafeFolderLabel("Clients/Archive"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,59 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.data.sync
|
package org.libremail.data.sync
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.libremail.data.security.EncryptedCacheGuard
|
import org.libremail.data.security.EncryptedCacheGuard
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.RingLogBuffer
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [SyncWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in as regression
|
* [SyncWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in as regression
|
||||||
* cover for the class of bug fixed in PruneWorker/BackfillWorker — while the cache is locked it must
|
* cover for the class of bug fixed in PruneWorker/BackfillWorker — while the cache is locked it must
|
||||||
* defer without resolving the (`Lazy`) [MailSyncer] (whose construction opens the Room DB).
|
* defer without resolving the (`Lazy`) [MailSyncer] (whose construction opens the Room DB). Also covers
|
||||||
|
* issue #329's AppLog breadcrumbs on the deferred/success/retry outcomes.
|
||||||
*/
|
*/
|
||||||
class SyncWorkerTest {
|
class SyncWorkerTest {
|
||||||
|
|
||||||
private val mailSyncer = mockk<MailSyncer>()
|
private val mailSyncer = mockk<MailSyncer>()
|
||||||
private val lazySyncer = mockk<Lazy<MailSyncer>> { every { get() } returns mailSyncer }
|
private val lazySyncer = mockk<Lazy<MailSyncer>> { every { get() } returns mailSyncer }
|
||||||
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
private val cacheGuard = mockk<EncryptedCacheGuard>()
|
||||||
|
private val logBuffer = RingLogBuffer()
|
||||||
|
|
||||||
private fun worker() = SyncWorker(mockk(relaxed = true), mockk(relaxed = true), lazySyncer, cacheGuard)
|
private fun worker() = SyncWorker(mockk(relaxed = true), mockk(relaxed = true), lazySyncer, cacheGuard)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here,
|
||||||
|
// mirroring org.libremail.reporting.AppLogTest — doWork() now breadcrumbs through AppLog.
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any(), any()) } returns 0
|
||||||
|
every { Log.d(any(), any(), any()) } returns 0
|
||||||
|
every { Log.i(any(), any()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
every { Log.e(any(), any(), any()) } returns 0
|
||||||
|
AppLog.install(logBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = unmockkAll()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retries without resolving the syncer when the cache is locked`() = runTest {
|
fun `retries without resolving the syncer when the cache is locked`() = runTest {
|
||||||
coEvery { cacheGuard.isCacheLocked() } returns true
|
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||||
@@ -53,4 +81,43 @@ class SyncWorkerTest {
|
|||||||
|
|
||||||
assertEquals(ListenableWorker.Result.retry(), worker().doWork())
|
assertEquals(ListenableWorker.Result.retry(), worker().doWork())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a deferred breadcrumb when the cache is locked, without touching the syncer`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns true
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("sync deferred: cache locked", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a success breadcrumb when syncing succeeds`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
|
coEvery { mailSyncer.syncAll() } returns Result.success(3)
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('I', entry.level)
|
||||||
|
assertEquals("sync worker: success", entry.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logs a scrubbed retry breadcrumb when syncing fails`() = runTest {
|
||||||
|
coEvery { cacheGuard.isCacheLocked() } returns false
|
||||||
|
coEvery { mailSyncer.syncAll() } returns Result.failure(IllegalStateException("auth failed for a@example.org"))
|
||||||
|
|
||||||
|
worker().doWork()
|
||||||
|
|
||||||
|
val entry = logBuffer.snapshot().single()
|
||||||
|
assertEquals('W', entry.level)
|
||||||
|
assertTrue(entry.message.startsWith("sync worker: retry"), entry.message)
|
||||||
|
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
|
||||||
|
assertFalse(entry.message.contains("a@example.org"), entry.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user