Export matched each item against the target and then skipped it, so a second run -- the usual final pass of a cutover -- never carried anything that had changed at the source since the first: read and flagged state, moves between folders, edited contacts, events and Sieve scripts. It reported them as skipped and exited 0, while the usage guide said matched items were updated. Matched items are now updated, with one batched /set per type: - Email: keywords are set to the archive's, added and removed, compared case-insensitively. Memberships of folders this run migrated are added and removed to match; folders that exist only on the target are left alone, and a message is never left in no folder. Properties the server did not report are not touched. - Contacts and events: when both copies carry `updated`, the archive's is written only if it is newer; otherwise each property the archive writes is compared, and those that differ are sent whole. - Sieve scripts: the target's copy is downloaded and compared byte for byte, and replaced with the archive's when it differs. Updated items are counted as `updated`; unchanged ones stay `skipped`. The usage guide now describes this.
4994 lines
162 KiB
Rust
4994 lines
162 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use inbuxa_migrate::db;
|
|
use inbuxa_migrate::jmap::account::AccountSelector;
|
|
use inbuxa_migrate::jmap::http::Auth;
|
|
use inbuxa_migrate::logging::Logger;
|
|
use inbuxa_migrate::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
|
|
use inbuxa_migrate::types::ObjectType;
|
|
use mockito::Matcher;
|
|
use serde_json::{Value, json};
|
|
|
|
fn tmp() -> PathBuf {
|
|
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!(
|
|
"inbuxa-migrate-mocksync-{}-{:?}-{n}.sqlite",
|
|
std::process::id(),
|
|
std::thread::current().id(),
|
|
));
|
|
let _ = std::fs::remove_file(&p);
|
|
p
|
|
}
|
|
|
|
fn session_body(base: &str) -> String {
|
|
json!({
|
|
"apiUrl": format!("{base}/jmap/api"),
|
|
"uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"),
|
|
"downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"),
|
|
"capabilities": { "urn:ietf:params:jmap:core": {
|
|
"maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16,
|
|
"maxConcurrentRequests": 4, "maxConcurrentUpload": 4,
|
|
"maxSizeRequest": 10000000, "maxSizeUpload": 50000000
|
|
} },
|
|
"accounts": { "w": { "name": "alice",
|
|
"accountCapabilities": { "urn:ietf:params:jmap:mail": {} } } }
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn export_email_already_exists_is_matched_not_failed() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let blob = db::blobs::intern_blob(
|
|
&conn,
|
|
b"From: a@x\r\nSubject: hi\r\nMessage-ID: <m-1@h>\r\n\r\nbody",
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
|
|
let _mq1 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["t1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mq2 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"t1","name":"Inbox","role":"inbox","parentId":null,
|
|
"myRights":{"mayDelete":true}}],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _up = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPLOADED"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let _imp = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"notCreated":{"e1":{"type":"alreadyExists","existingId":"x9"}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.clone(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
},
|
|
)
|
|
.expect("export run");
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.created, 0);
|
|
assert_eq!(email.failed, 0, "alreadyExists must not be a failure");
|
|
assert_eq!(email.skipped, 1, "alreadyExists folds into matched");
|
|
assert!(!summary.any_failed());
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_mailbox_name_collision_merges_without_create() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES
|
|
(1,'Junk Email',NULL,'junk'),
|
|
(2,'Junk Mail',NULL,NULL)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let blob = db::blobs::intern_blob(
|
|
&conn,
|
|
b"From: a@x\r\nSubject: spam\r\nMessage-ID: <m-2@h>\r\n\r\nbody",
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[2]','[\"$seen\"]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
|
|
let _mq1 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["c"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mq2 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"c","name":"Junk Mail","role":"junk","parentId":null,
|
|
"myRights":{"mayDelete":true}}],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let no_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/set".into()))
|
|
.expect(0)
|
|
.create();
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _up = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPLOADED"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let import_into_c = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex(r#""c":true"#.into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"E1","blobId":"b","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.clone(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
},
|
|
)
|
|
.expect("export run");
|
|
|
|
let mailbox = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("mailbox counts");
|
|
assert_eq!(mailbox.created, 0, "no mailbox is created");
|
|
assert_eq!(mailbox.failed, 0, "the name collision is not a failure");
|
|
assert_eq!(
|
|
mailbox.skipped, 2,
|
|
"role-matched Junk Email + merged Junk Mail"
|
|
);
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.created, 1, "the Junk Mail email lands on the target");
|
|
assert_eq!(email.failed, 0);
|
|
assert!(!summary.any_failed());
|
|
|
|
no_set.assert();
|
|
import_into_c.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_mailbox_already_exists_maps_existing_id() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Junk Mail',NULL,NULL)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let blob = db::blobs::intern_blob(
|
|
&conn,
|
|
b"From: a@x\r\nSubject: spam\r\nMessage-ID: <m-3@h>\r\n\r\nbody",
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
|
|
let _mq1 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["t1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mq2 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"t1","name":"Inbox","role":"inbox","parentId":null,
|
|
"myRights":{"mayDelete":true}}],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let set_collides = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/set".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/set",{"accountId":"w",
|
|
"notCreated":{"c1":{"type":"alreadyExists","existingId":"c"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _up = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPLOADED"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let import_into_c = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex(r#""c":true"#.into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"E1","blobId":"b","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.clone(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
},
|
|
)
|
|
.expect("export run");
|
|
|
|
let mailbox = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("mailbox counts");
|
|
assert_eq!(mailbox.created, 0, "the create collided");
|
|
assert_eq!(
|
|
mailbox.failed, 0,
|
|
"alreadyExists on Mailbox/set is not a failure"
|
|
);
|
|
assert_eq!(mailbox.skipped, 1, "existingId folds into matched");
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.created, 1, "the email lands in the existing folder");
|
|
assert_eq!(email.failed, 0);
|
|
assert!(!summary.any_failed());
|
|
|
|
set_collides.assert();
|
|
import_into_c.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn email_export_sends_one_email_per_import_call() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for n in 1..=2 {
|
|
let raw =
|
|
format!("From: a@x\r\nSubject: m{n}\r\nMessage-ID: <m-{n}@h>\r\n\r\nbody {n}",);
|
|
let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
|
|
let _mq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["t1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _mq_empty = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Mailbox/query".into()),
|
|
Matcher::Regex("anchor".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"t1","name":"Inbox","role":"inbox","parentId":null,
|
|
"myRights":{"mayDelete":true}}],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let _ups = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UP1"}).to_string())
|
|
.expect(2)
|
|
.create();
|
|
|
|
let single_only = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("e1".into()),
|
|
Matcher::Regex("e2".into()),
|
|
]))
|
|
.expect(0)
|
|
.create();
|
|
|
|
let imports = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",
|
|
{"accountId":"w","created":{"e":{"id":"x","blobId":"b","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(2)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 0,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.clone(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
},
|
|
)
|
|
.expect("export run");
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.created, 2, "both emails imported in per-item rounds");
|
|
assert_eq!(email.failed, 0, "no per-unit failure");
|
|
assert!(!summary.any_failed(), "no whole-run failure");
|
|
|
|
single_only.assert();
|
|
imports.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_email_blob_not_found_reuploads_and_retries() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let raw = b"From: a@x\r\nSubject: stale\r\nMessage-ID: <stale-1@h>\r\n\r\nbody";
|
|
let blob = db::blobs::intern_blob(&conn, raw).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
|
|
let _mq1 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["t1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mq2 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"t1","name":"Inbox","role":"inbox","parentId":null,
|
|
"myRights":{"mayDelete":true}}],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let up1 = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UP1"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let up2 = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UP2"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
|
|
let imp_stale = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("e1".into()),
|
|
Matcher::Regex("UP1".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"notCreated":{"e1":{"type":"blobNotFound"}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let imp_fresh = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("e1".into()),
|
|
Matcher::Regex("UP2".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"x1","blobId":"UP2","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.clone(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
},
|
|
)
|
|
.expect("export run");
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(
|
|
email.created, 1,
|
|
"the stale blob is re-uploaded and the email created"
|
|
);
|
|
assert_eq!(email.failed, 0, "blobNotFound self-heals, not a failure");
|
|
assert_eq!(email.skipped, 0);
|
|
assert!(!summary.any_failed());
|
|
|
|
up1.assert();
|
|
up2.assert();
|
|
imp_stale.assert();
|
|
imp_fresh.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
fn session_body_full(base: &str) -> String {
|
|
json!({
|
|
"apiUrl": format!("{base}/jmap/api"),
|
|
"uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"),
|
|
"downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"),
|
|
"capabilities": { "urn:ietf:params:jmap:core": {
|
|
"maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16,
|
|
"maxConcurrentRequests": 4, "maxConcurrentUpload": 4,
|
|
"maxSizeRequest": 10000000, "maxSizeUpload": 50000000
|
|
} },
|
|
"accounts": { "w": { "name": "alice",
|
|
"accountCapabilities": {
|
|
"urn:ietf:params:jmap:mail": {},
|
|
"urn:ietf:params:jmap:sieve": {},
|
|
"urn:ietf:params:jmap:contacts": {},
|
|
"urn:ietf:params:jmap:calendars": {},
|
|
"urn:ietf:params:jmap:filenode": {}
|
|
} } }
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
fn import_cfg_objects(base: &str, objects: Vec<ObjectType>) -> ImportConfig {
|
|
ImportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.to_owned(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: Some(objects),
|
|
allow_source_change: false,
|
|
}
|
|
}
|
|
|
|
fn export_cfg_objects(base: &str, objects: Vec<ObjectType>) -> ExportConfig {
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.to_owned(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: Some(objects),
|
|
prune: false,
|
|
yes: true,
|
|
}
|
|
}
|
|
|
|
fn common(archive: &Path) -> CommonConfig {
|
|
CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 1,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
}
|
|
}
|
|
|
|
fn common_dry(archive: &Path) -> CommonConfig {
|
|
CommonConfig {
|
|
dry_run: true,
|
|
..common(archive)
|
|
}
|
|
}
|
|
|
|
fn anchor_terminator(server: &mut mockito::Server, api: &str, type_name: &str) -> mockito::Mock {
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex(format!("{type_name}/query")),
|
|
Matcher::Regex("\"anchor\"".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[[format!("{type_name}/query"),
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_most(1024)
|
|
.create()
|
|
}
|
|
|
|
#[test]
|
|
fn import_removes_vanished_mailbox_from_archive_on_second_pass() {
|
|
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","B","C"]},"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":"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}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let s1 = sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
|
|
)
|
|
.expect("first import");
|
|
let mb1 = s1
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("mailbox counts");
|
|
assert_eq!(mb1.fetched, 3, "first pass fetched all three mailboxes");
|
|
{
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let n: i64 = conn
|
|
.query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(n, 3);
|
|
}
|
|
|
|
let _q2 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["A","C"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.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),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
|
|
)
|
|
.expect("second import");
|
|
let mb2 = s2
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.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();
|
|
let names: Vec<String> = conn
|
|
.prepare("SELECT name FROM mailboxes ORDER BY name")
|
|
.unwrap()
|
|
.query_map([], |r| r.get::<_, String>(0))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect();
|
|
assert_eq!(names, vec!["alpha".to_owned(), "charlie".to_owned()]);
|
|
}
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_present_item_change_is_propagated_via_changes() {
|
|
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 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)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["A"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
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()))
|
|
.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(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
|
|
)
|
|
.expect("second import");
|
|
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.updated, 1,
|
|
"the changed mailbox is detected via /changes and refreshed in place"
|
|
);
|
|
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, "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);
|
|
}
|
|
|
|
#[test]
|
|
fn import_removes_vanished_email_and_drops_cross_ref() {
|
|
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","E2"]},"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}},
|
|
{"id":"E2","blobId":"BLB2","receivedAt":"2020-01-02T00:00:00Z","mailboxIds":{"MX":true},"keywords":{}}
|
|
],"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();
|
|
let _dl2 = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB2/.*".into()))
|
|
.with_body("From: b@x\r\nMessage-ID: <2@h>\r\n\r\nbody-two")
|
|
.expect(1)
|
|
.create();
|
|
|
|
sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("first import");
|
|
{
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
assert_eq!(
|
|
conn.query_row::<i64, _, _>("SELECT count(*) FROM emails", [], |r| r.get(0))
|
|
.unwrap(),
|
|
2
|
|
);
|
|
}
|
|
|
|
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 _eq2 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":["E2"]},"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 _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),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("second import");
|
|
let em2 = s2
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(em2.deleted, 1, "vanished email is deleted from archive");
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let remaining: i64 = conn
|
|
.query_row("SELECT count(*) FROM emails", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(remaining, 1, "only the still-present email remains");
|
|
let blobs: i64 = conn
|
|
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(blobs, 1, "blob GC reclaims orphan blob of deleted email");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_unusable_received_at_falls_back_instead_of_dropping_the_email() {
|
|
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 _mbq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["MX"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _mbg = 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(),
|
|
)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":["E1","E2","E3"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _eg = 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":"30828-09-14T02:48:05Z","mailboxIds":{"MX":true},"keywords":{}},
|
|
{"id":"E2","blobId":"BLB2","mailboxIds":{"MX":true},"keywords":{}},
|
|
{"id":"E3","blobId":"BLB3","receivedAt":"2020-01-03 04:05:06Z","mailboxIds":{"MX":true},"keywords":{}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _dl1 = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into()))
|
|
.with_body(
|
|
"From: a@x\r\nMessage-ID: <1@h>\r\nDate: Mon, 12 May 2025 10:00:00 +0200\r\n\r\none",
|
|
)
|
|
.create();
|
|
let _dl2 = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB2/.*".into()))
|
|
.with_body("From: b@x\r\nMessage-ID: <2@h>\r\n\r\ntwo")
|
|
.create();
|
|
let _dl3 = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB3/.*".into()))
|
|
.with_body("From: c@x\r\nMessage-ID: <3@h>\r\n\r\nthree")
|
|
.create();
|
|
|
|
let summary = sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("import does not abort on an unusable receivedAt");
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.fetched, 3, "no email is dropped over its receivedAt");
|
|
assert_eq!(email.failed, 0, "an unusable receivedAt is not a failure");
|
|
assert!(!summary.any_failed());
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let mut stmt = conn
|
|
.prepare("SELECT received_at FROM emails ORDER BY id")
|
|
.unwrap();
|
|
let dates: Vec<String> = stmt
|
|
.query_map([], |r| r.get::<_, String>(0))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect();
|
|
drop(stmt);
|
|
assert_eq!(
|
|
dates[0], "2025-05-12T10:00:00+02:00",
|
|
"an out-of-range receivedAt falls back to the Date header"
|
|
);
|
|
assert_eq!(
|
|
dates[1], "1970-01-01T00:00:00Z",
|
|
"no receivedAt and no Date header falls back to the epoch"
|
|
);
|
|
assert_eq!(
|
|
dates[2], "2020-01-03T04:05:06Z",
|
|
"a space-separated receivedAt is salvaged, not discarded"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_file_node_without_node_type_is_inferred_not_skipped() {
|
|
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 _fnterm = anchor_terminator(&mut server, api, "FileNode");
|
|
|
|
let _fq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("FileNode/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["FileNode/query",
|
|
{"accountId":"w","ids":["F1","F2"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _fg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("FileNode/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["FileNode/get",{"accountId":"w","state":"sf1","list":[
|
|
{"id":"F1","parentId":null,"name":"docs","created":"2026-01-01T00:00:00Z"},
|
|
{"id":"F2","parentId":"F1","name":"a.txt","blobId":"BLBF","type":"text/plain","created":"2026-01-02T00:00:00Z"}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _dl = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BLBF/.*".into()))
|
|
.with_body("file bytes")
|
|
.create();
|
|
|
|
let summary = sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::FileNode]),
|
|
)
|
|
.expect("import does not abort when nodeType is absent");
|
|
|
|
let nodes = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "FileNode")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("file node counts");
|
|
assert_eq!(
|
|
nodes.fetched, 2,
|
|
"a server that omits nodeType still imports"
|
|
);
|
|
assert_eq!(nodes.failed, 0);
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let mut stmt = conn
|
|
.prepare("SELECT name, node_type FROM file_nodes ORDER BY name")
|
|
.unwrap();
|
|
let rows: Vec<(String, String)> = stmt
|
|
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect();
|
|
drop(stmt);
|
|
assert_eq!(
|
|
rows,
|
|
vec![
|
|
("a.txt".to_owned(), "file".to_owned()),
|
|
("docs".to_owned(), "directory".to_owned()),
|
|
],
|
|
"nodeType is inferred from the presence of a blobId"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_missing_email_blob_is_skipped_and_counted_once() {
|
|
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 _mbq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["MX"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _mbg = 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(),
|
|
)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":["E1","E2"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
let _eg = 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}},
|
|
{"id":"E2","blobId":"BLB2","receivedAt":"2020-01-02T00:00:00Z","mailboxIds":{"MX":true},"keywords":{}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.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")
|
|
.create();
|
|
let _dl2 = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BLB2/.*".into()))
|
|
.with_status(404)
|
|
.with_body(json!({"status":404,"title":"Not Found"}).to_string())
|
|
.create();
|
|
|
|
let summary = sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("import does not abort on a missing blob");
|
|
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(
|
|
email.fetched, 1,
|
|
"only the email with a present blob imports"
|
|
);
|
|
assert_eq!(
|
|
email.failed, 1,
|
|
"a missing blob counts the email failed exactly once, not twice"
|
|
);
|
|
assert!(summary.any_failed());
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
assert_eq!(
|
|
conn.query_row::<i64, _, _>("SELECT count(*) FROM emails", [], |r| r.get(0))
|
|
.unwrap(),
|
|
1,
|
|
"the skipped email leaves no row"
|
|
);
|
|
assert_eq!(
|
|
conn.query_row::<i64, _, _>(
|
|
"SELECT count(*) FROM sync_id_jmap WHERE type_name='Email'",
|
|
[],
|
|
|r| r.get(0)
|
|
)
|
|
.unwrap(),
|
|
1,
|
|
"no id mapping is recorded for the skipped email, so a re-run retries it"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_missing_target_email_is_created_on_rerun() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for n in 1..=2 {
|
|
let raw =
|
|
format!("From: a@x\r\nSubject: m{n}\r\nMessage-ID: <m-{n}@h>\r\n\r\nbody {n}",);
|
|
let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap();
|
|
let mm = inbuxa_migrate::sync::keys::index_to_json(
|
|
&inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw.as_bytes()),
|
|
);
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords,message_match)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]', ?2)",
|
|
rusqlite::params![blob, mm],
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
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 _mq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["T1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"T1","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":["X1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
|
|
{"id":"X1","messageId":["m-1@h"]}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"BUP"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e2":{"id":"Y2","blobId":"BUP","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("export");
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.skipped, 1, "Message-ID match with X1 skips it");
|
|
assert_eq!(email.created, 1, "missing email is created");
|
|
assert_eq!(email.failed, 0);
|
|
upload.assert();
|
|
create.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_email_blake3_fallback_matches_when_no_message_id() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let raw = "From: a@x\r\nSubject: hello\r\n\r\nno-msg-id-body";
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap();
|
|
let mm = inbuxa_migrate::sync::keys::index_to_json(
|
|
&inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw.as_bytes()),
|
|
);
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords,message_match)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]', ?2)",
|
|
rusqlite::params![blob, mm],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let local_idx = inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw.as_bytes());
|
|
assert!(local_idx.mids.is_empty(), "blob must lack Message-ID");
|
|
|
|
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 _mq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["T1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"T1","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":["X1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eg_min = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/get".into()),
|
|
Matcher::Regex("messageId".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
|
|
{"id":"X1","messageId":[]}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eg_full = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/get".into()),
|
|
Matcher::Regex("from".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
|
|
{"id":"X1","messageId":[],"from":[{"email":"a@x"}],"subject":"hello","sentAt":"","to":[]}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let no_upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.expect(0)
|
|
.create();
|
|
let no_import = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.expect(0)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("export");
|
|
no_upload.assert();
|
|
no_import.assert();
|
|
let email = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts");
|
|
assert_eq!(email.skipped, 1, "BLAKE3 fallback matched target");
|
|
assert_eq!(email.created, 0);
|
|
assert_eq!(email.failed, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_leaves_an_existing_target_default_address_book_alone() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,description,is_default)
|
|
VALUES (1,'Contacts',NULL,1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[
|
|
{"id":"P","name":"Personal","isDefault":true,"myRights":{"mayDelete":false}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let no_default_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("onSuccessSetIsDefault".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w"},"d"]]}).to_string(),
|
|
)
|
|
.expect(0)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("AddressBook/set".into()),
|
|
Matcher::Regex("Contacts".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"CID"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::AddressBook]),
|
|
)
|
|
.expect("export");
|
|
create.assert();
|
|
no_default_set.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_claims_the_default_address_book_when_the_target_has_none() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,description,is_default)
|
|
VALUES (1,'Contacts',NULL,1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[
|
|
{"id":"P","name":"Personal","isDefault":false,"myRights":{"mayDelete":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let default_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("onSuccessSetIsDefault".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w"},"d"]]}).to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("AddressBook/set".into()),
|
|
Matcher::Regex("Contacts".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"CID"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::AddressBook]),
|
|
)
|
|
.expect("export");
|
|
create.assert();
|
|
default_set.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_never_stores_two_default_address_books() {
|
|
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 _abg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","state":"sa1","list":[
|
|
{"id":"A1","name":"Contacts","isDefault":true,"isSubscribed":true,"sortOrder":0},
|
|
{"id":"A2","name":"GAL","isDefault":true,"isSubscribed":true,"sortOrder":0}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.create();
|
|
|
|
let summary = sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::AddressBook]),
|
|
)
|
|
.expect("import");
|
|
let books = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "AddressBook")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("address book counts");
|
|
assert_eq!(books.fetched, 2, "both address books are imported");
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let defaults: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM address_books WHERE is_default = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
defaults, 1,
|
|
"an archive must never hold more than one default address book"
|
|
);
|
|
let name: String = conn
|
|
.query_row(
|
|
"SELECT name FROM address_books WHERE is_default = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(name, "Contacts", "the first claimant keeps the default");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_address_book_creates_only_missing() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,description,is_default)
|
|
VALUES (1,'Personal',NULL,1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,description,is_default)
|
|
VALUES (2,'Work',NULL,0)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[
|
|
{"id":"P","name":"personal","isDefault":true,"myRights":{"mayDelete":false}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("AddressBook/set".into()),
|
|
Matcher::Regex("Work".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"WID"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::AddressBook]),
|
|
)
|
|
.expect("export");
|
|
create.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "AddressBook")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("address book counts");
|
|
assert_eq!(counts.skipped, 1, "Personal matches existing (case-fold)");
|
|
assert_eq!(counts.created, 1, "Work is created");
|
|
assert_eq!(counts.failed, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_calendar_creates_only_missing() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO calendars (id,name,is_default) VALUES (1,'Family',1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO calendars (id,name,is_default) VALUES (2,'Team',0)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Calendar/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Calendar/get",{"accountId":"w","list":[
|
|
{"id":"F","name":"family","isDefault":true,"myRights":{"mayDelete":false}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Calendar/set".into()),
|
|
Matcher::Regex("Team".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Calendar/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"TID"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Calendar]),
|
|
)
|
|
.expect("export");
|
|
create.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Calendar")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("calendar counts");
|
|
assert_eq!(counts.skipped, 1, "Family matches existing (case-fold)");
|
|
assert_eq!(counts.created, 1, "Team is created");
|
|
assert_eq!(counts.failed, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_contact_and_event_blobs_are_inlined_never_blob_ids() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let photo: &[u8] = b"\x89PNG\r\n\x1a\nfake-photo-bytes";
|
|
let agenda: &[u8] = b"agenda for the review meeting";
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO calendars (id,name,is_default) VALUES (1,'Work',1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let photo_blob = db::blobs::intern_blob(&conn, photo).unwrap();
|
|
let agenda_blob = db::blobs::intern_blob(&conn, agenda).unwrap();
|
|
let card = json!({
|
|
"@type": "Card",
|
|
"version": "1.0",
|
|
"name": { "full": "Photo Person" },
|
|
"media": { "photo": {
|
|
"@type": "Media", "kind": "photo",
|
|
"@blob": photo_blob, "mediaType": "image/png"
|
|
} }
|
|
})
|
|
.to_string();
|
|
conn.execute(
|
|
"INSERT INTO contact_cards (id,uid,address_book_ids,data)
|
|
VALUES (1,'card-uid-1','[1]',?1)",
|
|
[card.as_str()],
|
|
)
|
|
.unwrap();
|
|
let event = json!({
|
|
"@type": "Event",
|
|
"uid": "event-uid-1",
|
|
"title": "Review",
|
|
"start": "2026-03-01T09:00:00",
|
|
"duration": "PT1H",
|
|
"timeZone": "Etc/UTC",
|
|
"links": { "1": {
|
|
"@type": "Link", "rel": "enclosure",
|
|
"@blob": agenda_blob, "contentType": "text/plain", "title": "agenda.txt"
|
|
} }
|
|
})
|
|
.to_string();
|
|
conn.execute(
|
|
"INSERT INTO calendar_events (id,calendar_ids,is_draft,use_default_alerts,data)
|
|
VALUES (1,'[1]',0,0,?1)",
|
|
[event.as_str()],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 no_blob_id_in_card = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("ContactCard/set".into()),
|
|
Matcher::Regex("blobId".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TCARD"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(0)
|
|
.create();
|
|
let no_blob_id_in_event = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("CalendarEvent/set".into()),
|
|
Matcher::Regex("blobId".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TEVENT"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(0)
|
|
.create();
|
|
|
|
let _ab_get = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[],
|
|
"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _ab_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/set".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TAB"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _cal_get = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Calendar/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Calendar/get",{"accountId":"w","list":[],
|
|
"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _cal_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Calendar/set".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Calendar/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TCAL"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _cc_query = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _ce_query = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("CalendarEvent/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["CalendarEvent/query",{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let card_inlined = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("ContactCard/set".into()),
|
|
Matcher::Regex("data:image/png;base64,".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TCARD"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let event_inlined = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("CalendarEvent/set".into()),
|
|
Matcher::Regex("data:text/plain;base64,".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TEVENT"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(
|
|
&base,
|
|
vec![
|
|
ObjectType::AddressBook,
|
|
ObjectType::Calendar,
|
|
ObjectType::ContactCard,
|
|
ObjectType::CalendarEvent,
|
|
],
|
|
),
|
|
)
|
|
.expect("export");
|
|
|
|
no_blob_id_in_card.assert();
|
|
no_blob_id_in_event.assert();
|
|
card_inlined.assert();
|
|
event_inlined.assert();
|
|
|
|
for name in ["ContactCard", "CalendarEvent"] {
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == name)
|
|
.map(|(_, c)| c.clone())
|
|
.unwrap_or_else(|| panic!("{name} counts"));
|
|
assert_eq!(counts.created, 1, "{name} created: {counts:?}");
|
|
assert_eq!(counts.failed, 0, "{name} failed: {counts:?}");
|
|
}
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
fn session_body_small_request(base: &str, max_size_request: u64) -> String {
|
|
json!({
|
|
"apiUrl": format!("{base}/jmap/api"),
|
|
"uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"),
|
|
"downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"),
|
|
"capabilities": { "urn:ietf:params:jmap:core": {
|
|
"maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16,
|
|
"maxConcurrentRequests": 4, "maxConcurrentUpload": 4,
|
|
"maxSizeRequest": max_size_request, "maxSizeUpload": 50000000
|
|
} },
|
|
"accounts": { "w": { "name": "alice",
|
|
"accountCapabilities": {
|
|
"urn:ietf:params:jmap:mail": {},
|
|
"urn:ietf:params:jmap:contacts": {},
|
|
"urn:ietf:params:jmap:calendars": {}
|
|
} } }
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn export_oversized_contact_card_fails_alone_and_the_type_keeps_going() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let photo = vec![b'P'; 8000];
|
|
let photo_blob = db::blobs::intern_blob(&conn, &photo).unwrap();
|
|
let huge = json!({
|
|
"@type": "Card",
|
|
"name": { "full": "Photo Person" },
|
|
"media": { "photo": {
|
|
"@type": "Media", "kind": "photo",
|
|
"@blob": photo_blob, "mediaType": "image/png"
|
|
} }
|
|
})
|
|
.to_string();
|
|
conn.execute(
|
|
"INSERT INTO contact_cards (id,uid,address_book_ids,data)
|
|
VALUES (1,'huge-card','[1]',?1)",
|
|
[huge.as_str()],
|
|
)
|
|
.unwrap();
|
|
let small = json!({ "@type": "Card", "name": { "full": "Small Person" } }).to_string();
|
|
conn.execute(
|
|
"INSERT INTO contact_cards (id,uid,address_book_ids,data)
|
|
VALUES (2,'small-card','[1]',?1)",
|
|
[small.as_str()],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body_small_request(&base, 4000))
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _ccterm = anchor_terminator(&mut server, api, "ContactCard");
|
|
|
|
let _ab_get = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[],
|
|
"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _ab_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/set".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TAB"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _cc_query = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/query",{"accountId":"w",
|
|
"ids":["TSTALE"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _cc_get = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/get",{"accountId":"w","list":[
|
|
{"id":"TSTALE","uid":"stale-card"}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let destroyed = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("ContactCard/set".into()),
|
|
Matcher::Regex("destroy".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/set",{"accountId":"w",
|
|
"destroyed":["TSTALE"]},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let created = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("ContactCard/set".into()),
|
|
Matcher::Regex("Small Person".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"TSMALL"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
ExportConfig {
|
|
prune: true,
|
|
..export_cfg_objects(
|
|
&base,
|
|
vec![ObjectType::AddressBook, ObjectType::ContactCard],
|
|
)
|
|
},
|
|
)
|
|
.expect("the oversized card must not abort the run");
|
|
|
|
created.assert();
|
|
destroyed.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "ContactCard")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("ContactCard counts");
|
|
assert_eq!(counts.created, 1, "{counts:?}");
|
|
assert_eq!(counts.failed, 1, "{counts:?}");
|
|
assert_eq!(
|
|
counts.deleted, 1,
|
|
"the prune plan stays complete: {counts:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_archive_read_failure_while_inlining_exits_seven() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let photo_blob = db::blobs::intern_blob(&conn, b"photo-bytes").unwrap();
|
|
let card = json!({
|
|
"@type": "Card",
|
|
"media": { "photo": { "@type": "Media", "kind": "photo",
|
|
"@blob": photo_blob, "mediaType": "image/png" } }
|
|
})
|
|
.to_string();
|
|
conn.execute(
|
|
"INSERT INTO contact_cards (id,uid,address_book_ids,data)
|
|
VALUES (1,'card-uid-1','[1]',?1)",
|
|
[card.as_str()],
|
|
)
|
|
.unwrap();
|
|
conn.execute("ALTER TABLE blobs RENAME COLUMN data TO payload", [])
|
|
.unwrap();
|
|
}
|
|
|
|
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 _cc_query = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let no_create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/set".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"TCARD"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(0)
|
|
.create();
|
|
|
|
let err = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::ContactCard]),
|
|
)
|
|
.expect_err("an unreadable blobs table must abort the run");
|
|
|
|
no_create.assert();
|
|
assert!(err.aborts_run(), "{err}");
|
|
assert_eq!(err.exit_code(), 7, "{err}");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_sieve_script_matched_by_name_is_updated_when_its_content_differs() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let keepall_local = b"require [\"fileinto\"];\nkeep;\n";
|
|
let reject_local = b"require [\"reject\"];\nreject \"go away\";\n";
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
let blob1 = db::blobs::intern_blob(&conn, keepall_local).unwrap();
|
|
let blob2 = db::blobs::intern_blob(&conn, reject_local).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'keepall',1,?1)",
|
|
rusqlite::params![blob1],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'reject',0,?1)",
|
|
rusqlite::params![blob2],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("SieveScript/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/get",{"accountId":"w","list":[
|
|
{"id":"S1","name":"keepall","isActive":false,"blobId":"BSRV"}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let download = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BSRV/.*".into()))
|
|
.with_body(b"keep;\n".as_slice())
|
|
.expect(1)
|
|
.create();
|
|
let upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPN"}).to_string())
|
|
.expect(2)
|
|
.create();
|
|
let update = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("\"update\":\\{\"S1\":\\{\"blobId\":\"UPN\"".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"updated":{"S1":null}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("reject".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"S2"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _activate = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("onSuccessActivateScript".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
|
)
|
|
.expect("export");
|
|
upload.assert();
|
|
create.assert();
|
|
download.assert();
|
|
update.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "SieveScript")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("sieve counts");
|
|
assert_eq!(
|
|
counts.updated, 1,
|
|
"the name-matched script gets the archive's content"
|
|
);
|
|
assert_eq!(counts.skipped, 0);
|
|
assert_eq!(counts.created, 1, "the unmatched name is created");
|
|
assert_eq!(counts.failed, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_sieve_script_matched_by_name_with_the_same_content_is_left_alone() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let keepall_local = b"require [\"fileinto\"];\nkeep;\n";
|
|
let reject_local = b"require [\"reject\"];\nreject \"go away\";\n";
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
let blob1 = db::blobs::intern_blob(&conn, keepall_local).unwrap();
|
|
let blob2 = db::blobs::intern_blob(&conn, reject_local).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'keepall',1,?1)",
|
|
rusqlite::params![blob1],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'reject',0,?1)",
|
|
rusqlite::params![blob2],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("SieveScript/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/get",{"accountId":"w","list":[
|
|
{"id":"S1","name":"keepall","isActive":false,"blobId":"BSRV"}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let download = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BSRV/.*".into()))
|
|
.with_body(keepall_local.as_slice())
|
|
.expect(1)
|
|
.create();
|
|
let no_update = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("\"update\"".into()))
|
|
.expect(0)
|
|
.create();
|
|
let upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPN"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("reject".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"S2"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _activate = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("onSuccessActivateScript".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
|
)
|
|
.expect("export");
|
|
upload.assert();
|
|
create.assert();
|
|
download.assert();
|
|
no_update.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "SieveScript")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("sieve counts");
|
|
assert_eq!(
|
|
counts.skipped, 1,
|
|
"same name and same content: nothing to do"
|
|
);
|
|
assert_eq!(counts.updated, 0);
|
|
assert_eq!(counts.created, 1, "the unmatched name is created");
|
|
assert_eq!(counts.failed, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_sieve_scripts_identical_content_different_names_both_created() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let shared = b"require [\"fileinto\"];\nfileinto \"Archive\";\n";
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
let blob = db::blobs::intern_blob(&conn, shared).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'duplicate-A',0,?1)",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'duplicate-B',0,?1)",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
let dup_count: i64 = conn
|
|
.query_row(
|
|
"SELECT count(DISTINCT blob_id) FROM sieve_scripts",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(dup_count, 1, "both scripts share one blob (byte-identical)");
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("SieveScript/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/get",
|
|
{"accountId":"w","list":[],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPN"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let create_a = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("duplicate-A".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"S1"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create_b = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("duplicate-B".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"S2"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _deactivate = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("onSuccessDeactivateScript".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
|
)
|
|
.expect("export");
|
|
upload.assert();
|
|
create_a.assert();
|
|
create_b.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "SieveScript")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("sieve counts");
|
|
assert_eq!(
|
|
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.failed, 0);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_sieve_blob_not_found_on_dedup_reuse_reuploads_and_retries() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let shared = b"require [\"fileinto\"];\nfileinto \"Archive\";\n";
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
let blob = db::blobs::intern_blob(&conn, shared).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'dup-A',0,?1)",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'dup-B',0,?1)",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("SieveScript/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/get",
|
|
{"accountId":"w","list":[],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let up1 = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UP1"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let up2 = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UP2"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let create_a = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("dup-A".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"created":{"c1":{"id":"S1"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create_b_stale = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("dup-B".into()),
|
|
Matcher::Regex("UP1".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"notCreated":{"c2":{"type":"blobNotFound"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let create_b_fresh = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("SieveScript/set".into()),
|
|
Matcher::Regex("dup-B".into()),
|
|
Matcher::Regex("UP2".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w",
|
|
"created":{"c2":{"id":"S2"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _deactivate = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("onSuccessDeactivateScript".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::SieveScript]),
|
|
)
|
|
.expect("export");
|
|
up1.assert();
|
|
up2.assert();
|
|
create_a.assert();
|
|
create_b_stale.assert();
|
|
create_b_fresh.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "SieveScript")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("sieve counts");
|
|
assert_eq!(
|
|
counts.created, 2,
|
|
"the dedup-reused blobId that came back blobNotFound self-heals via re-upload"
|
|
);
|
|
assert_eq!(
|
|
counts.failed, 0,
|
|
"blobNotFound on a reused blob is not a failure"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_deeply_nested_mailbox_tree_orders_correctly() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
const DEPTH: usize = 10;
|
|
|
|
let ids: Vec<String> = (0..DEPTH).map(|i| format!("L{i}")).collect();
|
|
let mut servlist = Vec::new();
|
|
for (i, id) in ids.iter().enumerate() {
|
|
let parent = if i == 0 {
|
|
Value::Null
|
|
} else {
|
|
Value::String(ids[i - 1].clone())
|
|
};
|
|
servlist.push(json!({
|
|
"id": id, "name": format!("level{i}"),
|
|
"parentId": parent, "role": null,
|
|
"sortOrder": 0, "isSubscribed": true
|
|
}));
|
|
}
|
|
|
|
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 server_ids: Vec<Value> = ids.iter().rev().map(|s| Value::String(s.clone())).collect();
|
|
let _q = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids": server_ids},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _g = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",
|
|
{"accountId":"w","list": servlist,"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::import_jmap::run(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
|
|
)
|
|
.expect("import");
|
|
assert!(!summary.any_failed(), "import had failures: {summary:?}");
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let n: i64 = conn
|
|
.query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(n as usize, DEPTH);
|
|
for i in 1..DEPTH {
|
|
let pname: String = conn
|
|
.query_row(
|
|
"SELECT p.name FROM mailboxes c JOIN mailboxes p ON c.parent_id = p.id
|
|
WHERE c.name = ?1",
|
|
rusqlite::params![format!("level{i}")],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or_else(|e| panic!("level{i} parent lookup failed: {e}"));
|
|
assert_eq!(pname, format!("level{}", i - 1));
|
|
}
|
|
let root: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM mailboxes WHERE name='level0'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(root, None, "root has no parent");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_dry_run_sends_no_mutating_calls() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (2,'Sent',NULL,NULL)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let blob = db::blobs::intern_blob(
|
|
&conn,
|
|
b"From: a@x\r\nSubject: hi\r\nMessage-ID: <m-1@h>\r\n\r\nbody",
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
let _mq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let no_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex(r"/(set|import)".into()))
|
|
.expect(0)
|
|
.create();
|
|
let no_upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.expect(0)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common_dry(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("dry-run export must succeed");
|
|
assert!(
|
|
!summary.any_failed(),
|
|
"dry-run summary should not record failures: {summary:?}"
|
|
);
|
|
|
|
no_set.assert();
|
|
no_upload.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_dry_run_does_not_write_archive_or_download_blobs() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
{
|
|
let _ = db::init::open(&archive).unwrap();
|
|
}
|
|
|
|
let _root = server.mock("GET", "/").with_status(404).create();
|
|
let _wk = server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(&base))
|
|
.create();
|
|
let _term = anchor_terminator(&mut server, api, "Mailbox");
|
|
let _mq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["s1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let no_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex(r"/(set|import)".into()))
|
|
.expect(0)
|
|
.create();
|
|
let no_get = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.expect(0)
|
|
.create();
|
|
let no_download = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/".into()))
|
|
.expect(0)
|
|
.create();
|
|
|
|
let summary = sync::import_jmap::run(
|
|
common_dry(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox]),
|
|
)
|
|
.expect("dry-run import must succeed");
|
|
assert!(
|
|
!summary.any_failed(),
|
|
"dry-run import should not record failures: {summary:?}"
|
|
);
|
|
|
|
no_set.assert();
|
|
no_get.assert();
|
|
no_download.assert();
|
|
|
|
let conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let mailbox_rows: i64 = conn
|
|
.query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(mailbox_rows, 0, "dry-run must not insert into the archive");
|
|
let source_rows: i64 = conn
|
|
.query_row("SELECT count(*) FROM sources", [], |r| r.get(0))
|
|
.unwrap();
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn export_duplicate_role_mailbox_created_as_plain_folder_keeping_subtree() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (2,'Sent',NULL,'sent')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (3,'Éléments envoyés',NULL,'sent')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (4,'Brouillons locaux',3,NULL)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
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 _mq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["TI","TS"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _mg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"TI","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}},
|
|
{"id":"TS","name":"Sent","role":"sent","parentId":null,"myRights":{"mayDelete":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let role_create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Mailbox/set".into()),
|
|
Matcher::Regex("envoy".into()),
|
|
Matcher::Regex("\"role\"".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/set",{"accountId":"w",
|
|
"notCreated":{"c3":{"type":"invalidProperties","properties":["role"],
|
|
"description":"A mailbox with role 'sent' already exists."}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_most(1)
|
|
.create();
|
|
let folder_create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Mailbox/set".into()),
|
|
Matcher::Regex("envoy".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/set",{"accountId":"w",
|
|
"created":{"c3":{"id":"M3"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let child_create = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Mailbox/set".into()),
|
|
Matcher::Regex("Brouillons".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/set",{"accountId":"w",
|
|
"created":{"c4":{"id":"M4"}}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox]),
|
|
)
|
|
.expect("export");
|
|
|
|
role_create.assert();
|
|
folder_create.assert();
|
|
child_create.assert();
|
|
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("mailbox counts");
|
|
assert_eq!(
|
|
counts.skipped, 2,
|
|
"Inbox and Sent match existing role mailboxes"
|
|
);
|
|
assert_eq!(
|
|
counts.created, 2,
|
|
"duplicate-role folder and its child are both created"
|
|
);
|
|
assert_eq!(counts.failed, 0, "no cascade skip of the subtree");
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn import_jmap_duplicate_role_is_deduplicated_to_single_mailbox() {
|
|
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","B","C"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _g = 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":"Sent","parentId":null,"role":"sent","sortOrder":0,"isSubscribed":true},
|
|
{"id":"B","name":"Éléments envoyés","parentId":null,"role":"sent","sortOrder":0,"isSubscribed":true},
|
|
{"id":"C","name":"Sent Items","parentId":null,"role":"sent","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 conn = rusqlite::Connection::open(&archive).unwrap();
|
|
let total: i64 = conn
|
|
.query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(total, 3, "all three folders are imported");
|
|
let with_role: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM mailboxes WHERE role = 'sent'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(with_role, 1, "exactly one mailbox keeps the sent role");
|
|
let null_roles: i64 = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM mailboxes WHERE role IS NULL",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(null_roles, 2, "the surplus duplicates become plain folders");
|
|
drop(conn);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_aborts_with_exit_two_when_the_advertised_api_url_is_unreachable() {
|
|
let mut session_host = mockito::Server::new();
|
|
let mut api_host = mockito::Server::new();
|
|
let session_base = session_host.url();
|
|
let api_base = api_host.url();
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role,sort_order,is_subscribed)
|
|
VALUES (1,'Inbox',NULL,'inbox',0,1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,description,is_default)
|
|
VALUES (1,'Personal',NULL,1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let _root = session_host.mock("GET", "/").with_status(404).create();
|
|
let _wk = session_host
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body_full(&api_base))
|
|
.expect_at_least(1)
|
|
.create();
|
|
let nginx = api_host
|
|
.mock("POST", "/jmap/api")
|
|
.with_status(404)
|
|
.with_header("content-type", "text/html")
|
|
.with_body("<html><head><title>404 Not Found</title></head><body>nginx</body></html>")
|
|
.expect(1)
|
|
.create();
|
|
|
|
let err = sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.clone(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries: 0,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
export_cfg_objects(
|
|
&session_base,
|
|
vec![ObjectType::Mailbox, ObjectType::AddressBook],
|
|
),
|
|
)
|
|
.expect_err("an unreachable apiUrl must abort the run");
|
|
|
|
assert!(
|
|
matches!(err, inbuxa_migrate::error::Error::Connection(_)),
|
|
"a 404 from the advertised apiUrl is a whole-run connection failure, got {err:?}"
|
|
);
|
|
assert_eq!(err.exit_code(), 2, "must not report a partial failure");
|
|
nginx.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn aborting_import_still_reports_the_types_that_finished() {
|
|
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 _mq = 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_at_least(1)
|
|
.create();
|
|
let _mg = 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":"alpha","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _dead_endpoint = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_status(404)
|
|
.with_header("content-type", "text/html")
|
|
.with_body("<html><head><title>404 Not Found</title></head><body>nginx</body></html>")
|
|
.expect_at_least(1)
|
|
.create();
|
|
|
|
let outcome = sync::import_jmap::run_reporting(
|
|
common(&archive),
|
|
import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
);
|
|
|
|
let err = outcome
|
|
.error
|
|
.as_ref()
|
|
.expect("a 404 from the api endpoint aborts the run");
|
|
assert!(
|
|
matches!(err, inbuxa_migrate::error::Error::Connection(_)),
|
|
"the abort must keep its own exit code, got {err:?}"
|
|
);
|
|
assert_eq!(err.exit_code(), 2);
|
|
|
|
let mailbox = outcome
|
|
.summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Mailbox")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("the type that finished before the abort must still be reported");
|
|
assert_eq!(mailbox.fetched, 1);
|
|
assert!(
|
|
!outcome.summary.per_type.iter().any(|(t, _)| *t == "Email"),
|
|
"the type that aborted contributes no counts"
|
|
);
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
fn seed_single_email(archive: &Path) {
|
|
let conn = db::init::open(archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let blob = db::blobs::intern_blob(
|
|
&conn,
|
|
b"From: a@x\r\nSubject: hi\r\nMessage-ID: <m-9@h>\r\n\r\nbody",
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords)
|
|
VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')",
|
|
rusqlite::params![blob],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn mock_reconcile_for_single_email(server: &mut mockito::ServerGuard, base: &str, api: &str) {
|
|
server.mock("GET", "/").with_status(404).create();
|
|
server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body(base))
|
|
.create();
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["t1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"t1","name":"Inbox","role":"inbox","parentId":null,
|
|
"myRights":{"mayDelete":true}}],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",
|
|
{"accountId":"w","ids":[]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
}
|
|
|
|
fn run_export(archive: &Path, base: &str, max_retries: u32) -> sync::Summary {
|
|
sync::export::run(
|
|
CommonConfig {
|
|
archive: archive.to_path_buf(),
|
|
threads: 1,
|
|
dry_run: false,
|
|
max_retries,
|
|
allow_invalid_certs: false,
|
|
logger: Logger::from_flags(true, 0),
|
|
},
|
|
ExportConfig {
|
|
connect: ConnectConfig {
|
|
url: base.to_owned(),
|
|
auth: Auth::Basic {
|
|
user: "u".into(),
|
|
password: "p".into(),
|
|
},
|
|
account: AccountSelector::Id("w".into()),
|
|
},
|
|
objects: None,
|
|
prune: false,
|
|
yes: true,
|
|
},
|
|
)
|
|
.expect("export run")
|
|
}
|
|
|
|
fn email_counts(summary: &sync::Summary) -> sync::TypeCounts {
|
|
summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "Email")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("email counts")
|
|
}
|
|
|
|
fn method_error_body(error_type: &str) -> String {
|
|
json!({"methodResponses":[["error",
|
|
{"type": error_type,
|
|
"description":"This server is temporarily unavailable. \
|
|
Attempting this same operation later may succeed."},
|
|
"i"]]})
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn export_email_server_unavailable_is_retried_not_dropped() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
seed_single_email(&archive);
|
|
mock_reconcile_for_single_email(&mut server, &base, api);
|
|
|
|
let up = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPLOADED"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let failing = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(method_error_body("serverUnavailable"))
|
|
.expect(2)
|
|
.create();
|
|
let succeeding = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"E1","blobId":"UPLOADED","threadId":"T1","size":48}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = run_export(&archive, &base, 3);
|
|
let email = email_counts(&summary);
|
|
|
|
failing.assert();
|
|
succeeding.assert();
|
|
up.assert();
|
|
assert_eq!(
|
|
email.created, 1,
|
|
"the message must survive a transient error"
|
|
);
|
|
assert_eq!(email.failed, 0, "a retried message is not a failure");
|
|
assert!(!summary.any_failed());
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_email_exhausted_method_retries_stay_per_message() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
seed_single_email(&archive);
|
|
mock_reconcile_for_single_email(&mut server, &base, api);
|
|
|
|
let _up = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPLOADED"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let failing = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(method_error_body("serverUnavailable"))
|
|
.expect(3)
|
|
.create();
|
|
|
|
let summary = run_export(&archive, &base, 2);
|
|
let email = email_counts(&summary);
|
|
|
|
failing.assert();
|
|
assert_eq!(email.created, 0);
|
|
assert_eq!(
|
|
email.failed, 1,
|
|
"exhausting the retries stays a per-message failure, not a run abort"
|
|
);
|
|
assert!(summary.any_failed(), "the run must still exit 5");
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_email_fatal_method_error_is_not_retried() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
|
|
let archive = tmp();
|
|
seed_single_email(&archive);
|
|
mock_reconcile_for_single_email(&mut server, &base, api);
|
|
|
|
let _up = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"UPLOADED"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let failing = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(method_error_body("invalidArguments"))
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = run_export(&archive, &base, 5);
|
|
let email = email_counts(&summary);
|
|
|
|
failing.assert();
|
|
assert_eq!(
|
|
email.failed, 1,
|
|
"a permanent method error must not consume retries"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
/// Archive with an Inbox (1) and an Archive folder (2), and a target whose
|
|
/// Mailbox/get returns both as T1 and T2, so no folder is created.
|
|
fn two_folder_archive_and_target(
|
|
server: &mut mockito::Server,
|
|
archive: &Path,
|
|
) -> Vec<mockito::Mock> {
|
|
let conn = db::init::open(archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (id,name,parent_id,role) VALUES
|
|
(1,'Inbox',NULL,'inbox'), (2,'Archive',NULL,'archive')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
vec![
|
|
server.mock("GET", "/").with_status(404).create(),
|
|
server
|
|
.mock("GET", "/.well-known/jmap")
|
|
.with_body(session_body_full(&base))
|
|
.expect_at_least(1)
|
|
.create(),
|
|
anchor_terminator(server, api, "Mailbox"),
|
|
anchor_terminator(server, api, "Email"),
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/query",
|
|
{"accountId":"w","ids":["T1","T2"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create(),
|
|
server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Mailbox/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[
|
|
{"id":"T1","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}},
|
|
{"id":"T2","name":"Archive","role":"archive","parentId":null,"myRights":{"mayDelete":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create(),
|
|
]
|
|
}
|
|
|
|
fn insert_email_copy(archive: &Path, raw: &[u8], mailbox: i64) {
|
|
let conn = db::init::open(archive).unwrap();
|
|
let blob = db::blobs::intern_blob(&conn, raw).unwrap();
|
|
let mm = inbuxa_migrate::sync::keys::index_to_json(
|
|
&inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw),
|
|
);
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords,message_match)
|
|
VALUES (?1,'2020-01-01T00:00:00Z',?2,'[]',?3)",
|
|
rusqlite::params![blob, format!("[{mailbox}]"), mm],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
const ONE_MESSAGE: &[u8] = b"From: a@x\r\nSubject: both\r\nMessage-ID: <both@h>\r\n\r\nbody";
|
|
|
|
#[test]
|
|
fn export_message_in_two_folders_lands_once_in_both() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let _setup = two_folder_archive_and_target(&mut server, &archive);
|
|
insert_email_copy(&archive, ONE_MESSAGE, 1);
|
|
insert_email_copy(&archive, ONE_MESSAGE, 2);
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":[]},"q"]]}).to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"BUP"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let import = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("\"T1\":true".into()),
|
|
Matcher::Regex("\"T2\":true".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"Y1","blobId":"BUP","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("export");
|
|
let email = email_counts(&summary);
|
|
assert_eq!(email.created, 1, "one email, not one per folder");
|
|
assert_eq!(email.failed, 0);
|
|
upload.assert();
|
|
import.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_resumed_adds_the_folders_a_matched_message_is_missing() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let _setup = two_folder_archive_and_target(&mut server, &archive);
|
|
insert_email_copy(&archive, ONE_MESSAGE, 1);
|
|
insert_email_copy(&archive, ONE_MESSAGE, 2);
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["X1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
|
|
{"id":"X1","messageId":["both@h"],"size":ONE_MESSAGE.len(),"mailboxIds":{"T1":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let no_upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.expect(0)
|
|
.create();
|
|
let no_import = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.expect(0)
|
|
.create();
|
|
let set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/set".into()),
|
|
Matcher::Regex("\"mailboxIds/T2\":true".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/set",{"accountId":"w","updated":{"X1":null}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("export");
|
|
let email = email_counts(&summary);
|
|
assert_eq!(email.updated, 1, "the Archive membership is added");
|
|
assert_eq!(email.created, 0);
|
|
assert_eq!(email.failed, 0);
|
|
set.assert();
|
|
no_upload.assert();
|
|
no_import.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_different_messages_sharing_a_message_id_are_not_merged() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let _setup = two_folder_archive_and_target(&mut server, &archive);
|
|
let first: &[u8] = b"From: a@x\r\nSubject: one\r\nMessage-ID: <same@h>\r\n\r\nfirst body";
|
|
let second: &[u8] =
|
|
b"From: a@x\r\nSubject: one\r\nMessage-ID: <same@h>\r\n\r\na different, longer second body";
|
|
insert_email_copy(&archive, first, 1);
|
|
insert_email_copy(&archive, second, 1);
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["X1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
|
|
{"id":"X1","messageId":["same@h"],"size":second.len(),"mailboxIds":{"T1":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let upload = server
|
|
.mock("POST", Matcher::Regex("/jmap/upload/".into()))
|
|
.with_body(json!({"blobId":"BUP"}).to_string())
|
|
.expect(1)
|
|
.create();
|
|
let import = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/import".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"Y1","blobId":"BUP","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let no_set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/set".into()))
|
|
.expect(0)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("export");
|
|
let email = email_counts(&summary);
|
|
assert_eq!(
|
|
email.created, 1,
|
|
"the first message is not on the target yet"
|
|
);
|
|
assert_eq!(email.skipped, 1, "the second is, matched by size");
|
|
assert_eq!(email.failed, 0);
|
|
upload.assert();
|
|
import.assert();
|
|
no_set.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
#[test]
|
|
fn export_rerun_carries_a_read_flag_set_at_the_source() {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
let _setup = two_folder_archive_and_target(&mut server, &archive);
|
|
insert_email_copy(&archive, ONE_MESSAGE, 1);
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute("UPDATE emails SET keywords='[\"$seen\"]'", [])
|
|
.unwrap();
|
|
}
|
|
|
|
let _eq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["X1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _eg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("Email/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/get",{"accountId":"w","list":[
|
|
{"id":"X1","messageId":["both@h"],"size":ONE_MESSAGE.len(),
|
|
"mailboxIds":{"T1":true},"keywords":{"$flagged":true}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let set = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/set".into()),
|
|
Matcher::Regex("\"keywords/\\$seen\":true".into()),
|
|
Matcher::Regex("\"keywords/\\$flagged\":null".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/set",{"accountId":"w","updated":{"X1":null}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]),
|
|
)
|
|
.expect("export");
|
|
let email = email_counts(&summary);
|
|
assert_eq!(email.updated, 1);
|
|
assert_eq!(email.created, 0);
|
|
assert_eq!(email.failed, 0);
|
|
set.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
}
|
|
|
|
fn contact_rerun(local_updated: &str, updates_sent: usize) -> inbuxa_migrate::sync::TypeCounts {
|
|
let mut server = mockito::Server::new();
|
|
let base = server.url();
|
|
let api = "/jmap/api";
|
|
let archive = tmp();
|
|
{
|
|
let conn = db::init::open(&archive).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO address_books (id,name,description,is_default) VALUES (1,'Personal',NULL,1)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let card = json!({"@type":"Card","version":"1.0","uid":"u1",
|
|
"name":{"full":"Ann Brown"},"updated":local_updated});
|
|
conn.execute(
|
|
"INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (1,'u1','[1]',?1)",
|
|
rusqlite::params![card.to_string()],
|
|
)
|
|
.unwrap();
|
|
}
|
|
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 _abg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("AddressBook/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[
|
|
{"id":"P","name":"Personal","isDefault":true,"myRights":{"mayDelete":false}}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect_at_least(1)
|
|
.create();
|
|
let _term = anchor_terminator(&mut server, api, "ContactCard");
|
|
let _cq = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/query".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":["C1"]},"q"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let _cg = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::Regex("ContactCard/get".into()))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/get",{"accountId":"w","list":[
|
|
{"id":"C1","@type":"Card","version":"1.0","uid":"u1",
|
|
"name":{"full":"Ann"},"addressBookIds":{"P":true},
|
|
"updated":"2026-02-01T00:00:00Z"}
|
|
],"notFound":[]},"g"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let update = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("ContactCard/set".into()),
|
|
Matcher::Regex("\"update\"".into()),
|
|
Matcher::Regex("Ann Brown".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["ContactCard/set",{"accountId":"w","updated":{"C1":null}},"s"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(updates_sent)
|
|
.create();
|
|
|
|
let summary = sync::export::run(
|
|
common(&archive),
|
|
export_cfg_objects(
|
|
&base,
|
|
vec![ObjectType::AddressBook, ObjectType::ContactCard],
|
|
),
|
|
)
|
|
.expect("export");
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "ContactCard")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("contact counts");
|
|
update.assert();
|
|
let _ = std::fs::remove_file(&archive);
|
|
counts
|
|
}
|
|
|
|
#[test]
|
|
fn export_rerun_updates_a_contact_edited_at_the_source() {
|
|
let counts = contact_rerun("2026-03-01T00:00:00Z", 1);
|
|
assert_eq!(counts.updated, 1, "the newer archive copy is written");
|
|
assert_eq!(counts.failed, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn export_rerun_leaves_a_contact_alone_when_the_target_is_newer() {
|
|
let counts = contact_rerun("2026-01-01T00:00:00Z", 0);
|
|
assert_eq!(
|
|
counts.updated, 0,
|
|
"the target's copy is newer, so nothing is sent"
|
|
);
|
|
assert_eq!(counts.skipped, 1);
|
|
}
|