@@ -151,7 +151,10 @@ fn sweep_abandoned_containers() {
|
||||
return;
|
||||
}
|
||||
eprintln!("removing {} abandoned test container(s)", ids.len());
|
||||
let _ = Command::new("docker").args(["rm", "-f"]).args(&ids).output();
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f"])
|
||||
.args(&ids)
|
||||
.output();
|
||||
}
|
||||
|
||||
fn pick_free_port() -> ContainerResult<u16> {
|
||||
|
||||
@@ -1595,3 +1595,234 @@ fn mixed_source_archive_allows_distinct_kinds_against_same_url() {
|
||||
let no_conflict = sources::conflicting_source(&conn, "caldav", "https://x", "alice").unwrap();
|
||||
assert!(no_conflict.is_none());
|
||||
}
|
||||
|
||||
fn dav_archive() -> std::path::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!(
|
||||
"vandelay-mockdav-{}-{:?}-{n}.sqlite",
|
||||
std::process::id(),
|
||||
std::thread::current().id(),
|
||||
));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
fn caldav_import_config(url: &str) -> vandelay::sync::import_dav::DavImportConfig {
|
||||
vandelay::sync::import_dav::DavImportConfig {
|
||||
kind: vandelay::sync::import_dav::DavKindArg::Caldav,
|
||||
url: url.to_owned(),
|
||||
auth: vandelay::sync::import_dav::DavAuth::Basic {
|
||||
user: "u".to_owned(),
|
||||
password: "p".to_owned(),
|
||||
},
|
||||
allow_cleartext: true,
|
||||
dav_connections: 1,
|
||||
multiget_batch: 8,
|
||||
allow_source_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn dav_common(archive: &std::path::Path) -> vandelay::sync::CommonConfig {
|
||||
vandelay::sync::CommonConfig {
|
||||
archive: archive.to_path_buf(),
|
||||
threads: 1,
|
||||
dry_run: false,
|
||||
max_retries: 0,
|
||||
allow_invalid_certs: false,
|
||||
logger: vandelay::logging::Logger::from_flags(true, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_count(archive: &std::path::Path, table: &str) -> i64 {
|
||||
let conn = rusqlite::Connection::open(archive).expect("open archive");
|
||||
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||
.expect("count")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_collection_is_a_per_unit_failure_and_the_others_still_import() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = server.url();
|
||||
|
||||
let home_set = format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>{url}/dav/cal/u/work/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||||
<d:displayname>Work</d:displayname>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>{url}/dav/cal/u/shared/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||||
<d:displayname>Shared</d:displayname>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
);
|
||||
let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set);
|
||||
|
||||
let work_items = format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>{url}/dav/cal/u/work/e1.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop><d:resourcetype/><d:getetag>"v1"</d:getetag></d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
);
|
||||
let _work_enumeration =
|
||||
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/work/", &work_items);
|
||||
|
||||
let work_data = format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>{url}/dav/cal/u/work/e1.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"v1"</d:getetag>
|
||||
<c:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:[email protected]
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART:20260101T090000Z
|
||||
DTEND:20260101T100000Z
|
||||
SUMMARY:Standup
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
</c:calendar-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
);
|
||||
let _work_multiget =
|
||||
multistatus_response(&mut server, "REPORT", "/dav/cal/u/work/", &work_data);
|
||||
|
||||
let forbidden = server
|
||||
.mock("PROPFIND", "/dav/cal/u/shared/")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("you may not read this calendar")
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
let archive = dav_archive();
|
||||
let summary = vandelay::sync::import_dav::run(
|
||||
dav_common(&archive),
|
||||
caldav_import_config(&format!("{url}/dav/cal/u/")),
|
||||
)
|
||||
.expect("a forbidden collection must not abort the run");
|
||||
|
||||
forbidden.assert();
|
||||
|
||||
let events = summary
|
||||
.per_type
|
||||
.iter()
|
||||
.find(|(t, _)| *t == "calendarevent")
|
||||
.map(|(_, c)| c.clone())
|
||||
.expect("calendarevent counts");
|
||||
assert_eq!(events.failed, 1, "the forbidden calendar counts as failed");
|
||||
assert_eq!(events.fetched, 1, "the readable calendar still imported");
|
||||
assert!(
|
||||
summary.any_failed(),
|
||||
"a per-unit failure ends the run at exit 5"
|
||||
);
|
||||
|
||||
assert_eq!(row_count(&archive, "calendars"), 2);
|
||||
assert_eq!(row_count(&archive, "calendar_events"), 1);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_rejected_during_discovery_still_aborts_the_run() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = server.url();
|
||||
|
||||
let _rejected = server
|
||||
.mock("PROPFIND", mockito::Matcher::Any)
|
||||
.with_status(401)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("bad password")
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
let archive = dav_archive();
|
||||
let err = vandelay::sync::import_dav::run(
|
||||
dav_common(&archive),
|
||||
caldav_import_config(&format!("{url}/dav/cal/u/")),
|
||||
)
|
||||
.expect_err("credentials rejected for the principal aborts the run");
|
||||
|
||||
assert!(
|
||||
matches!(err, vandelay::error::Error::Connection(_)),
|
||||
"discovery-level auth rejection is a whole-run failure, got {err:?}"
|
||||
);
|
||||
assert_eq!(err.exit_code(), 2);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dav_import_reports_both_phases_when_every_collection_fails() {
|
||||
let mut server = mockito::Server::new();
|
||||
let url = server.url();
|
||||
|
||||
let home_set = format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>{url}/dav/cal/u/work/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||||
<d:displayname>Work</d:displayname>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
);
|
||||
let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set);
|
||||
let _forbidden = server
|
||||
.mock("PROPFIND", "/dav/cal/u/work/")
|
||||
.with_status(403)
|
||||
.with_body("nope")
|
||||
.expect_at_least(1)
|
||||
.create();
|
||||
|
||||
let archive = dav_archive();
|
||||
let outcome = vandelay::sync::import_dav::run_reporting(
|
||||
dav_common(&archive),
|
||||
caldav_import_config(&format!("{url}/dav/cal/u/")),
|
||||
);
|
||||
assert!(outcome.error.is_none());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.summary
|
||||
.per_type
|
||||
.iter()
|
||||
.map(|(t, _)| *t)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["calendar", "calendarevent"],
|
||||
"both phases are reported even when every collection failed"
|
||||
);
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
+252
-73
@@ -20,6 +20,7 @@ use vandelay::exchange_graph::oauth::{
|
||||
};
|
||||
use vandelay::exchange_graph::recurrence::convert_patterned_recurrence;
|
||||
use vandelay::exchange_graph::retry::{HttpClass, classify_http_status};
|
||||
use vandelay::exchange_graph::types::Surfaces;
|
||||
use vandelay::jmap::http::RetryPolicy;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
@@ -584,7 +585,7 @@ fn integration_dry_run_against_mock_server_lists_three_surfaces() {
|
||||
api_base: base.clone(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: None,
|
||||
surfaces: Surfaces::ALL,
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -693,10 +694,7 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -792,10 +790,7 @@ fn integration_duplicate_message_id_does_not_abort_run() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -884,10 +879,7 @@ fn integration_full_run_is_convergent_on_second_invocation() {
|
||||
api_base: api_base.clone(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -966,7 +958,7 @@ fn source_change_protection_refuses_a_different_account() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: None,
|
||||
surfaces: Surfaces::ALL,
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -1016,10 +1008,14 @@ fn make_common(archive: std::path::PathBuf) -> vandelay::sync::CommonConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn surfaces(list: &str) -> vandelay::exchange_graph::types::Surfaces {
|
||||
vandelay::exchange_graph::types::Surfaces::parse_list(list).unwrap()
|
||||
}
|
||||
|
||||
fn make_config(
|
||||
api_base: String,
|
||||
user_target: Option<String>,
|
||||
objects: Option<Vec<vandelay::types::ObjectType>>,
|
||||
surfaces: vandelay::exchange_graph::types::Surfaces,
|
||||
) -> vandelay::sync::import_exchange_graph::GraphImportConfig {
|
||||
vandelay::sync::import_exchange_graph::GraphImportConfig {
|
||||
auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired {
|
||||
@@ -1028,7 +1024,7 @@ fn make_config(
|
||||
api_base,
|
||||
user_target,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects,
|
||||
surfaces,
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -1094,7 +1090,7 @@ fn users_upn_routes_through_users_segment_not_me() {
|
||||
};
|
||||
let summary = vandelay::sync::import_exchange_graph::run(
|
||||
common,
|
||||
make_config(base, Some("[email protected]".to_owned()), None),
|
||||
make_config(base, Some("[email protected]".to_owned()), Surfaces::ALL),
|
||||
)
|
||||
.unwrap();
|
||||
let mailbox = summary
|
||||
@@ -1193,14 +1189,7 @@ fn series_master_with_exception_merges_into_recurrence_overrides() {
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let summary = vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(
|
||||
base,
|
||||
None,
|
||||
Some(vec![
|
||||
vandelay::types::ObjectType::Calendar,
|
||||
vandelay::types::ObjectType::CalendarEvent,
|
||||
]),
|
||||
),
|
||||
make_config(base, None, surfaces("calendar")),
|
||||
)
|
||||
.unwrap();
|
||||
let events = summary
|
||||
@@ -1227,6 +1216,26 @@ fn series_master_with_exception_merges_into_recurrence_overrides() {
|
||||
);
|
||||
let override_data = &overrides["2026-05-11T15:00:00"];
|
||||
assert_eq!(override_data["title"], "Moved sync");
|
||||
assert_eq!(override_data["start"], "2026-05-11T16:00:00");
|
||||
for ignored in [
|
||||
"@type",
|
||||
"uid",
|
||||
"method",
|
||||
"organizerCalendarAddress",
|
||||
"privacy",
|
||||
"prodId",
|
||||
"recurrenceId",
|
||||
"recurrenceIdTimeZone",
|
||||
"sentBy",
|
||||
"recurrenceRule",
|
||||
"recurrenceOverrides",
|
||||
"relatedTo",
|
||||
] {
|
||||
assert!(
|
||||
override_data.get(ignored).is_none(),
|
||||
"{ignored} must not appear in a PatchObject (jscalendarbis 3.3.4); got {override_data}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1285,14 +1294,7 @@ fn occurrence_event_is_skipped_no_row_no_id_mapping() {
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let summary = vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(
|
||||
base,
|
||||
None,
|
||||
Some(vec![
|
||||
vandelay::types::ObjectType::Calendar,
|
||||
vandelay::types::ObjectType::CalendarEvent,
|
||||
]),
|
||||
),
|
||||
make_config(base, None, surfaces("calendar")),
|
||||
)
|
||||
.unwrap();
|
||||
let events = summary
|
||||
@@ -1372,14 +1374,7 @@ fn hidden_mail_folder_has_is_subscribed_zero() {
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let _ = vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(
|
||||
base,
|
||||
None,
|
||||
Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
),
|
||||
make_config(base, None, surfaces("mail")),
|
||||
)
|
||||
.unwrap();
|
||||
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||
@@ -1473,14 +1468,7 @@ fn well_known_folder_probes_assign_jmap_roles() {
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let _ = vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(
|
||||
base,
|
||||
None,
|
||||
Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
),
|
||||
make_config(base, None, surfaces("mail")),
|
||||
)
|
||||
.unwrap();
|
||||
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||
@@ -1557,14 +1545,7 @@ SGVsbG8=\r\n--X--\r\n";
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let summary = vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive),
|
||||
make_config(
|
||||
base,
|
||||
None,
|
||||
Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
),
|
||||
make_config(base, None, surfaces("mail")),
|
||||
)
|
||||
.unwrap();
|
||||
let emails = summary
|
||||
@@ -1652,14 +1633,7 @@ fn event_get_carries_outlook_timezone_and_body_content_type_prefer() {
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
let _ = vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive),
|
||||
make_config(
|
||||
base,
|
||||
None,
|
||||
Some(vec![
|
||||
vandelay::types::ObjectType::Calendar,
|
||||
vandelay::types::ObjectType::CalendarEvent,
|
||||
]),
|
||||
),
|
||||
make_config(base, None, surfaces("calendar")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1758,7 +1732,7 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -1828,7 +1802,7 @@ fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Archive,
|
||||
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -1893,7 +1867,7 @@ fn allow_source_change_permits_overwriting_a_different_account() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: Some(vec![vandelay::types::ObjectType::Mailbox]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -1974,7 +1948,7 @@ fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: None,
|
||||
surfaces: Surfaces::ALL,
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -2142,10 +2116,7 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
|
||||
api_base: server.url(),
|
||||
user_target: None,
|
||||
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
|
||||
objects: Some(vec![
|
||||
vandelay::types::ObjectType::Mailbox,
|
||||
vandelay::types::ObjectType::Email,
|
||||
]),
|
||||
surfaces: surfaces("mail"),
|
||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||
graph_connections: 2,
|
||||
top: 100,
|
||||
@@ -2163,3 +2134,211 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
|
||||
"transient folder-enumeration failure must NOT delete the locally-known message"
|
||||
);
|
||||
}
|
||||
|
||||
fn row_count(conn: &rusqlite::Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn stub_contacts_surface(server: &mut Server) {
|
||||
server
|
||||
.mock("GET", "/me/contactFolders?$top=100")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[{"id":"CON1","displayName":"Contacts"}]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock("GET", "/me/contactFolders/CON1/childFolders?$top=100")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
"/me/contactFolders/CON1/contacts?$top=100&$select=id",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[{"id":"CT1"}]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock("GET", "/me/contacts/CT1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"CT1","displayName":"Alice Liddell","givenName":"Alice",
|
||||
"surname":"Liddell","emailAddresses":[{"name":"Alice","address":"[email protected]"}]}"#,
|
||||
)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
}
|
||||
|
||||
fn stub_mail_surface(server: &mut Server) {
|
||||
server
|
||||
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
stub_well_known_folders(server, "FMAIL");
|
||||
server
|
||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[{"id":"MSG-S"}]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock("GET", "/me/messages/MSG-S/$value")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body(
|
||||
"From: a@x\r\nTo: b@x\r\nSubject: surface\r\n\
|
||||
Date: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <surface@x>\r\n\r\nbody",
|
||||
)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
}
|
||||
|
||||
fn stub_calendar_surface(server: &mut Server) {
|
||||
server
|
||||
.mock("GET", "/me/mailboxSettings?$select=timeZone")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"timeZone":"UTC"}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock("GET", "/me/calendars?$top=100")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock(
|
||||
"GET",
|
||||
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"value":[{"id":"EV1","type":"singleInstance","iCalUId":"uid-ev1"}]}"#)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
server
|
||||
.mock("GET", "/me/events/EV1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"EV1","iCalUId":"uid-ev1","type":"singleInstance","subject":"Standup",
|
||||
"start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"},
|
||||
"end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"}}"#,
|
||||
)
|
||||
.expect_at_least(0)
|
||||
.create();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_surface_imports_only_address_books_and_cards() {
|
||||
let mut server = Server::new();
|
||||
server
|
||||
.mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"id":"uid-surface-c","userPrincipalName":"[email protected]"}"#)
|
||||
.create();
|
||||
stub_contacts_surface(&mut server);
|
||||
stub_calendar_surface(&mut server);
|
||||
stub_mail_surface(&mut server);
|
||||
let no_mail = server
|
||||
.mock("GET", Matcher::Regex(r"^/me/mailFolders\?".to_owned()))
|
||||
.with_status(500)
|
||||
.with_body("MUST NOT BE CALLED")
|
||||
.expect(0)
|
||||
.create();
|
||||
let no_calendars = server
|
||||
.mock("GET", Matcher::Regex(r"^/me/calendars\?".to_owned()))
|
||||
.with_status(500)
|
||||
.with_body("MUST NOT BE CALLED")
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let base = server.url();
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(base, None, surfaces("contacts")),
|
||||
)
|
||||
.unwrap();
|
||||
no_mail.assert();
|
||||
no_calendars.assert();
|
||||
|
||||
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||
assert_eq!(row_count(&conn, "address_books"), 1);
|
||||
assert_eq!(row_count(&conn, "contact_cards"), 1);
|
||||
assert_eq!(row_count(&conn, "mailboxes"), 0);
|
||||
assert_eq!(row_count(&conn, "emails"), 0);
|
||||
assert_eq!(row_count(&conn, "calendars"), 0);
|
||||
assert_eq!(row_count(&conn, "calendar_events"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mail_surface_imports_only_mailboxes_and_emails() {
|
||||
let mut server = Server::new();
|
||||
server
|
||||
.mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"id":"uid-surface-m","userPrincipalName":"[email protected]"}"#)
|
||||
.create();
|
||||
stub_mail_surface(&mut server);
|
||||
stub_calendar_surface(&mut server);
|
||||
stub_contacts_surface(&mut server);
|
||||
let no_calendars = server
|
||||
.mock("GET", Matcher::Regex(r"^/me/calendars\?".to_owned()))
|
||||
.with_status(500)
|
||||
.with_body("MUST NOT BE CALLED")
|
||||
.expect(0)
|
||||
.create();
|
||||
let no_contact_folders = server
|
||||
.mock("GET", Matcher::Regex(r"^/me/contactFolders\?".to_owned()))
|
||||
.with_status(500)
|
||||
.with_body("MUST NOT BE CALLED")
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
let base = server.url();
|
||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||
vandelay::sync::import_exchange_graph::run(
|
||||
make_common(archive.clone()),
|
||||
make_config(base, None, surfaces("mail")),
|
||||
)
|
||||
.unwrap();
|
||||
no_calendars.assert();
|
||||
no_contact_folders.assert();
|
||||
|
||||
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||
assert_eq!(row_count(&conn, "mailboxes"), 1);
|
||||
assert_eq!(row_count(&conn, "emails"), 1);
|
||||
assert_eq!(row_count(&conn, "address_books"), 0);
|
||||
assert_eq!(row_count(&conn, "contact_cards"), 0);
|
||||
assert_eq!(row_count(&conn, "calendars"), 0);
|
||||
assert_eq!(row_count(&conn, "calendar_events"), 0);
|
||||
}
|
||||
|
||||
@@ -1201,3 +1201,97 @@ fn get_state_reads_state_from_empty_get() {
|
||||
let st = get_state(&client(0), &url, "w", "Email").expect("get_state");
|
||||
assert_eq!(st.as_deref(), Some("snap-1"));
|
||||
}
|
||||
|
||||
fn archive_path() -> std::path::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!(
|
||||
"vandelay-mockjmap-{}-{n}.sqlite",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_urls_on_a_foreign_origin_are_reported_as_a_mismatch() {
|
||||
let mut server = mockito::Server::new();
|
||||
let base = server.url();
|
||||
let elsewhere = "https://mail.example".to_owned();
|
||||
let _root = server.mock("GET", "/").with_status(404).create();
|
||||
let _wk = server
|
||||
.mock("GET", "/.well-known/jmap")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(session_json(&elsewhere))
|
||||
.create();
|
||||
|
||||
let session = Session::discover(&client(0), &base).expect("session discovered");
|
||||
let mismatches = session.origin_mismatches(&base);
|
||||
assert_eq!(mismatches.len(), 1, "one warning per distinct origin");
|
||||
assert_eq!(
|
||||
mismatches[0].fields,
|
||||
vec!["apiUrl", "uploadUrl", "downloadUrl"]
|
||||
);
|
||||
let warning = mismatches[0].to_string();
|
||||
assert!(warning.contains("session advertises apiUrl"), "{warning}");
|
||||
assert!(warning.contains("connected to "), "{warning}");
|
||||
assert!(warning.contains("advertised HTTP URL setting"), "{warning}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_aborts_with_exit_two_when_the_advertised_api_url_is_unreachable() {
|
||||
let mut api_host = mockito::Server::new();
|
||||
let mut session_host = mockito::Server::new();
|
||||
let session_base = session_host.url();
|
||||
let api_base = api_host.url();
|
||||
|
||||
let _root = session_host.mock("GET", "/").with_status(404).create();
|
||||
let _wk = session_host
|
||||
.mock("GET", "/.well-known/jmap")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(session_json(&api_base))
|
||||
.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>404 Not Found</body></html>",
|
||||
)
|
||||
.create();
|
||||
|
||||
let archive = archive_path();
|
||||
let err = vandelay::sync::import_jmap::run(
|
||||
vandelay::sync::CommonConfig {
|
||||
archive: archive.clone(),
|
||||
threads: 1,
|
||||
dry_run: false,
|
||||
max_retries: 0,
|
||||
allow_invalid_certs: false,
|
||||
logger: vandelay::logging::Logger::from_flags(true, 0),
|
||||
},
|
||||
vandelay::sync::ImportConfig {
|
||||
connect: vandelay::sync::ConnectConfig {
|
||||
url: session_base.clone(),
|
||||
auth: Auth::Basic {
|
||||
user: "u".into(),
|
||||
password: "p".into(),
|
||||
},
|
||||
account: AccountSelector::Id("w".into()),
|
||||
},
|
||||
objects: None,
|
||||
allow_source_change: false,
|
||||
},
|
||||
)
|
||||
.expect_err("an unreachable apiUrl must abort the run");
|
||||
|
||||
assert!(
|
||||
matches!(err, vandelay::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");
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
}
|
||||
|
||||
@@ -1761,6 +1761,449 @@ fn export_calendar_creates_only_missing() {
|
||||
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_matches_by_name_not_content() {
|
||||
let mut server = mockito::Server::new();
|
||||
@@ -3264,3 +3707,369 @@ fn import_jmap_duplicate_role_is_deduplicated_to_single_mailbox() {
|
||||
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, vandelay::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, vandelay::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);
|
||||
}
|
||||
|
||||
@@ -689,6 +689,167 @@ fn prune_non_tree_type_contact_card() {
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
const ISSUE30_CARD_UID: &str = "vandelay-issue30-card";
|
||||
const ISSUE30_EVENT_UID: &str = "vandelay-issue30-event";
|
||||
|
||||
fn data_uri_bytes(resource: &Value, uri_key: &str, expect_media_type: &str) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
let uri = resource
|
||||
.get(uri_key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("no {uri_key} on {resource}"));
|
||||
assert!(
|
||||
resource.get("blobId").is_none(),
|
||||
"target must not hold a blobId for {uri_key}: {resource}"
|
||||
);
|
||||
let prefix = format!("data:{expect_media_type};base64,");
|
||||
let payload = uri
|
||||
.strip_prefix(&prefix)
|
||||
.unwrap_or_else(|| panic!("{uri_key} is not a {prefix}... data URI: {resource}"));
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(payload)
|
||||
.expect("base64 payload")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn export_inlines_contact_and_event_blobs_instead_of_blob_ids() {
|
||||
let fx = seeder::provision(base_url()).expect("provision");
|
||||
let tgt = fx.account("test6").expect("test6");
|
||||
let archive = tmp_archive("issue30");
|
||||
|
||||
let photo: &[u8] = b"\x89PNG\r\n\x1a\nvandelay issue 30 contact photo bytes";
|
||||
let agenda: &[u8] = b"vandelay issue 30 calendar enclosure bytes";
|
||||
{
|
||||
let conn = vandelay::db::init::open(&archive).expect("init archive");
|
||||
conn.execute(
|
||||
"INSERT INTO address_books (id,name,is_default) VALUES (1,'Issue30 Book',1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO calendars (id,name,is_default) VALUES (1,'Issue30 Calendar',1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let photo_blob = vandelay::db::blobs::intern_blob(&conn, photo).unwrap();
|
||||
let agenda_blob = vandelay::db::blobs::intern_blob(&conn, agenda).unwrap();
|
||||
let card = json!({
|
||||
"@type": "Card",
|
||||
"version": "1.0",
|
||||
"name": { "full": "Issue Thirty" },
|
||||
"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,?1,'[1]',?2)",
|
||||
[ISSUE30_CARD_UID, card.as_str()],
|
||||
)
|
||||
.unwrap();
|
||||
let event = json!({
|
||||
"@type": "Event",
|
||||
"uid": ISSUE30_EVENT_UID,
|
||||
"title": "Issue 30 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 summary = sync::export::run(
|
||||
common(&archive, false),
|
||||
export_cfg("test6", &tgt.account_id, false),
|
||||
)
|
||||
.expect("export");
|
||||
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 missing: {summary:?}"));
|
||||
assert_eq!(counts.created, 1, "{name} created exactly one: {counts:?}");
|
||||
assert_eq!(counts.failed, 0, "{name} had no failures: {counts:?}");
|
||||
}
|
||||
|
||||
let client = HttpClient::new(basic("test6"), RetryPolicy::new(5), true);
|
||||
let session = Session::discover(&client, base_url()).expect("discover target session");
|
||||
let api = session.api_url.clone();
|
||||
|
||||
let cards = client
|
||||
.post_json(
|
||||
&api,
|
||||
&json!({
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"],
|
||||
"methodCalls": [["ContactCard/get",
|
||||
{ "accountId": tgt.account_id, "ids": Value::Null }, "g"]]
|
||||
}),
|
||||
)
|
||||
.expect("ContactCard/get");
|
||||
let card = cards
|
||||
.pointer("/methodResponses/0/1/list")
|
||||
.and_then(Value::as_array)
|
||||
.expect("ContactCard list")
|
||||
.iter()
|
||||
.find(|c| c.get("uid").and_then(Value::as_str) == Some(ISSUE30_CARD_UID))
|
||||
.unwrap_or_else(|| panic!("exported card not found on the target: {cards}"))
|
||||
.clone();
|
||||
let photo_resource = card
|
||||
.pointer("/media/photo")
|
||||
.unwrap_or_else(|| panic!("card has no media.photo: {card}"));
|
||||
assert_eq!(
|
||||
data_uri_bytes(photo_resource, "uri", "image/png"),
|
||||
photo,
|
||||
"contact photo bytes survived the export: {card}"
|
||||
);
|
||||
|
||||
let events = client
|
||||
.post_json(
|
||||
&api,
|
||||
&json!({
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"],
|
||||
"methodCalls": [["CalendarEvent/get",
|
||||
{ "accountId": tgt.account_id, "ids": Value::Null }, "g"]]
|
||||
}),
|
||||
)
|
||||
.expect("CalendarEvent/get");
|
||||
let event = events
|
||||
.pointer("/methodResponses/0/1/list")
|
||||
.and_then(Value::as_array)
|
||||
.expect("CalendarEvent list")
|
||||
.iter()
|
||||
.find(|e| e.get("uid").and_then(Value::as_str) == Some(ISSUE30_EVENT_UID))
|
||||
.unwrap_or_else(|| panic!("exported event not found on the target: {events}"))
|
||||
.clone();
|
||||
let link = event
|
||||
.get("links")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|m| m.values().next())
|
||||
.unwrap_or_else(|| panic!("event has no links: {event}"));
|
||||
assert_eq!(
|
||||
data_uri_bytes(link, "href", "text/plain"),
|
||||
agenda,
|
||||
"calendar enclosure bytes survived the export: {event}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
seeder::teardown(base_url()).expect("teardown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Docker"]
|
||||
fn export_round_trip_and_convergence() {
|
||||
|
||||
Reference in New Issue
Block a user