Files
inbuxa-migrate/tests/mock_exchange_graph.rs
T
jcoffey-dev 5931b06392 Rename to inbuxa-migrate
The crate, binary, library path and every name the tool writes or reads
become inbuxa-migrate: the CLI name and about text, the HTTP user agent,
the credential variables (INBUXA_MIGRATE_PASSWORD, _TOKEN,
_EWS_CLIENT_SECRET and _GRAPH_TOKEN, with no fallback to the old names),
thread names, synthesized UIDs and calendar addresses
(urn:x-inbuxa-migrate:attendee:), and the Maildir file suffix used by the
export script. This is a new tool, so archives written by Vandelay are not
read back.

The version moves to the date scheme the release tags use (2026.9.30), and
the package metadata points at the inbuxa-migrate repository. The cargo-dist
and MSI metadata goes with the release tooling it served.

Modified files carry an added copyright line; the original notices stay.
2026-09-30 10:09:10 -07:00

3015 lines
103 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::sync::Once;
use encodify::base64::URL_SAFE_NO_PAD;
use inbuxa_migrate::exchange_graph::api::{Endpoints, collect_all_ids, paged_collect};
use inbuxa_migrate::exchange_graph::calendar_map::{EventType, classify_event_type, convert_event};
use inbuxa_migrate::exchange_graph::client::{Accept, GraphClient};
use inbuxa_migrate::exchange_graph::contact_map::convert_contact;
use inbuxa_migrate::exchange_graph::error::GraphError;
use inbuxa_migrate::exchange_graph::oauth::{
DeviceCodeResponse, TokenResponse, parse_device_code_response, parse_token_response,
run_device_code_polling_against,
};
use inbuxa_migrate::exchange_graph::recurrence::convert_patterned_recurrence;
use inbuxa_migrate::exchange_graph::retry::{HttpClass, classify_http_status};
use inbuxa_migrate::exchange_graph::types::Surfaces;
use inbuxa_migrate::jmap::http::RetryPolicy;
use mockito::{Matcher, Server};
use serde_json::json;
static INIT: Once = Once::new();
fn client_with_retries(retries: u32) -> GraphClient {
INIT.call_once(|| {});
GraphClient::new("BEARER".to_owned(), RetryPolicy::new(retries), false)
}
fn url_message_collection(server_url: &str, folder: &str, top: usize) -> String {
let e = Endpoints::for_me(server_url);
e.folder_messages_ids(folder, top)
}
fn make_jwt(exp: u64, upn: &str) -> String {
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
let claims = format!(r#"{{"tid":"tenant-1","upn":"{upn}","exp":{exp}}}"#);
let payload = URL_SAFE_NO_PAD.encode(claims.as_bytes());
format!("{header}.{payload}.")
}
#[test]
fn http_status_classifier_matches_spec_table() {
assert_eq!(classify_http_status(200), HttpClass::Success);
assert_eq!(classify_http_status(401), HttpClass::Auth);
assert_eq!(classify_http_status(403), HttpClass::Auth);
assert_eq!(classify_http_status(404), HttpClass::Vanished);
assert_eq!(classify_http_status(410), HttpClass::Vanished);
assert_eq!(classify_http_status(429), HttpClass::Retryable);
assert_eq!(classify_http_status(503), HttpClass::Retryable);
assert_eq!(classify_http_status(504), HttpClass::Retryable);
assert_eq!(classify_http_status(507), HttpClass::Fatal);
assert_eq!(classify_http_status(400), HttpClass::Fatal);
assert_eq!(classify_http_status(405), HttpClass::Fatal);
assert_eq!(classify_http_status(422), HttpClass::Fatal);
}
#[test]
fn device_code_response_parses_required_fields() {
let body = json!({
"device_code": "ABCDEF",
"user_code": "BR4S-7XYZ",
"verification_uri": "https://microsoft.com/devicelogin",
"interval": 5,
"expires_in": 900,
"message": "Open the URL and enter the code."
});
let parsed = parse_device_code_response(&body).unwrap();
assert_eq!(parsed.device_code, "ABCDEF");
assert_eq!(parsed.user_code, "BR4S-7XYZ");
assert_eq!(parsed.interval, 5);
assert_eq!(parsed.expires_in, 900);
}
#[test]
fn token_response_handles_pending_and_success() {
let pending = parse_token_response(400, &json!({"error": "authorization_pending"}));
assert!(matches!(pending, TokenResponse::Pending));
let success = parse_token_response(
200,
&json!({"access_token": make_jwt(9999999999, "[email protected]"), "expires_in": 3599}),
);
match success {
TokenResponse::Ok(t) => {
assert!(t.access_token.contains('.'));
assert_eq!(t.expires_in, Some(3599));
assert_eq!(t.upn.as_deref(), Some("[email protected]"));
}
other => panic!("expected Ok, got {other:?}"),
}
}
#[test]
fn token_response_declined_and_bad_code() {
let declined = parse_token_response(400, &json!({"error": "authorization_declined"}));
assert!(matches!(declined, TokenResponse::Declined));
let bad = parse_token_response(400, &json!({"error": "bad_verification_code"}));
assert!(matches!(bad, TokenResponse::BadCode));
}
#[test]
fn device_code_polling_loop_converges_on_success() {
let response = DeviceCodeResponse {
device_code: "DC".to_owned(),
user_code: "U".to_owned(),
verification_uri: "x".to_owned(),
interval: 0,
expires_in: 5,
message: None,
};
let mut attempt = 0;
let token = run_device_code_polling_against(
"https://login.x/common",
"client-id",
&response,
|_endpoint, _body| {
attempt += 1;
if attempt < 3 {
Ok((400, json!({"error": "authorization_pending"})))
} else {
Ok((
200,
json!({
"access_token": make_jwt(9999999999, "alice@x"),
"expires_in": 3599
}),
))
}
},
)
.unwrap();
assert_eq!(attempt, 3);
assert_eq!(token.upn.as_deref(), Some("alice@x"));
}
#[test]
fn device_code_polling_loop_aborts_on_decline() {
let response = DeviceCodeResponse {
device_code: "DC".to_owned(),
user_code: "U".to_owned(),
verification_uri: "x".to_owned(),
interval: 0,
expires_in: 5,
message: None,
};
let err = run_device_code_polling_against(
"https://login.x/common",
"client-id",
&response,
|_, _| Ok((400, json!({"error": "authorization_declined"}))),
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("declined"));
}
#[test]
fn pagination_collects_ids_across_three_pages_with_one_empty() {
let mut server = Server::new();
let base = server.url();
let next2 = format!("{base}/page2");
let next3 = format!("{base}/page3");
let page1 = json!({
"value": [
{"id": "M1"},
{"id": "M2"}
],
"@odata.nextLink": next2
})
.to_string();
let page2 = json!({
"value": [],
"@odata.nextLink": next3
})
.to_string();
let page3 = json!({
"value": [
{"id": "M3"}
]
})
.to_string();
let _m1 = server
.mock("GET", "/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(page1)
.create();
let _m2 = server
.mock("GET", "/page2")
.match_header("authorization", "Bearer BEARER")
.match_header("prefer", "IdType=\"ImmutableId\"")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(page2)
.create();
let _m3 = server
.mock("GET", "/page3")
.match_header("authorization", "Bearer BEARER")
.match_header("prefer", "IdType=\"ImmutableId\"")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(page3)
.create();
let client = client_with_retries(0);
let url = url_message_collection(&base, "F1", 100);
let ids = collect_all_ids(&client, &url, &[]).unwrap();
assert_eq!(ids, vec!["M1".to_owned(), "M2".to_owned(), "M3".to_owned()]);
}
#[test]
fn pagination_terminates_when_no_next_link_present() {
let mut server = Server::new();
let base = server.url();
let body = json!({"value": [{"id": "A"}, {"id": "B"}]}).to_string();
let _m = server
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let client = client_with_retries(0);
let url = url_message_collection(&base, "F", 100);
let mut pages = 0;
paged_collect(&client, &url, &[], |_| {
pages += 1;
Ok(())
})
.unwrap();
assert_eq!(pages, 1);
}
#[test]
fn header_redelivery_is_asserted_via_mockito_match() {
let mut server = Server::new();
let base = server.url();
let next = format!("{base}/page-two-different-segment");
let page1 = json!({
"value": [{"id": "OnlyOnPageOne"}],
"@odata.nextLink": next
})
.to_string();
let page2 = json!({"value": [{"id": "OnlyOnPageTwo"}]}).to_string();
let _m1 = server
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.match_header("prefer", "IdType=\"ImmutableId\"")
.match_header("authorization", "Bearer BEARER")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(page1)
.create();
let _m2 = server
.mock("GET", "/page-two-different-segment")
.match_header("prefer", "IdType=\"ImmutableId\"")
.match_header("authorization", "Bearer BEARER")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(page2)
.create();
let c = client_with_retries(0);
let url = url_message_collection(&base, "F", 100);
let ids = collect_all_ids(&c, &url, &[]).unwrap();
assert_eq!(ids.len(), 2);
}
#[test]
fn case_sensitive_immutable_id_round_trip() {
let mut server = Server::new();
let id = "AAkAAGFsaWNlAAA=";
let body = json!({"value": [{"id": id}]}).to_string();
let _m = server
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let base = server.url();
let client = client_with_retries(0);
let url = url_message_collection(&base, "F", 100);
let ids = collect_all_ids(&client, &url, &[]).unwrap();
assert_eq!(ids[0], id);
assert!(ids[0].contains('='));
}
#[test]
fn rate_limit_with_retry_after_is_honoured_then_succeeds() {
let mut server = Server::new();
let path = "/me?$select=id,userPrincipalName,displayName,mail";
let _m1 = server
.mock("GET", path)
.with_status(429)
.with_header("retry-after", "1")
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"TooManyRequests"}}"#)
.expect(1)
.create();
let _m2 = server
.mock("GET", path)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"uid","userPrincipalName":"[email protected]"}"#)
.expect(1)
.create();
let base = server.url();
let client = client_with_retries(3);
let started = std::time::Instant::now();
let resp = client
.get(
&format!("{base}/me?$select=id,userPrincipalName,displayName,mail"),
Accept::Json,
)
.unwrap();
let elapsed = started.elapsed();
assert_eq!(resp.status, 200);
assert!(elapsed >= std::time::Duration::from_millis(800));
assert!(client.retries_observed() >= 1);
assert!(client.retry_after_sleeps() >= 1);
}
#[test]
fn rate_limit_without_retry_after_uses_shared_escalation() {
let mut server = Server::new();
let path = "/me?$select=id,userPrincipalName,displayName,mail";
let _m1 = server
.mock("GET", path)
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"TooManyRequests"}}"#)
.expect(1)
.create();
let _m2 = server
.mock("GET", path)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"uid","userPrincipalName":"[email protected]"}"#)
.expect(1)
.create();
let base = server.url();
let client = client_with_retries(3);
let r = client
.get(
&format!("{base}/me?$select=id,userPrincipalName,displayName,mail"),
Accept::Json,
)
.unwrap();
assert_eq!(r.status, 200);
assert!(client.retries_observed() >= 1);
}
#[test]
fn service_unavailable_503_is_retried() {
let mut server = Server::new();
let _m1 = server
.mock("GET", "/me")
.with_status(503)
.expect(1)
.create();
let _m2 = server
.mock("GET", "/me")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"u","userPrincipalName":"a@b"}"#)
.expect(1)
.create();
let base = server.url();
let c = client_with_retries(3);
let r = c.get(&format!("{base}/me"), Accept::Json).unwrap();
assert_eq!(r.status, 200);
}
#[test]
fn http_404_on_per_item_get_is_treated_as_vanished() {
let mut server = Server::new();
let _m = server
.mock("GET", "/me/messages/GONE/$value")
.with_status(404)
.with_body("gone")
.create();
let base = server.url();
let c = client_with_retries(0);
let url = format!("{base}/me/messages/GONE/$value");
let err = c.get(&url, Accept::Text).unwrap_err();
assert!(matches!(err, GraphError::Vanished));
}
#[test]
fn http_401_is_auth_error() {
let mut server = Server::new();
let _m = server
.mock("GET", "/me")
.with_status(401)
.with_body("unauthenticated")
.create();
let base = server.url();
let c = client_with_retries(0);
let url = format!("{base}/me");
let err = c.get(&url, Accept::Json).unwrap_err();
assert!(matches!(err, GraphError::Auth(_)));
}
#[test]
fn malformed_4xx_is_fatal() {
let mut server = Server::new();
let _m = server
.mock("GET", "/me")
.with_status(422)
.with_body("malformed payload")
.create();
let base = server.url();
let c = client_with_retries(0);
let err = c.get(&format!("{base}/me"), Accept::Json).unwrap_err();
assert!(matches!(err, GraphError::HttpStatus { status: 422, .. }));
}
#[test]
fn mime_value_endpoint_returns_raw_rfc5322_bytes() {
let mut server = Server::new();
let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <abc@x>\r\n\r\nbody";
let _m = server
.mock("GET", "/me/messages/MID/$value")
.match_header("accept", "text/plain")
.match_header("authorization", "Bearer BEARER")
.match_header("prefer", "IdType=\"ImmutableId\"")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(mime)
.create();
let base = server.url();
let c = client_with_retries(0);
let url = format!("{base}/me/messages/MID/$value");
let resp = c.get(&url, Accept::Text).unwrap();
assert_eq!(resp.body, mime.as_bytes());
}
#[test]
fn recurrence_daily_with_interval_round_trips() {
let pr = json!({
"pattern": {"type": "daily", "interval": 2},
"range": {"type": "noEnd"}
});
let out = convert_patterned_recurrence(&pr).unwrap();
assert_eq!(out["frequency"], "daily");
assert_eq!(out["interval"], 2);
}
#[test]
fn recurrence_weekly_with_days_of_week_and_count() {
let pr = json!({
"pattern": {
"type": "weekly",
"interval": 1,
"daysOfWeek": ["monday", "wednesday", "friday"]
},
"range": {"type": "numbered", "numberOfOccurrences": 12}
});
let out = convert_patterned_recurrence(&pr).unwrap();
assert_eq!(out["frequency"], "weekly");
assert_eq!(out["count"], 12);
assert_eq!(out["byDay"][0]["day"], "mo");
assert_eq!(out["byDay"][2]["day"], "fr");
}
#[test]
fn recurrence_relative_monthly_emits_setpos() {
let pr = json!({
"pattern": {
"type": "relativeMonthly", "interval": 1,
"daysOfWeek": ["thursday"], "index": "third"
},
"range": {"type": "noEnd"}
});
let out = convert_patterned_recurrence(&pr).unwrap();
assert_eq!(out["frequency"], "monthly");
assert_eq!(out["bySetPosition"][0], 3);
assert_eq!(out["byDay"][0]["day"], "th");
}
#[test]
fn recurrence_yearly_with_end_date() {
let pr = json!({
"pattern": {"type": "absoluteYearly", "interval": 1, "month": 12, "dayOfMonth": 25},
"range": {"type": "endDate", "endDate": "2030-12-25"}
});
let out = convert_patterned_recurrence(&pr).unwrap();
assert_eq!(out["frequency"], "yearly");
assert_eq!(out["until"], "2030-12-25T23:59:59");
}
#[test]
fn occurrence_event_is_classified_for_skip() {
let occ = json!({"id": "occ-1", "type": "occurrence", "seriesMasterId": "master-1"});
assert_eq!(classify_event_type(&occ), EventType::Occurrence);
}
#[test]
fn exception_event_carries_series_master_id() {
let ex = json!({
"id": "ex-1",
"iCalUId": "uid-1",
"type": "exception",
"seriesMasterId": "master-1",
"originalStart": "2026-06-15T10:00:00Z",
"subject": "Moved",
"start": {"dateTime": "2026-06-15T11:00:00.0000000", "timeZone": "UTC"},
"end": {"dateTime": "2026-06-15T12:00:00.0000000", "timeZone": "UTC"}
});
let conv = convert_event(&ex, None).unwrap();
assert_eq!(conv.event_type, EventType::Exception);
assert_eq!(conv.series_master_id.as_deref(), Some("master-1"));
assert_eq!(conv.original_start.as_deref(), Some("2026-06-15T10:00:00Z"));
}
#[test]
fn contact_minimal_yields_jscontact_card() {
let c = json!({
"id": "graph-1",
"displayName": "Alice Liddell",
"givenName": "Alice",
"surname": "Liddell",
"emailAddresses": [{"name": "Alice", "address": "[email protected]"}]
});
let out = convert_contact(&c).unwrap();
assert!(out.uid.starts_with("inbuxa-migrate-graph-"));
assert_eq!(out.data["@type"], "Card");
assert_eq!(out.data["name"]["full"], "Alice Liddell");
let emails = out.data["emails"].as_object().unwrap();
assert_eq!(emails["email-1"]["address"], "[email protected]");
}
#[test]
fn integration_dry_run_against_mock_server_lists_three_surfaces() {
let mut server = Server::new();
let _principal = 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","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = 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}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _calendars = 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}]}"#)
.create();
let _contact_folders = server
.mock("GET", "/me/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"CON1","displayName":"Contacts"}]}"#)
.create();
let base = server.url();
let common = inbuxa_migrate::sync::CommonConfig {
archive: tempfile::NamedTempFile::new().unwrap().path().to_owned(),
threads: 2,
dry_run: true,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: base.clone(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: Surfaces::ALL,
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
let summary = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
let mailbox = summary
.per_type
.iter()
.find(|(t, _)| *t == "mailbox")
.map(|(_, c)| c.created)
.unwrap_or(0);
let calendar = summary
.per_type
.iter()
.find(|(t, _)| *t == "calendar")
.map(|(_, c)| c.created)
.unwrap_or(0);
let addressbook = summary
.per_type
.iter()
.find(|(t, _)| *t == "addressbook")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(mailbox, 1);
assert_eq!(calendar, 1);
assert_eq!(addressbook, 1);
}
#[test]
fn integration_full_run_mail_only_imports_mime_via_value() {
let mut server = Server::new();
let _principal = 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-1","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = 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}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _well_known: Vec<mockito::Mock> = [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
]
.iter()
.map(|name| {
let path = format!("/me/mailFolders/{name}?$select=id");
server
.mock("GET", path.as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(if *name == "inbox" {
r#"{"id":"FMAIL"}"#
} else {
r#"{"id":"OTHER"}"#
})
.create()
})
.collect();
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-1"}]}"#)
.create();
let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <abc@x>\r\n\r\nhello";
let _value = server
.mock("GET", "/me/messages/MSG-1/$value")
.match_header("accept", "text/plain")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(mime)
.create();
let tmp = tempfile::NamedTempFile::new().unwrap();
let archive_path = tmp.path().to_owned();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive_path.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
drop(tmp);
let summary = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
let emails = summary
.per_type
.iter()
.find(|(t, _)| *t == "email")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(emails, 1, "expected one email created, got {emails}");
}
#[test]
fn integration_duplicate_message_id_does_not_abort_run() {
let mut server = Server::new();
let _principal = 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-1","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = 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}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _well_known: Vec<mockito::Mock> = [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
]
.iter()
.map(|name| {
let path = format!("/me/mailFolders/{name}?$select=id");
server
.mock("GET", path.as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(if *name == "inbox" {
r#"{"id":"FMAIL"}"#
} else {
r#"{"id":"OTHER"}"#
})
.create()
})
.collect();
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-1"},{"id":"MSG-1"}]}"#)
.create();
let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <abc@x>\r\n\r\nhello";
let _value = server
.mock("GET", "/me/messages/MSG-1/$value")
.match_header("accept", "text/plain")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(mime)
.create();
let tmp = tempfile::NamedTempFile::new().unwrap();
let archive_path = tmp.path().to_owned();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive_path.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
drop(tmp);
let summary = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
let emails = summary
.per_type
.iter()
.find(|(t, _)| *t == "email")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(emails, 1, "expected one email created, got {emails}");
}
#[test]
fn integration_full_run_is_convergent_on_second_invocation() {
let mut server = Server::new();
let _principal = 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-2","userPrincipalName":"[email protected]"}"#)
.expect_at_least(2)
.create();
let _folders = 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(2)
.create();
let _children = 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(2)
.create();
for name in [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
] {
server
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(if name == "inbox" {
r#"{"id":"FMAIL"}"#
} else {
r#"{"id":"OTHER"}"#
})
.expect_at_least(2)
.create();
}
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-A"}]}"#)
.expect_at_least(2)
.create();
let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <abc@x>\r\n\r\nbody";
let _value = server
.mock("GET", "/me/messages/MSG-A/$value")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(mime)
.expect(1)
.create();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let api_base = server.url();
let make_config = || inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: api_base.clone(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
let make_common = |path: std::path::PathBuf| inbuxa_migrate::sync::CommonConfig {
archive: path,
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let first = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(),
)
.unwrap();
let created_first = first
.per_type
.iter()
.find(|(t, _)| *t == "email")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(created_first, 1);
let second =
inbuxa_migrate::sync::import_exchange_graph::run(make_common(archive), make_config())
.unwrap();
let created_second = second
.per_type
.iter()
.find(|(t, _)| *t == "email")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(created_second, 0, "second run should not re-import");
}
#[test]
fn source_change_protection_refuses_a_different_account() {
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
inbuxa_migrate::db::sources::upsert_source(
&conn,
&inbuxa_migrate::db::sources::SourceKey {
kind: "exchange_graph".to_owned(),
session_url:
"https://login.microsoftonline.com/common|https://graph.microsoft.com/v1.0"
.to_owned(),
account_id: "old-user-id".to_owned(),
},
Some("[email protected]"),
"[email protected]",
)
.unwrap();
drop(conn);
let mut server = Server::new();
let _principal = server
.mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"new-user-id","userPrincipalName":"[email protected]"}"#)
.create();
let common = inbuxa_migrate::sync::CommonConfig {
archive,
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: Surfaces::ALL,
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
let err = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap_err();
assert!(
matches!(err, inbuxa_migrate::error::Error::SourceChange(_)),
"expected SourceChange error, got {err:?}"
);
}
fn stub_well_known_folders(server: &mut Server, inbox_id: &str) {
for name in [
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
] {
let body = format!(r#"{{"id":"OTHER-{name}"}}"#);
server
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect_at_least(0)
.create();
}
server
.mock("GET", "/me/mailFolders/inbox?$select=id")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(r#"{{"id":"{inbox_id}"}}"#))
.expect_at_least(0)
.create();
}
fn make_common(archive: std::path::PathBuf) -> inbuxa_migrate::sync::CommonConfig {
inbuxa_migrate::sync::CommonConfig {
archive,
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
}
}
fn surfaces(list: &str) -> inbuxa_migrate::exchange_graph::types::Surfaces {
inbuxa_migrate::exchange_graph::types::Surfaces::parse_list(list).unwrap()
}
fn make_config(
api_base: String,
user_target: Option<String>,
surfaces: inbuxa_migrate::exchange_graph::types::Surfaces,
) -> inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base,
user_target,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces,
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
}
}
#[test]
fn users_upn_routes_through_users_segment_not_me() {
let mut server = Server::new();
let _resolve = server
.mock(
"GET",
Matcher::Regex(r"^/users/alice[@%][^/]*\?\$select=id".to_owned()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"user-uuid-7","userPrincipalName":"[email protected]"}"#)
.create();
let _principal = server
.mock(
"GET",
Matcher::Regex(r"^/users/user-uuid-7\?\$select=id".to_owned()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"id":"user-uuid-7","userPrincipalName":"[email protected]","displayName":"Alice"}"#,
)
.create();
let _folders = server
.mock(
"GET",
"/users/user-uuid-7/mailFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.expect_at_least(1)
.create();
let _calendars = server
.mock("GET", "/users/user-uuid-7/calendars?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.expect_at_least(0)
.create();
let _contact_folders = server
.mock("GET", "/users/user-uuid-7/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.expect_at_least(0)
.create();
let base = server.url();
let common = inbuxa_migrate::sync::CommonConfig {
archive: tempfile::NamedTempFile::new().unwrap().path().to_owned(),
threads: 2,
dry_run: true,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
common,
make_config(base, Some("[email protected]".to_owned()), Surfaces::ALL),
)
.unwrap();
let mailbox = summary
.per_type
.iter()
.find(|(t, _)| *t == "mailbox")
.map(|(_, c)| c.created)
.unwrap_or(99);
assert_eq!(mailbox, 0);
}
#[test]
fn series_master_with_exception_merges_into_recurrence_overrides() {
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","userPrincipalName":"[email protected]"}"#)
.create();
server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock("GET", "/me/mailboxSettings?$select=timeZone")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"timeZone":"UTC"}"#)
.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}]}"#)
.create();
server
.mock("GET", "/me/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.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":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}
]}"#,
)
.create();
server
.mock(
"GET",
Matcher::Regex(r"^/me/calendars/CAL1/calendarView\?startDateTime=".to_owned()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"value":[{
"id":"EX",
"iCalUId":"uid-master",
"type":"exception",
"seriesMasterId":"MASTER",
"originalStart":"2026-05-11T15:00:00Z",
"subject":"Moved sync",
"start":{"dateTime":"2026-05-11T16:00:00.0000000","timeZone":"UTC"},
"end":{"dateTime":"2026-05-11T17:00:00.0000000","timeZone":"UTC"}
}]}"#,
)
.expect_at_least(1)
.create();
server
.mock("GET", "/me/events/MASTER")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"id":"MASTER",
"iCalUId":"uid-master",
"type":"seriesMaster",
"subject":"Weekly sync",
"start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"},
"end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"},
"recurrence":{
"pattern":{"type":"weekly","interval":1,"daysOfWeek":["monday"]},
"range":{"type":"noEnd"}
}
}"#,
)
.create();
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("calendar")),
)
.unwrap();
let events = summary
.per_type
.iter()
.find(|(t, _)| *t == "calendarevent")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(events, 1, "exactly one master row, exception merged in");
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let row: String = conn
.query_row("SELECT data FROM calendar_events", [], |row| row.get(0))
.unwrap();
let v: serde_json::Value = serde_json::from_str(&row).unwrap();
let overrides = v
.get("recurrenceOverrides")
.and_then(|o| o.as_object())
.expect("recurrenceOverrides present");
assert!(
overrides.contains_key("2026-05-11T15:00:00"),
"override keyed by originalStart as LocalDateTime; got keys: {:?}",
overrides.keys().collect::<Vec<_>>()
);
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]
fn occurrence_event_is_skipped_no_row_no_id_mapping() {
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","userPrincipalName":"[email protected]"}"#)
.create();
server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock("GET", "/me/mailboxSettings?$select=timeZone")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"timeZone":"UTC"}"#)
.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}]}"#)
.create();
server
.mock("GET", "/me/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.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":"OCC","type":"occurrence","seriesMasterId":"MASTER"}
]}"#,
)
.create();
let occ_get = server
.mock("GET", "/me/events/OCC")
.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();
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("calendar")),
)
.unwrap();
let events = summary
.per_type
.iter()
.find(|(t, _)| *t == "calendarevent")
.map(|(_, c)| c.created)
.unwrap_or(99);
assert_eq!(events, 0, "occurrence must not be fetched or stored");
occ_get.assert();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM calendar_events", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn hidden_mail_folder_has_is_subscribed_zero() {
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","userPrincipalName":"[email protected]"}"#)
.create();
server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"value":[
{"id":"FVISIBLE","displayName":"Inbox","isHidden":false},
{"id":"FHIDDEN","displayName":"SearchFolders","isHidden":true}
]}"#,
)
.create();
server
.mock(
"GET",
"/me/mailFolders/FVISIBLE/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock(
"GET",
"/me/mailFolders/FHIDDEN/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock(
"GET",
"/me/mailFolders/FVISIBLE/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock(
"GET",
"/me/mailFolders/FHIDDEN/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
stub_well_known_folders(&mut server, "FVISIBLE");
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let _ = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("mail")),
)
.unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let visible: i64 = conn
.query_row(
"SELECT is_subscribed FROM mailboxes WHERE name = 'Inbox'",
[],
|row| row.get(0),
)
.unwrap();
let hidden: i64 = conn
.query_row(
"SELECT is_subscribed FROM mailboxes WHERE name = 'SearchFolders'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(visible, 1);
assert_eq!(hidden, 0);
}
#[test]
fn well_known_folder_probes_assign_jmap_roles() {
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","userPrincipalName":"[email protected]"}"#)
.create();
server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"value":[
{"id":"INBOX-ID","displayName":"Inbox","isHidden":false},
{"id":"DRAFTS-ID","displayName":"Drafts","isHidden":false},
{"id":"SENT-ID","displayName":"Sent Items","isHidden":false},
{"id":"TRASH-ID","displayName":"Deleted Items","isHidden":false},
{"id":"JUNK-ID","displayName":"Junk Email","isHidden":false},
{"id":"ARCHIVE-ID","displayName":"Archive","isHidden":false}
]}"#,
)
.create();
for fid in [
"INBOX-ID",
"DRAFTS-ID",
"SENT-ID",
"TRASH-ID",
"JUNK-ID",
"ARCHIVE-ID",
] {
server
.mock(
"GET",
format!("/me/mailFolders/{fid}/childFolders?$top=100&includeHiddenFolders=true")
.as_str(),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock(
"GET",
format!("/me/mailFolders/{fid}/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories").as_str(),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
}
let pairs = [
("inbox", "INBOX-ID"),
("drafts", "DRAFTS-ID"),
("sentitems", "SENT-ID"),
("deleteditems", "TRASH-ID"),
("junkemail", "JUNK-ID"),
("archive", "ARCHIVE-ID"),
];
for (name, id) in pairs {
server
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(r#"{{"id":"{id}"}}"#))
.create();
}
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let _ = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("mail")),
)
.unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let mut stmt = conn
.prepare("SELECT name, role FROM mailboxes ORDER BY name")
.unwrap();
let rows: Vec<(String, Option<String>)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
let by_name: std::collections::HashMap<String, Option<String>> = rows.into_iter().collect();
assert_eq!(by_name["Inbox"].as_deref(), Some("inbox"));
assert_eq!(by_name["Drafts"].as_deref(), Some("drafts"));
assert_eq!(by_name["Sent Items"].as_deref(), Some("sent"));
assert_eq!(by_name["Deleted Items"].as_deref(), Some("trash"));
assert_eq!(by_name["Junk Email"].as_deref(), Some("junk"));
assert_eq!(by_name["Archive"].as_deref(), Some("archive"));
}
#[test]
fn attachment_message_does_not_trigger_attachments_endpoint() {
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","userPrincipalName":"[email protected]"}"#)
.create();
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}]}"#)
.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":[]}"#)
.create();
stub_well_known_folders(&mut server, "FMAIL");
server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-ATT"}]}"#)
.create();
let mime_with_attachment = "From: a@x\r\nTo: b@x\r\nSubject: with attachment\r\n\
Date: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <abc@x>\r\n\
Content-Type: multipart/mixed; boundary=\"X\"\r\n\r\n\
--X\r\nContent-Type: text/plain\r\n\r\nbody\r\n\
--X\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"a.bin\"\r\nContent-Transfer-Encoding: base64\r\n\r\n\
SGVsbG8=\r\n--X--\r\n";
server
.mock("GET", "/me/messages/MSG-ATT/$value")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(mime_with_attachment)
.create();
let no_attachments = server
.mock(
"GET",
Matcher::Regex(r"^/me/messages/[^/]+/attachments".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();
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive),
make_config(base, None, surfaces("mail")),
)
.unwrap();
let emails = summary
.per_type
.iter()
.find(|(t, _)| *t == "email")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(emails, 1);
no_attachments.assert();
}
#[test]
fn event_get_carries_outlook_timezone_and_body_content_type_prefer() {
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","userPrincipalName":"[email protected]"}"#)
.create();
server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
server
.mock("GET", "/me/mailboxSettings?$select=timeZone")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"timeZone":"Pacific Standard Time"}"#)
.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"}]}"#)
.create();
server
.mock("GET", "/me/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _events_list = server
.mock(
"GET",
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
)
.match_header(
"prefer",
Matcher::AllOf(vec![
Matcher::Regex(r#"outlook\.timezone="UTC""#.to_owned()),
Matcher::Regex(r#"IdType="ImmutableId""#.to_owned()),
]),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"EVT","type":"singleInstance"}]}"#)
.expect(1)
.create();
let _event_get = server
.mock("GET", "/me/events/EVT")
.match_header(
"prefer",
Matcher::AllOf(vec![
Matcher::Regex(r#"outlook\.body-content-type="text""#.to_owned()),
Matcher::Regex(r#"outlook\.timezone="UTC""#.to_owned()),
Matcher::Regex(r#"IdType="ImmutableId""#.to_owned()),
]),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"id":"EVT","iCalUId":"uid-evt","type":"singleInstance",
"subject":"Test","start":{"dateTime":"2026-05-27T10:00:00.0000000","timeZone":"UTC"},
"end":{"dateTime":"2026-05-27T11:00:00.0000000","timeZone":"UTC"}
}"#,
)
.expect(1)
.create();
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let _ = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive),
make_config(base, None, surfaces("calendar")),
)
.unwrap();
}
#[test]
fn graph_client_retries_after_401_when_bearer_is_swapped() {
let mut server = Server::new();
let _m1 = server
.mock("GET", "/me")
.match_header("authorization", "Bearer EXPIRED")
.with_status(401)
.with_body("expired")
.expect(1)
.create();
let _m2 = server
.mock("GET", "/me")
.match_header("authorization", "Bearer FRESH")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"u","userPrincipalName":"a@b"}"#)
.expect(1)
.create();
let base = server.url();
let client = GraphClient::new("EXPIRED".to_owned(), RetryPolicy::new(0), false);
let url = format!("{base}/me");
let err = client.get(&url, Accept::Json).unwrap_err();
assert!(matches!(err, GraphError::Auth(_)));
client.set_bearer("FRESH".to_owned());
let resp = client.get(&url, Accept::Json).unwrap();
assert_eq!(resp.status, 200);
}
#[test]
fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
let mut server = Server::new();
let _principal = 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-pad","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"AAkA-Padded==","displayName":"Inbox","isHidden":false}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/AAkA-Padded%3D%3D/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
for name in [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
] {
server
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"OTHER"}"#)
.create();
}
let _ids = server
.mock(
"GET",
"/me/mailFolders/AAkA-Padded%3D%3D/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let tmp = tempfile::NamedTempFile::new().unwrap();
let archive = tmp.path().to_owned();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
drop(tmp);
let _ = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let (graph_id, type_name): (String, String) = conn
.query_row(
"SELECT graph_id, type_name FROM sync_id_exchange_graph",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(
graph_id, "AAkA-Padded==",
"case-sensitive padded id round-trips verbatim"
);
assert_eq!(type_name, "mailbox");
let account_id: String = conn
.query_row(
"SELECT account_id FROM sources WHERE kind = 'exchange_graph'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
account_id, "uid-pad",
"primary kind uses directory id verbatim"
);
}
#[test]
fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() {
let mut server = Server::new();
let _principal = 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-arch","userPrincipalName":"[email protected]"}"#)
.create();
let _root = server
.mock(
"GET",
"/me/mailFolders/archive/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let tmp = tempfile::NamedTempFile::new().unwrap();
let archive = tmp.path().to_owned();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Archive,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
drop(tmp);
let _ = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let account_id: String = conn
.query_row(
"SELECT account_id FROM sources WHERE kind = 'exchange_graph'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(account_id, "uid-arch#archive");
}
#[test]
fn allow_source_change_permits_overwriting_a_different_account() {
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
inbuxa_migrate::db::sources::upsert_source(
&conn,
&inbuxa_migrate::db::sources::SourceKey {
kind: "exchange_graph".to_owned(),
session_url: "https://login.microsoftonline.com/common|https://x.example".to_owned(),
account_id: "different-uid".to_owned(),
},
Some("[email protected]"),
"[email protected]",
)
.unwrap();
drop(conn);
let mut server = Server::new();
let _principal = server
.mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"new-uid","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: true,
};
let result = inbuxa_migrate::sync::import_exchange_graph::run(common, config);
assert!(
result.is_ok(),
"--allow-source-change must permit a different account, got {result:?}"
);
}
#[test]
fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() {
let mut server = Server::new();
let _principal = server
.mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"u","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = 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}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _calendars = server
.mock("GET", "/me/calendars?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _contacts = server
.mock("GET", "/me/contactFolders?$top=100")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let no_messages = server
.mock(
"GET",
Matcher::Regex(r"^/me/mailFolders/.*/messages".to_owned()),
)
.expect(0)
.create();
let no_well_known = server
.mock(
"GET",
Matcher::Regex(r"^/me/mailFolders/[a-z]+\?".to_owned()),
)
.expect(0)
.create();
let tmp = tempfile::NamedTempFile::new().unwrap();
let archive = tmp.path().to_owned();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive.clone(),
threads: 2,
dry_run: true,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: Surfaces::ALL,
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: false,
};
drop(tmp);
let _ = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
no_messages.assert();
no_well_known.assert();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let sources: i64 = conn
.query_row("SELECT count(*) FROM sources", [], |row| row.get(0))
.unwrap();
assert_eq!(sources, 0, "dry-run must not write the sources row");
let mailboxes: i64 = conn
.query_row("SELECT count(*) FROM mailboxes", [], |row| row.get(0))
.unwrap();
assert_eq!(mailboxes, 0, "dry-run must not insert mailbox rows");
}
#[test]
fn refresh_access_token_failure_message_carries_status() {
let other = parse_token_response(401, &json!({"error": "invalid_grant"}));
match other {
TokenResponse::Other { error, .. } => assert_eq!(error, "invalid_grant"),
v => panic!("expected Other for invalid_grant, got {v:?}"),
}
}
#[test]
fn token_response_expired_recognised() {
let r = parse_token_response(400, &json!({"error": "expired_token"}));
assert!(matches!(r, TokenResponse::Expired));
}
#[test]
fn token_response_bad_code_recognised() {
let r = parse_token_response(400, &json!({"error": "bad_verification_code"}));
assert!(matches!(r, TokenResponse::BadCode));
}
#[test]
fn folder_enumeration_failure_skips_vanished_deletion() {
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let setup_conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let sid = inbuxa_migrate::db::sources::upsert_source(
&setup_conn,
&inbuxa_migrate::db::sources::SourceKey {
kind: "exchange_graph".to_owned(),
session_url: "stale".to_owned(),
account_id: "stale-uid".to_owned(),
},
Some("[email protected]"),
"[email protected]",
)
.unwrap();
setup_conn
.execute(
"INSERT INTO mailboxes (id, name, sort_order, is_subscribed) VALUES (42, 'Inbox', 0, 1)",
[],
)
.unwrap();
inbuxa_migrate::db::exchange_graph_ids::insert(
&setup_conn,
sid,
inbuxa_migrate::db::exchange_graph_ids::MAILBOX,
"FMAIL",
42,
)
.unwrap();
setup_conn
.execute(
"INSERT INTO blobs (hash, data) VALUES (x'aa', x'68656c6c6f')",
[],
)
.unwrap();
let blob_id: i64 = setup_conn
.query_row("SELECT id FROM blobs WHERE hash = x'aa'", [], |r| r.get(0))
.unwrap();
setup_conn
.execute(
"INSERT INTO emails (id, blob_id, received_at, mailbox_ids, keywords, message_match)
VALUES (1, ?1, '2024-01-01T00:00:00Z', '[42]', '[]', '[]')",
rusqlite::params![blob_id],
)
.unwrap();
inbuxa_migrate::db::exchange_graph_ids::insert(
&setup_conn,
sid,
inbuxa_migrate::db::exchange_graph_ids::EMAIL,
"MSG-LOCAL",
1,
)
.unwrap();
setup_conn
.execute(
"UPDATE sources SET kind = 'exchange_graph',
session_url = ?1,
account_id = 'uid-flake'
WHERE id = ?2",
rusqlite::params![
"https://login.microsoftonline.com/common|".to_owned()
+ "https://graph.microsoft.com/v1.0",
sid
],
)
.unwrap();
drop(setup_conn);
let mut server = Server::new();
let _principal = 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-flake","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = 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}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
for name in [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
] {
server
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"FMAIL"}"#)
.create();
}
let _enum_fail = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
.with_status(503)
.with_body("transient outage")
.create();
let common = inbuxa_migrate::sync::CommonConfig {
archive: archive.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: inbuxa_migrate::logging::Logger::new(0),
};
let config = inbuxa_migrate::sync::import_exchange_graph::GraphImportConfig {
auth: inbuxa_migrate::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: inbuxa_migrate::exchange_graph::types::MailboxKind::Primary,
surfaces: surfaces("mail"),
event_body_format: inbuxa_migrate::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
exception_window_years: 5,
contact_photos: false,
event_attachments: true,
allow_source_change: true,
};
let _ = inbuxa_migrate::sync::import_exchange_graph::run(common, config).unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let remaining: i64 = conn
.query_row("SELECT count(*) FROM emails WHERE id = 1", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(
remaining, 1,
"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":[]}"#)
.expect_at_least(0)
.create();
server
.mock("GET", "/me/contactFolders/contacts")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"CON1","displayName":"Contacts","wellKnownName":"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,isRead,isDraft,isReadReceiptRequested,flag,categories")
.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();
inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("contacts")),
)
.unwrap();
no_mail.assert();
no_calendars.assert();
let conn = inbuxa_migrate::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();
inbuxa_migrate::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 = inbuxa_migrate::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);
}
fn stub_principal(server: &mut Server) {
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","userPrincipalName":"[email protected]"}"#)
.expect_at_least(0)
.create();
}
fn json_mock(server: &mut Server, path: &str, body: &str) {
server
.mock("GET", path)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect_at_least(0)
.create();
}
#[test]
fn default_contact_folder_is_imported_although_contactfolders_omits_it() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/contactFolders?$top=100",
r#"{"value":[]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/contacts",
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DEFAULT/childFolders?$top=100",
r#"{"value":[]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DEFAULT/contacts?$top=100&$select=id",
r#"{"value":[{"id":"C1"},{"id":"C2"}]}"#,
);
json_mock(
&mut server,
"/me/contacts/C1",
r#"{"id":"C1","displayName":"Alice"}"#,
);
json_mock(
&mut server,
"/me/contacts/C2",
r#"{"id":"C2","displayName":"Bob"}"#,
);
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("contacts")),
)
.unwrap();
let count = |name: &str| {
summary
.per_type
.iter()
.find(|(t, _)| *t == name)
.map(|(_, c)| c.created)
.unwrap_or(0)
};
assert_eq!(
count("addressbook"),
1,
"the default Contacts folder must become an address book even though \
/me/contactFolders never lists it"
);
assert_eq!(
count("contactcard"),
2,
"both default-folder contacts import"
);
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let is_default: i64 = conn
.query_row("SELECT is_default FROM address_books", [], |row| row.get(0))
.unwrap();
assert_eq!(is_default, 1, "the well-known folder is the default book");
}
#[test]
fn default_contact_folder_falls_back_to_parent_of_an_existing_contact() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/contactFolders?$top=100",
r#"{"value":[]}"#,
);
server
.mock("GET", "/me/contactFolders/contacts")
.with_status(404)
.with_body(r#"{"error":{"code":"ErrorItemNotFound"}}"#)
.expect_at_least(1)
.create();
json_mock(
&mut server,
"/me/contacts?$top=1&$select=id,parentFolderId",
r#"{"value":[{"id":"C1","parentFolderId":"DERIVED"}]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DERIVED",
r#"{"id":"DERIVED","displayName":"Kontakte"}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DERIVED/childFolders?$top=100",
r#"{"value":[]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DERIVED/contacts?$top=100&$select=id",
r#"{"value":[{"id":"C1"}]}"#,
);
json_mock(
&mut server,
"/me/contacts/C1",
r#"{"id":"C1","displayName":"Alice"}"#,
);
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("contacts")),
)
.unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let name: String = conn
.query_row("SELECT name FROM address_books", [], |row| row.get(0))
.unwrap();
assert_eq!(
name, "Kontakte",
"when the well-known name is unavailable the folder is derived from a contact's parent"
);
}
#[test]
fn contact_folder_reachable_by_two_paths_is_inserted_once() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/contactFolders?$top=100",
r#"{"value":[{"id":"CHILD","displayName":"Work","parentFolderId":"DEFAULT"}]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/contacts",
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DEFAULT/childFolders?$top=100",
r#"{"value":[{"id":"CHILD","displayName":"Work","parentFolderId":"DEFAULT"}]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/CHILD/childFolders?$top=100",
r#"{"value":[]}"#,
);
for folder in ["DEFAULT", "CHILD"] {
json_mock(
&mut server,
&format!("/me/contactFolders/{folder}/contacts?$top=100&$select=id"),
r#"{"value":[]}"#,
);
}
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("contacts")),
)
.expect("a folder reachable by two paths must not violate the id-mapping uniqueness");
let created = summary
.per_type
.iter()
.find(|(t, _)| *t == "addressbook")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(
created, 2,
"the default folder and its one child, each once"
);
}
#[test]
fn message_state_becomes_jmap_keywords() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/mailFolders?$top=100&includeHiddenFolders=true",
r#"{"value":[{"id":"F1","displayName":"Inbox","isHidden":false}]}"#,
);
json_mock(
&mut server,
"/me/mailFolders/F1/childFolders?$top=100&includeHiddenFolders=true",
r#"{"value":[]}"#,
);
for name in [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
] {
server
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
.with_status(404)
.expect_at_least(0)
.create();
}
json_mock(
&mut server,
"/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
r#"{"value":[
{"id":"M1","isRead":true,"isDraft":false,"isReadReceiptRequested":true,
"flag":{"flagStatus":"flagged"},"categories":["Red Category","VIP"]},
{"id":"M2","isRead":false,"isDraft":true,
"flag":{"flagStatus":"notFlagged"},"categories":[]}
]}"#,
);
for id in ["M1", "M2"] {
server
.mock("GET", format!("/me/messages/{id}/$value").as_str())
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(format!(
"From: [email protected]\r\nTo: [email protected]\r\nSubject: {id}\r\nMessage-ID: <{id}@x>\r\n\r\nbody\r\n"
))
.expect_at_least(0)
.create();
}
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("mail")),
)
.unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let mut stmt = conn
.prepare("SELECT keywords FROM emails ORDER BY id")
.unwrap();
let rows: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
let all = rows.join(" ");
for expected in [
"$seen",
"$notified",
"$flagged",
"red category",
"vip",
"$draft",
] {
assert!(all.contains(expected), "missing {expected} in {all}");
}
}
#[test]
fn drive_items_import_as_file_nodes_and_skip_facetless_items() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/drive/root?$select=id,name",
r#"{"id":"ROOT","name":"root"}"#,
);
let select = "id,name,size,folder,file,package,remoteItem,createdDateTime,lastModifiedDateTime";
json_mock(
&mut server,
&format!("/me/drive/items/ROOT/children?$top=100&$select={select}"),
r#"{"value":[
{"id":"D1","name":"Docs","folder":{"childCount":1},
"createdDateTime":"2026-01-01T00:00:00Z","lastModifiedDateTime":"2026-01-02T00:00:00Z"},
{"id":"V1","name":"Personal Vault","remoteItem":{},
"createdDateTime":"2026-01-01T00:00:00Z"},
{"id":"F1","name":"top.txt","size":5,"file":{"mimeType":"text/plain"},
"createdDateTime":"2026-01-01T00:00:00Z","lastModifiedDateTime":"2026-01-02T00:00:00Z"}
]}"#,
);
json_mock(
&mut server,
&format!("/me/drive/items/D1/children?$top=100&$select={select}"),
r#"{"value":[
{"id":"F2","name":"inner.bin","size":3,"file":{"mimeType":"application/octet-stream"},
"createdDateTime":"2026-01-01T00:00:00Z"}
]}"#,
);
for (id, body) in [("F1", "hello"), ("F2", "abc")] {
server
.mock("GET", format!("/me/drive/items/{id}/content").as_str())
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_body(body)
.expect_at_least(0)
.create();
}
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let summary = inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("files")),
)
.unwrap();
let created = summary
.per_type
.iter()
.find(|(t, _)| *t == "filenode")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(
created, 3,
"one directory and two files; the remoteItem has no file or folder facet and is skipped"
);
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let vault: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE name = 'Personal Vault'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(vault, 0, "a facetless drive item must never become a node");
let nested: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes child JOIN file_nodes parent
ON child.parent_id = parent.id
WHERE child.name = 'inner.bin' AND parent.name = 'Docs'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(nested, 1, "child files hang off their directory");
let body: Vec<u8> = conn
.query_row(
"SELECT b.data FROM file_nodes f JOIN blobs b ON b.id = f.blob_id
WHERE f.name = 'top.txt'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(body, b"hello", "file content is stored verbatim");
}
fn stub_empty_calendar_scaffold(server: &mut Server) {
stub_principal(server);
json_mock(
server,
"/me/mailFolders?$top=100&includeHiddenFolders=true",
r#"{"value":[]}"#,
);
json_mock(
server,
"/me/mailboxSettings?$select=timeZone",
r#"{"timeZone":"UTC"}"#,
);
json_mock(server, "/me/contactFolders?$top=100", r#"{"value":[]}"#);
}
#[test]
fn a_deleted_occurrence_becomes_an_excluded_override() {
let mut server = Server::new();
stub_empty_calendar_scaffold(&mut server);
json_mock(
&mut server,
"/me/calendars?$top=100",
r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#,
);
json_mock(
&mut server,
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
r#"{"value":[{"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}]}"#,
);
let today = chrono::Utc::now().date_naive();
let start = today + chrono::Duration::days(10);
json_mock(
&mut server,
"/me/events/MASTER",
&format!(
r#"{{
"id":"MASTER","iCalUId":"uid-master","type":"seriesMaster",
"subject":"Daily standup",
"start":{{"dateTime":"{start}T09:00:00.0000000","timeZone":"UTC"}},
"end":{{"dateTime":"{start}T09:30:00.0000000","timeZone":"UTC"}},
"recurrence":{{
"pattern":{{"type":"daily","interval":1}},
"range":{{"type":"numbered","startDate":"{start}",
"numberOfOccurrences":4}}
}}
}}"#,
start = start.format("%Y-%m-%d")
),
);
server
.mock(
"GET",
Matcher::Regex(r"calendarView.*type%20eq%20%27exception%27".to_owned()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.expect_at_least(1)
.create();
// Graph returns three of the four occurrences: the third was deleted, and a
// deleted occurrence is simply absent rather than reported as cancelled.
let occurrences = format!(
r#"{{"value":[
{{"id":"O1","type":"occurrence","seriesMasterId":"MASTER",
"start":{{"dateTime":"{d0}T09:00:00.0000000","timeZone":"UTC"}}}},
{{"id":"O2","type":"occurrence","seriesMasterId":"MASTER",
"start":{{"dateTime":"{d1}T09:00:00.0000000","timeZone":"UTC"}}}},
{{"id":"O4","type":"occurrence","seriesMasterId":"MASTER",
"start":{{"dateTime":"{d3}T09:00:00.0000000","timeZone":"UTC"}}}}
]}}"#,
d0 = start.format("%Y-%m-%d"),
d1 = (start + chrono::Duration::days(1)).format("%Y-%m-%d"),
d3 = (start + chrono::Duration::days(3)).format("%Y-%m-%d"),
);
server
.mock(
"GET",
Matcher::Regex(r"calendarView.*type%20eq%20%27occurrence%27".to_owned()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(occurrences)
.expect_at_least(1)
.create();
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("calendar")),
)
.unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let row: String = conn
.query_row("SELECT data FROM calendar_events", [], |row| row.get(0))
.unwrap();
let v: serde_json::Value = serde_json::from_str(&row).unwrap();
let overrides = v["recurrenceOverrides"]
.as_object()
.expect("the missing occurrence produces an override");
let missing = format!(
"{}T09:00:00",
(start + chrono::Duration::days(2)).format("%Y-%m-%d")
);
assert_eq!(
overrides.len(),
1,
"only the absent occurrence is excluded; got {:?}",
overrides.keys().collect::<Vec<_>>()
);
assert_eq!(
overrides[&missing]["excluded"], true,
"v1.0 cannot report a cancelled occurrence, so it is inferred from the gap \
between the expanded rule and the occurrences Graph returned"
);
}
#[test]
fn event_file_attachments_become_enclosure_links() {
let mut server = Server::new();
stub_empty_calendar_scaffold(&mut server);
json_mock(
&mut server,
"/me/calendars?$top=100",
r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#,
);
json_mock(
&mut server,
"/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId",
r#"{"value":[{"id":"EV","type":"singleInstance","iCalUId":"uid-ev"}]}"#,
);
json_mock(
&mut server,
"/me/events/EV",
r#"{"id":"EV","iCalUId":"uid-ev","type":"singleInstance","subject":"With a file",
"hasAttachments":true,
"start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"},
"end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"}}"#,
);
json_mock(
&mut server,
"/me/events/EV/attachments",
r##"{"value":[
{"@odata.type":"#microsoft.graph.fileAttachment","id":"A1","name":"agenda.txt",
"contentType":"text/plain","size":5,"contentBytes":"aGVsbG8="},
{"@odata.type":"#microsoft.graph.itemAttachment","id":"A2","name":"nested.msg"},
{"@odata.type":"#microsoft.graph.referenceAttachment","id":"A3","name":"link"}
]}"##,
);
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
inbuxa_migrate::sync::import_exchange_graph::run(
make_common(archive.clone()),
make_config(base, None, surfaces("calendar")),
)
.unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let row: String = conn
.query_row("SELECT data FROM calendar_events", [], |row| row.get(0))
.unwrap();
let v: serde_json::Value = serde_json::from_str(&row).unwrap();
let links = v["links"].as_object().expect("links present");
assert_eq!(
links.len(),
1,
"only fileAttachment carries bytes; item and reference attachments are skipped"
);
let link = &links["1"];
assert_eq!(link["rel"], "enclosure");
assert_eq!(link["title"], "agenda.txt");
assert_eq!(link["contentType"], "text/plain");
let blob: Vec<u8> = conn
.query_row(
"SELECT b.data FROM blobs b WHERE b.id = ?1",
[link["@blob"].as_i64().unwrap()],
|row| row.get(0),
)
.unwrap();
assert_eq!(blob, b"hello");
}
#[test]
fn contact_photo_categories_and_im_addresses_are_imported() {
let mut server = Server::new();
stub_principal(&mut server);
json_mock(
&mut server,
"/me/contactFolders?$top=100",
r#"{"value":[]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/contacts",
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DEFAULT/childFolders?$top=100",
r#"{"value":[]}"#,
);
json_mock(
&mut server,
"/me/contactFolders/DEFAULT/contacts?$top=100&$select=id",
r#"{"value":[{"id":"C1"}]}"#,
);
json_mock(
&mut server,
"/me/contacts/C1",
r#"{"id":"C1","displayName":"Alice","categories":["Red Category","VIP"],
"imAddresses":["sip:[email protected]",""]}"#,
);
// Graph reports image/jpeg for every contact photo regardless of the real bytes,
// so the stored mediaType has to come from the content itself.
server
.mock("GET", "/me/contacts/C1/photo/$value")
.with_status(200)
.with_header("content-type", "image/jpeg")
.with_body(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice())
.expect_at_least(1)
.create();
let base = server.url();
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
let mut config = make_config(base, None, surfaces("contacts"));
config.contact_photos = true;
inbuxa_migrate::sync::import_exchange_graph::run(make_common(archive.clone()), config).unwrap();
let conn = inbuxa_migrate::db::init::open(&archive).unwrap();
let row: String = conn
.query_row("SELECT data FROM contact_cards", [], |row| row.get(0))
.unwrap();
let v: serde_json::Value = serde_json::from_str(&row).unwrap();
assert_eq!(v["keywords"]["red category"], true);
assert_eq!(v["keywords"]["vip"], true);
assert_eq!(
v["onlineServices"]["1"]["uri"], "sip:[email protected]",
"RFC 9553 2.3.2: a URI-shaped IM address belongs in uri, not user"
);
assert!(
v["onlineServices"].as_object().unwrap().len() == 1,
"the empty IM entry is dropped"
);
assert_eq!(v["media"]["photo"]["kind"], "photo");
assert_eq!(
v["media"]["photo"]["mediaType"], "image/png",
"the type is sniffed from the bytes, not taken from Graph's jpeg header"
);
}