perf(icloud): respect iCloud Mail IMAP connection & message-size limits #471
+121
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.domain.model.MailProvider
|
||||
|
||||
/**
|
||||
* On-device proof of issue #363's iCloud connection cap, on the REAL Android coroutine runtime (not
|
||||
* coroutines-test virtual time) across the CI API matrix. A real, production-wired [IcloudConnectionLimiter]
|
||||
* (its `@Inject` constructor, so this also pins the production cap — mirrored here as [PRODUCTION_CAP]) —
|
||||
* the per-account permit gate [MailBackfiller] consults around every connection it opens for an iCloud
|
||||
* account — must:
|
||||
*
|
||||
* - let up to the cap run concurrently with no waiting;
|
||||
* - make a caller past the cap wait for a live in-flight one to release, then resume it;
|
||||
* - never gate a non-iCloud account, even while an iCloud account's cap is fully held.
|
||||
*
|
||||
* Deliberately mock-free (no `mockk`, no framework `Context`) and uses only the public production
|
||||
* constructor (no internal test-only constructor, which — unlike the JVM `test` source set —
|
||||
* `androidTest` cannot see, mirroring [BackfillPacerInstrumentedTest]'s own hardcoded-cap idiom): the
|
||||
* limiter is the whole synchronisation primitive #363 adds, so exercising it directly is both the
|
||||
* faithful behavioural test and the most portable across API 29-37. The JVM `IcloudConnectionLimiterTest`
|
||||
* / `MailBackfillerTest` cover the same contract (with a smaller configured cap for speed) plus the full
|
||||
* backfiller wiring under coroutines-test.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IcloudConnectionLimiterInstrumentedTest {
|
||||
|
||||
private val icloudAccount = MailProvider.ICLOUD.createAccount("me@icloud.com")
|
||||
private val gmailAccount = MailProvider.GMAIL.createAccount("me@gmail.com")
|
||||
|
||||
@Test
|
||||
fun aCallerPastTheCapWaitsForALivePermitThenResumesOnceItReleases() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter()
|
||||
|
||||
// Exhaust every production permit for this account.
|
||||
val entered = List(PRODUCTION_CAP) { CompletableDeferred<Unit>() }
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holders = entered.map { deferred ->
|
||||
launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudAccount) {
|
||||
deferred.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
entered.forEach { it.await() }
|
||||
|
||||
val extraEntered = CompletableDeferred<Unit>()
|
||||
val extra = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudAccount) { extraEntered.complete(Unit) }
|
||||
}
|
||||
delay(PARK_PROBE_MS)
|
||||
assertFalse("a caller past the cap must wait while every permit is held", extraEntered.isCompleted)
|
||||
|
||||
release.complete(Unit)
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { extraEntered.await() }
|
||||
holders.forEach { it.join() }
|
||||
extra.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonIcloudAccountIsNeverGatedEvenWhileTheIcloudCapIsFullyHeld() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter()
|
||||
val entered = List(PRODUCTION_CAP) { CompletableDeferred<Unit>() }
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holders = entered.map { deferred ->
|
||||
launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudAccount) {
|
||||
deferred.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
entered.forEach { it.await() }
|
||||
|
||||
// A regression that gated every provider on one shared cap would hang this withTimeout.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(gmailAccount) { true } }
|
||||
|
||||
assertTrue("a non-iCloud account must never wait behind the iCloud-only cap", ran)
|
||||
release.complete(Unit)
|
||||
holders.forEach { it.join() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePermitReleasesEvenWhenTheGuardedBlockThrowsSoNoCallerIsStrandedWaiting() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter()
|
||||
|
||||
val failed = runCatching { limiter.withPermit(icloudAccount) { throw IllegalStateException("boom") } }
|
||||
assertTrue("the failing block propagates to the caller", failed.isFailure)
|
||||
|
||||
// A regression would hang here forever instead of acquiring the "stuck" permit.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(icloudAccount) { true } }
|
||||
assertTrue("a failed block must not strand its permit held", ran)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Mirrors [IcloudConnectionLimiter.MAX_CONCURRENT_CONNECTIONS] (`internal`, so not a symbolic
|
||||
* reference — see the class doc). A production regression to that constant would only make this
|
||||
* test over- or under-exhaust the cap, not silently pass, since every permit is awaited by name.
|
||||
*/
|
||||
const val PRODUCTION_CAP = 5
|
||||
|
||||
/** Slack given to a parked waiter to (wrongly) resume before we assert it is still parked. */
|
||||
const val PARK_PROBE_MS = 300L
|
||||
|
||||
/** Generous bound for the permit hand-off; only a real park/resume regression approaches it. */
|
||||
const val HAND_OFF_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.accountLogRef
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Proactive per-account concurrency cap for iCloud Mail (issue #363): the provider-specific policy that
|
||||
* keeps LibreMail's own connect-per-operation IMAP traffic ([org.libremail.mail.ImapClient]) from ever
|
||||
* requesting more than [maxConcurrentConnections] connections at once for one iCloud account. Apple
|
||||
* documents a 5-8 concurrent-connection ceiling per account; production pins to the conservative low end
|
||||
* ([MAX_CONCURRENT_CONNECTIONS]) so LibreMail never approaches it even when backfill, an interactive
|
||||
* fetch, IDLE, and a send happen to overlap.
|
||||
*
|
||||
* This is the *proactive* counterpart to the two mechanisms issues #360/#356 already built for the
|
||||
* shared sync engine — composing with them rather than duplicating either:
|
||||
* - [AccountThrottleGate] (#360) reacts *after* a provider has already throttled or locked the account;
|
||||
* this gate keeps LibreMail's own request pattern from approaching that point in the first place.
|
||||
* [MailBackfiller] consults both: the throttle gate first (skip an already-throttled account for the
|
||||
* rest of the slice), then this one around each connection it actually opens.
|
||||
* - [BackfillPacer] (#356) paces *how often* a new backfill slice starts; this gate bounds *how many*
|
||||
* connections may be open at once within a slice — a different axis, so the two never fight.
|
||||
*
|
||||
* A no-op for every other provider: [withPermit] resolves [account]'s IMAP host to a [MailProvider] and
|
||||
* only acquires a permit for [MailProvider.ICLOUD] — Gmail/Yahoo/AOL/Outlook accounts run [block]
|
||||
* immediately, unconstrained (their own caps are issues #361/#362/#364's concern, kept as their own
|
||||
* provider-scoped policy rather than a shared table, so the four tickets land independently).
|
||||
*
|
||||
* State is a per-account [Semaphore], created on first use and kept for the process lifetime (a handful
|
||||
* of accounts at most, so this never grows unbounded) — in-process only, like every other gate in this
|
||||
* package; a process restart simply starts every account back at full availability.
|
||||
*/
|
||||
@Singleton
|
||||
class IcloudConnectionLimiter internal constructor(private val maxConcurrentConnections: Int) {
|
||||
|
||||
/** Production wiring: the conservative, documented-cap-respecting default. */
|
||||
@Inject constructor() : this(MAX_CONCURRENT_CONNECTIONS)
|
||||
|
||||
private val permits = ConcurrentHashMap<String, Semaphore>()
|
||||
|
||||
/**
|
||||
* Runs [block] holding one of [account]'s connection permits when it is an iCloud account —
|
||||
* suspending until one frees rather than rejecting, so a caller at the cap simply waits its turn
|
||||
* instead of failing. Every other provider's account runs [block] immediately with no gating. The
|
||||
* permit is always released, even if [block] throws (via [kotlinx.coroutines.sync.withPermit]), so a
|
||||
* failed fetch can never strand another caller waiting forever.
|
||||
*/
|
||||
suspend fun <T> withPermit(account: Account, block: suspend () -> T): T {
|
||||
if (MailProvider.forImapHost(account.imap.host) != MailProvider.ICLOUD) return block()
|
||||
val semaphore = permits.computeIfAbsent(account.id) { Semaphore(maxConcurrentConnections) }
|
||||
if (semaphore.availablePermits == 0) {
|
||||
AppLog.i(TAG, "${accountLogRef(account.id)} connection cap reached; waiting for a permit")
|
||||
}
|
||||
return semaphore.withPermit { block() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "IcloudConnLimit"
|
||||
|
||||
/**
|
||||
* Apple documents 5-8 concurrent IMAP connections per account (issue #363); pinned to the
|
||||
* conservative low end rather than the documented maximum. `internal` so tests can assert the
|
||||
* production cap without a magic number.
|
||||
*/
|
||||
internal const val MAX_CONCURRENT_CONNECTIONS = 5
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ class MailBackfiller @Inject constructor(
|
||||
private val maintenanceGate: MailMaintenanceGate,
|
||||
private val throttleGate: AccountThrottleGate,
|
||||
private val interactiveGate: InteractiveImapGate,
|
||||
// iCloud-specific connection cap (#363); a no-op for every other provider — see its own KDoc.
|
||||
private val icloudConnectionLimiter: IcloudConnectionLimiter,
|
||||
private val bandwidthTracker: GmailBandwidthTracker,
|
||||
) {
|
||||
/** One folder's slice outcome: pages fetched, and whether an immediate follow-up slice has work to do. */
|
||||
@@ -184,7 +186,11 @@ class MailBackfiller @Inject constructor(
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
val fetched = imapClient.fetchOlderThan(params, folder, beforeUid, BACKFILL_BATCH_SIZE)
|
||||
// iCloud-specific connection cap (#363): waits for a free permit rather than opening past the
|
||||
// account's documented ceiling; a no-op for every other provider (IcloudConnectionLimiter).
|
||||
val fetched = icloudConnectionLimiter.withPermit(account) {
|
||||
imapClient.fetchOlderThan(params, folder, beforeUid, BACKFILL_BATCH_SIZE)
|
||||
}
|
||||
batches++
|
||||
val entities = fetched.map { it.toEntity(account.id, folder) }
|
||||
// Stop at the genuine end of the folder, or at the age floor (#13): a page ENTIRELY older
|
||||
@@ -292,7 +298,10 @@ class MailBackfiller @Inject constructor(
|
||||
* and backfill content paths can never disagree (#88/#89). Header paging above is not gated here:
|
||||
* WorkManager's battery-not-low constraint on the backfill work is the scheduler-level control.
|
||||
* Best-effort and cancellable between messages so an interruption stops promptly; anything not
|
||||
* fetched is filled in lazily when the message is opened.
|
||||
* fetched is filled in lazily when the message is opened. Each message's prefetch (its body plus
|
||||
* every attachment) is routed through [icloudConnectionLimiter] for an iCloud [account] — issue
|
||||
* #363's "K + attachments" share of the per-page connection count the ticket describes — and is a
|
||||
* no-op passthrough for every other provider.
|
||||
*/
|
||||
private suspend fun prefetchIfEnabled(account: Account, ids: List<String>) {
|
||||
// Debug-only fetch gate (issue #393): pause proactive body prefetch so a later open is a genuine
|
||||
@@ -322,7 +331,7 @@ class MailBackfiller @Inject constructor(
|
||||
}
|
||||
for (id in ids) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
mailRepository.prefetchMessage(id)
|
||||
icloudConnectionLimiter.withPermit(account) { mailRepository.prefetchMessage(id) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.mail.GraphSendException
|
||||
import org.libremail.mail.GraphSender
|
||||
import org.libremail.mail.IcloudSendLimits
|
||||
import org.libremail.mail.SendableAttachment
|
||||
import org.libremail.mail.SmtpSender
|
||||
import org.libremail.reporting.AppLog
|
||||
@@ -112,6 +113,10 @@ class SendWorker @AssistedInject constructor(
|
||||
if (account.authType == AuthType.OAUTH_OUTLOOK) {
|
||||
sendOutlook(connectionFactory, account, message, attachments)
|
||||
} else {
|
||||
// iCloud-specific size guard (#363): Apple caps outgoing message size, so this fails fast,
|
||||
// locally, and cleanly (caught by the runCatching below) before smtpSender ever opens a
|
||||
// connection — a no-op for every other provider.
|
||||
IcloudSendLimits.requireWithinLimit(account, message, attachments)
|
||||
smtpSender.send(
|
||||
connectionFactory.smtpParamsFor(account),
|
||||
from = account.email,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.accountLogRef
|
||||
|
||||
private const val BYTES_PER_MB = 1024L * 1024L
|
||||
|
||||
/** Rounds [this] many bytes up to the nearest whole megabyte, for a human-readable size in an error message. */
|
||||
private fun Long.toWholeMb(): Long = (this + BYTES_PER_MB - 1) / BYTES_PER_MB
|
||||
|
||||
/**
|
||||
* Thrown when an outgoing message would exceed iCloud Mail's documented size cap (issue #363). The
|
||||
* message text carries only byte counts — never a subject, address, or body fragment — so it is safe to
|
||||
* surface verbatim as the outbox row's user-visible error ([org.libremail.data.sync.SendWorker]) and to
|
||||
* pass to [AppLog], per the repo's PII-free logging rule.
|
||||
*/
|
||||
class MessageTooLargeException(val estimatedBytes: Long, val limitBytes: Long) :
|
||||
Exception(
|
||||
"Message is too large for iCloud Mail (about ${estimatedBytes.toWholeMb()} MB; " +
|
||||
"the limit is ${limitBytes.toWholeMb()} MB)",
|
||||
)
|
||||
|
||||
/**
|
||||
* Enforces iCloud Mail's documented ~20 MB outgoing message-size cap (issue #363) before [SmtpSender] is
|
||||
* ever asked to send. Apple's limit is the message as it travels the wire — body plus MIME/base64-encoded
|
||||
* attachments — so [requireWithinLimit] estimates that encoded size (base64 inflates binary bytes by
|
||||
* roughly 4/3) rather than comparing raw attachment file sizes directly against 20 MB, which would
|
||||
* under-count and let a doomed send through to a real server-side rejection. Mirrors [GraphSender]'s
|
||||
* pre-send `MAX_ATTACHMENT_BYTES` guard for the same reason: fail fast and locally, with a clear message,
|
||||
* rather than spend a connection on a send that cannot succeed.
|
||||
*
|
||||
* A no-op for every other provider — Gmail/Yahoo/AOL/Outlook each document their own outgoing-size
|
||||
* limits, tracked by issues #361/#362/#364, kept as their own provider-scoped policy rather than a shared
|
||||
* table so the four tickets land independently.
|
||||
*/
|
||||
object IcloudSendLimits {
|
||||
|
||||
/** Apple's documented outgoing message-size cap (body + attachments), unless using Mail Drop. */
|
||||
const val DOCUMENTED_LIMIT_BYTES = 20L * BYTES_PER_MB
|
||||
|
||||
/**
|
||||
* Throws [MessageTooLargeException] when [account] is an iCloud account and [message] plus
|
||||
* [attachments]' estimated encoded size exceeds [DOCUMENTED_LIMIT_BYTES]. A no-op for every other
|
||||
* provider, and for an iCloud message that fits.
|
||||
*/
|
||||
fun requireWithinLimit(account: Account, message: OutgoingMessage, attachments: List<SendableAttachment>) {
|
||||
if (MailProvider.forImapHost(account.imap.host) != MailProvider.ICLOUD) return
|
||||
val estimated = estimatedEncodedBytes(message, attachments)
|
||||
if (estimated <= DOCUMENTED_LIMIT_BYTES) return
|
||||
AppLog.w(
|
||||
TAG,
|
||||
"${accountLogRef(account.id)} outgoing message over iCloud size cap: " +
|
||||
"estimated=${estimated}B limit=${DOCUMENTED_LIMIT_BYTES}B",
|
||||
)
|
||||
throw MessageTooLargeException(estimated, DOCUMENTED_LIMIT_BYTES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the message's size once it is on the wire: plain/HTML body bytes as-is (this app's MIME
|
||||
* structure never base64-encodes the text parts — see [SmtpSender.setBody]) plus each attachment's
|
||||
* *base64-encoded* size (3 raw bytes become 4 encoded chars, rounded up to the next whole group the
|
||||
* way a real encoder pads a partial one).
|
||||
*/
|
||||
private fun estimatedEncodedBytes(message: OutgoingMessage, attachments: List<SendableAttachment>): Long {
|
||||
val textBytes = message.body.toByteArray(Charsets.UTF_8).size.toLong() +
|
||||
(message.bodyHtml?.toByteArray(Charsets.UTF_8)?.size?.toLong() ?: 0L)
|
||||
val attachmentEncodedBytes = attachments.sumOf { it.file.length().toBase64EncodedSize() }
|
||||
return textBytes + attachmentEncodedBytes
|
||||
}
|
||||
|
||||
private fun Long.toBase64EncodedSize(): Long =
|
||||
((this + BASE64_RAW_GROUP_SIZE - 1) / BASE64_RAW_GROUP_SIZE) * BASE64_ENCODED_GROUP_SIZE
|
||||
|
||||
private const val TAG = "IcloudSendLimits"
|
||||
|
||||
/** Base64 groups 3 raw bytes... */
|
||||
private const val BASE64_RAW_GROUP_SIZE = 3L
|
||||
|
||||
/** ...into 4 encoded characters. */
|
||||
private const val BASE64_ENCODED_GROUP_SIZE = 4L
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [IcloudConnectionLimiter] (issue #363) must cap an iCloud account's concurrent connections at its
|
||||
* configured limit — a caller past the cap waits for a free permit rather than failing — isolate that
|
||||
* budget per account, release a stuck permit even when the guarded block throws, and pass every other
|
||||
* provider's account straight through, unconstrained.
|
||||
*
|
||||
* Deliberately real-dispatcher (`runBlocking` + `Dispatchers.Default`), not coroutines-test virtual time:
|
||||
* the behaviour under test is real suspension shared across two independently launched coroutines via a
|
||||
* [kotlinx.coroutines.sync.Semaphore], which a virtual clock cannot observe. Mirrors
|
||||
* [org.libremail.data.sync.BackfillPacerTest]'s "composes with" tests and the #355
|
||||
* `InteractiveImapGateInstrumentedTest` park/resume idiom.
|
||||
*/
|
||||
class IcloudConnectionLimiterTest {
|
||||
|
||||
private val logBuffer = RingLogBuffer()
|
||||
|
||||
private val icloud = MailProvider.ICLOUD.createAccount("me@icloud.com")
|
||||
private val gmail = MailProvider.GMAIL.createAccount("me@gmail.com")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// AppLog forwards to android.util.Log, a throwing no-op stub under plain JVM unit tests; fully
|
||||
// qualified (no import) per the ForbiddenImport style already used by GraphSenderSendTest.
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.i(any(), any()) } returns 0
|
||||
AppLog.install(logBuffer)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkAll()
|
||||
|
||||
@Test
|
||||
fun `a second caller waits for the first to release when the cap is one`() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holder = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
|
||||
val secondEntered = CompletableDeferred<Unit>()
|
||||
val second = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) { secondEntered.complete(Unit) }
|
||||
}
|
||||
delay(PARK_PROBE_MS)
|
||||
assertFalse(secondEntered.isCompleted, "the second caller must wait for the permit")
|
||||
|
||||
release.complete(Unit)
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { secondEntered.await() }
|
||||
holder.join()
|
||||
second.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up to the cap runs concurrently without waiting`() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 2)
|
||||
val entered1 = CompletableDeferred<Unit>()
|
||||
val entered2 = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val first = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) {
|
||||
entered1.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
val second = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) {
|
||||
entered2.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
|
||||
// Both entering (rather than one waiting on the other) proves the cap of 2 admits 2 at once.
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) {
|
||||
entered1.await()
|
||||
entered2.await()
|
||||
}
|
||||
|
||||
release.complete(Unit)
|
||||
first.join()
|
||||
second.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every other provider runs unconstrained even while the cap is fully held`() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holder = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) { release.await() }
|
||||
}
|
||||
|
||||
// A Gmail account must never wait behind the iCloud-only cap — a regression would hang this.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(gmail) { true } }
|
||||
|
||||
assertTrue(ran)
|
||||
release.complete(Unit)
|
||||
holder.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two iCloud accounts each get their own budget`() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
val other = MailProvider.ICLOUD.createAccount("someone-else@icloud.com")
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holder = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) { release.await() }
|
||||
}
|
||||
|
||||
// A different account's own single permit is untouched by the first account's held permit.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(other) { true } }
|
||||
|
||||
assertTrue(ran)
|
||||
release.complete(Unit)
|
||||
holder.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the permit releases even when the guarded block throws`() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
|
||||
val failed = runCatching { limiter.withPermit(icloud) { throw IllegalStateException("boom") } }
|
||||
|
||||
assertTrue(failed.isFailure)
|
||||
// A regression would hang here forever waiting on the "stuck" permit instead of acquiring it.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(icloud) { true } }
|
||||
assertTrue(ran)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waiting for a full cap logs a PII-free breadcrumb`() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holder = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloud) {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
|
||||
val second = launch(Dispatchers.Default) { limiter.withPermit(icloud) { } }
|
||||
delay(PARK_PROBE_MS)
|
||||
|
||||
val messages = logBuffer.snapshot().map { it.message }
|
||||
assertTrue(messages.any { it.contains("connection cap reached") }, "messages=$messages")
|
||||
messages.forEach { assertFalse(it.contains("icloud.com"), it) }
|
||||
|
||||
release.complete(Unit)
|
||||
holder.join()
|
||||
second.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `production wiring uses the documented conservative cap`() {
|
||||
assertEquals(5, IcloudConnectionLimiter.MAX_CONCURRENT_CONNECTIONS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Slack given to a parked waiter to (wrongly) resume before we assert it is still parked. */
|
||||
const val PARK_PROBE_MS = 300L
|
||||
|
||||
/** Generous bound for a permit hand-off; only a real park/resume regression approaches it. */
|
||||
const val HAND_OFF_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.BackfillProgressEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.AppSettings
|
||||
@@ -42,6 +43,7 @@ import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.FetchedMessage
|
||||
@@ -530,6 +532,94 @@ class MailBackfillerTest {
|
||||
assertTrue(logBuffer.snapshot().any { it.message.startsWith("backfill skip acct:") })
|
||||
}
|
||||
|
||||
// --- issue #363: iCloud Mail connection cap --------------------------------------------------
|
||||
|
||||
/**
|
||||
* The core of #363: an iCloud account's page fetch is routed through [IcloudConnectionLimiter], so
|
||||
* once its one connection permit is held elsewhere backfill's next page WAITS for it to free instead
|
||||
* of opening past iCloud's documented cap — proven by holding the limiter's only permit externally
|
||||
* and observing backfill park until it releases. Mirrors the #355 park/resume test's real-dispatcher
|
||||
* hand-off idiom just below (a virtual-time `runTest` can't observe a real suspend shared by two
|
||||
* launched coroutines across a [kotlinx.coroutines.sync.Semaphore]).
|
||||
*/
|
||||
@Test
|
||||
fun `an iCloud account's backfill waits for a connection permit instead of exceeding the cap`() =
|
||||
runBlocking<Unit> {
|
||||
cached += fetchedMessage(uid = "60").toEntity("acct", "INBOX")
|
||||
val icloudEntity = accountEntity.copy(imap = ServerConfigEmbedded("imap.mail.me.com", 993, "SSL_TLS"))
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
val fetched = CompletableDeferred<Unit>()
|
||||
val imapClient = mockk<ImapClient>()
|
||||
coEvery { imapClient.fetchOlderThan(any(), any(), any(), any()) } coAnswers {
|
||||
fetched.complete(Unit)
|
||||
emptyList() // one page, then the folder completes and the slice ends
|
||||
}
|
||||
|
||||
// Something else already holds the account's one iCloud connection permit.
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holder = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudEntity.toDomain()) {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
|
||||
val backfillJob = launch(Dispatchers.Default) {
|
||||
backfiller(
|
||||
AccountSettings("acct"),
|
||||
imapClient = imapClient,
|
||||
icloudConnectionLimiter = limiter,
|
||||
account = icloudEntity,
|
||||
).runBackfill()
|
||||
}
|
||||
delay(PARK_PROBE_MS)
|
||||
assertFalse(fetched.isCompleted, "backfill must wait for a free connection permit")
|
||||
|
||||
release.complete(Unit)
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { backfillJob.join() }
|
||||
assertTrue(fetched.isCompleted, "backfill proceeds once a permit frees")
|
||||
holder.join()
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-iCloud account (the GreenMail fixture's host) is never gated by [IcloudConnectionLimiter]:
|
||||
* exhausting the SAME limiter instance's one permit for an unrelated iCloud account must not affect
|
||||
* it — the gate is iCloud-only policy, not a general connection pool (#361/#362/#364 are unaffected).
|
||||
*/
|
||||
@Test
|
||||
fun `a non-iCloud account's backfill is never gated by the iCloud connection limiter`() = runBlocking<Unit> {
|
||||
cached += fetchedMessage(uid = "60").toEntity("acct", "INBOX")
|
||||
val limiter = IcloudConnectionLimiter(maxConcurrentConnections = 1)
|
||||
val imapClient = mockk<ImapClient>(relaxed = true)
|
||||
coEvery { imapClient.fetchOlderThan(any(), any(), any(), any()) } returns emptyList()
|
||||
|
||||
// Hold the one permit for a DIFFERENT, genuinely-iCloud account.
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holder = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(MailProvider.ICLOUD.createAccount("other@icloud.com")) {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
|
||||
val moreWork = withTimeout(HAND_OFF_TIMEOUT_MS) {
|
||||
backfiller(
|
||||
AccountSettings("acct"),
|
||||
imapClient = imapClient,
|
||||
icloudConnectionLimiter = limiter,
|
||||
).runBackfill()
|
||||
}
|
||||
|
||||
assertFalse(moreWork)
|
||||
coVerify(atLeast = 1) { imapClient.fetchOlderThan(any(), any(), any(), any()) }
|
||||
release.complete(Unit)
|
||||
holder.join()
|
||||
}
|
||||
|
||||
// --- issue #355: interactive-fetch priority -------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -737,6 +827,7 @@ class MailBackfillerTest {
|
||||
imapClient: ImapClient = client,
|
||||
throttleGate: AccountThrottleGate = AccountThrottleGate(),
|
||||
interactiveGate: InteractiveImapGate = InteractiveImapGate(),
|
||||
icloudConnectionLimiter: IcloudConnectionLimiter = IcloudConnectionLimiter(),
|
||||
bandwidthTracker: GmailBandwidthTracker = GmailBandwidthTracker(),
|
||||
account: AccountEntity = accountEntity,
|
||||
): MailBackfiller {
|
||||
@@ -796,6 +887,7 @@ class MailBackfillerTest {
|
||||
maintenanceGate = MailMaintenanceGate(),
|
||||
throttleGate = throttleGate,
|
||||
interactiveGate = interactiveGate,
|
||||
icloudConnectionLimiter = icloudConnectionLimiter,
|
||||
bandwidthTracker = bandwidthTracker,
|
||||
).also {
|
||||
lastMessageDao = messageDao
|
||||
|
||||
@@ -202,6 +202,7 @@ class MailMaintenanceGateTest {
|
||||
maintenanceGate = gate,
|
||||
throttleGate = AccountThrottleGate(),
|
||||
interactiveGate = InteractiveImapGate(),
|
||||
icloudConnectionLimiter = IcloudConnectionLimiter(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@ class MailSyncConcurrencyTest {
|
||||
maintenanceGate = MailMaintenanceGate(),
|
||||
throttleGate = AccountThrottleGate(),
|
||||
interactiveGate = InteractiveImapGate(),
|
||||
icloudConnectionLimiter = IcloudConnectionLimiter(),
|
||||
bandwidthTracker = GmailBandwidthTracker(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import org.libremail.reporting.accountLogRef
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
@@ -221,6 +222,47 @@ class SendWorkerTest {
|
||||
messages.forEach { assertFalse(it.contains("acct@example.org"), it) }
|
||||
}
|
||||
|
||||
// --- issue #363: iCloud message-size cap -----------------------------------------------------
|
||||
|
||||
private fun icloudAccount(id: String = "acct") =
|
||||
account(id, "PASSWORD_IMAP").copy(imap = ServerConfigEmbedded("imap.mail.me.com", 993, "SSL_TLS"))
|
||||
|
||||
/** A sparse file of exactly [bytes] — allocates the size without writing content (fast, no disk churn). */
|
||||
private fun stageFileOfSize(messageId: String, index: Int, name: String, bytes: Long) {
|
||||
val file = File(cacheDir, "outbox/$messageId/$index").apply { mkdirs() }.resolve(name)
|
||||
RandomAccessFile(file, "rw").use { it.setLength(bytes) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an oversized iCloud message fails cleanly without ever attempting to send (issue #363)`() = runTest {
|
||||
stageFileOfSize("m1", index = 0, name = "big.bin", bytes = 21L * 1024 * 1024) // over the 20 MB cap
|
||||
val attachmentsJson = listOf(OutgoingAttachment(uri = "content://1", name = "big.bin"))
|
||||
.toOutgoingAttachmentsJson()
|
||||
coEvery { outboxDao.getAll() } returns listOf(entity(attachments = attachmentsJson))
|
||||
coEvery { accountDao.getById("acct") } returns icloudAccount()
|
||||
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
|
||||
|
||||
assertEquals(Result.retry(), worker().doWork())
|
||||
|
||||
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
|
||||
coVerify { outboxDao.setError("m1", match { it.contains("iCloud Mail") && it.contains("MB") }) }
|
||||
coVerify(exactly = 0) { outboxDao.delete(any()) }
|
||||
val messages = logBuffer.snapshot().map { it.message }
|
||||
messages.forEach { assertFalse(it.contains("acct@example.org"), it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an iCloud message within the size cap sends normally`() = runTest {
|
||||
coEvery { outboxDao.getAll() } returns listOf(entity())
|
||||
coEvery { accountDao.getById("acct") } returns icloudAccount()
|
||||
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
|
||||
|
||||
assertEquals(Result.success(), worker().doWork())
|
||||
|
||||
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
|
||||
coVerify { outboxDao.delete("m1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sends an Outlook message over Graph, logging a PII-free breadcrumb`() = runTest {
|
||||
coEvery { outboxDao.getAll() } returns listOf(entity())
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [IcloudSendLimits] (issue #363) must enforce Apple's documented ~20 MB outgoing message-size cap for
|
||||
* iCloud accounts only, estimating the *encoded* wire size (base64 inflates binary attachment bytes by
|
||||
* ~4/3) rather than comparing raw file bytes directly — so a message can be rejected even when every
|
||||
* individual attachment's raw size looks like it fits — and it must never touch any other provider.
|
||||
*/
|
||||
class IcloudSendLimitsTest {
|
||||
|
||||
private val logBuffer = RingLogBuffer()
|
||||
|
||||
private val icloudAccount = MailProvider.ICLOUD.createAccount("me@icloud.com")
|
||||
private val gmailAccount = MailProvider.GMAIL.createAccount("me@gmail.com")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// AppLog forwards to android.util.Log, a throwing no-op stub under plain JVM unit tests; fully
|
||||
// qualified (no import) per the ForbiddenImport style already used by GraphSenderSendTest.
|
||||
mockkStatic(android.util.Log::class)
|
||||
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||
AppLog.install(logBuffer)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkAll()
|
||||
|
||||
private fun message(body: String = "hi") =
|
||||
OutgoingMessage(accountId = icloudAccount.id, to = "bob@example.org", subject = "Hi", body = body)
|
||||
|
||||
/** A sparse file of exactly [bytes] — allocates the size without writing content (fast, no disk churn). */
|
||||
private fun fileOfSize(bytes: Long): File {
|
||||
val file = File.createTempFile("icloud-limit", ".bin")
|
||||
RandomAccessFile(file, "rw").use { it.setLength(bytes) }
|
||||
file.deleteOnExit()
|
||||
return file
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a small iCloud message passes`() {
|
||||
val attachment = SendableAttachment(fileOfSize(1024))
|
||||
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(), listOf(attachment))
|
||||
// No exception: reaching this line is the assertion.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an attachment exactly at the encoded boundary passes`() {
|
||||
// Empty body so only the attachment's encoded size is in play. Raw bytes chosen so ceil(raw/3)*4
|
||||
// lands EXACTLY on the documented cap (a multiple of 3, so the base64 group count divides evenly):
|
||||
// 15_728_640 * 4 / 3 = 20_971_520 = DOCUMENTED_LIMIT_BYTES.
|
||||
val attachment = SendableAttachment(fileOfSize(15_728_640L))
|
||||
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(body = ""), listOf(attachment))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an attachment one base64 group past the boundary fails`() {
|
||||
// One byte over the exact-boundary raw size above rolls the base64 group count up by one full
|
||||
// 4-byte group, pushing the estimate just past the cap. Empty body keeps the math exact.
|
||||
val attachment = SendableAttachment(fileOfSize(15_728_641L))
|
||||
|
||||
val ex = assertFailsWithMessageTooLarge {
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(body = ""), listOf(attachment))
|
||||
}
|
||||
assertTrue(ex.estimatedBytes > ex.limitBytes)
|
||||
assertEquals(IcloudSendLimits.DOCUMENTED_LIMIT_BYTES, ex.limitBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base64 inflation alone can push a raw-under-cap attachment over the encoded cap`() {
|
||||
// 15.8 MB raw is comfortably UNDER the 20 MB documented cap, but base64 (~4/3 inflation) encodes
|
||||
// it to just over 20 MB — proving the guard checks the encoded size, not the raw file size.
|
||||
val rawBytes = 15_800_000L
|
||||
assertTrue(rawBytes < IcloudSendLimits.DOCUMENTED_LIMIT_BYTES, "the raw size must look like it fits")
|
||||
val attachment = SendableAttachment(fileOfSize(rawBytes))
|
||||
|
||||
assertFailsWithMessageTooLarge {
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(), listOf(attachment))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a large body alone, with no attachments, can exceed the cap`() {
|
||||
val hugeBody = "a".repeat(21_000_000) // ~21 MB of body text, over the 20 MB cap on its own
|
||||
|
||||
assertFailsWithMessageTooLarge {
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(hugeBody), emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `body bytes count toward the total alongside attachments`() {
|
||||
// Neither the body nor the attachment alone would trip the cap, but together they do — proving
|
||||
// body bytes are added to the estimate rather than the check considering attachments only.
|
||||
val body = "a".repeat(2_000_000) // 2 MB
|
||||
val attachment = SendableAttachment(fileOfSize(14_300_000L)) // encodes to ~18.2 MB, alone under cap
|
||||
|
||||
// The attachment alone (empty body) must NOT trip the cap.
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(), listOf(attachment))
|
||||
|
||||
// The same attachment WITH the 2 MB body pushes the estimate over the cap.
|
||||
assertFailsWithMessageTooLarge {
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(body), listOf(attachment))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every other provider is never gated, even wildly over iCloud's cap`() {
|
||||
val attachment = SendableAttachment(fileOfSize(50L * 1024 * 1024)) // 50 MB, far over iCloud's cap
|
||||
|
||||
IcloudSendLimits.requireWithinLimit(gmailAccount, message(), listOf(attachment))
|
||||
// No exception for Gmail: reaching this line is the assertion.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an oversized send logs a PII-free breadcrumb naming only byte counts`() {
|
||||
val attachment = SendableAttachment(fileOfSize(21L * 1024 * 1024))
|
||||
|
||||
assertFailsWithMessageTooLarge {
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(), listOf(attachment))
|
||||
}
|
||||
|
||||
val messages = logBuffer.snapshot().map { it.message }
|
||||
assertTrue(messages.any { it.contains("over iCloud size cap") }, "messages=$messages")
|
||||
messages.forEach { line ->
|
||||
assertFalse(line.contains("@icloud.com"), line)
|
||||
assertFalse(line.contains("me@"), line)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the exception message names iCloud Mail and both sizes in whole megabytes, with no PII`() {
|
||||
val attachment = SendableAttachment(fileOfSize(25L * 1024 * 1024))
|
||||
|
||||
val ex = assertFailsWithMessageTooLarge {
|
||||
IcloudSendLimits.requireWithinLimit(icloudAccount, message(), listOf(attachment))
|
||||
}
|
||||
|
||||
val text = requireNotNull(ex.message)
|
||||
assertTrue(text.contains("iCloud Mail"), text)
|
||||
assertTrue(text.contains("MB"), text)
|
||||
assertFalse(text.contains("@"), text)
|
||||
}
|
||||
|
||||
private fun assertFailsWithMessageTooLarge(block: () -> Unit): MessageTooLargeException {
|
||||
val failure = runCatching(block)
|
||||
assertTrue(failure.isFailure, "expected a MessageTooLargeException")
|
||||
val exception = failure.exceptionOrNull()
|
||||
assertTrue(exception is MessageTooLargeException, "expected MessageTooLargeException, was $exception")
|
||||
return exception
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,12 @@ complexity:
|
||||
# logging (issue #358) added its required android.util.Log mock + one breadcrumb test, tipping it
|
||||
# over. Excluded rather than artificially split — same "operation-rich cohesive suite" rationale as
|
||||
# the TooManyFunctions relaxation above.
|
||||
excludes: ['**/data/repository/MailRepositoryImplTest.kt']
|
||||
#
|
||||
# MailBackfillerTest is the same pattern: one cohesive single-SUT suite (a test per backfill concern
|
||||
# — #12 core paging, #94/#95 boundary edge cases, #322 batching, #360 throttle, #355 interactive
|
||||
# priority, #329 logging) already at the boundary; the iCloud connection-cap wiring tests (issue
|
||||
# #363) tipped it over.
|
||||
excludes: ['**/data/repository/MailRepositoryImplTest.kt', '**/data/sync/MailBackfillerTest.kt']
|
||||
|
||||
naming:
|
||||
FunctionNaming:
|
||||
|
||||
Reference in New Issue
Block a user