v1.0.0
This commit is contained in:
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"");
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user