Skip to main content

jmap_contacts_types/
lib.rs

1//! RFC 9610 + RFC 9553 data types for JMAP Contacts.
2//!
3//! Provides [`AddressBook`], [`AddressBookRights`], [`ContactCard`],
4//! [`ContactCardFilterCondition`], [`ContactCardComparator`],
5//! [`ContactsCapability`], and [`ContactsAccountCapability`].
6//!
7//! This crate is types-only: no method handlers, no async, no network I/O.
8//! It sits between `jmap-types` (shared JMAP base primitives) and the
9//! server/client crates that consume these types.
10//!
11//! All types implement [`serde::Serialize`] and [`serde::Deserialize`] with
12//! the camelCase field names required by the JMAP wire format.
13//!
14//! ## JSContact sub-object types
15//!
16//! The RFC 9553 JSContact sub-object types ([`Name`], [`EmailAddress`],
17//! [`Phone`], [`Address`], etc.) live in the dedicated
18//! `jmap-jscontact-types` crate and are re-exported here for caller
19//! ergonomics. The [`ContactCard`] struct keeps its 22 sub-object fields
20//! typed as `Option<serde_json::Value>` as the wire-format anchor — typed
21//! access is opt-in via [`serde_json::from_value`] on a chosen field.
22//!
23//! ```rust
24//! use jmap_contacts_types::{ContactCard, Name};
25//!
26//! let card_json = serde_json::json!({
27//!     "name": {
28//!         "components": [
29//!             { "kind": "given", "value": "Vincent" },
30//!             { "kind": "surname", "value": "van Gogh" }
31//!         ],
32//!         "isOrdered": true
33//!     }
34//! });
35//! let card: ContactCard = serde_json::from_value(card_json).unwrap();
36//! let name: Name = serde_json::from_value(card.name.unwrap()).unwrap();
37//! assert_eq!(name.components.unwrap()[0].value, "Vincent");
38//! ```
39//!
40//! # Logging safety
41//!
42//! This crate derives plain [`Debug`] on all PII-bearing types
43//! ([`ContactCard`], [`AddressBook`], and the 30+ re-exported RFC 9553
44//! JSContact sub-types: [`Name`], [`EmailAddress`], [`Phone`],
45//! [`Address`], [`Note`], [`Anniversary`], [`PersonalInfo`],
46//! [`CryptoKey`], etc.). Consumers using `tracing` or `log` macros
47//! that interpolate values via `?` (debug) or `%` (display) emit the
48//! full record contents to the configured sink. This includes phone
49//! numbers, email addresses, postal addresses, names, notes, and
50//! CryptoKey URIs — all of which are typically regulated as
51//! personally-identifying information under GDPR Article 5, CCPA
52//! §1798.100, and similar privacy frameworks.
53//!
54//! Consumers SHOULD wrap PII-bearing values in a redacting newtype
55//! before logging, or use a structured-logging field-allowlist:
56//!
57//! ```ignore
58//! // BAD — leaks every name, phone, email, address into the log sink.
59//! tracing::debug!(card = ?contact_card, "fetched contact");
60//!
61//! // GOOD — emits only the opaque id and surfaces nothing else.
62//! tracing::debug!(card_id = %contact_card.id.as_deref().unwrap_or_default(), "fetched contact");
63//! ```
64//!
65//! The workspace has a Debug-redaction precedent in `jmap-base-client`
66//! for credential-bearing types ([`BearerAuth`], [`BasicAuth`],
67//! `Session`, `AccountInfo`) but **not** for PII-bearing types. The
68//! decision whether to propagate that precedent workspace-wide is
69//! tracked by a separate workspace-policy bead; this crate's current
70//! posture is plain `Debug` plus this safety contract. JMAP-glx8.26.
71//!
72//! [`BearerAuth`]: https://docs.rs/jmap-base-client
73//! [`BasicAuth`]: https://docs.rs/jmap-base-client
74//!
75//! # Example
76//!
77//! ```rust
78//! use jmap_contacts_types::AddressBook;
79//!
80//! let json = r#"{
81//!     "id": "ab1",
82//!     "name": "Personal",
83//!     "sortOrder": 0,
84//!     "isDefault": true,
85//!     "isSubscribed": true,
86//!     "shareWith": null,
87//!     "myRights": {
88//!         "mayRead": true,
89//!         "mayWrite": true,
90//!         "mayShare": true,
91//!         "mayDelete": false
92//!     }
93//! }"#;
94//!
95//! let ab: AddressBook = serde_json::from_str(json).unwrap();
96//! assert_eq!(ab.name, "Personal");
97//! ```
98
99#![forbid(unsafe_code)]
100
101mod addressbook;
102mod backend;
103mod capability;
104mod card;
105mod collision;
106
107pub use addressbook::{AddressBook, AddressBookRights};
108pub use backend::{AddressBookProperty, ContactCardProperty};
109pub use capability::{ContactsAccountCapability, ContactsCapability, JMAP_CONTACTS_URI};
110pub use card::{
111    ContactCard, ContactCardComparator, ContactCardComparatorProperty, ContactCardFilter,
112    ContactCardFilterCondition,
113};
114pub use collision::CollisionError;
115
116/// Generic filter algebra from `jmap-types` (RFC 8620 §5.5).
117///
118/// Re-exported here so callers of `jmap-contacts-types` do not need a
119/// direct dependency on `jmap-types`. Mirrors the same root
120/// re-exports in `jmap-mail-types`, the workspace canonical
121/// extension-types template.
122pub use jmap_types::{Filter, FilterOperator, Operator};
123
124// ── JSContact sub-object re-exports (RFC 9553) ───────────────────────────────
125//
126// Mirrors the jmap-calendars-types re-export pattern. Top-level access to
127// every public RFC 9553 typed sub-object so callers can write
128// `jmap_contacts_types::Name` symmetrically with the wire field name on
129// `ContactCard`.
130pub use jmap_jscontact_types::{
131    Address, AddressComponent, Anniversary, AnniversaryDate, Author, Calendar, CryptoKey,
132    Directory, EmailAddress, LanguagePref, Link, Media, Name, NameComponent, Nickname, Note,
133    OnlineService, OrgUnit, Organization, PartialDate, PersonalInfo, Phone, Pronouns, Relation,
134    SchedulingAddress, SpeakToAs, Timestamp, Title,
135};
136
137#[cfg(test)]
138mod tests {
139    use std::collections::BTreeMap;
140
141    use jmap_types::Id;
142    use serde_json::json;
143
144    use super::*;
145
146    // ── AddressBookRights ────────────────────────────────────────────────
147
148    /// AddressBookRights::default() MUST produce all-false.
149    /// Oracle: spec §2 — four boolean fields, default-false is the safe value.
150    #[test]
151    fn address_book_rights_default_is_all_false() {
152        let r = AddressBookRights::default();
153        assert!(!r.may_read, "may_read default should be false");
154        assert!(!r.may_write, "may_write default should be false");
155        assert!(!r.may_share, "may_share default should be false");
156        assert!(!r.may_delete, "may_delete default should be false");
157    }
158
159    /// Deserialize AddressBookRights from spec §4.1 example JSON.
160    /// Oracle: literal JSON from RFC 9610 §4.1.
161    #[test]
162    fn address_book_rights_deserialize() {
163        let json = r#"{
164            "mayRead": true,
165            "mayWrite": false,
166            "mayShare": false,
167            "mayDelete": false
168        }"#;
169        let r: AddressBookRights = serde_json::from_str(json).unwrap();
170        assert!(r.may_read);
171        assert!(!r.may_write);
172        assert!(!r.may_share);
173        assert!(!r.may_delete);
174    }
175
176    /// AddressBookRights round-trip: serialize then deserialize must be
177    /// identity.
178    #[test]
179    fn address_book_rights_round_trip() {
180        let original = AddressBookRights {
181            may_read: true,
182            may_write: true,
183            may_share: false,
184            may_delete: false,
185            may_set_metadata: Some(true),
186            extra: serde_json::Map::new(),
187        };
188        let json = serde_json::to_string(&original).unwrap();
189        let decoded: AddressBookRights = serde_json::from_str(&json).unwrap();
190        assert_eq!(original, decoded);
191    }
192
193    // ── AddressBook ──────────────────────────────────────────────────────
194
195    /// Deserialize an AddressBook from the spec §4.1 example response.
196    /// Oracle: literal JSON from RFC 9610 §4.1.
197    #[test]
198    fn address_book_deserialize_spec_example() {
199        // Taken verbatim from RFC 9610 §4.1.
200        let json = r#"{
201            "id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
202            "name": "Personal",
203            "description": null,
204            "sortOrder": 0,
205            "isDefault": true,
206            "isSubscribed": true,
207            "shareWith": {
208                "3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
209                    "mayRead": true,
210                    "mayWrite": false,
211                    "mayShare": false,
212                    "mayDelete": false
213                }
214            },
215            "myRights": {
216                "mayRead": true,
217                "mayWrite": true,
218                "mayShare": true,
219                "mayDelete": false
220            }
221        }"#;
222        let ab: AddressBook = serde_json::from_str(json).unwrap();
223        assert_eq!(ab.name, "Personal");
224        assert!(ab.is_default);
225        assert!(ab.is_subscribed);
226        assert_eq!(ab.sort_order, 0);
227        assert!(ab.description.is_none());
228
229        let share_with = ab.share_with.unwrap();
230        let principal_id: Id = Id::from("3f1502e0-63fe-4335-9ff3-e739c188f5dd");
231        let shared_rights = &share_with[&principal_id];
232        assert!(shared_rights.may_read);
233        assert!(!shared_rights.may_write);
234
235        assert!(ab.my_rights.may_read);
236        assert!(ab.my_rights.may_write);
237        assert!(ab.my_rights.may_share);
238        assert!(!ab.my_rights.may_delete);
239    }
240
241    /// Deserialize the second AddressBook from §4.1 (shareWith: null).
242    /// Oracle: literal JSON from RFC 9610 §4.1.
243    #[test]
244    fn address_book_share_with_null() {
245        let json = r#"{
246            "id": "cd40089d-35f9-4fd7-980b-ba3a9f1d74fe",
247            "name": "Autosaved",
248            "description": null,
249            "sortOrder": 1,
250            "isDefault": false,
251            "isSubscribed": true,
252            "shareWith": null,
253            "myRights": {
254                "mayRead": true,
255                "mayWrite": true,
256                "mayShare": true,
257                "mayDelete": false
258            }
259        }"#;
260        let ab: AddressBook = serde_json::from_str(json).unwrap();
261        assert_eq!(ab.name, "Autosaved");
262        assert!(ab.share_with.is_none());
263    }
264
265    /// AddressBook with share_with: None serializes as "shareWith": null, not absent.
266    /// Oracle: RFC 9610 §2 type `Id[AddressBookRights]|null`; §4.1 second
267    /// AddressBook example shows `"shareWith": null` explicitly on the wire.
268    #[test]
269    fn address_book_share_with_null_serializes() {
270        // Build an AddressBook that has share_with: None (the null case).
271        let json = r#"{
272            "id": "ab-null-sw",
273            "name": "Private",
274            "sortOrder": 0,
275            "isDefault": false,
276            "isSubscribed": true,
277            "shareWith": null,
278            "myRights": {
279                "mayRead": true,
280                "mayWrite": true,
281                "mayShare": false,
282                "mayDelete": false
283            }
284        }"#;
285        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
286        assert!(ab.share_with.is_none());
287
288        let serialized = serde_json::to_value(&ab).expect("serialize");
289        let obj = serialized.as_object().expect("object");
290        assert!(
291            obj.contains_key("shareWith"),
292            "shareWith must be present in wire JSON (required-nullable)"
293        );
294        assert!(
295            obj["shareWith"].is_null(),
296            "shareWith must serialize as null when None"
297        );
298    }
299
300    /// AddressBook with share_with: Some(map) round-trips correctly.
301    /// Oracle: RFC 9610 §4.1 first AddressBook example with populated shareWith map.
302    #[test]
303    fn address_book_share_with_some_serializes() {
304        // From RFC 9610 §4.1 first AddressBook.
305        let json = r#"{
306            "id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
307            "name": "Personal",
308            "description": null,
309            "sortOrder": 0,
310            "isDefault": true,
311            "isSubscribed": true,
312            "shareWith": {
313                "3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
314                    "mayRead": true,
315                    "mayWrite": false,
316                    "mayShare": false,
317                    "mayDelete": false
318                }
319            },
320            "myRights": {
321                "mayRead": true,
322                "mayWrite": true,
323                "mayShare": true,
324                "mayDelete": false
325            }
326        }"#;
327        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
328        assert!(ab.share_with.is_some());
329
330        let serialized = serde_json::to_value(&ab).expect("serialize");
331        let obj = serialized.as_object().expect("object");
332        assert!(
333            obj.contains_key("shareWith"),
334            "shareWith must be present when set"
335        );
336        assert!(
337            !obj["shareWith"].is_null(),
338            "shareWith must not be null when populated"
339        );
340        // Round-trip: deserialize the serialized form, verify equality.
341        let back: AddressBook = serde_json::from_value(serialized).expect("round-trip deserialize");
342        assert_eq!(ab.share_with, back.share_with);
343    }
344
345    /// AddressBook round-trip: description Some vs None.
346    #[test]
347    fn address_book_round_trip_with_description() {
348        // Construct via JSON (avoids #[non_exhaustive] struct literal restriction).
349        let original_json = json!({
350            "id": "ab1",
351            "name": "Work",
352            "description": "Work contacts",
353            "sortOrder": 5,
354            "isDefault": false,
355            "isSubscribed": true,
356            "myRights": {
357                "mayRead": true,
358                "mayWrite": false,
359                "mayShare": false,
360                "mayDelete": false
361            }
362        });
363        let ab: AddressBook = serde_json::from_value(original_json).unwrap();
364        assert_eq!(ab.description.as_deref(), Some("Work contacts"));
365        let rt: AddressBook = serde_json::from_str(&serde_json::to_string(&ab).unwrap()).unwrap();
366        assert_eq!(rt.description.as_deref(), Some("Work contacts"));
367    }
368
369    /// description: null → None in Rust → "description": null on the wire.
370    ///
371    /// Oracle: RFC 9553 + RFC 9610 §4.1 — both example
372    /// AddressBook objects show `"description": null` explicitly.  The field
373    /// must be present as null, never absent.
374    #[test]
375    fn address_book_description_null_serializes() {
376        let json = r#"{
377            "id": "AB1",
378            "name": "Personal",
379            "description": null,
380            "sortOrder": 0,
381            "isDefault": false,
382            "isSubscribed": false,
383            "shareWith": null,
384            "myRights": {
385                "mayRead": true,
386                "mayWrite": true,
387                "mayShare": false,
388                "mayDelete": false
389            }
390        }"#;
391        let ab: AddressBook = serde_json::from_str(json).expect("deserialize");
392        assert!(ab.description.is_none(), "description should be None");
393
394        let out = serde_json::to_value(&ab).expect("serialize");
395        assert!(
396            out.as_object().expect("object").contains_key("description"),
397            "description key must be present in wire JSON"
398        );
399        assert!(
400            out["description"].is_null(),
401            "description must serialize as null, not be absent"
402        );
403    }
404
405    // ── ContactCard ──────────────────────────────────────────────────────
406
407    /// Deserialize the ContactCard from the spec §4.1 example.
408    /// Oracle: literal JSON from RFC 9610 §4.1.
409    #[test]
410    fn contact_card_deserialize_spec_example() {
411        // From RFC 9610 §4.1 response.
412        let json = r#"{
413            "id": "3",
414            "addressBookIds": {
415                "062adcfa-105d-455c-bc60-6db68b69c3f3": true
416            },
417            "name": {
418                "components": [
419                    { "kind": "given", "value": "Joe" },
420                    { "kind": "surname", "value": "Bloggs" }
421                ],
422                "isOrdered": true
423            },
424            "emails": {
425                "0": {
426                    "contexts": {
427                        "private": true
428                    },
429                    "address": "joe.bloggs@example.com"
430                }
431            }
432        }"#;
433        let card: ContactCard = serde_json::from_str(json).unwrap();
434
435        let id = card.id.as_ref().unwrap();
436        assert_eq!(id.as_ref(), "3");
437
438        let ab_ids = card.address_book_ids.as_ref().unwrap();
439        let ab_key: Id = Id::from("062adcfa-105d-455c-bc60-6db68b69c3f3");
440        assert!(ab_ids[&ab_key]);
441
442        // name is a serde_json::Value
443        let name = card.name.as_ref().unwrap();
444        assert_eq!(name["isOrdered"], serde_json::Value::Bool(true));
445        assert_eq!(name["components"][0]["kind"], "given");
446        assert_eq!(name["components"][0]["value"], "Joe");
447
448        // emails is a serde_json::Value
449        let emails = card.emails.as_ref().unwrap();
450        assert_eq!(emails["0"]["address"], "joe.bloggs@example.com");
451    }
452
453    /// ContactCard with kind="group" and members map.
454    /// Oracle: hand-written from RFC 9553 §2.1.6 example.
455    #[test]
456    fn contact_card_group_with_members() {
457        let json = r#"{
458            "version": "1.0",
459            "uid": "urn:uuid:ab4310aa-fa43-11e9-8f0b-362b9e155667",
460            "kind": "group",
461            "name": { "full": "The Doe family" },
462            "members": {
463                "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true,
464                "urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519": true
465            }
466        }"#;
467        let card: ContactCard = serde_json::from_str(json).unwrap();
468        assert_eq!(card.kind.as_deref(), Some("group"));
469        let members = card.members.as_ref().unwrap();
470        assert!(members["urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af"]);
471        assert_eq!(members.len(), 2);
472    }
473
474    /// ContactCard with no optional fields: only id and addressBookIds.
475    #[test]
476    fn contact_card_minimal_fields() {
477        let json = r#"{
478            "id": "minimal1",
479            "addressBookIds": { "ab1": true }
480        }"#;
481        let card: ContactCard = serde_json::from_str(json).unwrap();
482        assert!(card.version.is_none());
483        assert!(card.uid.is_none());
484        assert!(card.kind.is_none());
485        assert!(card.emails.is_none());
486        assert!(card.phones.is_none());
487        assert!(card.addresses.is_none());
488    }
489
490    /// ContactCard skip_serializing_if omits None fields from output.
491    #[test]
492    fn contact_card_serializes_only_present_fields() {
493        // Build from JSON to bypass #[non_exhaustive] struct literal restriction.
494        let card: ContactCard = serde_json::from_value(json!({
495            "id": "x1",
496            "addressBookIds": { "ab1": true },
497            "version": "1.0",
498            "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
499        }))
500        .unwrap();
501
502        let serialized = serde_json::to_value(&card).unwrap();
503        // Only present fields should appear.
504        assert!(serialized.get("id").is_some());
505        assert!(serialized.get("version").is_some());
506        assert!(serialized.get("uid").is_some());
507        // Absent fields must NOT appear.
508        assert!(serialized.get("emails").is_none());
509        assert!(serialized.get("phones").is_none());
510        assert!(serialized.get("addresses").is_none());
511        assert!(serialized.get("kind").is_none());
512    }
513
514    /// `localizations` round-trips as a `BTreeMap<String, PatchObject>` and
515    /// is byte-identical on the wire (proving `#[serde(transparent)]` on
516    /// `PatchObject` introduces no wrapper key).
517    ///
518    /// Oracle: hand-written JSON modelled on RFC 9553 §2.7.1 localizations
519    /// (BCP 47 language tag keys → PatchObject values).
520    #[test]
521    fn contact_card_localizations_patch_object_transparent() {
522        let json = r#"{"localizations":{"de":{"name/full":"Joe Bloggs (DE)"}}}"#;
523        let card: ContactCard = serde_json::from_str(json).expect("deserialize");
524
525        let locs = card.localizations.as_ref().expect("localizations present");
526        let de = locs.get("de").expect("de locale present");
527        assert_eq!(
528            de.as_map().get("name/full").and_then(|v| v.as_str()),
529            Some("Joe Bloggs (DE)"),
530        );
531
532        let serialized = serde_json::to_string(&card).expect("serialize");
533        assert_eq!(serialized, json);
534    }
535
536    // ── ContactCardFilterCondition ───────────────────────────────────────
537
538    /// Verify that slash-keyed filter fields serialize with the correct wire
539    /// names.
540    /// Oracle: RFC 9610 §3.3.1 field list.
541    #[test]
542    fn filter_condition_slash_keys_serialize_correctly() {
543        let filter: ContactCardFilterCondition = serde_json::from_value(json!({
544            "name/given": "John",
545            "name/surname": "Smith",
546            "name/surname2": "Doe"
547        }))
548        .unwrap();
549
550        assert_eq!(filter.name_given.as_deref(), Some("John"));
551        assert_eq!(filter.name_surname.as_deref(), Some("Smith"));
552        assert_eq!(filter.name_surname2.as_deref(), Some("Doe"));
553
554        let serialized = serde_json::to_value(&filter).unwrap();
555        assert_eq!(serialized["name/given"], "John");
556        assert_eq!(serialized["name/surname"], "Smith");
557        assert_eq!(serialized["name/surname2"], "Doe");
558    }
559
560    /// All filter fields deserialize from spec §3.3.1 field names.
561    /// Oracle: field names taken directly from RFC 9610 §3.3.1.
562    #[test]
563    fn filter_condition_all_fields() {
564        let json = r#"{
565            "inAddressBook": "ab1",
566            "uid": "urn:uuid:abc123",
567            "hasMember": "urn:uuid:def456",
568            "kind": "individual",
569            "createdBefore": "2024-01-01T00:00:00Z",
570            "createdAfter": "2020-01-01T00:00:00Z",
571            "updatedBefore": "2024-06-01T00:00:00Z",
572            "updatedAfter": "2021-06-01T00:00:00Z",
573            "text": "John",
574            "name": "Doe",
575            "name/given": "Jane",
576            "name/surname": "Smith",
577            "name/surname2": "Gomez",
578            "nickname": "Johnny",
579            "organization": "ACME",
580            "email": "john@example.com",
581            "phone": "+1-555-1234",
582            "onlineService": "twitter",
583            "address": "Main St",
584            "note": "VIP"
585        }"#;
586        let f: ContactCardFilterCondition = serde_json::from_str(json).unwrap();
587        assert_eq!(
588            f.in_address_book.as_ref().map(|id| id.as_ref()),
589            Some("ab1")
590        );
591        assert_eq!(f.uid.as_deref(), Some("urn:uuid:abc123"));
592        assert_eq!(f.has_member.as_deref(), Some("urn:uuid:def456"));
593        assert_eq!(f.kind.as_deref(), Some("individual"));
594        assert_eq!(
595            f.created_before.as_ref().map(AsRef::as_ref),
596            Some("2024-01-01T00:00:00Z")
597        );
598        assert_eq!(
599            f.created_after.as_ref().map(AsRef::as_ref),
600            Some("2020-01-01T00:00:00Z")
601        );
602        assert_eq!(
603            f.updated_before.as_ref().map(AsRef::as_ref),
604            Some("2024-06-01T00:00:00Z")
605        );
606        assert_eq!(
607            f.updated_after.as_ref().map(AsRef::as_ref),
608            Some("2021-06-01T00:00:00Z")
609        );
610        assert_eq!(f.text.as_deref(), Some("John"));
611        assert_eq!(f.name.as_deref(), Some("Doe"));
612        assert_eq!(f.name_given.as_deref(), Some("Jane"));
613        assert_eq!(f.name_surname.as_deref(), Some("Smith"));
614        assert_eq!(f.name_surname2.as_deref(), Some("Gomez"));
615        assert_eq!(f.nickname.as_deref(), Some("Johnny"));
616        assert_eq!(f.organization.as_deref(), Some("ACME"));
617        assert_eq!(f.email.as_deref(), Some("john@example.com"));
618        assert_eq!(f.phone.as_deref(), Some("+1-555-1234"));
619        assert_eq!(f.online_service.as_deref(), Some("twitter"));
620        assert_eq!(f.address.as_deref(), Some("Main St"));
621        assert_eq!(f.note.as_deref(), Some("VIP"));
622    }
623
624    /// Empty filter condition serializes to `{}`.
625    #[test]
626    fn filter_condition_empty_serializes_to_empty_object() {
627        let f = ContactCardFilterCondition::default();
628        let v = serde_json::to_value(&f).unwrap();
629        assert_eq!(v, json!({}));
630    }
631
632    // ── Capability ───────────────────────────────────────────────────────
633
634    /// ContactsCapability serializes to `{}`.
635    /// Oracle: RFC 9610 §1.4.1 — session-level capability is an empty object.
636    #[test]
637    fn contacts_capability_is_empty_object() {
638        let cap = ContactsCapability::default();
639        let v = serde_json::to_value(&cap).unwrap();
640        assert_eq!(v, json!({}));
641    }
642
643    /// ContactsAccountCapability deserializes correctly.
644    /// Oracle: RFC 9610 §1.4.1 field list.
645    #[test]
646    fn contacts_account_capability_deserialize() {
647        let json = r#"{
648            "maxAddressBooksPerCard": 5,
649            "mayCreateAddressBook": true
650        }"#;
651        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
652        assert_eq!(cap.max_address_books_per_card, Some(5));
653        assert!(cap.may_create_address_book);
654    }
655
656    /// ContactsAccountCapability with null maxAddressBooksPerCard.
657    /// Oracle: RFC 9610 §1.4.1 — "null for no limit".
658    #[test]
659    fn contacts_account_capability_null_max() {
660        let json = r#"{
661            "maxAddressBooksPerCard": null,
662            "mayCreateAddressBook": false
663        }"#;
664        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
665        assert!(cap.max_address_books_per_card.is_none());
666        assert!(!cap.may_create_address_book);
667    }
668
669    /// maxAddressBooksPerCard accepts the largest UnsignedInt.
670    /// Oracle: RFC 9610 §1.4.1 "maxAddressBooksPerCard: UnsignedInt|null";
671    /// RFC 8620 §1.3 "the value MUST be in the range 0 <= value <= 2^53-1".
672    #[test]
673    fn contacts_account_capability_max_unsigned_int() {
674        let json = r#"{
675            "maxAddressBooksPerCard": 9007199254740991,
676            "mayCreateAddressBook": true
677        }"#;
678        let cap: ContactsAccountCapability = serde_json::from_str(json).unwrap();
679        assert_eq!(cap.max_address_books_per_card, Some(9_007_199_254_740_991));
680    }
681
682    /// URI constant has the correct value.
683    #[test]
684    fn jmap_contacts_uri_value() {
685        assert_eq!(JMAP_CONTACTS_URI, "urn:ietf:params:jmap:contacts");
686    }
687
688    // ── ContactCardComparator ────────────────────────────────────────────
689
690    /// is_ascending defaults to true when absent from JSON.
691    /// Oracle: JMAP base protocol default comparator behavior.
692    #[test]
693    fn comparator_is_ascending_defaults_to_true() {
694        let json = r#"{ "property": "created" }"#;
695        let c: ContactCardComparator = serde_json::from_str(json).unwrap();
696        assert!(c.is_ascending);
697        assert!(c.collation.is_none());
698    }
699
700    /// Comparator round-trip with all fields.
701    #[test]
702    fn comparator_round_trip() {
703        let json = r#"{ "property": "name/given", "isAscending": false, "collation": "i;unicode-casemap" }"#;
704        let c: ContactCardComparator = serde_json::from_str(json).unwrap();
705        assert_eq!(c.property, ContactCardComparatorProperty::NameGiven);
706        assert!(!c.is_ascending);
707        assert_eq!(c.collation.as_deref(), Some("i;unicode-casemap"));
708        let rt: ContactCardComparator =
709            serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
710        assert_eq!(rt.property, ContactCardComparatorProperty::NameGiven);
711        assert!(!rt.is_ascending);
712    }
713
714    /// RFC 9610 §3.3.2: "created" and "updated" "MUST be supported for
715    /// sorting"; "name/given", "name/surname" and "name/surname2" "SHOULD
716    /// be supported for sorting". A name not listed survives as `Other`
717    /// and re-encodes to the same JSON (bd JMAP-wf92.11).
718    #[test]
719    fn comparator_sort_properties() {
720        use ContactCardComparatorProperty as P;
721        for (wire, want) in [
722            ("created", P::Created),
723            ("updated", P::Updated),
724            ("name/given", P::NameGiven),
725            ("name/surname", P::NameSurname),
726            ("name/surname2", P::NameSurname2),
727        ] {
728            let c: ContactCardComparator =
729                serde_json::from_value(serde_json::json!({ "property": wire })).unwrap();
730            assert_eq!(c.property, want);
731        }
732        let wire = serde_json::json!({ "property": "uid", "isAscending": true });
733        let c: ContactCardComparator = serde_json::from_value(wire).unwrap();
734        assert_eq!(c.property, P::Other("uid".to_owned()));
735        // RFC 8620 §5.5 isAscending "(default: true)": the default is omitted.
736        assert_eq!(
737            serde_json::to_value(&c).unwrap(),
738            serde_json::json!({ "property": "uid" })
739        );
740    }
741
742    // ── ContactCard sloppy-field → jmap-jscontact-types typed round-trip ──
743    //
744    // RFC 9610 §3 defers all RFC 9553 sub-object shapes to the JSContact
745    // spec. The sloppy `Option<serde_json::Value>` fields on `ContactCard`
746    // are the wire-format anchor; consumers obtain typed views via
747    // `serde_json::from_value` into the corresponding type from
748    // `jmap-jscontact-types`. The tests below cover each sloppy field
749    // against RFC 9553 example JSON, mirroring the per-field acceptance
750    // criterion in bd:JMAP-sehw.2.
751    //
752    // Oracle: hand-typed JSON taken from RFC 9553 worked examples.
753
754    /// Deserialize the sloppy `name` field through `Name`.
755    /// Oracle: RFC 9553 Figure 16.
756    #[test]
757    fn sloppy_field_name_roundtrips_through_jscontact_name() {
758        let card_json = json!({
759            "name": {
760                "components": [
761                    { "kind": "given", "value": "Vincent" },
762                    { "kind": "surname", "value": "van Gogh" }
763                ],
764                "isOrdered": true
765            }
766        });
767        let card: ContactCard = serde_json::from_value(card_json).unwrap();
768        let name: Name = serde_json::from_value(card.name.clone().unwrap()).unwrap();
769        let back = serde_json::to_value(&name).unwrap();
770        assert_eq!(back, card.name.unwrap());
771    }
772
773    /// Deserialize the sloppy `nicknames` field through `BTreeMap<String, Nickname>`.
774    /// Oracle: RFC 9553 Figure 21.
775    #[test]
776    fn sloppy_field_nicknames_roundtrips_through_jscontact_nickname_map() {
777        let card_json = json!({
778            "nicknames": {
779                "k391": { "name": "Johnny" }
780            }
781        });
782        let card: ContactCard = serde_json::from_value(card_json).unwrap();
783        let nicks: BTreeMap<String, Nickname> =
784            serde_json::from_value(card.nicknames.clone().unwrap()).unwrap();
785        assert_eq!(nicks["k391"].name, "Johnny");
786        let back = serde_json::to_value(&nicks).unwrap();
787        assert_eq!(back, card.nicknames.unwrap());
788    }
789
790    /// Deserialize the sloppy `organizations` field through `BTreeMap<String, Organization>`.
791    /// Oracle: RFC 9553 Figure 22.
792    #[test]
793    fn sloppy_field_organizations_roundtrips_through_jscontact_organization_map() {
794        let card_json = json!({
795            "organizations": {
796                "o1": {
797                    "name": "ABC, Inc.",
798                    "units": [
799                        { "name": "North American Division" },
800                        { "name": "Marketing" }
801                    ],
802                    "sortAs": "ABC"
803                }
804            }
805        });
806        let card: ContactCard = serde_json::from_value(card_json).unwrap();
807        let orgs: BTreeMap<String, Organization> =
808            serde_json::from_value(card.organizations.clone().unwrap()).unwrap();
809        assert_eq!(orgs["o1"].name.as_deref(), Some("ABC, Inc."));
810        let back = serde_json::to_value(&orgs).unwrap();
811        assert_eq!(back, card.organizations.unwrap());
812    }
813
814    /// Deserialize the sloppy `speakToAs` field through `SpeakToAs`.
815    /// Oracle: RFC 9553 Figure 23.
816    #[test]
817    fn sloppy_field_speak_to_as_roundtrips_through_jscontact_speak_to_as() {
818        let card_json = json!({
819            "speakToAs": {
820                "grammaticalGender": "neuter",
821                "pronouns": {
822                    "k19": { "pronouns": "they/them", "pref": 2 }
823                }
824            }
825        });
826        let card: ContactCard = serde_json::from_value(card_json).unwrap();
827        let speak: SpeakToAs = serde_json::from_value(card.speak_to_as.clone().unwrap()).unwrap();
828        let back = serde_json::to_value(&speak).unwrap();
829        assert_eq!(back, card.speak_to_as.unwrap());
830    }
831
832    /// Deserialize the sloppy `titles` field through `BTreeMap<String, Title>`.
833    /// Oracle: RFC 9553 Figure 24.
834    #[test]
835    fn sloppy_field_titles_roundtrips_through_jscontact_title_map() {
836        let card_json = json!({
837            "titles": {
838                "le9": { "kind": "title", "name": "Research Scientist" }
839            }
840        });
841        let card: ContactCard = serde_json::from_value(card_json).unwrap();
842        let titles: BTreeMap<String, Title> =
843            serde_json::from_value(card.titles.clone().unwrap()).unwrap();
844        assert_eq!(titles["le9"].name, "Research Scientist");
845        let back = serde_json::to_value(&titles).unwrap();
846        assert_eq!(back, card.titles.unwrap());
847    }
848
849    /// Deserialize the sloppy `emails` field through `BTreeMap<String, EmailAddress>`.
850    /// Oracle: RFC 9553 Figure 25.
851    #[test]
852    fn sloppy_field_emails_roundtrips_through_jscontact_email_address_map() {
853        let card_json = json!({
854            "emails": {
855                "e1": { "contexts": { "work": true }, "address": "jqpublic@xyz.example.com" }
856            }
857        });
858        let card: ContactCard = serde_json::from_value(card_json).unwrap();
859        let emails: BTreeMap<String, EmailAddress> =
860            serde_json::from_value(card.emails.clone().unwrap()).unwrap();
861        assert_eq!(emails["e1"].address, "jqpublic@xyz.example.com");
862        let back = serde_json::to_value(&emails).unwrap();
863        assert_eq!(back, card.emails.unwrap());
864    }
865
866    /// Deserialize the sloppy `onlineServices` field through `BTreeMap<String, OnlineService>`.
867    /// Oracle: RFC 9553 Figure 26.
868    #[test]
869    fn sloppy_field_online_services_roundtrips_through_jscontact_online_service_map() {
870        let card_json = json!({
871            "onlineServices": {
872                "x1": { "uri": "xmpp:alice@example.com" }
873            }
874        });
875        let card: ContactCard = serde_json::from_value(card_json).unwrap();
876        let svcs: BTreeMap<String, OnlineService> =
877            serde_json::from_value(card.online_services.clone().unwrap()).unwrap();
878        let back = serde_json::to_value(&svcs).unwrap();
879        assert_eq!(back, card.online_services.unwrap());
880    }
881
882    /// Deserialize the sloppy `phones` field through `BTreeMap<String, Phone>`.
883    /// Oracle: RFC 9553 Figure 27.
884    #[test]
885    fn sloppy_field_phones_roundtrips_through_jscontact_phone_map() {
886        let card_json = json!({
887            "phones": {
888                "tel0": {
889                    "contexts": { "private": true },
890                    "features": { "voice": true },
891                    "number": "tel:+1-555-555-5555;ext=5555",
892                    "pref": 1
893                }
894            }
895        });
896        let card: ContactCard = serde_json::from_value(card_json).unwrap();
897        let phones: BTreeMap<String, Phone> =
898            serde_json::from_value(card.phones.clone().unwrap()).unwrap();
899        assert_eq!(phones["tel0"].number, "tel:+1-555-555-5555;ext=5555");
900        let back = serde_json::to_value(&phones).unwrap();
901        assert_eq!(back, card.phones.unwrap());
902    }
903
904    /// Deserialize the sloppy `preferredLanguages` field through `BTreeMap<String, LanguagePref>`.
905    /// Oracle: RFC 9553 Figure 28.
906    #[test]
907    fn sloppy_field_preferred_languages_roundtrips_through_jscontact_language_pref_map() {
908        let card_json = json!({
909            "preferredLanguages": {
910                "l1": { "language": "en", "contexts": { "work": true }, "pref": 1 }
911            }
912        });
913        let card: ContactCard = serde_json::from_value(card_json).unwrap();
914        let langs: BTreeMap<String, LanguagePref> =
915            serde_json::from_value(card.preferred_languages.clone().unwrap()).unwrap();
916        assert_eq!(langs["l1"].language, "en");
917        let back = serde_json::to_value(&langs).unwrap();
918        assert_eq!(back, card.preferred_languages.unwrap());
919    }
920
921    /// Deserialize the sloppy `calendars` field through `BTreeMap<String, Calendar>`.
922    /// Oracle: RFC 9553 Figure 29.
923    #[test]
924    fn sloppy_field_calendars_roundtrips_through_jscontact_calendar_map() {
925        let card_json = json!({
926            "calendars": {
927                "calA": { "kind": "calendar", "uri": "webcal://calendar.example.com/calA.ics" }
928            }
929        });
930        let card: ContactCard = serde_json::from_value(card_json).unwrap();
931        let cals: BTreeMap<String, Calendar> =
932            serde_json::from_value(card.calendars.clone().unwrap()).unwrap();
933        let back = serde_json::to_value(&cals).unwrap();
934        assert_eq!(back, card.calendars.unwrap());
935    }
936
937    /// Deserialize the sloppy `schedulingAddresses` field through `BTreeMap<String, SchedulingAddress>`.
938    /// Oracle: RFC 9553 Figure 30.
939    #[test]
940    fn sloppy_field_scheduling_addresses_roundtrips_through_jscontact_scheduling_address_map() {
941        let card_json = json!({
942            "schedulingAddresses": {
943                "sched1": { "uri": "mailto:janedoe@example.com" }
944            }
945        });
946        let card: ContactCard = serde_json::from_value(card_json).unwrap();
947        let scheds: BTreeMap<String, SchedulingAddress> =
948            serde_json::from_value(card.scheduling_addresses.clone().unwrap()).unwrap();
949        assert_eq!(scheds["sched1"].uri, "mailto:janedoe@example.com");
950        let back = serde_json::to_value(&scheds).unwrap();
951        assert_eq!(back, card.scheduling_addresses.unwrap());
952    }
953
954    /// Deserialize the sloppy `addresses` field through `BTreeMap<String, Address>`.
955    /// Oracle: RFC 9553 Figure 31.
956    #[test]
957    fn sloppy_field_addresses_roundtrips_through_jscontact_address_map() {
958        let card_json = json!({
959            "addresses": {
960                "k23": {
961                    "contexts": { "work": true },
962                    "components": [
963                        { "kind": "number", "value": "54321" },
964                        { "kind": "name", "value": "Oak St" }
965                    ],
966                    "countryCode": "US",
967                    "isOrdered": true
968                }
969            }
970        });
971        let card: ContactCard = serde_json::from_value(card_json).unwrap();
972        let addrs: BTreeMap<String, Address> =
973            serde_json::from_value(card.addresses.clone().unwrap()).unwrap();
974        assert_eq!(addrs["k23"].country_code.as_deref(), Some("US"));
975        let back = serde_json::to_value(&addrs).unwrap();
976        assert_eq!(back, card.addresses.unwrap());
977    }
978
979    /// Deserialize the sloppy `cryptoKeys` field through `BTreeMap<String, CryptoKey>`.
980    /// Oracle: RFC 9553 Figure 34.
981    #[test]
982    fn sloppy_field_crypto_keys_roundtrips_through_jscontact_crypto_key_map() {
983        let card_json = json!({
984            "cryptoKeys": {
985                "mykey1": { "uri": "https://www.example.com/keys/jdoe.cer" }
986            }
987        });
988        let card: ContactCard = serde_json::from_value(card_json).unwrap();
989        let keys: BTreeMap<String, CryptoKey> =
990            serde_json::from_value(card.crypto_keys.clone().unwrap()).unwrap();
991        let back = serde_json::to_value(&keys).unwrap();
992        assert_eq!(back, card.crypto_keys.unwrap());
993    }
994
995    /// Deserialize the sloppy `directories` field through `BTreeMap<String, Directory>`.
996    /// Oracle: RFC 9553 Figure 36.
997    #[test]
998    fn sloppy_field_directories_roundtrips_through_jscontact_directory_map() {
999        let card_json = json!({
1000            "directories": {
1001                "dir1": {
1002                    "kind": "entry",
1003                    "uri": "https://dir.example.com/addrbook/jdoe/Jean%20Dupont.vcf"
1004                }
1005            }
1006        });
1007        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1008        let dirs: BTreeMap<String, Directory> =
1009            serde_json::from_value(card.directories.clone().unwrap()).unwrap();
1010        assert_eq!(dirs["dir1"].kind.as_deref(), Some("entry"));
1011        let back = serde_json::to_value(&dirs).unwrap();
1012        assert_eq!(back, card.directories.unwrap());
1013    }
1014
1015    /// Deserialize the sloppy `links` field through `BTreeMap<String, Link>`.
1016    /// Oracle: RFC 9553 Figure 37.
1017    #[test]
1018    fn sloppy_field_links_roundtrips_through_jscontact_link_map() {
1019        let card_json = json!({
1020            "links": {
1021                "link3": { "kind": "contact", "uri": "mailto:contact@example.com", "pref": 1 }
1022            }
1023        });
1024        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1025        let links: BTreeMap<String, Link> =
1026            serde_json::from_value(card.links.clone().unwrap()).unwrap();
1027        let back = serde_json::to_value(&links).unwrap();
1028        assert_eq!(back, card.links.unwrap());
1029    }
1030
1031    /// Deserialize the sloppy `media` field through `BTreeMap<String, Media>`.
1032    /// Oracle: RFC 9553 Figure 38.
1033    #[test]
1034    fn sloppy_field_media_roundtrips_through_jscontact_media_map() {
1035        let card_json = json!({
1036            "media": {
1037                "res47": { "kind": "logo", "uri": "https://www.example.com/pub/logos/abccorp.jpg" }
1038            }
1039        });
1040        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1041        let media: BTreeMap<String, Media> =
1042            serde_json::from_value(card.media.clone().unwrap()).unwrap();
1043        assert_eq!(media["res47"].kind.as_deref(), Some("logo"));
1044        let back = serde_json::to_value(&media).unwrap();
1045        assert_eq!(back, card.media.unwrap());
1046    }
1047
1048    /// Deserialize the sloppy `anniversaries` field through `BTreeMap<String, Anniversary>`.
1049    /// Oracle: RFC 9553 Figure 41.
1050    #[test]
1051    fn sloppy_field_anniversaries_roundtrips_through_jscontact_anniversary_map() {
1052        let card_json = json!({
1053            "anniversaries": {
1054                "k8": {
1055                    "kind": "birth",
1056                    "date": { "year": 1953, "month": 4, "day": 15 }
1057                }
1058            }
1059        });
1060        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1061        let annivs: BTreeMap<String, Anniversary> =
1062            serde_json::from_value(card.anniversaries.clone().unwrap()).unwrap();
1063        assert_eq!(annivs["k8"].kind, "birth");
1064        let back = serde_json::to_value(&annivs).unwrap();
1065        assert_eq!(back, card.anniversaries.unwrap());
1066    }
1067
1068    /// Deserialize the sloppy `notes` field through `BTreeMap<String, Note>`.
1069    /// Oracle: RFC 9553 Figure 43.
1070    #[test]
1071    fn sloppy_field_notes_roundtrips_through_jscontact_note_map() {
1072        let card_json = json!({
1073            "notes": {
1074                "n1": {
1075                    "note": "Open office hours are 1600 to 1715 EST, Mon-Fri",
1076                    "created": "2022-11-23T15:01:32Z",
1077                    "author": { "name": "John" }
1078                }
1079            }
1080        });
1081        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1082        let notes: BTreeMap<String, Note> =
1083            serde_json::from_value(card.notes.clone().unwrap()).unwrap();
1084        assert_eq!(
1085            notes["n1"].author.as_ref().unwrap().name.as_deref(),
1086            Some("John")
1087        );
1088        let back = serde_json::to_value(&notes).unwrap();
1089        assert_eq!(back, card.notes.unwrap());
1090    }
1091
1092    /// Deserialize the sloppy `personalInfo` field through `BTreeMap<String, PersonalInfo>`.
1093    /// Oracle: RFC 9553 Figure 44.
1094    #[test]
1095    fn sloppy_field_personal_info_roundtrips_through_jscontact_personal_info_map() {
1096        let card_json = json!({
1097            "personalInfo": {
1098                "pi2": { "kind": "expertise", "value": "chemistry", "level": "high" }
1099            }
1100        });
1101        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1102        let info: BTreeMap<String, PersonalInfo> =
1103            serde_json::from_value(card.personal_info.clone().unwrap()).unwrap();
1104        assert_eq!(info["pi2"].kind, "expertise");
1105        let back = serde_json::to_value(&info).unwrap();
1106        assert_eq!(back, card.personal_info.unwrap());
1107    }
1108
1109    /// Deserialize the sloppy `relatedTo` field through `BTreeMap<String, Relation>`.
1110    /// Oracle: RFC 9553 Figure 13.
1111    #[test]
1112    fn sloppy_field_related_to_roundtrips_through_jscontact_relation_map() {
1113        let card_json = json!({
1114            "relatedTo": {
1115                "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {
1116                    "relation": { "friend": true }
1117                }
1118            }
1119        });
1120        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1121        let rels: BTreeMap<String, Relation> =
1122            serde_json::from_value(card.related_to.clone().unwrap()).unwrap();
1123        let back = serde_json::to_value(&rels).unwrap();
1124        assert_eq!(back, card.related_to.unwrap());
1125    }
1126
1127    /// Typed `keywords` field (RFC 9553 §2.8.2 `String[Boolean]`) round-trips.
1128    /// Oracle: RFC 9553 Figure 42.
1129    ///
1130    /// RFC 9553 §2.8.2 declares `keywords` as `String[Boolean]` — there is
1131    /// no JSContact object type for keywords, only a tag→true map. The
1132    /// field is typed as `Option<BTreeMap<String, bool>>` to match the
1133    /// sibling `members` and `address_book_ids` fields and the spec's
1134    /// closed shape (JMAP-glx8.3).
1135    #[test]
1136    fn keywords_roundtrips_as_string_bool_map() {
1137        let card_json = json!({
1138            "keywords": {
1139                "internet": true,
1140                "IETF": true
1141            }
1142        });
1143        let card: ContactCard = serde_json::from_value(card_json).unwrap();
1144        let kws = card.keywords.as_ref().expect("keywords present");
1145        assert!(kws["internet"]);
1146        assert!(kws["IETF"]);
1147        assert_eq!(kws.len(), 2);
1148    }
1149
1150    /// Verify the top-level JSContact re-export resolves to the same crate
1151    /// as `jmap_jscontact_types`.
1152    ///
1153    /// Type identity is asserted at **compile time** by the const fn
1154    /// pointer below: if `jmap_jscontact_types::Name` and the top-level
1155    /// `Name` re-export ever resolved to distinct types, the
1156    /// `const _ASSERT_*` item would fail to typecheck and the build would
1157    /// break — no runtime call needed. JMAP-glx8.21.
1158    #[test]
1159    fn top_level_jscontact_reexport_is_jmap_jscontact_types() {
1160        const _ASSERT_TOP_LEVEL_SAME_AS_DIRECT: fn(jmap_jscontact_types::Name) -> Name = |x| x;
1161
1162        // Smoke-test that both paths actually deserialize the same wire
1163        // JSON; a regression in re-export visibility would show up as a
1164        // build error here, not a runtime panic.
1165        let v = json!({ "full": "Vincent van Gogh" });
1166        let _direct: jmap_jscontact_types::Name = serde_json::from_value(v.clone()).unwrap();
1167        let _top_level: Name = serde_json::from_value(v).unwrap();
1168    }
1169
1170    // ── Extras-preservation policy tests (JMAP-lbdy.5) ───────────────────
1171
1172    /// `AddressBookRights.extra` captures vendor fields and preserves them.
1173    #[test]
1174    fn address_book_rights_preserves_vendor_extras() {
1175        let raw = json!({
1176            "mayRead": true,
1177            "mayWrite": false,
1178            "mayShare": false,
1179            "mayDelete": false,
1180            "acmeCorpMayMerge": true
1181        });
1182        let r: AddressBookRights = serde_json::from_value(raw).unwrap();
1183        assert_eq!(
1184            r.extra.get("acmeCorpMayMerge").and_then(|v| v.as_bool()),
1185            Some(true)
1186        );
1187        let back = serde_json::to_value(&r).unwrap();
1188        assert_eq!(back["acmeCorpMayMerge"], true);
1189    }
1190
1191    /// `AddressBook.extra` captures vendor fields and preserves them.
1192    #[test]
1193    fn address_book_preserves_vendor_extras() {
1194        let raw = json!({
1195            "id": "ab1",
1196            "name": "Personal",
1197            "description": null,
1198            "sortOrder": 0,
1199            "isDefault": true,
1200            "isSubscribed": true,
1201            "shareWith": null,
1202            "myRights": {
1203                "mayRead": true, "mayWrite": true,
1204                "mayShare": false, "mayDelete": false
1205            },
1206            "acmeCorpRetentionDays": 365
1207        });
1208        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1209        assert_eq!(
1210            ab.extra
1211                .get("acmeCorpRetentionDays")
1212                .and_then(|v| v.as_u64()),
1213            Some(365)
1214        );
1215        let back = serde_json::to_value(&ab).unwrap();
1216        assert_eq!(back["acmeCorpRetentionDays"], 365);
1217    }
1218
1219    /// `ContactCard.extra` captures vendor fields and preserves them.
1220    #[test]
1221    fn contact_card_preserves_vendor_extras() {
1222        let raw = json!({
1223            "uid": "card-1",
1224            "version": "1.0",
1225            "acmeCorpExternalId": "ldap-42"
1226        });
1227        let c: ContactCard = serde_json::from_value(raw).unwrap();
1228        assert_eq!(
1229            c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
1230            Some("ldap-42")
1231        );
1232        let back = serde_json::to_value(&c).unwrap();
1233        assert_eq!(back["acmeCorpExternalId"], "ldap-42");
1234    }
1235
1236    // ── MUST-be-true map values (JMAP-glx8.11) ───────────────────────────
1237    //
1238    // `addressBookIds`, `members`, and `keywords` are JMAP `Id[Boolean]` /
1239    // `String[Boolean]` sets where the spec requires each value MUST be
1240    // `true` (RFC 9610 §3, RFC 9553 §2.1.6, RFC 9553 §2.8.2). The type
1241    // shape `BTreeMap<_, bool>` cannot enforce that — caller / server is
1242    // responsible for rejecting `false`. The tests below pin the
1243    // deserialise-cleanly contract so handler-layer validators know what
1244    // they will see on the wire, matching the canonical `Email.mailbox_ids`
1245    // precedent in `jmap-mail-types`.
1246
1247    /// `addressBookIds` with `false` deserialises cleanly; the type does
1248    /// not enforce MUST-be-true. Oracle: hand-built JSON.
1249    #[test]
1250    fn contact_card_address_book_ids_accepts_false_deserialise() {
1251        let raw = json!({
1252            "id": "card-1",
1253            "addressBookIds": { "ab1": false }
1254        });
1255        let c: ContactCard = serde_json::from_value(raw).unwrap();
1256        let ids = c.address_book_ids.as_ref().unwrap();
1257        let ab1: Id = Id::from("ab1");
1258        // false is preserved on round-trip — handler layer rejects, not the type.
1259        assert_eq!(ids.get(&ab1).copied(), Some(false));
1260    }
1261
1262    /// `members` with `false` deserialises cleanly; the type does not
1263    /// enforce MUST-be-true. Oracle: hand-built JSON.
1264    #[test]
1265    fn contact_card_members_accepts_false_deserialise() {
1266        let raw = json!({
1267            "id": "card-1",
1268            "kind": "group",
1269            "members": { "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": false }
1270        });
1271        let c: ContactCard = serde_json::from_value(raw).unwrap();
1272        let mems = c.members.as_ref().unwrap();
1273        assert_eq!(
1274            mems.get("urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
1275                .copied(),
1276            Some(false),
1277        );
1278    }
1279
1280    /// `keywords` with `false` deserialises cleanly; the type does not
1281    /// enforce MUST-be-true. Oracle: hand-built JSON.
1282    #[test]
1283    fn contact_card_keywords_accepts_false_deserialise() {
1284        let raw = json!({
1285            "id": "card-1",
1286            "keywords": { "IETF": false }
1287        });
1288        let c: ContactCard = serde_json::from_value(raw).unwrap();
1289        let kws = c.keywords.as_ref().unwrap();
1290        assert_eq!(kws.get("IETF").copied(), Some(false));
1291    }
1292
1293    // ── Negative deserialize at the wire boundary (JMAP-glx8.23) ─────────
1294    //
1295    // The crate sits at the JMAP-wire deserialize boundary. Downstream
1296    // consumers (jmap-contacts-server, third-party kits) need a documented
1297    // contract for what malformed / oversized / spec-violating input does
1298    // when it reaches `serde_json::from_str` on these types. The tests
1299    // below pin that contract: the type-crate either accepts cleanly
1300    // (handler layer rejects later) or returns `Err`. No test asserts a
1301    // handler-layer behavior; those concerns live in `jmap-contacts-server`
1302    // (bd:JMAP-yfpq).
1303    //
1304    // Oracle: hand-built JSON whose shape is independent of the code
1305    // under test, per workspace test-integrity rule.
1306
1307    /// Malformed JSON returns `Err` from `serde_json::from_str` — does not
1308    /// panic, does not silently parse a partial value. Oracle: three
1309    /// hand-built malformed inputs.
1310    #[test]
1311    fn malformed_json_returns_error() {
1312        // Truncated object — closing brace missing.
1313        let truncated = r#"{"id": "card-1""#;
1314        assert!(serde_json::from_str::<ContactCard>(truncated).is_err());
1315
1316        // Mismatched braces — closes with `]` instead of `}`.
1317        let mismatched = r#"{"id": "card-1"]"#;
1318        assert!(serde_json::from_str::<ContactCard>(mismatched).is_err());
1319
1320        // Unterminated string literal.
1321        let unterminated = r#"{"id": "card-1}"#;
1322        assert!(serde_json::from_str::<ContactCard>(unterminated).is_err());
1323    }
1324
1325    /// Oversized `name` value on `AddressBook` deserialises cleanly: the
1326    /// type accepts up to `String::MAX_LEN` regardless of the 255-octet
1327    /// upper bound declared by RFC 9610 §2. The handler layer
1328    /// (`jmap-contacts-server`, bd:JMAP-yfpq) is responsible for
1329    /// rejecting names that exceed the spec bound with
1330    /// `invalidProperties` per RFC 8620 §5.3. Oracle: hand-built JSON
1331    /// with a 1000-byte string.
1332    #[test]
1333    fn oversized_name_deserialises_to_typed_field() {
1334        let big_name = "x".repeat(1000);
1335        let raw = json!({
1336            "id": "ab-big-name",
1337            "name": big_name,
1338            "description": null,
1339            "sortOrder": 0,
1340            "isDefault": false,
1341            "isSubscribed": true,
1342            "shareWith": null,
1343            "myRights": {
1344                "mayRead": true, "mayWrite": false,
1345                "mayShare": false, "mayDelete": false
1346            }
1347        });
1348        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1349        assert_eq!(ab.name.len(), 1000);
1350    }
1351
1352    /// `sortOrder` values in `[2^31, 2^32)` deserialise cleanly into the
1353    /// typed `u32` field, even though RFC 9610 §2 declares the spec range
1354    /// `[0, 2^31)`. The handler layer is responsible for rejecting values
1355    /// outside the spec range. This test documents the spec-range vs
1356    /// type-range gap. Oracle: hand-built JSON with `sortOrder` =
1357    /// 2_147_483_648 (one past the spec ceiling, well within `u32::MAX`).
1358    #[test]
1359    fn sort_order_overflow_outside_spec_range_accepts() {
1360        let raw = json!({
1361            "id": "ab-overflow",
1362            "name": "Overflow",
1363            "description": null,
1364            "sortOrder": 2_147_483_648u64,
1365            "isDefault": false,
1366            "isSubscribed": true,
1367            "shareWith": null,
1368            "myRights": {
1369                "mayRead": true, "mayWrite": false,
1370                "mayShare": false, "mayDelete": false
1371            }
1372        });
1373        let ab: AddressBook = serde_json::from_value(raw).unwrap();
1374        assert_eq!(ab.sort_order, 2_147_483_648);
1375    }
1376
1377    /// A Sloppy-Value field (`ContactCard.name`, typed as
1378    /// `Option<serde_json::Value>`) accepts any JSON shape, including
1379    /// non-object values like a bare string. The handler layer is
1380    /// responsible for rejecting non-object wire shapes per RFC 9553 §2.2.
1381    /// Typed access via `serde_json::from_value::<Name>(...)` returns
1382    /// `Err` when the wire shape is not an object. Oracle: hand-built
1383    /// JSON with `name` as a string instead of an object.
1384    #[test]
1385    fn sloppy_field_accepts_non_object_value() {
1386        let raw = json!({
1387            "id": "card-1",
1388            "name": "vincent"
1389        });
1390        let c: ContactCard = serde_json::from_value(raw).unwrap();
1391        let name_value = c.name.as_ref().unwrap();
1392        assert!(name_value.is_string(), "sloppy field preserved the string");
1393
1394        // Typed access through jmap-jscontact-types fails cleanly — does
1395        // not panic — when the wire shape is not the expected object.
1396        let typed = serde_json::from_value::<Name>(name_value.clone());
1397        assert!(typed.is_err(), "Name expects an object, got a string");
1398    }
1399
1400    /// A deeply nested but bounded sloppy-field value deserialises
1401    /// cleanly: serde_json's default recursion limit is 128 levels of
1402    /// nesting, and 100-deep nesting fits comfortably under that. Oracle:
1403    /// hand-built 100-level nested JSON object inside the `name` field.
1404    #[test]
1405    fn deeply_nested_sloppy_field_within_serde_json_limit_succeeds() {
1406        // Build {"id":"c","name":{"x":{"x":...{"x":1}...}}} with 100
1407        // levels of "x" nesting inside name.
1408        let mut nested = String::from("1");
1409        for _ in 0..100 {
1410            nested = format!("{{\"x\":{nested}}}");
1411        }
1412        let payload = format!("{{\"id\":\"c\",\"name\":{nested}}}");
1413        let c: ContactCard = serde_json::from_str(&payload)
1414            .expect("100-deep nesting must be within serde_json's default 128 recursion limit");
1415        assert!(c.name.is_some());
1416    }
1417
1418    /// A sloppy-field value nested past serde_json's default 128-level
1419    /// recursion limit returns `Err`. Oracle: hand-built 200-level
1420    /// nested JSON object inside the `name` field. This documents that
1421    /// the type-crate inherits serde_json's stack-overflow defense; it
1422    /// does NOT make a memory-budget claim — see JMAP-glx8.24 for the
1423    /// memory-and-CPU contract.
1424    #[test]
1425    fn deeply_nested_sloppy_field_beyond_serde_json_limit_errors() {
1426        let mut nested = String::from("1");
1427        for _ in 0..200 {
1428            nested = format!("{{\"x\":{nested}}}");
1429        }
1430        let payload = format!("{{\"id\":\"c\",\"name\":{nested}}}");
1431        let result: Result<ContactCard, _> = serde_json::from_str(&payload);
1432        assert!(
1433            result.is_err(),
1434            "200-deep nesting must exceed serde_json's default 128 recursion limit"
1435        );
1436    }
1437
1438    /// JSON object with a duplicate **typed** field key: `serde_derive`'s
1439    /// generated visitor REJECTS the duplicate with a "duplicate field"
1440    /// error. RFC 8259 §4 leaves duplicate-key handling
1441    /// implementation-defined; this crate's typed-struct contract is
1442    /// strict-reject. Cross-implementation behavior (Go, Python, Java)
1443    /// varies — see the extras-collision contract on
1444    /// [`ContactCard::extra`] (card.rs:247-267). Oracle: hand-built
1445    /// JSON with two `"uid"` keys.
1446    #[test]
1447    fn duplicate_typed_field_keys_rejected() {
1448        let raw = r#"{"id":"card-1","uid":"first","uid":"second"}"#;
1449        let err = serde_json::from_str::<ContactCard>(raw)
1450            .expect_err("typed field duplicate must be rejected");
1451        let msg = err.to_string();
1452        assert!(
1453            msg.contains("duplicate field"),
1454            "expected duplicate-field error, got: {msg}"
1455        );
1456    }
1457
1458    /// JSON object with a duplicate **vendor-extra** key: the
1459    /// `#[serde(flatten)]` `extra` field is backed by
1460    /// `serde_json::Map<String, Value>`, which last-wins on duplicate
1461    /// keys (matching the untyped `serde_json::Value` path). This
1462    /// documents the asymmetry — typed fields reject duplicates,
1463    /// vendor extras absorb the last value silently. Cross-reference:
1464    /// [`ContactCard::extra`] collision contract. Oracle: hand-built
1465    /// JSON with two `"acmeCorpExternalId"` keys.
1466    #[test]
1467    fn duplicate_vendor_extra_keys_last_wins() {
1468        let raw = r#"{"id":"card-1","acmeCorpExternalId":"first","acmeCorpExternalId":"second"}"#;
1469        let c: ContactCard = serde_json::from_str(raw)
1470            .expect("vendor-extra duplicates fold into extra map, last-wins");
1471        assert_eq!(
1472            c.extra.get("acmeCorpExternalId").and_then(|v| v.as_str()),
1473            Some("second"),
1474            "extra map last-wins on duplicate keys"
1475        );
1476    }
1477
1478    // ── validate_extras (JMAP-glx8.25) ───────────────────────────────────
1479    //
1480    // The `validate_extras()` method on each affected type is a defensive
1481    // pre-serialize check that detects keys in `extra` that shadow typed
1482    // wire-format fields. The tests below pin the contract: clean extras
1483    // pass, every typed wire-field name collides when programmatically
1484    // inserted into `extra`, and multi-collision reports list all keys.
1485
1486    /// `ContactCard::validate_extras` returns `Ok(())` when `extra` is
1487    /// empty or contains only non-colliding vendor keys.
1488    #[test]
1489    fn contact_card_validate_extras_ok_on_clean() {
1490        let c = ContactCard::default();
1491        assert!(c.validate_extras().is_ok(), "empty extras must pass");
1492
1493        let mut c = ContactCard::default();
1494        c.extra.insert("acmeCorpFoo".into(), json!("bar"));
1495        assert!(c.validate_extras().is_ok(), "vendor-only key must pass");
1496    }
1497
1498    /// `ContactCard::validate_extras` returns `Err` listing every
1499    /// typed wire-format field name when programmatically inserted
1500    /// into `extra`. Oracle: the spec'd list of camelCase field names
1501    /// from RFC 9610 §3 + RFC 9553 §2 + workspace extras-preservation
1502    /// policy.
1503    #[test]
1504    fn contact_card_validate_extras_detects_every_typed_field_collision() {
1505        // Every typed wire-format field name on ContactCard. Kept here
1506        // independent of the const in card.rs as the test oracle.
1507        let typed_fields = [
1508            "id",
1509            "addressBookIds",
1510            "version",
1511            "created",
1512            "kind",
1513            "language",
1514            "members",
1515            "prodId",
1516            "relatedTo",
1517            "uid",
1518            "updated",
1519            "name",
1520            "nicknames",
1521            "organizations",
1522            "speakToAs",
1523            "titles",
1524            "emails",
1525            "onlineServices",
1526            "phones",
1527            "preferredLanguages",
1528            "calendars",
1529            "schedulingAddresses",
1530            "addresses",
1531            "cryptoKeys",
1532            "directories",
1533            "links",
1534            "media",
1535            "localizations",
1536            "anniversaries",
1537            "keywords",
1538            "notes",
1539            "personalInfo",
1540        ];
1541        for field in typed_fields {
1542            let mut c = ContactCard::default();
1543            c.extra.insert(field.into(), json!("collision"));
1544            let err = c
1545                .validate_extras()
1546                .expect_err(&format!("{field} collision must be detected"));
1547            assert_eq!(err.keys(), &[field.to_string()]);
1548        }
1549    }
1550
1551    /// `ContactCard::validate_extras` reports multiple collisions in
1552    /// alphabetical order in a single `Err`.
1553    #[test]
1554    fn contact_card_validate_extras_reports_multiple_collisions() {
1555        let mut c = ContactCard::default();
1556        c.extra.insert("uid".into(), json!("a"));
1557        c.extra.insert("id".into(), json!("b"));
1558        c.extra.insert("acmeCorpFoo".into(), json!("c"));
1559        let err = c.validate_extras().expect_err("multi-collision must error");
1560        assert_eq!(err.keys(), &["id".to_string(), "uid".to_string()]);
1561        let msg = err.to_string();
1562        assert!(msg.contains("id, uid"), "got: {msg}");
1563    }
1564
1565    /// `AddressBook::validate_extras` returns `Ok(())` on clean extras
1566    /// and `Err` on every typed wire-format field collision.
1567    #[test]
1568    fn address_book_validate_extras_detects_every_typed_field_collision() {
1569        let typed_fields = [
1570            "id",
1571            "name",
1572            "description",
1573            "sortOrder",
1574            "isDefault",
1575            "isSubscribed",
1576            "shareWith",
1577            "myRights",
1578        ];
1579        // Build a minimal AddressBook (without using the literal field
1580        // names that would interfere with the test).
1581        let template = json!({
1582            "id": "ab-test",
1583            "name": "Test",
1584            "description": null,
1585            "sortOrder": 0,
1586            "isDefault": false,
1587            "isSubscribed": true,
1588            "shareWith": null,
1589            "myRights": {
1590                "mayRead": true, "mayWrite": false,
1591                "mayShare": false, "mayDelete": false
1592            }
1593        });
1594        let mut ab: AddressBook = serde_json::from_value(template).unwrap();
1595        assert!(ab.validate_extras().is_ok(), "clean extras pass");
1596
1597        for field in typed_fields {
1598            ab.extra.clear();
1599            ab.extra.insert(field.into(), json!("collision"));
1600            let err = ab
1601                .validate_extras()
1602                .expect_err(&format!("{field} collision must be detected"));
1603            assert_eq!(err.keys(), &[field.to_string()]);
1604        }
1605    }
1606
1607    /// `AddressBookRights::validate_extras` returns `Ok(())` on clean
1608    /// extras and `Err` on every typed wire-format field collision.
1609    #[test]
1610    fn address_book_rights_validate_extras_detects_every_typed_field_collision() {
1611        let typed_fields = ["mayRead", "mayWrite", "mayShare", "mayDelete"];
1612        let mut r = AddressBookRights::default();
1613        assert!(r.validate_extras().is_ok(), "clean extras pass");
1614
1615        for field in typed_fields {
1616            r.extra.clear();
1617            r.extra.insert(field.into(), json!(true));
1618            let err = r
1619                .validate_extras()
1620                .expect_err(&format!("{field} collision must be detected"));
1621            assert_eq!(err.keys(), &[field.to_string()]);
1622        }
1623    }
1624
1625    /// Oracle: workspace map-ordering policy (bd:JMAP-pdux, bd:JMAP-glx8.20)
1626    /// — wire-format maps serialize in sorted key order. Input keys are
1627    /// written out of order; expected bytes are hand-written.
1628    #[test]
1629    fn contact_card_maps_serialize_in_sorted_key_order() {
1630        let raw = r#"{"addressBookIds":{"ab2":true,"ab1":true},"localizations":{"fr":{"name/full":"Jean"},"de":{"name/full":"Johann"}}}"#;
1631        let card: ContactCard = serde_json::from_str(raw).expect("deserialize");
1632        let j = serde_json::to_string(&card).expect("serialize");
1633        assert_eq!(
1634            j,
1635            r#"{"addressBookIds":{"ab1":true,"ab2":true},"localizations":{"de":{"name/full":"Johann"},"fr":{"name/full":"Jean"}}}"#
1636        );
1637    }
1638
1639    /// draft-ietf-jmap-metadata-04 §4.1: the rights object "gains the following
1640    /// property: *maySetMetadata*: Boolean", present only when the metadata
1641    /// capability lists the type. Oracle: hand-written JSON with the spec's wire name.
1642    #[test]
1643    fn address_book_rights_may_set_metadata_typed_and_optional() {
1644        let with = serde_json::json!({
1645            "mayRead": true, "mayWrite": false, "mayShare": false, "mayDelete": false,
1646            "maySetMetadata": true
1647        });
1648        let r: AddressBookRights = serde_json::from_value(with.clone()).unwrap();
1649        assert_eq!(r.may_set_metadata, Some(true));
1650        assert!(!r.extra.contains_key("maySetMetadata"));
1651        assert_eq!(serde_json::to_value(&r).unwrap(), with);
1652
1653        let without = serde_json::json!({
1654            "mayRead": true, "mayWrite": false, "mayShare": false, "mayDelete": false
1655        });
1656        let r: AddressBookRights = serde_json::from_value(without.clone()).unwrap();
1657        assert_eq!(r.may_set_metadata, None);
1658        assert_eq!(serde_json::to_value(&r).unwrap(), without);
1659    }
1660
1661    /// `maySetMetadata` is a typed wire field, so an `extra` key with that
1662    /// name must be reported as a collision.
1663    #[test]
1664    fn address_book_rights_extra_may_set_metadata_collides() {
1665        let mut r = AddressBookRights::default();
1666        r.extra
1667            .insert("maySetMetadata".to_owned(), serde_json::Value::Bool(true));
1668        assert!(r.validate_extras().is_err());
1669    }
1670}