test(imap): cover on-demand failure path; bound the cooldown map

Add a MockBackend error mode and a test asserting a failed on-demand body
fetch returns NO [UNAVAILABLE] (not a placeholder body) and arms the
cooldown, and that a cooled-down mail then short-circuits to NO without
touching the backend. Also prune elapsed entries when arming a cooldown so
the map stays bounded by the mails currently cooling down rather than every
mail that has ever failed.
This commit is contained in:
Anthony
2026-06-16 16:24:28 +02:00
parent 55546990d3
commit e743f3184d
2 changed files with 57 additions and 2 deletions
+51
View File
@@ -1640,6 +1640,7 @@ mod tests {
unread_calls: Mutex<Vec<(Vec<IdTupleGenerated>, bool)>>,
sent: Mutex<Vec<ParsedMessage>>,
moved: Mutex<Vec<(Vec<IdTupleGenerated>, String)>>,
fail_details: Mutex<bool>,
}
impl MockBackend {
@@ -1651,9 +1652,14 @@ mod tests {
unread_calls: Mutex::new(Vec::new()),
sent: Mutex::new(Vec::new()),
moved: Mutex::new(Vec::new()),
fail_details: Mutex::new(false),
}
}
fn set_fail_details(&self, v: bool) {
*self.fail_details.lock().unwrap() = v;
}
fn with_mails(mails: Vec<Mail>) -> Self {
let m = Self::new();
*m.mails.lock().unwrap() = mails;
@@ -1736,6 +1742,48 @@ mod tests {
assert_eq!(moved[0].0.len(), 1);
}
#[tokio::test]
async fn on_demand_body_fetch_failure_returns_no_and_cooldowns() {
let mail = make_mail("ed", "subj", false);
let backend = Arc::new(MockBackend::with_mails(vec![mail.clone()]));
backend.set_fail_details(true);
let store = MailStore::new();
store.set_folder_list(vec![inbox_folder()]).await;
store
.set_folder(
"inbox",
vec![StoredMail {
mail,
details: None,
rfc2822: None,
uid: 1,
attachments_pending: false,
}],
)
.await;
let mut session = ImapSession::new(store.clone(), backend.clone(), None, None);
session.handle_command("a LOGIN u p").await;
session.handle_command("b SELECT INBOX").await;
// A failed on-demand body fetch returns NO [UNAVAILABLE] (not a fake
// body) and arms the shared cooldown.
let resp = session.handle_command("c UID FETCH 1 (BODY[])").await;
assert!(
resp.last().unwrap().contains("NO [UNAVAILABLE]"),
"expected NO on failure, got {resp:?}"
);
assert!(store.body_fetch_on_cooldown("ed"));
// While cooling down, a repeat fetch is short-circuited to NO without
// touching the backend, even though it would now succeed.
backend.set_fail_details(false);
let resp2 = session.handle_command("d UID FETCH 1 (BODY[])").await;
assert!(
resp2.last().unwrap().contains("NO [UNAVAILABLE]"),
"cooldown should keep returning NO, got {resp2:?}"
);
}
#[tokio::test]
async fn copy_is_rejected() {
let backend = Arc::new(MockBackend::with_mails(vec![]));
@@ -1860,6 +1908,9 @@ mod tests {
Ok(None)
}
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
if *self.fail_details.lock().unwrap() {
return Err("simulated Too Many Requests".to_string());
}
let key = mail
._id
.as_ref()
+6 -2
View File
@@ -74,8 +74,12 @@ impl MailStore {
/// Arm a cooldown after a failed on-demand body fetch so the bridge does not
/// immediately re-hit a throttled or erroring server for the same mail.
pub(crate) fn mark_body_fetch_failed(&self, element_id: &str) {
crate::util::lock_recover(&self.body_fetch_cooldown)
.insert(element_id.to_string(), Instant::now() + BODY_FETCH_COOLDOWN);
let mut map = crate::util::lock_recover(&self.body_fetch_cooldown);
let now = Instant::now();
// Drop elapsed entries so the map stays bounded by the number of mails
// currently cooling down, not every mail that has ever failed.
map.retain(|_, until| *until > now);
map.insert(element_id.to_string(), now + BODY_FETCH_COOLDOWN);
}
pub fn subscribe(&self) -> watch::Receiver<u64> {