#![forbid(unsafe_code)]
mod addressbook;
mod backend;
mod capability;
mod card;
mod collision;
pub use addressbook::{AddressBook, AddressBookRights};
pub use backend::{AddressBookProperty, ContactCardProperty};
pub use capability::{ContactsAccountCapability, ContactsCapability, JMAP_CONTACTS_URI};
pub use card::{
ContactCard, ContactCardComparator, ContactCardComparatorProperty, ContactCardFilter,
ContactCardFilterCondition,
};
pub use collision::CollisionError;
pub use jmap_types::{Filter, FilterOperator, Operator};
pub use jmap_jscontact_types::{
Address, AddressComponent, Anniversary, AnniversaryDate, Author, Calendar, CryptoKey,
Directory, EmailAddress, LanguagePref, Link, Media, Name, NameComponent, Nickname, Note,
OnlineService, OrgUnit, Organization, PartialDate, PersonalInfo, Phone, Pronouns, Relation,
SchedulingAddress, SpeakToAs, Timestamp, Title,
};
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use jmap_types::Id;
use serde_json::json;
use super::*;
#[test]
fn address_book_rights_default_is_all_false() {
let r = AddressBookRights::default();
assert!(!r.may_read, "may_read default should be false");
assert!(!r.may_write, "may_write default should be false");
assert!(!r.may_share, "may_share default should be false");
assert!(!r.may_delete, "may_delete default should be false");
}
#[test]
fn address_book_rights_deserialize() {
let json = r#"{
"mayRead": true,
"mayWrite": false,
"mayShare": false,
"mayDelete": false
}"#;
let r: AddressBookRights = serde_json::from_str(json).unwrap();
assert!(r.may_read);
assert!(!r.may_write);
assert!(!r.may_share);
assert!(!r.may_delete);
}
#[test]
fn address_book_rights_round_trip() {
let original = AddressBookRights {
may_read: true,
may_write: true,
may_share: false,
may_delete: false,
may_set_metadata: Some(true),
extra: serde_json::Map::new(),
};
let json = serde_json::to_string(&original).unwrap();
let decoded: AddressBookRights = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[test]
fn address_book_deserialize_spec_example() {
let json = r#"{
"id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
"name": "Personal",
"description": null,
"sortOrder": 0,
"isDefault": true,
"isSubscribed": true,
"shareWith": {
"3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
"mayRead": true,
"mayWrite": false,
"mayShare": false,
"mayDelete": false
}
},
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayShare": true,
"mayDelete": false
}
}"#;
let ab: AddressBook = serde_json::from_str(json).unwrap();
assert_eq!(ab.name, "Personal");
assert!(ab.is_default);
assert!(ab.is_subscribed);
assert_eq!(ab.sort_order, 0);
assert!(ab.description.is_none());
let share_with = ab.share_with.unwrap();
let principal_id: Id = Id::from("3f1502e0-63fe-4335-9ff3-e739c188f5dd");
let shared_rights = &share_with[&principal_id];
assert!(shared_rights.may_read);
assert!(!shared_rights.may_write);
assert!(ab.my_rights.may_read);
assert!(ab.my_rights.may_write);
assert!(ab.my_rights.may_share);
assert!(!ab.my_rights.may_delete);
}
#[test]
fn address_book_share_with_null() {
let json = r#"{
"id": "cd40089d-35f9-4fd7-980b-ba3a9f1d74fe",
"name": "Autosaved",
"description": null,
"sortOrder": 1,
"isDefault": false,
"isSubscribed": true,
"shareWith": null,
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayShare": true,
"mayDelete": false
}
}"#;
let ab: AddressBook = serde_json::from_str(json).unwrap();
assert_eq!(ab.name, "Autosaved");
assert!(ab.share_with.is_none());
}
#[test]
fn address_book_share_with_null_serializes() {
let json = r#"{
"id": "ab-null-sw",
"name": "Private",
"sortOrder": 0,
"isDefault": false,
"isSubscribed": true,
"shareWith": null,
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayShare": false,
"mayDelete": false
}
}"#;
let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
assert!(ab.share_with.is_none());
let serialized = serde_json::to_value(&ab).expect("serialize");
let obj = serialized.as_object().expect("object");
assert!(
obj.contains_key("shareWith"),
"shareWith must be present in wire JSON (required-nullable)"
);
assert!(
obj["shareWith"].is_null(),
"shareWith must serialize as null when None"
);
}
#[test]
fn address_book_share_with_some_serializes() {
let json = r#"{
"id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
"name": "Personal",
"description": null,
"sortOrder": 0,
"isDefault": true,
"isSubscribed": true,
"shareWith": {
"3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
"mayRead": true,
"mayWrite": false,
"mayShare": false,
"mayDelete": false
}
},
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayShare": true,
"mayDelete": false
}
}"#;
let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
assert!(ab.share_with.is_some());
let serialized = serde_json::to_value(&ab).expect("serialize");
let obj = serialized.as_object().expect("object");
assert!(
obj.contains_key("shareWith"),
"shareWith must be present when set"
);
assert!(
!obj["shareWith"].is_null(),
"shareWith must not be null when populated"
);
let back: AddressBook = serde_json::from_value(serialized).expect("round-trip deserialize");
assert_eq!(ab.share_with, back.share_with);
}
#[test]
fn address_book_round_trip_with_description() {
let original_json = json!({
"id": "ab1",
"name": "Work",
"description": "Work contacts",
"sortOrder": 5,
"isDefault": false,
"isSubscribed": true,
"myRights": {
"mayRead": true,
"mayWrite": false,
"mayShare": false,
"mayDelete": false
}
});
let ab: AddressBook = serde_json::from_value(original_json).unwrap();
assert_eq!(ab.description.as_deref(), Some("Work contacts"));
let rt: AddressBook = serde_json::from_str(&serde_json::to_string(&ab).unwrap()).unwrap();
assert_eq!(rt.description.as_deref(), Some("Work contacts"));
}
#[test]
fn address_book_description_null_serializes() {
let json = r#"{
"id": "AB1",
"name": "Personal",
"description": null,
"sortOrder": 0,
"isDefault": false,
"isSubscribed": false,
"shareWith": null,
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayShare": false,
"mayDelete": false
}
}"#;
let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
assert!(ab.description.is_none(), "description should be None");
let out = serde_json::to_value(&ab).expect("serialize");
assert!(
out.as_object().expect("object").contains_key("description"),
"description key must be present in wire JSON"
);
assert!(
out["description"].is_null(),
"description must serialize as null, not be absent"
);
}
#[test]
fn contact_card_deserialize_spec_example() {
let json = r#"{
"id": "3",
"addressBookIds": {
"062adcfa-105d-455c-bc60-6db68b69c3f3": true
},
"name": {
"components": [
{ "kind": "given", "value": "Joe" },
{ "kind": "surname", "value": "Bloggs" }
],
"isOrdered": true
},
"emails": {
"0": {
"contexts": {
"private": true
},
"address": "joe.bloggs@example.com"
}
}
}"#;
let card: ContactCard = serde_json::from_str(json).unwrap();
let id = card.id.as_ref().unwrap();
assert_eq!(id.as_ref(), "3");
let ab_ids = card.address_book_ids.as_ref().unwrap();
let ab_key: Id = Id::from("062adcfa-105d-455c-bc60-6db68b69c3f3");
assert!(ab_ids[&ab_key]);
let name = card.name.as_ref().unwrap();
assert_eq!(name["isOrdered"], serde_json::Value::Bool(true));
assert_eq!(name["components"][0]["kind"], "given");
assert_eq!(name["components"][0]["value"], "Joe");
let emails = card.emails.as_ref().unwrap();
assert_eq!(emails["0"]["address"], "joe.bloggs@example.com");
}
#[test]
fn contact_card_group_with_members() {
let json = r#"{
"version": "1.0",
"uid": "urn:uuid:ab4310aa-fa43-11e9-8f0b-362b9e155667",
"kind": "group",
"name": { "full": "The Doe family" },
"members": {
"urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true,
"urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519": true
}
}"#;
let card: ContactCard = serde_json::from_str(json).unwrap();
assert_eq!(card.kind.as_deref(), Some("group"));
let members = card.members.as_ref().unwrap();
assert!(members["urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af"]);
assert_eq!(members.len(), 2);
}
#[test]
fn contact_card_minimal_fields() {
let json = r#"{
"id": "minimal1",
"addressBookIds": { "ab1": true }
}"#;
let card: ContactCard = serde_json::from_str(json).unwrap();
assert!(card.version.is_none());
assert!(card.uid.is_none());
assert!(card.kind.is_none());
assert!(card.emails.is_none());
assert!(card.phones.is_none());
assert!(card.addresses.is_none());
}
#[test]
fn contact_card_serializes_only_present_fields() {
let card: ContactCard = serde_json::from_value(json!({
"id": "x1",
"addressBookIds": { "ab1": true },
"version": "1.0",
"uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
}))
.unwrap();
let serialized = serde_json::to_value(&card).unwrap();
assert!(serialized.get("id").is_some());
assert!(serialized.get("version").is_some());
assert!(serialized.get("uid").is_some());
assert!(serialized.get("emails").is_none());
assert!(serialized.get("phones").is_none());
assert!(serialized.get("addresses").is_none());
assert!(serialized.get("kind").is_none());
}
#[test]
fn contact_card_localizations_patch_object_transparent() {
let json = r#"{"localizations":{"de":{"name/full":"Joe Bloggs (DE)"}}}"#;
let card: ContactCard = serde_json::from_str(json).expect("deserialize");
let locs = card.localizations.as_ref().expect("localizations present");
let de = locs.get("de").expect("de locale present");
assert_eq!(
de.as_map().get("name/full").and_then(|v| v.as_str()),
Some("Joe Bloggs (DE)"),
);
let serialized = serde_json::to_string(&card).expect("serialize");
assert_eq!(serialized, json);
}
#[test]
fn filter_condition_slash_keys_serialize_correctly() {
let filter: ContactCardFilterCondition = serde_json::from_value(json!({
"name/given": "John",
"name/surname": "Smith",
"name/surname2": "Doe"
}))
.unwrap();
assert_eq!(filter.name_given.as_deref(), Some("John"));
assert_eq!(filter.name_surname.as_deref(), Some("Smith"));
assert_eq!(filter.name_surname2.as_deref(), Some("Doe"));
let serialized = serde_json::to_value(&filter).unwrap();
assert_eq!(serialized["name/given"], "John");
assert_eq!(serialized["name/surname"], "Smith");
assert_eq!(serialized["name/surname2"], "Doe");
}
#[test]
fn filter_condition_all_fields() {
let json = r#"{
"inAddressBook": "ab1",
"uid": "urn:uuid:abc123",
"hasMember": "urn:uuid:def456",
"kind": "individual",
"createdBefore": "2024-01-01T00:00:00Z",
"createdAfter": "2020-01-01T00:00:00Z",
"updatedBefore": "2024-06-01T00:00:00Z",
"updatedAfter": "2021-06-01T00:00:00Z",
"text": "John",
"name": "Doe",
"name/given": "Jane",
"name/surname": "Smith",
"name/surname2": "Gomez",
"nickname": "Johnny",
"organization": "ACME",
"email": "john@example.com",
"phone": "+1-555-1234",
"onlineService": "twitter",
"address": "Main St",
"note": "VIP"
}"#;
let f: ContactCardFilterCondition = serde_json::from_str(json).unwrap();
assert_eq!(
f.in_address_book.as_ref().map(|id| id.as_ref()),
Some("ab1")
);
assert_eq!(f.uid.as_deref(), Some("urn:uuid:abc123"));
assert_eq!(f.has_member.as_deref(), Some("urn:uuid:def456"));
assert_eq!(f.kind.as_deref(), Some("individual"));
assert_eq!(
f.created_before.as_ref().map(AsRef::as_ref),
Some("2024-01-01T00:00:00Z")
);
assert_eq!(
f.created_after.as_ref().map(AsRef::as_ref),
Some("2020-01-01T00:00:00Z")
);
assert_eq!(
f.updated_before.as_ref().map(AsRef::as_ref),
Some("2024-06-01T00:00:00Z")
);
assert_eq!(
f.updated_after.as_ref().map(AsRef::as_ref),
Some("2021-06-01T00:00:00Z")
);
assert_eq!(f.text.as_deref(), Some("John"));
assert_eq!(f.name.as_deref(), Some("Doe"));
assert_eq!(f.name_given.as_deref(), Some("Jane"));
assert_eq!(f.name_surname.as_deref(), Some("Smith"));
assert_eq!(f.name_surname2.as_deref(), Some("Gomez"));
assert_eq!(f.nickname.as_deref(), Some("Johnny"));
assert_eq!(f.organization.as_deref(), Some("ACME"));
assert_eq!(f.email.as_deref(), Some("john@example.com"));
assert_eq!(f.phone.as_deref(), Some("+1-555-1234"));
assert_eq!(f.online_service.as_deref(), Some("twitter"));
assert_eq!(f.address.as_deref(), Some("Main St"));
assert_eq!(f.note.as_deref(), Some("VIP"));
}
#[test]
fn filter_condition_empty_serializes_to_empty_object() {
let f = ContactCardFilterCondition::default();
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v, json!({}));
}
#[test]
fn contacts_capability_is_empty_object() {
let cap = ContactsCapability::default();
let v = serde_json::to_value(&cap).unwrap();
assert_eq!(v, json!({}));
}
#[test]
fn contacts_account_capability_deserialize() {
let json = r#"{
"maxAddressBooksPerCard": 5,
"mayCreateAddressBook": true
}"#;
let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
assert_eq!(cap.max_address_books_per_card, Some(5));
assert!(cap.may_create_address_book);
}
#[test]
fn contacts_account_capability_null_max() {
let json = r#"{
"maxAddressBooksPerCard": null,
"mayCreateAddressBook": false
}"#;
let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
assert!(cap.max_address_books_per_card.is_none());
assert!(!cap.may_create_address_book);
}
#[test]
fn contacts_account_capability_max_unsigned_int() {
let json = r#"{
"maxAddressBooksPerCard": 9007199254740991,
"mayCreateAddressBook": true
}"#;
let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
assert_eq!(cap.max_address_books_per_card, Some(9_007_199_254_740_991));
}
#[test]
fn jmap_contacts_uri_value() {
assert_eq!(JMAP_CONTACTS_URI, "urn:ietf:params:jmap:contacts");
}
#[test]
fn comparator_is_ascending_defaults_to_true() {
let json = r#"{ "property": "created" }"#;
let c: ContactCardComparator = serde_json::from_str(json).unwrap();
assert!(c.is_ascending);
assert!(c.collation.is_none());
}
#[test]
fn comparator_round_trip() {
let json = r#"{ "property": "name/given", "isAscending": false, "collation": "i;unicode-casemap" }"#;
let c: ContactCardComparator = serde_json::from_str(json).unwrap();
assert_eq!(c.property, ContactCardComparatorProperty::NameGiven);
assert!(!c.is_ascending);
assert_eq!(c.collation.as_deref(), Some("i;unicode-casemap"));
let rt: ContactCardComparator =
serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(rt.property, ContactCardComparatorProperty::NameGiven);
assert!(!rt.is_ascending);
}
#[test]
fn comparator_sort_properties() {
use ContactCardComparatorProperty as P;
for (wire, want) in [
("created", P::Created),
("updated", P::Updated),
("name/given", P::NameGiven),
("name/surname", P::NameSurname),
("name/surname2", P::NameSurname2),
] {
let c: ContactCardComparator =
serde_json::from_value(serde_json::json!({ "property": wire })).unwrap();
assert_eq!(c.property, want);
}
let wire = serde_json::json!({ "property": "uid", "isAscending": true });
let c: ContactCardComparator = serde_json::from_value(wire).unwrap();
assert_eq!(c.property, P::Other("uid".to_owned()));
assert_eq!(
serde_json::to_value(&c).unwrap(),
serde_json::json!({ "property": "uid" })
);
}
#[test]
fn sloppy_field_name_roundtrips_through_jscontact_name() {
let card_json = json!({
"name": {
"components": [
{ "kind": "given", "value": "Vincent" },
{ "kind": "surname", "value": "van Gogh" }
],
"isOrdered": true
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let name: Name = serde_json::from_value(card.name.clone().unwrap()).unwrap();
let back = serde_json::to_value(&name).unwrap();
assert_eq!(back, card.name.unwrap());
}
#[test]
fn sloppy_field_nicknames_roundtrips_through_jscontact_nickname_map() {
let card_json = json!({
"nicknames": {
"k391": { "name": "Johnny" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let nicks: BTreeMap<String, Nickname> =
serde_json::from_value(card.nicknames.clone().unwrap()).unwrap();
assert_eq!(nicks["k391"].name, "Johnny");
let back = serde_json::to_value(&nicks).unwrap();
assert_eq!(back, card.nicknames.unwrap());
}
#[test]
fn sloppy_field_organizations_roundtrips_through_jscontact_organization_map() {
let card_json = json!({
"organizations": {
"o1": {
"name": "ABC, Inc.",
"units": [
{ "name": "North American Division" },
{ "name": "Marketing" }
],
"sortAs": "ABC"
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let orgs: BTreeMap<String, Organization> =
serde_json::from_value(card.organizations.clone().unwrap()).unwrap();
assert_eq!(orgs["o1"].name.as_deref(), Some("ABC, Inc."));
let back = serde_json::to_value(&orgs).unwrap();
assert_eq!(back, card.organizations.unwrap());
}
#[test]
fn sloppy_field_speak_to_as_roundtrips_through_jscontact_speak_to_as() {
let card_json = json!({
"speakToAs": {
"grammaticalGender": "neuter",
"pronouns": {
"k19": { "pronouns": "they/them", "pref": 2 }
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let speak: SpeakToAs = serde_json::from_value(card.speak_to_as.clone().unwrap()).unwrap();
let back = serde_json::to_value(&speak).unwrap();
assert_eq!(back, card.speak_to_as.unwrap());
}
#[test]
fn sloppy_field_titles_roundtrips_through_jscontact_title_map() {
let card_json = json!({
"titles": {
"le9": { "kind": "title", "name": "Research Scientist" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let titles: BTreeMap<String, Title> =
serde_json::from_value(card.titles.clone().unwrap()).unwrap();
assert_eq!(titles["le9"].name, "Research Scientist");
let back = serde_json::to_value(&titles).unwrap();
assert_eq!(back, card.titles.unwrap());
}
#[test]
fn sloppy_field_emails_roundtrips_through_jscontact_email_address_map() {
let card_json = json!({
"emails": {
"e1": { "contexts": { "work": true }, "address": "jqpublic@xyz.example.com" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let emails: BTreeMap<String, EmailAddress> =
serde_json::from_value(card.emails.clone().unwrap()).unwrap();
assert_eq!(emails["e1"].address, "jqpublic@xyz.example.com");
let back = serde_json::to_value(&emails).unwrap();
assert_eq!(back, card.emails.unwrap());
}
#[test]
fn sloppy_field_online_services_roundtrips_through_jscontact_online_service_map() {
let card_json = json!({
"onlineServices": {
"x1": { "uri": "xmpp:alice@example.com" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let svcs: BTreeMap<String, OnlineService> =
serde_json::from_value(card.online_services.clone().unwrap()).unwrap();
let back = serde_json::to_value(&svcs).unwrap();
assert_eq!(back, card.online_services.unwrap());
}
#[test]
fn sloppy_field_phones_roundtrips_through_jscontact_phone_map() {
let card_json = json!({
"phones": {
"tel0": {
"contexts": { "private": true },
"features": { "voice": true },
"number": "tel:+1-555-555-5555;ext=5555",
"pref": 1
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let phones: BTreeMap<String, Phone> =
serde_json::from_value(card.phones.clone().unwrap()).unwrap();
assert_eq!(phones["tel0"].number, "tel:+1-555-555-5555;ext=5555");
let back = serde_json::to_value(&phones).unwrap();
assert_eq!(back, card.phones.unwrap());
}
#[test]
fn sloppy_field_preferred_languages_roundtrips_through_jscontact_language_pref_map() {
let card_json = json!({
"preferredLanguages": {
"l1": { "language": "en", "contexts": { "work": true }, "pref": 1 }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let langs: BTreeMap<String, LanguagePref> =
serde_json::from_value(card.preferred_languages.clone().unwrap()).unwrap();
assert_eq!(langs["l1"].language, "en");
let back = serde_json::to_value(&langs).unwrap();
assert_eq!(back, card.preferred_languages.unwrap());
}
#[test]
fn sloppy_field_calendars_roundtrips_through_jscontact_calendar_map() {
let card_json = json!({
"calendars": {
"calA": { "kind": "calendar", "uri": "webcal://calendar.example.com/calA.ics" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let cals: BTreeMap<String, Calendar> =
serde_json::from_value(card.calendars.clone().unwrap()).unwrap();
let back = serde_json::to_value(&cals).unwrap();
assert_eq!(back, card.calendars.unwrap());
}
#[test]
fn sloppy_field_scheduling_addresses_roundtrips_through_jscontact_scheduling_address_map() {
let card_json = json!({
"schedulingAddresses": {
"sched1": { "uri": "mailto:janedoe@example.com" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let scheds: BTreeMap<String, SchedulingAddress> =
serde_json::from_value(card.scheduling_addresses.clone().unwrap()).unwrap();
assert_eq!(scheds["sched1"].uri, "mailto:janedoe@example.com");
let back = serde_json::to_value(&scheds).unwrap();
assert_eq!(back, card.scheduling_addresses.unwrap());
}
#[test]
fn sloppy_field_addresses_roundtrips_through_jscontact_address_map() {
let card_json = json!({
"addresses": {
"k23": {
"contexts": { "work": true },
"components": [
{ "kind": "number", "value": "54321" },
{ "kind": "name", "value": "Oak St" }
],
"countryCode": "US",
"isOrdered": true
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let addrs: BTreeMap<String, Address> =
serde_json::from_value(card.addresses.clone().unwrap()).unwrap();
assert_eq!(addrs["k23"].country_code.as_deref(), Some("US"));
let back = serde_json::to_value(&addrs).unwrap();
assert_eq!(back, card.addresses.unwrap());
}
#[test]
fn sloppy_field_crypto_keys_roundtrips_through_jscontact_crypto_key_map() {
let card_json = json!({
"cryptoKeys": {
"mykey1": { "uri": "https://www.example.com/keys/jdoe.cer" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let keys: BTreeMap<String, CryptoKey> =
serde_json::from_value(card.crypto_keys.clone().unwrap()).unwrap();
let back = serde_json::to_value(&keys).unwrap();
assert_eq!(back, card.crypto_keys.unwrap());
}
#[test]
fn sloppy_field_directories_roundtrips_through_jscontact_directory_map() {
let card_json = json!({
"directories": {
"dir1": {
"kind": "entry",
"uri": "https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf"
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let dirs: BTreeMap<String, Directory> =
serde_json::from_value(card.directories.clone().unwrap()).unwrap();
assert_eq!(dirs["dir1"].kind.as_deref(), Some("entry"));
let back = serde_json::to_value(&dirs).unwrap();
assert_eq!(back, card.directories.unwrap());
}
#[test]
fn sloppy_field_links_roundtrips_through_jscontact_link_map() {
let card_json = json!({
"links": {
"link3": { "kind": "contact", "uri": "mailto:contact@example.com", "pref": 1 }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let links: BTreeMap<String, Link> =
serde_json::from_value(card.links.clone().unwrap()).unwrap();
let back = serde_json::to_value(&links).unwrap();
assert_eq!(back, card.links.unwrap());
}
#[test]
fn sloppy_field_media_roundtrips_through_jscontact_media_map() {
let card_json = json!({
"media": {
"res47": { "kind": "logo", "uri": "https://www.example.com/pub/logos/abccorp.jpg" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let media: BTreeMap<String, Media> =
serde_json::from_value(card.media.clone().unwrap()).unwrap();
assert_eq!(media["res47"].kind.as_deref(), Some("logo"));
let back = serde_json::to_value(&media).unwrap();
assert_eq!(back, card.media.unwrap());
}
#[test]
fn sloppy_field_anniversaries_roundtrips_through_jscontact_anniversary_map() {
let card_json = json!({
"anniversaries": {
"k8": {
"kind": "birth",
"date": { "year": 1953, "month": 4, "day": 15 }
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let annivs: BTreeMap<String, Anniversary> =
serde_json::from_value(card.anniversaries.clone().unwrap()).unwrap();
assert_eq!(annivs["k8"].kind, "birth");
let back = serde_json::to_value(&annivs).unwrap();
assert_eq!(back, card.anniversaries.unwrap());
}
#[test]
fn sloppy_field_notes_roundtrips_through_jscontact_note_map() {
let card_json = json!({
"notes": {
"n1": {
"note": "Open office hours are 1600 to 1715 EST, Mon-Fri",
"created": "2022-11-23T15:01:32Z",
"author": { "name": "John" }
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let notes: BTreeMap<String, Note> =
serde_json::from_value(card.notes.clone().unwrap()).unwrap();
assert_eq!(
notes["n1"].author.as_ref().unwrap().name.as_deref(),
Some("John")
);
let back = serde_json::to_value(¬es).unwrap();
assert_eq!(back, card.notes.unwrap());
}
#[test]
fn sloppy_field_personal_info_roundtrips_through_jscontact_personal_info_map() {
let card_json = json!({
"personalInfo": {
"pi2": { "kind": "expertise", "value": "chemistry", "level": "high" }
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let info: BTreeMap<String, PersonalInfo> =
serde_json::from_value(card.personal_info.clone().unwrap()).unwrap();
assert_eq!(info["pi2"].kind, "expertise");
let back = serde_json::to_value(&info).unwrap();
assert_eq!(back, card.personal_info.unwrap());
}
#[test]
fn sloppy_field_related_to_roundtrips_through_jscontact_relation_map() {
let card_json = json!({
"relatedTo": {
"urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {
"relation": { "friend": true }
}
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let rels: BTreeMap<String, Relation> =
serde_json::from_value(card.related_to.clone().unwrap()).unwrap();
let back = serde_json::to_value(&rels).unwrap();
assert_eq!(back, card.related_to.unwrap());
}
#[test]
fn keywords_roundtrips_as_string_bool_map() {
let card_json = json!({
"keywords": {
"internet": true,
"IETF": true
}
});
let card: ContactCard = serde_json::from_value(card_json).unwrap();
let kws = card.keywords.as_ref().expect("keywords present");
assert!(kws["internet"]);
assert!(kws["IETF"]);
assert_eq!(kws.len(), 2);
}
#[test]
fn top_level_jscontact_reexport_is_jmap_jscontact_types() {
const _ASSERT_TOP_LEVEL_SAME_AS_DIRECT: fn(jmap_jscontact_types::Name) -> Name = |x| x;
let v = json!({ "full": "Vincent van Gogh" });
let _direct: jmap_jscontact_types::Name = serde_json::from_value(v.clone()).unwrap();
let _top_level: Name = serde_json::from_value(v).unwrap();
}
#[test]
fn address_book_rights_preserves_vendor_extras() {
let raw = json!({
"mayRead": true,
"mayWrite": false,
"mayShare": false,
"mayDelete": false,
"acmeCorpMayMerge": true
});
let r: AddressBookRights = serde_json::from_value(raw).unwrap();
assert_eq!(
r.extra.get("acmeCorpMayMerge").and_then(|v| v.as_bool()),
Some(true)
);
let back = serde_json::to_value(&r).unwrap();
assert_eq!(back["acmeCorpMayMerge"], true);
}
#[test]
fn address_book_preserves_vendor_extras() {
let raw = json!({
"id": "ab1",
"name": "Personal",
"description": null,
"sortOrder": 0,
"isDefault": true,
"isSubscribed": true,
"shareWith": null,
"myRights": {
"mayRead": true, "mayWrite": true,
"mayShare": false, "mayDelete": false
},
"acmeCorpRetentionDays": 365
});
let ab: AddressBook = serde_json::from_value(raw).unwrap();
assert_eq!(
ab.extra
.get("acmeCorpRetentionDays")
.and_then(|v| v.as_u64()),
Some(365)
);
let back = serde_json::to_value(&ab).unwrap();
assert_eq!(back["acmeCorpRetentionDays"], 365);
}
#[test]
fn contact_card_preserves_vendor_extras() {
let raw = json!({
"uid": "card-1",
"version": "1.0",
"acmeCorpExternalId": "ldap-42"
});
let c: ContactCard = serde_json::from_value(raw).unwrap();
assert_eq!(
c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
Some("ldap-42")
);
let back = serde_json::to_value(&c).unwrap();
assert_eq!(back["acmeCorpExternalId"], "ldap-42");
}
#[test]
fn contact_card_address_book_ids_accepts_false_deserialise() {
let raw = json!({
"id": "card-1",
"addressBookIds": { "ab1": false }
});
let c: ContactCard = serde_json::from_value(raw).unwrap();
let ids = c.address_book_ids.as_ref().unwrap();
let ab1: Id = Id::from("ab1");
assert_eq!(ids.get(&ab1).copied(), Some(false));
}
#[test]
fn contact_card_members_accepts_false_deserialise() {
let raw = json!({
"id": "card-1",
"kind": "group",
"members": { "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": false }
});
let c: ContactCard = serde_json::from_value(raw).unwrap();
let mems = c.members.as_ref().unwrap();
assert_eq!(
mems.get("urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
.copied(),
Some(false),
);
}
#[test]
fn contact_card_keywords_accepts_false_deserialise() {
let raw = json!({
"id": "card-1",
"keywords": { "IETF": false }
});
let c: ContactCard = serde_json::from_value(raw).unwrap();
let kws = c.keywords.as_ref().unwrap();
assert_eq!(kws.get("IETF").copied(), Some(false));
}
#[test]
fn malformed_json_returns_error() {
let truncated = r#"{"id": "card-1""#;
assert!(serde_json::from_str::<ContactCard>(truncated).is_err());
let mismatched = r#"{"id": "card-1"]"#;
assert!(serde_json::from_str::<ContactCard>(mismatched).is_err());
let unterminated = r#"{"id": "card-1}"#;
assert!(serde_json::from_str::<ContactCard>(unterminated).is_err());
}
#[test]
fn oversized_name_deserialises_to_typed_field() {
let big_name = "x".repeat(1000);
let raw = json!({
"id": "ab-big-name",
"name": big_name,
"description": null,
"sortOrder": 0,
"isDefault": false,
"isSubscribed": true,
"shareWith": null,
"myRights": {
"mayRead": true, "mayWrite": false,
"mayShare": false, "mayDelete": false
}
});
let ab: AddressBook = serde_json::from_value(raw).unwrap();
assert_eq!(ab.name.len(), 1000);
}
#[test]
fn sort_order_overflow_outside_spec_range_accepts() {
let raw = json!({
"id": "ab-overflow",
"name": "Overflow",
"description": null,
"sortOrder": 2_147_483_648u64,
"isDefault": false,
"isSubscribed": true,
"shareWith": null,
"myRights": {
"mayRead": true, "mayWrite": false,
"mayShare": false, "mayDelete": false
}
});
let ab: AddressBook = serde_json::from_value(raw).unwrap();
assert_eq!(ab.sort_order, 2_147_483_648);
}
#[test]
fn sloppy_field_accepts_non_object_value() {
let raw = json!({
"id": "card-1",
"name": "vincent"
});
let c: ContactCard = serde_json::from_value(raw).unwrap();
let name_value = c.name.as_ref().unwrap();
assert!(name_value.is_string(), "sloppy field preserved the string");
let typed = serde_json::from_value::<Name>(name_value.clone());
assert!(typed.is_err(), "Name expects an object, got a string");
}
#[test]
fn deeply_nested_sloppy_field_within_serde_json_limit_succeeds() {
let mut nested = String::from("1");
for _ in 0..100 {
nested = format!("{{\"x\":{nested}}}");
}
let payload = format!("{{\"id\":\"c\",\"name\":{nested}}}");
let c: ContactCard = serde_json::from_str(&payload)
.expect("100-deep nesting must be within serde_json's default 128 recursion limit");
assert!(c.name.is_some());
}
#[test]
fn deeply_nested_sloppy_field_beyond_serde_json_limit_errors() {
let mut nested = String::from("1");
for _ in 0..200 {
nested = format!("{{\"x\":{nested}}}");
}
let payload = format!("{{\"id\":\"c\",\"name\":{nested}}}");
let result: Result<ContactCard, _> = serde_json::from_str(&payload);
assert!(
result.is_err(),
"200-deep nesting must exceed serde_json's default 128 recursion limit"
);
}
#[test]
fn duplicate_typed_field_keys_rejected() {
let raw = r#"{"id":"card-1","uid":"first","uid":"second"}"#;
let err = serde_json::from_str::<ContactCard>(raw)
.expect_err("typed field duplicate must be rejected");
let msg = err.to_string();
assert!(
msg.contains("duplicate field"),
"expected duplicate-field error, got: {msg}"
);
}
#[test]
fn duplicate_vendor_extra_keys_last_wins() {
let raw = r#"{"id":"card-1","acmeCorpExternalId":"first","acmeCorpExternalId":"second"}"#;
let c: ContactCard = serde_json::from_str(raw)
.expect("vendor-extra duplicates fold into extra map, last-wins");
assert_eq!(
c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
Some("second"),
"extra map last-wins on duplicate keys"
);
}
#[test]
fn contact_card_validate_extras_ok_on_clean() {
let c = ContactCard::default();
assert!(c.validate_extras().is_ok(), "empty extras must pass");
let mut c = ContactCard::default();
c.extra.insert("acmeCorpFoo".into(), json!("bar"));
assert!(c.validate_extras().is_ok(), "vendor-only key must pass");
}
#[test]
fn contact_card_validate_extras_detects_every_typed_field_collision() {
let typed_fields = [
"id",
"addressBookIds",
"version",
"created",
"kind",
"language",
"members",
"prodId",
"relatedTo",
"uid",
"updated",
"name",
"nicknames",
"organizations",
"speakToAs",
"titles",
"emails",
"onlineServices",
"phones",
"preferredLanguages",
"calendars",
"schedulingAddresses",
"addresses",
"cryptoKeys",
"directories",
"links",
"media",
"localizations",
"anniversaries",
"keywords",
"notes",
"personalInfo",
];
for field in typed_fields {
let mut c = ContactCard::default();
c.extra.insert(field.into(), json!("collision"));
let err = c
.validate_extras()
.expect_err(&format!("{field} collision must be detected"));
assert_eq!(err.keys(), &[field.to_string()]);
}
}
#[test]
fn contact_card_validate_extras_reports_multiple_collisions() {
let mut c = ContactCard::default();
c.extra.insert("uid".into(), json!("a"));
c.extra.insert("id".into(), json!("b"));
c.extra.insert("acmeCorpFoo".into(), json!("c"));
let err = c.validate_extras().expect_err("multi-collision must error");
assert_eq!(err.keys(), &["id".to_string(), "uid".to_string()]);
let msg = err.to_string();
assert!(msg.contains("id, uid"), "got: {msg}");
}
#[test]
fn address_book_validate_extras_detects_every_typed_field_collision() {
let typed_fields = [
"id",
"name",
"description",
"sortOrder",
"isDefault",
"isSubscribed",
"shareWith",
"myRights",
];
let template = json!({
"id": "ab-test",
"name": "Test",
"description": null,
"sortOrder": 0,
"isDefault": false,
"isSubscribed": true,
"shareWith": null,
"myRights": {
"mayRead": true, "mayWrite": false,
"mayShare": false, "mayDelete": false
}
});
let mut ab: AddressBook = serde_json::from_value(template).unwrap();
assert!(ab.validate_extras().is_ok(), "clean extras pass");
for field in typed_fields {
ab.extra.clear();
ab.extra.insert(field.into(), json!("collision"));
let err = ab
.validate_extras()
.expect_err(&format!("{field} collision must be detected"));
assert_eq!(err.keys(), &[field.to_string()]);
}
}
#[test]
fn address_book_rights_validate_extras_detects_every_typed_field_collision() {
let typed_fields = ["mayRead", "mayWrite", "mayShare", "mayDelete"];
let mut r = AddressBookRights::default();
assert!(r.validate_extras().is_ok(), "clean extras pass");
for field in typed_fields {
r.extra.clear();
r.extra.insert(field.into(), json!(true));
let err = r
.validate_extras()
.expect_err(&format!("{field} collision must be detected"));
assert_eq!(err.keys(), &[field.to_string()]);
}
}
#[test]
fn contact_card_maps_serialize_in_sorted_key_order() {
let raw = r#"{"addressBookIds":{"ab2":true,"ab1":true},"localizations":{"fr":{"name/full":"Jean"},"de":{"name/full":"Johann"}}}"#;
let card: ContactCard = serde_json::from_str(raw).expect("deserialize");
let j = serde_json::to_string(&card).expect("serialize");
assert_eq!(
j,
r#"{"addressBookIds":{"ab1":true,"ab2":true},"localizations":{"de":{"name/full":"Johann"},"fr":{"name/full":"Jean"}}}"#
);
}
#[test]
fn address_book_rights_may_set_metadata_typed_and_optional() {
let with = serde_json::json!({
"mayRead": true, "mayWrite": false, "mayShare": false, "mayDelete": false,
"maySetMetadata": true
});
let r: AddressBookRights = serde_json::from_value(with.clone()).unwrap();
assert_eq!(r.may_set_metadata, Some(true));
assert!(!r.extra.contains_key("maySetMetadata"));
assert_eq!(serde_json::to_value(&r).unwrap(), with);
let without = serde_json::json!({
"mayRead": true, "mayWrite": false, "mayShare": false, "mayDelete": false
});
let r: AddressBookRights = serde_json::from_value(without.clone()).unwrap();
assert_eq!(r.may_set_metadata, None);
assert_eq!(serde_json::to_value(&r).unwrap(), without);
}
#[test]
fn address_book_rights_extra_may_set_metadata_collides() {
let mut r = AddressBookRights::default();
r.extra
.insert("maySetMetadata".to_owned(), serde_json::Value::Bool(true));
assert!(r.validate_extras().is_err());
}
}