This commit is contained in:
Maurus Decimus
2026-05-30 08:09:57 +02:00
parent 0dfbaf8096
commit b173805212
35 changed files with 2160 additions and 95 deletions
-1
View File
@@ -454,7 +454,6 @@ fn mailbox_kinds_are_three_separate_sources() {
#[test]
fn get_item_batches_chunk_the_id_list() {
let ids: Vec<ItemId> = (0..7).map(|i| ItemId::new(format!("I{i}"), "K")).collect();
let chunks: Vec<&[ItemId]> = ids.chunks(3).collect();
assert_eq!(chunks.len(), 3);
+14 -4
View File
@@ -807,7 +807,11 @@ fn coordinator_present_run_is_convergent() {
assert_eq!(email.1.deleted, 0, "convergent run deletes nothing");
assert_eq!(email.1.updated, 0, "unchanged flags update nothing");
let dbc = Connection::open(&archive).unwrap();
assert_eq!(count(&dbc, "blobs"), 1, "no body re-fetched on a present-only run");
assert_eq!(
count(&dbc, "blobs"),
1,
"no body re-fetched on a present-only run"
);
}
#[test]
@@ -830,7 +834,10 @@ fn coordinator_present_flag_change_updates_keywords() {
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.updated, 1, "a changed flag set is counted as updated");
assert_eq!(
email.1.updated, 1,
"a changed flag set is counted as updated"
);
assert_eq!(email.1.created, 0, "present message is not re-created");
assert_eq!(email.1.fetched, 0, "no body fetched on a present-only run");
let dbc = Connection::open(&archive).unwrap();
@@ -869,7 +876,11 @@ fn coordinator_present_newly_deleted_is_left_intact() {
"a present message that newly gained \\Deleted is skipped, not updated"
);
let dbc = Connection::open(&archive).unwrap();
assert_eq!(count(&dbc, "emails"), 1, "the archived message is preserved");
assert_eq!(
count(&dbc, "emails"),
1,
"the archived message is preserved"
);
let kw: String = dbc
.query_row("SELECT keywords FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
@@ -1080,7 +1091,6 @@ fn coordinator_dispatches_to_multiple_worker_connections() {
continue;
}
if cmd.starts_with("UID FETCH") {
let after = cmd.strip_prefix("UID FETCH ").unwrap_or("");
let set = after.split_whitespace().next().unwrap_or("");
let uids = parse_uid_set(set);
+130 -1
View File
@@ -10,7 +10,9 @@ use serde_json::json;
use vandelay::jmap::account::{self, AccountSelector};
use vandelay::jmap::error::JmapError;
use vandelay::jmap::http::{Auth, HttpClient, RetryPolicy};
use vandelay::jmap::request::{self, SetRequest, get_all, get_objects, set_call};
use vandelay::jmap::request::{
self, SetRequest, get_all, get_changes, get_objects, get_state, set_call,
};
use vandelay::jmap::session::{Limits, Session};
use vandelay::jmap::wire::JmapId;
use vandelay::jmap::wire::identity::Identity;
@@ -1040,3 +1042,130 @@ fn shared_throttle_level_grows_across_concurrent_workers() {
"both workers saw a 429 (or more) before recovery"
);
}
#[test]
fn get_changes_paginates_until_no_more() {
let mut server = mockito::Server::new();
let api = "/jmap/api";
let _p1 = server
.mock("POST", api)
.match_body(mockito::Matcher::Regex("\"sinceState\":\"s1\"".into()))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s1",
"newState":"s2","hasMoreChanges":true,"created":["A"],"updated":["U1"],
"destroyed":["D1"]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let _p2 = server
.mock("POST", api)
.match_body(mockito::Matcher::Regex("\"sinceState\":\"s2\"".into()))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s2",
"newState":"s3","hasMoreChanges":false,"created":[],"updated":["U2"],
"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let url = format!("{}{}", server.url(), api);
let r = get_changes(&client(0), &url, "w", "Mailbox", "s1", &limits(500)).expect("changes");
let updated: Vec<String> = r.updated.iter().map(|i| i.0.clone()).collect();
assert_eq!(updated, vec!["U1".to_owned(), "U2".to_owned()]);
let created: Vec<String> = r.created.iter().map(|i| i.0.clone()).collect();
assert_eq!(created, vec!["A".to_owned()]);
let destroyed: Vec<String> = r.destroyed.iter().map(|i| i.0.clone()).collect();
assert_eq!(destroyed, vec!["D1".to_owned()]);
assert_eq!(r.new_state, "s3", "cursor advances to the final newState");
}
#[test]
fn get_changes_cannot_calculate_changes_is_typed_error() {
let mut server = mockito::Server::new();
let api = "/jmap/api";
let _m = server
.mock("POST", api)
.with_body(
json!({"methodResponses":[["error",{"type":"cannotCalculateChanges"},"c"]]})
.to_string(),
)
.create();
let url = format!("{}{}", server.url(), api);
let err =
get_changes(&client(0), &url, "w", "Email", "stale", &limits(500)).expect_err("must error");
assert!(
matches!(err, JmapError::CannotCalculateChanges),
"got {err:?}"
);
}
#[test]
fn get_changes_dedups_repeated_ids_across_pages() {
let mut server = mockito::Server::new();
let api = "/jmap/api";
let _p1 = server
.mock("POST", api)
.match_body(mockito::Matcher::Regex("\"sinceState\":\"s1\"".into()))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s1",
"newState":"s2","hasMoreChanges":true,"created":[],"updated":["U1","U2"],
"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let _p2 = server
.mock("POST", api)
.match_body(mockito::Matcher::Regex("\"sinceState\":\"s2\"".into()))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s2",
"newState":"s3","hasMoreChanges":false,"created":[],"updated":["U1","U3"],
"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let url = format!("{}{}", server.url(), api);
let r = get_changes(&client(0), &url, "w", "Mailbox", "s1", &limits(500)).expect("changes");
let updated: Vec<String> = r.updated.iter().map(|i| i.0.clone()).collect();
assert_eq!(
updated,
vec!["U1".to_owned(), "U2".to_owned(), "U3".to_owned()],
"an id repeated across pages is fetched once, first-seen order preserved"
);
}
#[test]
fn get_changes_unknown_method_is_typed_error() {
let mut server = mockito::Server::new();
let api = "/jmap/api";
let _m = server
.mock("POST", api)
.with_body(json!({"methodResponses":[["error",{"type":"unknownMethod"},"c"]]}).to_string())
.create();
let url = format!("{}{}", server.url(), api);
let err =
get_changes(&client(0), &url, "w", "Email", "s1", &limits(500)).expect_err("must error");
assert!(matches!(err, JmapError::UnknownMethod), "got {err:?}");
}
#[test]
fn get_state_reads_state_from_empty_get() {
let mut server = mockito::Server::new();
let api = "/jmap/api";
let _m = server
.mock("POST", api)
.match_body(mockito::Matcher::Regex("Email/get".into()))
.with_body(
json!({"methodResponses":[["Email/get",{"accountId":"w","state":"snap-1",
"list":[],"notFound":[]},"g"]]})
.to_string(),
)
.create();
let url = format!("{}{}", server.url(), api);
let st = get_state(&client(0), &url, "w", "Email").expect("get_state");
assert_eq!(st.as_deref(), Some("snap-1"));
}
-4
View File
@@ -379,7 +379,6 @@ fn qmail_root_only_maildir_imports_inbox_only() {
}
fn cyrus_export_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
@@ -537,7 +536,6 @@ fn rejects_path_without_cur_subdir() {
#[test]
fn rejects_dovecot_layout_fs_tree() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
for s in ["cur", "new", "tmp"] {
@@ -720,7 +718,6 @@ fn unreadable_file_is_counted_and_warned_not_aborted() {
#[test]
fn pointing_at_a_dot_subfolder_warns_but_imports() {
let parent = TempDir::new().unwrap();
ensure_maildir(parent.path());
let sub = ensure_subfolder(parent.path(), ".Sent");
@@ -738,7 +735,6 @@ fn pointing_at_a_dot_subfolder_warns_but_imports() {
#[test]
fn malformed_message_yields_zero_message_match_but_imports() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
write_message(td.path(), "cur", "1.M0.host:2,S", b"");
-6
View File
@@ -533,7 +533,6 @@ fn vanished_script_is_deleted_locally() {
#[test]
fn active_flag_flip_does_not_violate_partial_unique_index() {
let seed: Script = Box::new(|conn| {
auth_then(conn, |c| {
let _ = c.read_line()?;
@@ -580,7 +579,6 @@ fn active_flag_flip_does_not_violate_partial_unique_index() {
#[test]
fn resume_after_partial_run_completes_remainder() {
let seed: Script = Box::new(|conn| {
auth_then(conn, |c| {
let _ = c.read_line()?;
@@ -694,7 +692,6 @@ fn transient_no_on_getscript_retries_then_succeeds() {
#[test]
fn bye_mid_listscripts_reconnects_and_succeeds() {
let first: Script = Box::new(|conn| {
conn.write_capability("PLAIN", false)?;
conn.write_line("OK")?;
@@ -749,7 +746,6 @@ fn bye_mid_listscripts_reconnects_and_succeeds() {
#[test]
fn bye_mid_getscript_reconnects_and_completes_remaining_scripts() {
let first: Script = Box::new(|conn| {
conn.write_capability("PLAIN", false)?;
conn.write_line("OK")?;
@@ -812,7 +808,6 @@ fn bye_mid_getscript_reconnects_and_completes_remaining_scripts() {
#[test]
fn post_auth_unsolicited_capability_is_consumed() {
let server = MockSieveServer::start(|conn| {
conn.write_capability("PLAIN", false)?;
conn.write_line("OK")?;
@@ -994,7 +989,6 @@ fn three_scripts_with_middle_active_lands_the_middle_active() {
#[test]
fn transient_no_exhausting_max_retries_skips_script_and_followup_picks_it_up() {
let archive = tempfile("retry_exhaustion");
let first = MockSieveServer::start(|conn| {
auth_then(conn, |c| {
+822 -14
View File
@@ -433,7 +433,7 @@ fn import_removes_vanished_mailbox_from_archive_on_second_pass() {
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[
{"id":"A","name":"alpha","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true},
{"id":"B","name":"bravo","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true},
{"id":"C","name":"charlie","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
@@ -473,6 +473,16 @@ fn import_removes_vanished_mailbox_from_archive_on_second_pass() {
)
.expect(1)
.create();
let _ch2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/changes".into()))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s1",
"newState":"s2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
@@ -486,6 +496,7 @@ fn import_removes_vanished_mailbox_from_archive_on_second_pass() {
.map(|(_, c)| c.clone())
.expect("mailbox counts");
assert_eq!(mb2.fetched, 0, "second pass fetches nothing");
assert_eq!(mb2.updated, 0, "no changed mailboxes reported");
assert_eq!(mb2.deleted, 1, "vanished mailbox B is deleted");
{
let conn = rusqlite::Connection::open(&archive).unwrap();
@@ -502,7 +513,7 @@ fn import_removes_vanished_mailbox_from_archive_on_second_pass() {
}
#[test]
fn import_present_item_change_on_server_is_not_propagated() {
fn import_present_item_change_is_propagated_via_changes() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
@@ -530,7 +541,7 @@ fn import_present_item_change_on_server_is_not_propagated() {
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[
{"id":"A","name":"OriginalName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]})
.to_string(),
@@ -543,6 +554,17 @@ fn import_present_item_change_on_server_is_not_propagated() {
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("first import");
{
let conn = rusqlite::Connection::open(&archive).unwrap();
let cursor: String = conn
.query_row(
"SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'",
[],
|r| r.get(0),
)
.expect("first import records the state cursor");
assert_eq!(cursor, "s1");
}
let _q2 = server
.mock("POST", api)
@@ -554,10 +576,29 @@ fn import_present_item_change_on_server_is_not_propagated() {
)
.expect(1)
.create();
let nope_get = server
let changes = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/changes".into()),
Matcher::Regex("\"sinceState\":\"s1\"".into()),
]))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s1",
"newState":"s2","hasMoreChanges":false,"created":[],"updated":["A"],"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let _g2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.expect(0)
.with_body(
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s2","list":[
{"id":"A","name":"UpdatedName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]})
.to_string(),
)
.expect(1)
.create();
let s2 = sync::import_jmap::run(
@@ -565,22 +606,34 @@ fn import_present_item_change_on_server_is_not_propagated() {
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("second import");
nope_get.assert();
changes.assert();
let mb2 = s2
.per_type
.iter()
.find(|(t, _)| *t == "Mailbox")
.map(|(_, c)| c.clone())
.expect("mailbox counts");
assert_eq!(mb2.fetched, 0, "no new objects on the second pass");
assert_eq!(
mb2.fetched, 0,
"present items must not be re-fetched: changes on server are intentionally ignored"
mb2.updated, 1,
"the changed mailbox is detected via /changes and refreshed in place"
);
let name: String = rusqlite::Connection::open(&archive)
.unwrap()
let conn = rusqlite::Connection::open(&archive).unwrap();
let name: String = conn
.query_row("SELECT name FROM mailboxes WHERE id=1", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "OriginalName", "archive name was not overwritten");
assert_eq!(
name, "UpdatedName",
"a server-side property change is propagated into the archive"
);
let cursor: String = conn
.query_row(
"SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cursor, "s2", "cursor advances to the changes newState");
let _ = std::fs::remove_file(&archive);
}
@@ -614,7 +667,7 @@ fn import_removes_vanished_email_and_drops_cross_ref() {
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[
{"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]})
.to_string(),
@@ -635,7 +688,7 @@ fn import_removes_vanished_email_and_drops_cross_ref() {
.mock("POST", api)
.match_body(Matcher::Regex("Email/get".into()))
.with_body(
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[
{"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}},
{"id":"E2","blobId":"BLB2","receivedAt":"2020-01-02T00:00:00Z","mailboxIds":{"MX":true},"keywords":{}}
],"notFound":[]},"g"]]})
@@ -688,6 +741,26 @@ fn import_removes_vanished_email_and_drops_cross_ref() {
)
.expect(1)
.create();
let _mbch2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/changes".into()))
.with_body(
json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"sm1",
"newState":"sm2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let _ech2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/changes".into()))
.with_body(
json!({"methodResponses":[["Email/changes",{"accountId":"w","oldState":"se1",
"newState":"se2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]})
.to_string(),
)
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
@@ -1297,7 +1370,10 @@ fn export_sieve_scripts_identical_content_different_names_both_created() {
counts.created, 2,
"two scripts with identical content but distinct names must both reach the target"
);
assert_eq!(counts.skipped, 0, "neither distinct name collapses onto the other");
assert_eq!(
counts.skipped, 0,
"neither distinct name collapses onto the other"
);
assert_eq!(counts.failed, 0);
let _ = std::fs::remove_file(&archive);
}
@@ -1539,3 +1615,735 @@ fn import_dry_run_does_not_write_archive_or_download_blobs() {
assert_eq!(source_rows, 0, "dry-run must not record the JMAP source");
let _ = std::fs::remove_file(&archive);
}
#[test]
fn import_email_keyword_change_is_propagated_without_blob_refetch() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _mbterm = anchor_terminator(&mut server, api, "Mailbox");
let _emterm = anchor_terminator(&mut server, api, "Email");
let _mbq1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _mbg1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[
{"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let _eq1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/query".into()))
.with_body(
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _eg1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/get".into()))
.with_body(json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[
{"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let _dl1 = server
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into()))
.with_body("From: a@x\r\nMessage-ID: <1@h>\r\n\r\nbody-one")
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
)
.expect("first import");
let _mbq2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _mbch2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/changes".into()))
.with_body(json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"sm1","newState":"sm2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}).to_string())
.expect(1)
.create();
let _eq2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/query".into()))
.with_body(
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let echanges = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/changes".into()))
.with_body(json!({"methodResponses":[["Email/changes",{"accountId":"w","oldState":"se1","newState":"se2","hasMoreChanges":false,"created":[],"updated":["E1"],"destroyed":[]},"c"]]}).to_string())
.expect(1)
.create();
let _eg2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/get".into()))
.with_body(
json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se2","list":[
{"id":"E1","mailboxIds":{"MX":true},"keywords":{"$seen":true,"$flagged":true}}
],"notFound":[]},"g"]]})
.to_string(),
)
.expect(1)
.create();
let no_blob_refetch = server
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into()))
.with_body("should-not-be-fetched")
.expect(0)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
)
.expect("second import");
echanges.assert();
no_blob_refetch.assert();
let em2 = s2
.per_type
.iter()
.find(|(t, _)| *t == "Email")
.map(|(_, c)| c.clone())
.expect("email counts");
assert_eq!(em2.updated, 1, "the changed email is refreshed");
assert_eq!(em2.fetched, 0, "no new emails");
let conn = rusqlite::Connection::open(&archive).unwrap();
let kw: String = conn
.query_row("SELECT keywords FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
assert!(
kw.contains("$seen") && kw.contains("$flagged"),
"keyword change propagated into the archive: {kw}"
);
let blobs: i64 = conn
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
.unwrap();
assert_eq!(
blobs, 1,
"the immutable body blob is not re-downloaded on update"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn import_cannot_calculate_changes_falls_back_to_full_refresh() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _term = anchor_terminator(&mut server, api, "Mailbox");
let _q1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _g1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[
{"id":"A","name":"OriginalName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("first import");
let _q2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let cannot = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/changes".into()))
.with_body(
json!({"methodResponses":[["error",{"type":"cannotCalculateChanges"},"c"]]})
.to_string(),
)
.expect(1)
.create();
let capture_state = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/get".into()),
Matcher::Regex("\"ids\":\\[\\]".into()),
]))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let refresh_get = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/get".into()),
Matcher::Regex("\"A\"".into()),
]))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[
{"id":"A","name":"RefreshedName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("second import");
cannot.assert();
capture_state.assert();
refresh_get.assert();
let mb2 = s2
.per_type
.iter()
.find(|(t, _)| *t == "Mailbox")
.map(|(_, c)| c.clone())
.expect("mailbox counts");
assert_eq!(mb2.updated, 1, "fallback refreshes the present object");
let conn = rusqlite::Connection::open(&archive).unwrap();
let name: String = conn
.query_row("SELECT name FROM mailboxes WHERE id=1", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "RefreshedName", "A-fallback propagated the change");
let cursor: String = conn
.query_row(
"SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
cursor, "s9",
"fallback captured a fresh cursor for the next run"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn import_failed_update_holds_cursor_for_retry() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _mbterm = anchor_terminator(&mut server, api, "Mailbox");
let _emterm = anchor_terminator(&mut server, api, "Email");
let _mbq1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _mbg1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[
{"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let _eq1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/query".into()))
.with_body(
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _eg1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/get".into()))
.with_body(json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[
{"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let _dl1 = server
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into()))
.with_body("From: a@x\r\nMessage-ID: <1@h>\r\n\r\nbody-one")
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
)
.expect("first import");
let _mbq2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _mbch2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/changes".into()))
.with_body(json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"sm1","newState":"sm2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}).to_string())
.expect(1)
.create();
let _eq2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/query".into()))
.with_body(
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _ech2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/changes".into()))
.with_body(json!({"methodResponses":[["Email/changes",{"accountId":"w","oldState":"se1","newState":"se2","hasMoreChanges":false,"created":[],"updated":["E1"],"destroyed":[]},"c"]]}).to_string())
.expect(1)
.create();
let bad_update = server
.mock("POST", api)
.match_body(Matcher::Regex("Email/get".into()))
.with_body(
json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se2","list":[
{"id":"E1","mailboxIds":{},"keywords":{"$seen":true,"$flagged":true}}
],"notFound":[]},"g"]]})
.to_string(),
)
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
)
.expect("second import");
bad_update.assert();
let em2 = s2
.per_type
.iter()
.find(|(t, _)| *t == "Email")
.map(|(_, c)| c.clone())
.expect("email counts");
assert_eq!(
em2.updated, 0,
"the failed update is not counted as applied"
);
assert!(em2.failed >= 1, "the unresolvable update is counted failed");
let conn = rusqlite::Connection::open(&archive).unwrap();
let cursor: String = conn
.query_row(
"SELECT state FROM sync_state_jmap WHERE type_name='Email'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
cursor, "se1",
"cursor is held at the pre-change state so the failed update retries next run"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn import_unknown_method_changes_falls_back_to_full_refresh() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _term = anchor_terminator(&mut server, api, "Mailbox");
let _q1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _g1 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/get".into()))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[
{"id":"A","name":"OriginalName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("first import");
let _q2 = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let unknown = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/changes".into()))
.with_body(json!({"methodResponses":[["error",{"type":"unknownMethod"},"c"]]}).to_string())
.expect(1)
.create();
let _capture_state = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/get".into()),
Matcher::Regex("\"ids\":\\[\\]".into()),
]))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let refresh_get = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/get".into()),
Matcher::Regex("\"A\"".into()),
]))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[
{"id":"A","name":"RefreshedName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("second import");
unknown.assert();
refresh_get.assert();
let mb2 = s2
.per_type
.iter()
.find(|(t, _)| *t == "Mailbox")
.map(|(_, c)| c.clone())
.expect("mailbox counts");
assert_eq!(
mb2.updated, 1,
"a server without Mailbox/changes degrades to a full refresh instead of aborting"
);
let conn = rusqlite::Connection::open(&archive).unwrap();
let name: String = conn
.query_row("SELECT name FROM mailboxes WHERE id=1", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "RefreshedName");
let _ = std::fs::remove_file(&archive);
}
fn sieve_get_body(name: &str, blob: &str) -> String {
json!({"methodResponses":[["SieveScript/get",{"accountId":"w","state":"x","list":[
{"id":"S1","name":name,"isActive":true,"blobId":blob}
],"notFound":[]},"g"]]})
.to_string()
}
#[test]
fn import_sieve_script_reimport_unchanged_is_convergent() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _term = anchor_terminator(&mut server, api, "SieveScript");
let _dl = server
.mock("GET", Matcher::Regex("/jmap/dl/w/B1/.*".into()))
.with_body("keep;\n")
.create();
let _q1 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/query".into()))
.with_body(
json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _g1 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(sieve_get_body("main", "B1"))
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("first import");
let _q2 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/query".into()))
.with_body(
json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _g2 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(sieve_get_body("main", "B1"))
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("second import");
let ss = s2
.per_type
.iter()
.find(|(t, _)| *t == "SieveScript")
.map(|(_, c)| c.clone())
.expect("sieve counts");
assert_eq!(ss.created, 0, "no new scripts");
assert_eq!(
ss.updated, 0,
"an unchanged SieveScript must not be counted as updated on re-import (convergent)"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn import_sieve_script_content_change_is_propagated() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _term = anchor_terminator(&mut server, api, "SieveScript");
let _dl1 = server
.mock("GET", Matcher::Regex("/jmap/dl/w/B1/.*".into()))
.with_body("keep;\n")
.create();
let _dl2 = server
.mock("GET", Matcher::Regex("/jmap/dl/w/B2/.*".into()))
.with_body("discard;\n")
.create();
let _q1 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/query".into()))
.with_body(
json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _g1 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(sieve_get_body("main", "B1"))
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("first import");
let _q2 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/query".into()))
.with_body(
json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
let _g2 = server
.mock("POST", api)
.match_body(Matcher::Regex("SieveScript/get".into()))
.with_body(sieve_get_body("main", "B2"))
.expect(1)
.create();
let s2 = sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::SieveScript]),
)
.expect("second import");
let ss = s2
.per_type
.iter()
.find(|(t, _)| *t == "SieveScript")
.map(|(_, c)| c.clone())
.expect("sieve counts");
assert_eq!(
ss.updated, 1,
"the changed script content is re-fetched and updated"
);
let conn = rusqlite::Connection::open(&archive).unwrap();
let body: Vec<u8> = conn
.query_row(
"SELECT b.data FROM blobs b JOIN sieve_scripts s ON s.blob_id = b.id LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
String::from_utf8(body).unwrap(),
"discard;\n",
"new script content propagated into the archive blob"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn first_run_cursor_is_captured_up_front_not_from_the_fetch() {
let mut server = mockito::Server::new();
let base = server.url();
let api = "/jmap/api";
let archive = tmp();
let _root = server.mock("GET", "/").with_status(404).create();
let _wk = server
.mock("GET", "/.well-known/jmap")
.with_body(session_body_full(&base))
.expect_at_least(1)
.create();
let _term = anchor_terminator(&mut server, api, "Mailbox");
let _q = server
.mock("POST", api)
.match_body(Matcher::Regex("Mailbox/query".into()))
.with_body(
json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]})
.to_string(),
)
.expect(1)
.create();
// Up-front state snapshot (ids:[]) reports an EARLIER state than the new-fetch.
let _state = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/get".into()),
Matcher::Regex("\"ids\":\\[\\]".into()),
]))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"before","list":[],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
// The new-fetch reports a LATER state; if we (incorrectly) captured from here, the cursor
// would be "after" and an object changed mid-run could be missed next run.
let _g = server
.mock("POST", api)
.match_body(Matcher::AllOf(vec![
Matcher::Regex("Mailbox/get".into()),
Matcher::Regex("\"A\"".into()),
]))
.with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"after","list":[
{"id":"A","name":"Personal","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
],"notFound":[]},"g"]]}).to_string())
.expect(1)
.create();
sync::import_jmap::run(
common(&archive),
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
)
.expect("import");
let cursor: String = rusqlite::Connection::open(&archive)
.unwrap()
.query_row(
"SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
cursor, "before",
"cursor must be the pre-fetch snapshot (lower bound), not the post-fetch state"
);
let _ = std::fs::remove_file(&archive);
}
+92 -2
View File
@@ -11,8 +11,11 @@ use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use serde_json::{Map, Value, json};
use vandelay::jmap::account::{self, AccountSelector};
use vandelay::jmap::http::{Auth, HttpClient, RetryPolicy};
use vandelay::jmap::request::Request;
use vandelay::jmap::session::Session;
use vandelay::logging::Logger;
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
@@ -1148,3 +1151,90 @@ fn live_blob_quota_429_triggers_retry_after_then_succeeds() {
drop(_ttl_guard);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn import_delta_propagates_email_keyword_change_via_changes() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("delta-email");
sync::import_jmap::run(
common(&archive, false),
import_cfg(AccountSelector::Id(acc.account_id.clone())),
)
.expect("first import");
let (jmap_id, local_id): (String, i64) = {
let conn = Connection::open(&archive).unwrap();
conn.query_row(
"SELECT s.jmap_id, s.local_id FROM sync_id_jmap s
JOIN emails e ON e.id = s.local_id
WHERE s.type_name = 'Email' AND e.keywords NOT LIKE '%$flagged%'
LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("an unflagged email exists in the archive")
};
let client = HttpClient::new(basic("test1"), RetryPolicy::new(5), true);
let session = Session::discover(&client, base_url()).expect("session discovered");
let account = account::resolve(
&AccountSelector::Id(acc.account_id.clone()),
&session,
&client,
)
.expect("account resolved");
let mut update = Map::new();
update.insert(jmap_id.clone(), json!({ "keywords/$flagged": true }));
let mut req = Request::new();
req.call(
"Email/set",
json!({ "accountId": account, "update": Value::Object(update) }),
"s",
);
let resp = req.send(&client, &session.api_url).expect("Email/set sent");
let mr = resp.first().expect("a method response");
assert!(
mr.args
.get("updated")
.and_then(|u| u.get(&jmap_id))
.is_some(),
"server accepted the keyword update: {:?}",
mr.args
);
let s2 = sync::import_jmap::run(
common(&archive, false),
import_cfg(AccountSelector::Id(acc.account_id.clone())),
)
.expect("second import");
let conn = Connection::open(&archive).unwrap();
let kw: String = conn
.query_row(
"SELECT keywords FROM emails WHERE id = ?1",
[local_id],
|r| r.get(0),
)
.unwrap();
assert!(
kw.contains("$flagged"),
"delta re-import propagated the new flag into the archive: {kw}"
);
let em = s2
.per_type
.iter()
.find(|(t, _)| *t == "Email")
.map(|(_, c)| c.clone())
.expect("email counts");
assert!(
em.updated >= 1,
"the changed email was detected via Email/changes and refreshed (updated={})",
em.updated
);
drop(conn);
seeder::teardown(base_url()).expect("teardown");
let _ = std::fs::remove_file(&archive);
}
-3
View File
@@ -428,7 +428,6 @@ fn dry_run_then_real_run_produces_same_counts_for_new() {
#[test]
fn trashed_flag_added_between_runs_deletes_present_row() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let path = write(
@@ -459,7 +458,6 @@ fn trashed_flag_added_between_runs_deletes_present_row() {
#[cfg(unix)]
#[test]
fn symlinked_subfolder_is_followed_and_appears_as_its_own_folder() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let real = ensure_subfolder(td.path(), ".Real");
@@ -602,7 +600,6 @@ fn blob_hashes(conn: &Connection) -> std::collections::HashSet<String> {
#[test]
#[ignore = "requires Docker"]
fn maildir_message_count_matches_jmap_for_same_corpus() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let corpus = seeder::data::load_mbox(30).expect("mbox corpus");
-4
View File
@@ -209,7 +209,6 @@ fn managesieve_second_run_is_convergent() {
#[test]
#[ignore = "requires Docker"]
fn managesieve_and_jmap_imports_share_blob_bytes() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let msieve_archive = tmp_archive("parity-msieve");
@@ -310,7 +309,6 @@ fn managesieve_dry_run_reports_diff_without_writing() {
#[test]
#[ignore = "requires Docker"]
fn managesieve_source_change_protection_refuses_second_account() {
let fx = seeder::provision(base_url()).expect("provision");
let acc1 = fx.account("test1").expect("test1");
let acc2 = fx.account("test2").expect("test2");
@@ -330,7 +328,6 @@ fn managesieve_source_change_protection_refuses_second_account() {
#[test]
#[ignore = "requires Docker"]
fn managesieve_implicit_tls_path_succeeds_when_offered() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("implicit_tls");
@@ -350,7 +347,6 @@ fn managesieve_implicit_tls_path_succeeds_when_offered() {
#[test]
#[ignore = "requires Docker"]
fn managesieve_round_trip_via_jmap_export_converges() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let dst = fx.account("test4").expect("test4");