Initial commit
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConvertedContact {
|
||||
pub uid: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphError> {
|
||||
let graph_id = graph_contact
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| GraphError::Malformed("contact has no id".to_owned()))?;
|
||||
let uid = synthetic_uid(graph_id);
|
||||
|
||||
let mut card = Map::new();
|
||||
card.insert("@type".to_owned(), Value::from("Card"));
|
||||
card.insert("version".to_owned(), Value::from("1.0"));
|
||||
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
||||
card.insert("kind".to_owned(), Value::from("individual"));
|
||||
|
||||
let mut name_object = Map::new();
|
||||
name_object.insert("@type".to_owned(), Value::from("Name"));
|
||||
if let Some(display) = graph_contact.get("displayName").and_then(Value::as_str)
|
||||
&& !display.is_empty()
|
||||
{
|
||||
name_object.insert("full".to_owned(), Value::from(display.to_owned()));
|
||||
}
|
||||
let name_slots = [
|
||||
("title", graph_contact.get("title").and_then(Value::as_str)),
|
||||
(
|
||||
"given",
|
||||
graph_contact.get("givenName").and_then(Value::as_str),
|
||||
),
|
||||
(
|
||||
"given2",
|
||||
graph_contact.get("middleName").and_then(Value::as_str),
|
||||
),
|
||||
(
|
||||
"surname",
|
||||
graph_contact.get("surname").and_then(Value::as_str),
|
||||
),
|
||||
(
|
||||
"generation",
|
||||
graph_contact.get("generation").and_then(Value::as_str),
|
||||
),
|
||||
];
|
||||
let mut components: Vec<Value> = Vec::new();
|
||||
let mut slot_index: std::collections::HashMap<&'static str, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for (kind, value) in name_slots {
|
||||
let Some(v) = value else { continue };
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
slot_index.insert(kind, components.len());
|
||||
components.push(json!({"@type": "NameComponent", "kind": kind, "value": v}));
|
||||
}
|
||||
if !components.is_empty() {
|
||||
name_object.insert("components".to_owned(), Value::Array(components));
|
||||
}
|
||||
if !name_object.is_empty() {
|
||||
card.insert("name".to_owned(), Value::Object(name_object));
|
||||
}
|
||||
|
||||
if let Some(nick) = graph_contact.get("nickName").and_then(Value::as_str)
|
||||
&& !nick.is_empty()
|
||||
{
|
||||
let mut nicks = Map::new();
|
||||
nicks.insert(
|
||||
"nick-1".to_owned(),
|
||||
json!({"@type": "Nickname", "name": nick}),
|
||||
);
|
||||
card.insert("nicknames".to_owned(), Value::Object(nicks));
|
||||
}
|
||||
|
||||
let mut emails = Map::new();
|
||||
if let Some(addrs) = graph_contact
|
||||
.get("emailAddresses")
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
for (i, e) in addrs.iter().enumerate() {
|
||||
let Some(addr) = e.get("address").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if addr.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = format!("email-{}", i + 1);
|
||||
let mut entry = Map::new();
|
||||
entry.insert("@type".to_owned(), Value::from("EmailAddress"));
|
||||
entry.insert("address".to_owned(), Value::from(addr.to_owned()));
|
||||
if i == 0 {
|
||||
entry.insert("pref".to_owned(), Value::from(1));
|
||||
}
|
||||
emails.insert(key, Value::Object(entry));
|
||||
}
|
||||
}
|
||||
if !emails.is_empty() {
|
||||
card.insert("emails".to_owned(), Value::Object(emails));
|
||||
}
|
||||
|
||||
let mut phones = Map::new();
|
||||
push_phones(
|
||||
&mut phones,
|
||||
graph_contact
|
||||
.get("businessPhones")
|
||||
.and_then(Value::as_array),
|
||||
"work",
|
||||
);
|
||||
push_phones(
|
||||
&mut phones,
|
||||
graph_contact.get("homePhones").and_then(Value::as_array),
|
||||
"private",
|
||||
);
|
||||
if let Some(mobile) = graph_contact.get("mobilePhone").and_then(Value::as_str)
|
||||
&& !mobile.is_empty()
|
||||
{
|
||||
let key = format!("phone-{}", phones.len() + 1);
|
||||
phones.insert(
|
||||
key,
|
||||
json!({
|
||||
"@type": "Phone",
|
||||
"number": mobile,
|
||||
"features": {"mobile": true, "voice": true}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !phones.is_empty() {
|
||||
card.insert("phones".to_owned(), Value::Object(phones));
|
||||
}
|
||||
|
||||
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");
|
||||
if !addresses.is_empty() {
|
||||
card.insert("addresses".to_owned(), Value::Object(addresses));
|
||||
}
|
||||
|
||||
let mut organizations = Map::new();
|
||||
let has_company = graph_contact
|
||||
.get("companyName")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
let has_dept = graph_contact
|
||||
.get("department")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
let has_office = graph_contact
|
||||
.get("officeLocation")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
let has_title = graph_contact
|
||||
.get("jobTitle")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
if has_company || has_dept || has_office {
|
||||
let mut org = Map::new();
|
||||
org.insert("@type".to_owned(), Value::from("Organization"));
|
||||
if let Some(name) = graph_contact.get("companyName").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
org.insert("name".to_owned(), Value::from(name.to_owned()));
|
||||
}
|
||||
let mut units: Vec<Value> = Vec::new();
|
||||
if let Some(dept) = graph_contact.get("department").and_then(Value::as_str)
|
||||
&& !dept.is_empty()
|
||||
{
|
||||
units.push(json!({"@type": "OrgUnit", "name": dept}));
|
||||
}
|
||||
if let Some(office) = graph_contact.get("officeLocation").and_then(Value::as_str)
|
||||
&& !office.is_empty()
|
||||
{
|
||||
units.push(json!({"@type": "OrgUnit", "name": office}));
|
||||
}
|
||||
if !units.is_empty() {
|
||||
org.insert("units".to_owned(), Value::Array(units));
|
||||
}
|
||||
organizations.insert("org-1".to_owned(), Value::Object(org));
|
||||
}
|
||||
if has_title
|
||||
&& let Some(title) = graph_contact.get("jobTitle").and_then(Value::as_str)
|
||||
&& !title.is_empty()
|
||||
{
|
||||
let mut titles = Map::new();
|
||||
let mut title_obj = Map::new();
|
||||
title_obj.insert("@type".to_owned(), Value::from("Title"));
|
||||
title_obj.insert("name".to_owned(), Value::from(title.to_owned()));
|
||||
if organizations.contains_key("org-1") {
|
||||
title_obj.insert("organizationId".to_owned(), Value::from("org-1"));
|
||||
}
|
||||
titles.insert("title-1".to_owned(), Value::Object(title_obj));
|
||||
card.insert("titles".to_owned(), Value::Object(titles));
|
||||
}
|
||||
let has_org_one = organizations.contains_key("org-1");
|
||||
if !organizations.is_empty() {
|
||||
card.insert("organizations".to_owned(), Value::Object(organizations));
|
||||
}
|
||||
|
||||
if let Some(notes) = graph_contact.get("personalNotes").and_then(Value::as_str)
|
||||
&& !notes.is_empty()
|
||||
{
|
||||
let mut map = Map::new();
|
||||
map.insert("note-1".to_owned(), json!({"@type": "Note", "note": notes}));
|
||||
card.insert("notes".to_owned(), Value::Object(map));
|
||||
}
|
||||
|
||||
let mut localizations = Map::new();
|
||||
let mut ja = Map::new();
|
||||
if let Some(name) = graph_contact.get("yomiCompanyName").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
&& has_org_one
|
||||
{
|
||||
ja.insert(
|
||||
"organizations/org-1/name".to_owned(),
|
||||
Value::from(name.to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(name) = graph_contact.get("yomiGivenName").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
&& let Some(idx) = slot_index.get("given")
|
||||
{
|
||||
ja.insert(
|
||||
format!("name/components/{idx}/value"),
|
||||
Value::from(name.to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(name) = graph_contact.get("yomiSurname").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
&& let Some(idx) = slot_index.get("surname")
|
||||
{
|
||||
ja.insert(
|
||||
format!("name/components/{idx}/value"),
|
||||
Value::from(name.to_owned()),
|
||||
);
|
||||
}
|
||||
if !ja.is_empty() {
|
||||
localizations.insert("ja".to_owned(), Value::Object(ja));
|
||||
}
|
||||
if !localizations.is_empty() {
|
||||
card.insert("localizations".to_owned(), Value::Object(localizations));
|
||||
}
|
||||
|
||||
if let Some(birthday) = graph_contact.get("birthday").and_then(Value::as_str)
|
||||
&& let Some(date) = parse_partial_date(birthday)
|
||||
{
|
||||
let mut anniversaries = Map::new();
|
||||
anniversaries.insert(
|
||||
"anniversary-1".to_owned(),
|
||||
json!({
|
||||
"@type": "Anniversary",
|
||||
"kind": "birth",
|
||||
"date": date,
|
||||
}),
|
||||
);
|
||||
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)
|
||||
&& !link.is_empty()
|
||||
{
|
||||
let mut links = Map::new();
|
||||
links.insert(
|
||||
"link-1".to_owned(),
|
||||
json!({"@type": "Link", "uri": link, "contexts": {"work": true}}),
|
||||
);
|
||||
card.insert("links".to_owned(), Value::Object(links));
|
||||
}
|
||||
|
||||
if let Some(created) = graph_contact.get("createdDateTime").and_then(Value::as_str) {
|
||||
card.insert("created".to_owned(), Value::from(created.to_owned()));
|
||||
}
|
||||
if let Some(updated) = graph_contact
|
||||
.get("lastModifiedDateTime")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
card.insert("updated".to_owned(), Value::from(updated.to_owned()));
|
||||
}
|
||||
|
||||
Ok(ConvertedContact {
|
||||
uid,
|
||||
data: Value::Object(card),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn synthetic_uid(graph_id: &str) -> String {
|
||||
let hash = blake3::hash(graph_id.as_bytes());
|
||||
format!("vandelay-graph-{}", hash.to_hex())
|
||||
}
|
||||
|
||||
fn parse_partial_date(raw: &str) -> Option<Value> {
|
||||
let date_part = raw.split('T').next().unwrap_or(raw);
|
||||
let mut iter = date_part.split('-');
|
||||
let year: u64 = iter.next()?.parse().ok()?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("@type".to_owned(), Value::from("PartialDate"));
|
||||
obj.insert("year".to_owned(), Value::from(year));
|
||||
if let Some(month_str) = iter.next()
|
||||
&& let Ok(month) = month_str.parse::<u64>()
|
||||
&& (1..=12).contains(&month)
|
||||
{
|
||||
obj.insert("month".to_owned(), Value::from(month));
|
||||
if let Some(day_str) = iter.next()
|
||||
&& let Ok(day) = day_str.parse::<u64>()
|
||||
&& (1..=31).contains(&day)
|
||||
{
|
||||
obj.insert("day".to_owned(), Value::from(day));
|
||||
}
|
||||
}
|
||||
Some(Value::Object(obj))
|
||||
}
|
||||
|
||||
fn push_phones(target: &mut Map<String, Value>, list: Option<&Vec<Value>>, context: &str) {
|
||||
let Some(list) = list else { return };
|
||||
for n in list.iter().filter_map(Value::as_str) {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = format!("phone-{}", target.len() + 1);
|
||||
target.insert(
|
||||
key,
|
||||
json!({
|
||||
"@type": "Phone",
|
||||
"number": n,
|
||||
"contexts": {context: true}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context: &str) {
|
||||
let Some(source) = source else { return };
|
||||
let Some(map) = source.as_object() else {
|
||||
return;
|
||||
};
|
||||
let street = map
|
||||
.get("street")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty());
|
||||
let component_fields: Vec<(&str, &str)> = [
|
||||
("city", "locality"),
|
||||
("state", "region"),
|
||||
("postalCode", "postcode"),
|
||||
("countryOrRegion", "country"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(graph_key, jsc_kind)| {
|
||||
map.get(graph_key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| (jsc_kind, s))
|
||||
})
|
||||
.collect();
|
||||
if street.is_none() && component_fields.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut address = Map::new();
|
||||
address.insert("@type".to_owned(), Value::from("Address"));
|
||||
address.insert("contexts".to_owned(), json!({context: true}));
|
||||
if let Some(s) = street {
|
||||
address.insert("full".to_owned(), Value::from(s.to_owned()));
|
||||
}
|
||||
if !component_fields.is_empty() {
|
||||
let components: Vec<Value> = component_fields
|
||||
.iter()
|
||||
.map(|(kind, value)| json!({"@type": "AddressComponent", "kind": kind, "value": value}))
|
||||
.collect();
|
||||
address.insert("components".to_owned(), Value::Array(components));
|
||||
}
|
||||
let key = format!("addr-{}", target.len() + 1);
|
||||
target.insert(key, Value::Object(address));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn minimal_contact_yields_card_with_synthetic_uid() {
|
||||
let c = json!({"id": "GRAPH-1", "displayName": "Alice"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
assert!(conv.uid.starts_with("vandelay-graph-"));
|
||||
assert_eq!(conv.data["@type"], "Card");
|
||||
assert_eq!(conv.data["kind"], "individual");
|
||||
assert_eq!(conv.data["name"]["full"], "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_uid_is_stable_per_graph_id() {
|
||||
let a = synthetic_uid("AAA");
|
||||
let b = synthetic_uid("AAA");
|
||||
let c = synthetic_uid("BBB");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emails_map_with_first_as_pref() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"emailAddresses": [
|
||||
{"address": "[email protected]", "name": "Alice"},
|
||||
{"address": "[email protected]", "name": "Alice B"}
|
||||
]
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let emails = conv.data["emails"].as_object().unwrap();
|
||||
assert_eq!(emails["email-1"]["address"], "[email protected]");
|
||||
assert_eq!(emails["email-1"]["pref"], 1);
|
||||
assert_eq!(emails["email-2"]["address"], "[email protected]");
|
||||
assert!(emails["email-2"].get("pref").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phones_carry_context_per_source_field() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"businessPhones": ["+1-555-2000"],
|
||||
"homePhones": ["+1-555-1000"],
|
||||
"mobilePhone": "+1-555-3000"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let phones = conv.data["phones"].as_object().unwrap();
|
||||
assert_eq!(phones.len(), 3);
|
||||
let has_work = phones
|
||||
.values()
|
||||
.any(|v| v["contexts"]["work"] == Value::Bool(true));
|
||||
let has_private = phones
|
||||
.values()
|
||||
.any(|v| v["contexts"]["private"] == Value::Bool(true));
|
||||
let has_mobile = phones
|
||||
.values()
|
||||
.any(|v| v["features"]["mobile"] == Value::Bool(true));
|
||||
assert!(has_work && has_private && has_mobile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_map_three_contexts() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"homeAddress": {"city": "Home City"},
|
||||
"businessAddress": {"city": "Work City"},
|
||||
"otherAddress": {"city": "Other City"}
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let addresses = conv.data["addresses"].as_object().unwrap();
|
||||
assert_eq!(addresses.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_and_title_pair_into_separate_jscontact_objects() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"companyName": "Acme",
|
||||
"department": "Engineering",
|
||||
"officeLocation": "Building 9",
|
||||
"jobTitle": "Architect"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let orgs = conv.data["organizations"].as_object().unwrap();
|
||||
assert!(orgs.contains_key("org-1"));
|
||||
assert_eq!(orgs["org-1"]["name"], "Acme");
|
||||
let titles = conv.data["titles"].as_object().unwrap();
|
||||
assert_eq!(titles["title-1"]["name"], "Architect");
|
||||
assert_eq!(titles["title-1"]["organizationId"], "org-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yomi_fields_populate_japanese_localization() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"displayName": "Yamada Taro",
|
||||
"givenName": "Taro",
|
||||
"surname": "Yamada",
|
||||
"companyName": "Acme",
|
||||
"yomiCompanyName": "アクメ",
|
||||
"yomiGivenName": "タロウ",
|
||||
"yomiSurname": "ヤマダ"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let ja = conv.data["localizations"]["ja"].as_object().unwrap();
|
||||
assert_eq!(ja["organizations/org-1/name"], "アクメ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birthday_emits_partialdate_object() {
|
||||
let c = json!({"id": "X", "birthday": "1980-04-12T00:00:00Z"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let ann = conv.data["anniversaries"].as_object().unwrap();
|
||||
let date = &ann["anniversary-1"]["date"];
|
||||
assert_eq!(date["@type"], "PartialDate");
|
||||
assert_eq!(date["year"], 1980);
|
||||
assert_eq!(date["month"], 4);
|
||||
assert_eq!(date["day"], 12);
|
||||
assert_eq!(ann["anniversary-1"]["kind"], "birth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_phone_uses_features_not_contexts() {
|
||||
let c = json!({"id": "X", "mobilePhone": "+1-555-3000"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let phones = conv.data["phones"].as_object().unwrap();
|
||||
let phone = phones.values().next().unwrap();
|
||||
assert_eq!(phone["features"]["mobile"], true);
|
||||
assert!(phone.get("contexts").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_maps_to_generation_kind() {
|
||||
let c = json!({"id": "X", "generation": "Jr."});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let comps = conv.data["name"]["components"].as_array().unwrap();
|
||||
assert!(
|
||||
comps
|
||||
.iter()
|
||||
.any(|c| c["kind"] == "generation" && c["value"] == "Jr.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_jobtitle_does_not_emit_empty_organization() {
|
||||
let c = json!({"id": "X", "jobTitle": "Architect"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
assert!(conv.data.get("organizations").is_none());
|
||||
let titles = conv.data["titles"].as_object().unwrap();
|
||||
assert_eq!(titles["title-1"]["name"], "Architect");
|
||||
assert!(titles["title-1"].get("organizationId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_children_become_related_to() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"manager": "[email protected]",
|
||||
"spouseName": "Bob",
|
||||
"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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user