@@ -14,6 +14,27 @@ pub fn windows_to_iana(windows_name: &str) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn resolve_to_iana(value: &str) -> Option<String> {
|
||||
use calcard::common::timezone::Tz;
|
||||
use std::str::FromStr;
|
||||
|
||||
let key = value.trim();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = key.strip_prefix("tzone://Microsoft/") {
|
||||
return rest
|
||||
.eq_ignore_ascii_case("Utc")
|
||||
.then(|| "Etc/UTC".to_owned());
|
||||
}
|
||||
if let Some(iana) = windows_to_iana(key) {
|
||||
return Some(iana.to_owned());
|
||||
}
|
||||
Tz::from_str(key)
|
||||
.ok()
|
||||
.and_then(|tz| tz.name().map(|name| name.into_owned()))
|
||||
}
|
||||
|
||||
const TABLE: &[(&str, &str)] = &[
|
||||
("UTC", "Etc/UTC"),
|
||||
("Coordinated Universal Time", "Etc/UTC"),
|
||||
@@ -248,6 +269,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_handles_windows_iana_offset_and_microsoft_tzone() {
|
||||
assert_eq!(
|
||||
resolve_to_iana("Romance Standard Time").as_deref(),
|
||||
Some("Europe/Paris")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_to_iana("Europe/Paris").as_deref(),
|
||||
Some("Europe/Paris")
|
||||
);
|
||||
assert_eq!(resolve_to_iana("UTC").as_deref(), Some("Etc/UTC"));
|
||||
assert_eq!(
|
||||
resolve_to_iana("tzone://Microsoft/Utc").as_deref(),
|
||||
Some("Etc/UTC")
|
||||
);
|
||||
assert_eq!(resolve_to_iana("tzone://Microsoft/Custom"), None);
|
||||
assert_eq!(resolve_to_iana("Made Up Zone"), None);
|
||||
assert_eq!(resolve_to_iana(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinated_universal_time_alias_resolves_to_utc() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange_ews::parse::{CalendarItemRaw, RawAttendee, RawOccurrence};
|
||||
use crate::exchange_ews::recurrence::to_jscalendar_rule;
|
||||
use crate::exchange_ews::tz::windows_to_iana;
|
||||
use crate::exchange_ews::tz::resolve_to_iana;
|
||||
|
||||
pub struct EventValue {
|
||||
pub data: Value,
|
||||
@@ -73,7 +73,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
let iana = raw
|
||||
.start_tz
|
||||
.as_deref()
|
||||
.map(|tz| windows_to_iana(tz).unwrap_or(tz).to_owned());
|
||||
.map(|tz| resolve_to_iana(tz).unwrap_or_else(|| "Etc/UTC".to_owned()));
|
||||
if let Some(true) = raw.is_all_day_event {
|
||||
if let Some(start) = raw.start.as_ref() {
|
||||
let date_only = start.split('T').next().unwrap_or(start.as_str());
|
||||
@@ -155,7 +155,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
||||
if let Some(rec) = raw.recurrence.as_ref()
|
||||
&& let Some(rule) = to_jscalendar_rule(rec)
|
||||
{
|
||||
event.insert("recurrenceRules".to_owned(), Value::Array(vec![rule]));
|
||||
event.insert("recurrenceRule".to_owned(), rule);
|
||||
}
|
||||
let overrides = build_recurrence_overrides(
|
||||
&raw.modified_occurrences,
|
||||
@@ -429,7 +429,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_windows_timezone_passes_through_verbatim() {
|
||||
fn unknown_windows_timezone_falls_back_to_utc() {
|
||||
let raw = CalendarItemRaw {
|
||||
uid: Some("uid-x".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
@@ -439,7 +439,7 @@ mod tests {
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
assert_eq!(v["start"], "2025-06-15T14:00:00");
|
||||
assert_eq!(v["timeZone"], "Made Up Time");
|
||||
assert_eq!(v["timeZone"], "Etc/UTC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -514,8 +514,9 @@ mod tests {
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
assert_eq!(v["recurrenceRules"][0]["frequency"], "daily");
|
||||
assert_eq!(v["recurrenceRules"][0]["count"], 3);
|
||||
assert!(v.get("recurrenceRules").is_none());
|
||||
assert_eq!(v["recurrenceRule"]["frequency"], "daily");
|
||||
assert_eq!(v["recurrenceRule"]["count"], 3);
|
||||
let over = v["recurrenceOverrides"].as_object().unwrap();
|
||||
assert!(over.contains_key("2025-06-16T14:00:00"));
|
||||
assert_eq!(over["2025-06-17T14:00:00"]["excluded"], true);
|
||||
|
||||
@@ -174,34 +174,6 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
|
||||
Value::Object(map_singleton("1", json!({"@type": "Note", "note": notes}))),
|
||||
);
|
||||
}
|
||||
let mut related: Map<String, Value> = Map::new();
|
||||
if let Some(v) = raw.spouse.as_ref() {
|
||||
related.insert(
|
||||
v.clone(),
|
||||
json!({"@type": "Relation", "relation": {"spouse": true}}),
|
||||
);
|
||||
}
|
||||
for child in &raw.children {
|
||||
related.insert(
|
||||
child.clone(),
|
||||
json!({"@type": "Relation", "relation": {"child": true}}),
|
||||
);
|
||||
}
|
||||
if let Some(v) = raw.manager.as_ref() {
|
||||
related.insert(
|
||||
v.clone(),
|
||||
json!({"@type": "Relation", "relation": {"x-manager": true}}),
|
||||
);
|
||||
}
|
||||
if let Some(v) = raw.assistant.as_ref() {
|
||||
related.insert(
|
||||
v.clone(),
|
||||
json!({"@type": "Relation", "relation": {"x-assistant": true}}),
|
||||
);
|
||||
}
|
||||
if !related.is_empty() {
|
||||
card.insert("relatedTo".to_owned(), Value::Object(related));
|
||||
}
|
||||
Value::Object(card)
|
||||
}
|
||||
|
||||
@@ -440,15 +412,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relations_use_x_prefix_for_manager_and_assistant() {
|
||||
fn unresolvable_relations_are_dropped() {
|
||||
let raw = ContactItemRaw {
|
||||
display_name: Some("X".to_owned()),
|
||||
manager: Some("Boss".to_owned()),
|
||||
assistant: Some("Asst".to_owned()),
|
||||
spouse: Some("Partner".to_owned()),
|
||||
children: vec!["Kid".to_owned()],
|
||||
..ContactItemRaw::default()
|
||||
};
|
||||
let v = to_jscontact(&raw);
|
||||
assert_eq!(v["relatedTo"]["Boss"]["relation"]["x-manager"], true);
|
||||
assert_eq!(v["relatedTo"]["Asst"]["relation"]["x-assistant"], true);
|
||||
assert!(v.get("relatedTo").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange::tz::windows_to_iana;
|
||||
use crate::exchange::tz::resolve_to_iana;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::exchange_graph::recurrence::convert_patterned_recurrence_rule;
|
||||
|
||||
@@ -26,16 +26,21 @@ pub fn graph_calendar_color_to_hex(value: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
pub fn windows_or_iana_to_iana(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(iana) = windows_to_iana(value) {
|
||||
return Some(iana.to_owned());
|
||||
}
|
||||
if value.contains('/') {
|
||||
return Some(value.to_owned());
|
||||
}
|
||||
None
|
||||
resolve_to_iana(value)
|
||||
}
|
||||
|
||||
fn utc_naive_to_local(utc_naive: &str, iana: &str) -> Option<String> {
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
let tz: Tz = iana.parse().ok()?;
|
||||
let naive = NaiveDateTime::parse_from_str(utc_naive, "%Y-%m-%dT%H:%M:%S").ok()?;
|
||||
Some(
|
||||
tz.from_utc_datetime(&naive)
|
||||
.naive_local()
|
||||
.format("%Y-%m-%dT%H:%M:%S")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -93,7 +98,6 @@ pub fn convert_event(
|
||||
|
||||
let mut card = Map::new();
|
||||
card.insert("@type".to_owned(), Value::from("Event"));
|
||||
card.insert("version".to_owned(), Value::from("2.0"));
|
||||
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
||||
|
||||
if let Some(subject) = graph_event.get("subject").and_then(Value::as_str)
|
||||
@@ -112,17 +116,12 @@ pub fn convert_event(
|
||||
let start_dt = extract_local_datetime(graph_event.get("start")).map(strip_fractional);
|
||||
let end_dt = extract_local_datetime(graph_event.get("end")).map(strip_fractional);
|
||||
|
||||
if let Some(start) = start_dt.as_deref() {
|
||||
card.insert("start".to_owned(), Value::from(start.to_owned()));
|
||||
}
|
||||
let is_all_day = graph_event
|
||||
.get("isAllDay")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let (Some(start), Some(end)) = (start_dt.as_deref(), end_dt.as_deref())
|
||||
&& let Some(dur) = duration_iso8601(start, end)
|
||||
{
|
||||
card.insert("duration".to_owned(), Value::from(dur));
|
||||
}
|
||||
|
||||
let event_tz = graph_event
|
||||
let display_tz = graph_event
|
||||
.get("originalStartTimeZone")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(windows_or_iana_to_iana)
|
||||
@@ -133,15 +132,28 @@ pub fn convert_event(
|
||||
.and_then(Value::as_str)
|
||||
.and_then(windows_or_iana_to_iana)
|
||||
})
|
||||
.or_else(|| fallback_calendar_tz.map(str::to_owned));
|
||||
if let Some(tz) = event_tz {
|
||||
card.insert("timeZone".to_owned(), Value::from(tz));
|
||||
.or_else(|| fallback_calendar_tz.and_then(windows_or_iana_to_iana))
|
||||
.filter(|z| z != "Etc/UTC");
|
||||
|
||||
if let Some(start) = start_dt.as_deref() {
|
||||
let value = match (is_all_day, display_tz.as_deref()) {
|
||||
(false, Some(tz)) => utc_naive_to_local(start, tz).unwrap_or_else(|| start.to_owned()),
|
||||
_ => start.to_owned(),
|
||||
};
|
||||
card.insert("start".to_owned(), Value::from(value));
|
||||
}
|
||||
|
||||
if let Some(all_day) = graph_event.get("isAllDay").and_then(Value::as_bool)
|
||||
&& all_day
|
||||
if let (Some(start), Some(end)) = (start_dt.as_deref(), end_dt.as_deref())
|
||||
&& let Some(dur) = duration_iso8601(start, end)
|
||||
{
|
||||
card.insert("duration".to_owned(), Value::from(dur));
|
||||
}
|
||||
|
||||
if is_all_day {
|
||||
card.insert("showWithoutTime".to_owned(), Value::Bool(true));
|
||||
} else {
|
||||
let label = display_tz.unwrap_or_else(|| "Etc/UTC".to_owned());
|
||||
card.insert("timeZone".to_owned(), Value::from(label));
|
||||
}
|
||||
|
||||
if let Some(true) = graph_event.get("isCancelled").and_then(Value::as_bool) {
|
||||
@@ -559,7 +571,8 @@ mod tests {
|
||||
assert!(kws.contains_key("Red"));
|
||||
assert!(kws.contains_key("Internal"));
|
||||
assert!(conv.data.get("categories").is_none());
|
||||
assert_eq!(conv.data["version"], "2.0");
|
||||
assert!(conv.data.get("version").is_none());
|
||||
assert_eq!(conv.data["timeZone"], "Etc/UTC");
|
||||
let alerts = conv.data["alerts"].as_object().unwrap();
|
||||
assert_eq!(alerts["alert-1"]["trigger"]["offset"], "-PT15M");
|
||||
}
|
||||
@@ -678,6 +691,58 @@ mod tests {
|
||||
v["start"]["timeZone"] = Value::from("UTC");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["timeZone"], "America/Los_Angeles");
|
||||
assert_eq!(conv.data["start"], "2026-05-27T03:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_times_are_converted_into_the_original_zone() {
|
||||
let mut v = sample();
|
||||
v["originalStartTimeZone"] = Value::from("Europe/Paris");
|
||||
v["start"]["dateTime"] = Value::from("2025-03-12T07:30:00.0000000");
|
||||
v["start"]["timeZone"] = Value::from("UTC");
|
||||
v["end"]["dateTime"] = Value::from("2025-03-12T08:00:00.0000000");
|
||||
v["end"]["timeZone"] = Value::from("UTC");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["start"], "2025-03-12T08:30:00");
|
||||
assert_eq!(conv.data["timeZone"], "Europe/Paris");
|
||||
assert_eq!(conv.data["duration"], "PT30M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summer_event_shifts_by_two_hours_in_paris() {
|
||||
let mut v = sample();
|
||||
v["originalStartTimeZone"] = Value::from("Romance Standard Time");
|
||||
v["start"]["dateTime"] = Value::from("2025-07-15T06:30:00.0000000");
|
||||
v["start"]["timeZone"] = Value::from("UTC");
|
||||
v["end"]["dateTime"] = Value::from("2025-07-15T07:30:00.0000000");
|
||||
v["end"]["timeZone"] = Value::from("UTC");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["start"], "2025-07-15T08:30:00");
|
||||
assert_eq!(conv.data["timeZone"], "Europe/Paris");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_microsoft_zone_falls_back_to_utc() {
|
||||
let mut v = sample();
|
||||
v["originalStartTimeZone"] = Value::from("tzone://Microsoft/Custom");
|
||||
v["start"]["dateTime"] = Value::from("2025-03-12T07:30:00.0000000");
|
||||
v["start"]["timeZone"] = Value::from("UTC");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["start"], "2025-03-12T07:30:00");
|
||||
assert_eq!(conv.data["timeZone"], "Etc/UTC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_day_keeps_date_and_omits_timezone() {
|
||||
let mut v = sample();
|
||||
v["isAllDay"] = Value::Bool(true);
|
||||
v["originalStartTimeZone"] = Value::from("Europe/Paris");
|
||||
v["start"]["dateTime"] = Value::from("2025-03-12T00:00:00.0000000");
|
||||
v["start"]["timeZone"] = Value::from("UTC");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["start"], "2025-03-12T00:00:00");
|
||||
assert_eq!(conv.data["showWithoutTime"], true);
|
||||
assert!(conv.data.get("timeZone").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
|
||||
if !components.is_empty() {
|
||||
name_object.insert("components".to_owned(), Value::Array(components));
|
||||
}
|
||||
if !name_object.is_empty() {
|
||||
if name_object.contains_key("full") || name_object.contains_key("components") {
|
||||
card.insert("name".to_owned(), Value::Object(name_object));
|
||||
}
|
||||
|
||||
@@ -139,9 +139,17 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
|
||||
}
|
||||
|
||||
let mut addresses = Map::new();
|
||||
push_address(&mut addresses, graph_contact.get("homeAddress"), "private");
|
||||
push_address(&mut addresses, graph_contact.get("businessAddress"), "work");
|
||||
push_address(&mut addresses, graph_contact.get("otherAddress"), "other");
|
||||
push_address(
|
||||
&mut addresses,
|
||||
graph_contact.get("homeAddress"),
|
||||
Some("private"),
|
||||
);
|
||||
push_address(
|
||||
&mut addresses,
|
||||
graph_contact.get("businessAddress"),
|
||||
Some("work"),
|
||||
);
|
||||
push_address(&mut addresses, graph_contact.get("otherAddress"), None);
|
||||
if !addresses.is_empty() {
|
||||
card.insert("addresses".to_owned(), Value::Object(addresses));
|
||||
}
|
||||
@@ -265,35 +273,6 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
|
||||
card.insert("anniversaries".to_owned(), Value::Object(anniversaries));
|
||||
}
|
||||
|
||||
let mut related = Map::new();
|
||||
if let Some(m) = graph_contact.get("manager").and_then(Value::as_str)
|
||||
&& !m.is_empty()
|
||||
{
|
||||
related.insert(
|
||||
m.to_owned(),
|
||||
json!({"@type": "Relation", "relation": {"co-worker": true}}),
|
||||
);
|
||||
}
|
||||
if let Some(s) = graph_contact.get("spouseName").and_then(Value::as_str)
|
||||
&& !s.is_empty()
|
||||
{
|
||||
related.insert(
|
||||
s.to_owned(),
|
||||
json!({"@type": "Relation", "relation": {"spouse": true}}),
|
||||
);
|
||||
}
|
||||
if let Some(kids) = graph_contact.get("children").and_then(Value::as_array) {
|
||||
for kid in kids.iter().filter_map(Value::as_str) {
|
||||
related.insert(
|
||||
kid.to_owned(),
|
||||
json!({"@type": "Relation", "relation": {"child": true}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !related.is_empty() {
|
||||
card.insert("relatedTo".to_owned(), Value::Object(related));
|
||||
}
|
||||
|
||||
if let Some(link) = graph_contact
|
||||
.get("businessHomePage")
|
||||
.and_then(Value::as_str)
|
||||
@@ -308,13 +287,19 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
|
||||
}
|
||||
|
||||
if let Some(created) = graph_contact.get("createdDateTime").and_then(Value::as_str) {
|
||||
card.insert("created".to_owned(), Value::from(created.to_owned()));
|
||||
card.insert(
|
||||
"created".to_owned(),
|
||||
Value::from(normalise_utc_datetime(created)),
|
||||
);
|
||||
}
|
||||
if let Some(updated) = graph_contact
|
||||
.get("lastModifiedDateTime")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
card.insert("updated".to_owned(), Value::from(updated.to_owned()));
|
||||
card.insert(
|
||||
"updated".to_owned(),
|
||||
Value::from(normalise_utc_datetime(updated)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ConvertedContact {
|
||||
@@ -328,6 +313,20 @@ pub fn synthetic_uid(graph_id: &str) -> String {
|
||||
format!("vandelay-graph-{}", hash.to_hex())
|
||||
}
|
||||
|
||||
fn normalise_utc_datetime(raw: &str) -> String {
|
||||
let trailing_z = raw.ends_with('Z');
|
||||
let trimmed = raw.trim_end_matches('Z');
|
||||
let base = match trimmed.find('.') {
|
||||
Some(dot) => &trimmed[..dot],
|
||||
None => trimmed,
|
||||
};
|
||||
if trailing_z {
|
||||
format!("{base}Z")
|
||||
} else {
|
||||
base.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_partial_date(raw: &str) -> Option<Value> {
|
||||
let date_part = raw.split('T').next().unwrap_or(raw);
|
||||
let mut iter = date_part.split('-');
|
||||
@@ -368,7 +367,7 @@ fn push_phones(target: &mut Map<String, Value>, list: Option<&Vec<Value>>, conte
|
||||
}
|
||||
}
|
||||
|
||||
fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context: &str) {
|
||||
fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context: Option<&str>) {
|
||||
let Some(source) = source else { return };
|
||||
let Some(map) = source.as_object() else {
|
||||
return;
|
||||
@@ -396,7 +395,9 @@ fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context
|
||||
}
|
||||
let mut address = Map::new();
|
||||
address.insert("@type".to_owned(), Value::from("Address"));
|
||||
address.insert("contexts".to_owned(), json!({context: true}));
|
||||
if let Some(context) = context {
|
||||
address.insert("contexts".to_owned(), json!({context: true}));
|
||||
}
|
||||
if let Some(s) = street {
|
||||
address.insert("full".to_owned(), Value::from(s.to_owned()));
|
||||
}
|
||||
@@ -486,6 +487,15 @@ mod tests {
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let addresses = conv.data["addresses"].as_object().unwrap();
|
||||
assert_eq!(addresses.len(), 3);
|
||||
let has_other_context = addresses
|
||||
.values()
|
||||
.any(|a| a.get("contexts").and_then(|c| c.get("other")).is_some());
|
||||
assert!(!has_other_context);
|
||||
let without_context = addresses
|
||||
.values()
|
||||
.filter(|a| a.get("contexts").is_none())
|
||||
.count();
|
||||
assert_eq!(without_context, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -569,7 +579,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_children_become_related_to() {
|
||||
fn related_contacts_are_dropped_without_resolvable_uids() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"manager": "[email protected]",
|
||||
@@ -577,10 +587,26 @@ mod tests {
|
||||
"children": ["Kid A", "Kid B"]
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let related = conv.data["relatedTo"].as_object().unwrap();
|
||||
assert!(related.contains_key("[email protected]"));
|
||||
assert!(related.contains_key("Bob"));
|
||||
assert!(related.contains_key("Kid A"));
|
||||
assert!(related.contains_key("Kid B"));
|
||||
assert!(conv.data.get("relatedTo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nameless_contact_omits_name_object() {
|
||||
let c = json!({"id": "X", "businessPhones": ["+1"]});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
assert!(conv.data.get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_strip_fractional_seconds() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"displayName": "Alice",
|
||||
"createdDateTime": "2026-05-01T08:00:00.0000000Z",
|
||||
"lastModifiedDateTime": "2026-05-02T08:00:00.123Z"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
assert_eq!(conv.data["created"], "2026-05-01T08:00:00Z");
|
||||
assert_eq!(conv.data["updated"], "2026-05-02T08:00:00Z");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user