Skip to main content

jmap_types/
methods.rs

1//! RFC 8620 §5 generic method-response shapes shared by all JMAP method types.
2//!
3//! These wire types are normative — every JMAP `/get`, `/set`, `/changes`,
4//! `/query`, and `/queryChanges` method (across mail, calendars, contacts,
5//! chat, etc.) returns one of these shapes. Centralising them here avoids
6//! drift between the seven `jmap-*-client` crates that previously each
7//! defined their own copies. Server crates may still hand-build wire JSON
8//! for their `/set` responses (so they can use the typed
9//! [`SetErrorType`](crate::backend) enum at construction time); this crate's
10//! [`SetError`] is the deserialization target on the client side.
11//!
12//! All types use camelCase JSON via `#[serde(rename_all = "camelCase")]` and
13//! are marked `#[non_exhaustive]` so future RFC errata or extensions can add
14//! fields without a SemVer break.
15//!
16//! # Spec references
17//!
18//! | Type | Spec |
19//! |---|---|
20//! | [`GetResponse`] | RFC 8620 §5.1 |
21//! | [`ChangesResponse`] | RFC 8620 §5.2 |
22//! | [`SetResponse`], [`SetError`] | RFC 8620 §5.3 |
23//! | [`QueryResponse`] | RFC 8620 §5.5 |
24//! | [`QueryChangesResponse`], [`AddedItem`] | RFC 8620 §5.6 |
25
26use std::collections::HashMap;
27
28use serde::{Deserialize, Serialize};
29
30use crate::{Id, State};
31
32// ---------------------------------------------------------------------------
33// /get
34// ---------------------------------------------------------------------------
35
36/// RFC 8620 §5.1 — `Foo/get` response shape.
37///
38/// `T` is the type of object being fetched (e.g. `Mailbox`, `CalendarEvent`).
39/// `state` is the opaque state token the server returns alongside the result;
40/// it advances every time any object of the requested type changes in the
41/// account. `not_found` lists ids the client requested that the server could
42/// not find — `null` is treated as an empty list per RFC 8620 §5.1.
43#[non_exhaustive]
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct GetResponse<T> {
47    /// The account the response refers to.
48    pub account_id: Id,
49    /// Opaque state token for this object type at the time of the response.
50    pub state: State,
51    /// The fetched objects, one per id that was found.
52    pub list: Vec<T>,
53    /// Ids that were requested but not found. `null` on the wire is treated
54    /// as an empty list per RFC 8620 §5.1.
55    pub not_found: Option<Vec<Id>>,
56    /// Catch-all for vendor / site / private extension fields not covered
57    /// by the typed fields above. Preserves unknown fields across
58    /// deserialize/serialize round-trip per workspace extras-preservation
59    /// policy (see workspace AGENTS.md).
60    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
61    pub extra: serde_json::Map<String, serde_json::Value>,
62}
63
64// ---------------------------------------------------------------------------
65// /changes
66// ---------------------------------------------------------------------------
67
68/// RFC 8620 §5.2 — `Foo/changes` response shape.
69///
70/// Reports the ids of objects created, updated, or destroyed since
71/// `old_state`. If `has_more_changes` is `true`, the client should call
72/// `/changes` again with `since_state = new_state` to retrieve the next
73/// page; otherwise `new_state` is the current state.
74///
75/// # Extension fields
76///
77/// Some JMAP data-type extensions add an `updatedProperties` field to
78/// their `/changes` response shape:
79///
80/// - RFC 8621 §2.2 (`Mailbox/changes`): set when only `totalEmails` /
81///   `unreadEmails` / `totalThreads` / `unreadThreads` changed.
82/// - RFC 9425 §5 (`Quota/changes`): set when only the `used` property
83///   changed.
84///
85/// For all other `/changes` methods (RFC 8621 §3.2 `Thread/changes`,
86/// §4.3 `Email/changes`, plus every extension `/changes` method not
87/// listed above) the server omits the field, and clients deserialize
88/// it as `None`. Carrying the field on the base type avoids duplicating
89/// the `ChangesResponse` shape into per-extension newtypes.
90#[non_exhaustive]
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ChangesResponse {
94    /// The account the response refers to.
95    pub account_id: Id,
96    /// The state token the client passed in.
97    pub old_state: State,
98    /// The current (or next-page) state token.
99    pub new_state: State,
100    /// `true` if there are more changes the client must page through.
101    pub has_more_changes: bool,
102    /// Ids of objects created since `old_state`.
103    pub created: Vec<Id>,
104    /// Ids of objects updated since `old_state`.
105    pub updated: Vec<Id>,
106    /// Ids of objects destroyed since `old_state`.
107    pub destroyed: Vec<Id>,
108    /// Optional list of property names that changed (RFC 8621 §2.2,
109    /// RFC 9425 §5). Servers MAY set this for `Mailbox/changes` and
110    /// `Quota/changes` responses when the only changes are to a small
111    /// known subset of properties; clients can then back-reference
112    /// `/updatedProperties` into a follow-up `Mailbox/get` or
113    /// `Quota/get` to fetch only those fields. For all other `/changes`
114    /// methods the field is absent on the wire and `None` here.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub updated_properties: Option<Vec<String>>,
117    /// Catch-all for vendor / site / private extension fields not covered
118    /// by the typed fields above. Preserves unknown fields across
119    /// deserialize/serialize round-trip per workspace extras-preservation
120    /// policy (see workspace AGENTS.md).
121    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
122    pub extra: serde_json::Map<String, serde_json::Value>,
123}
124
125// ---------------------------------------------------------------------------
126// /set
127// ---------------------------------------------------------------------------
128
129/// A per-item failure in a `/set` response (RFC 8620 §5.3).
130///
131/// Appears as the value type in the `notCreated`, `notUpdated`, and
132/// `notDestroyed` maps of [`SetResponse`]. The `error_type` field uses
133/// `String` rather than a typed enum so extension errors (e.g.
134/// `"calendarHasEvent"`, `"noSupportedScheduleMethods"`) round-trip
135/// cleanly without requiring a version-bump on every new spec extension.
136///
137/// All fields beyond `error_type` are optional and present only when the
138/// corresponding error type calls for them per RFC 8620 §5.3 / RFC 8621
139/// §5.5, §5.7, §7.5:
140///
141/// | Field | Set when error_type is | Spec |
142/// |---|---|---|
143/// | `description` | any (optional human-readable detail) | RFC 8620 §5.3 |
144/// | `properties` | `invalidProperties` | RFC 8620 §5.3 |
145/// | `existing_id` | `alreadyExists` | RFC 8620 §5.4, RFC 8621 §5.7 |
146/// | `not_found` | `blobNotFound` | RFC 8621 §5.5 |
147/// | `max_recipients` | `tooManyRecipients` | RFC 8621 §7.5 |
148/// | `invalid_recipients` | `invalidRecipients` | RFC 8621 §7.5 |
149/// | `max_size` | `tooLarge` | RFC 8621 §7.5 |
150///
151/// # Extension fields
152///
153/// JMAP extensions (e.g. JMAP Chat's `serverRetryAfter` for slow-mode
154/// rate limiting) MAY add additional SetError fields beyond the RFC 8620
155/// base set. The `extra` field captures any such field via
156/// `#[serde(flatten)]` so it round-trips losslessly. Extension crates
157/// (e.g. `jmap-chat-client`) provide typed accessor helpers that read
158/// from `extra` — the base type stays free of extension-specific fields.
159#[non_exhaustive]
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct SetError {
163    /// The machine-readable error type (e.g. `"forbidden"`, `"notFound"`,
164    /// `"alreadyExists"`, or an extension-defined string).
165    #[serde(rename = "type")]
166    pub error_type: String,
167    /// Human-readable description of the error. Optional per RFC 8620 §5.3.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub description: Option<String>,
170    /// Property names that caused the error (for `invalidProperties`).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub properties: Option<Vec<String>>,
173    /// The existing object id (for `alreadyExists`).
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub existing_id: Option<Id>,
176    /// Missing blob ids (for `blobNotFound`).
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub not_found: Option<Vec<Id>>,
179    /// Maximum recipients allowed (for `tooManyRecipients`).
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub max_recipients: Option<u64>,
182    /// Invalid recipient addresses (for `invalidRecipients`).
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub invalid_recipients: Option<Vec<String>>,
185    /// Maximum message size in octets (for `tooLarge`).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub max_size: Option<u64>,
188    /// Catch-all for extension SetError fields not in the RFC 8620 base
189    /// set. Captured via `#[serde(flatten)]` so they round-trip losslessly.
190    /// Extension crates provide typed accessors (e.g.
191    /// `jmap-chat-client`'s helper for reading `serverRetryAfter`).
192    ///
193    /// Uses `serde_json::Map` (which preserves insertion order) rather than
194    /// `HashMap` to match the workspace extras-preservation policy (see
195    /// workspace `AGENTS.md`) and to give callers deterministic serialized
196    /// output.
197    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
198    pub extra: serde_json::Map<String, serde_json::Value>,
199}
200
201impl SetError {
202    /// Construct a `SetError` with the given type string and all optional
203    /// fields `None` / empty. Use this when deserializing tests or when
204    /// constructing a wire-shaped error from a typed source. Server crates
205    /// that want a typed enum for construction should use
206    /// `jmap_server::backend::SetError` (declared in the `jmap-server`
207    /// crate, not linkable from here since `jmap-types` does not depend on
208    /// `jmap-server`) — this type is deliberately String-typed for
209    /// client-side parsing flexibility.
210    pub fn new(error_type: impl Into<String>) -> Self {
211        Self {
212            error_type: error_type.into(),
213            description: None,
214            properties: None,
215            existing_id: None,
216            not_found: None,
217            max_recipients: None,
218            invalid_recipients: None,
219            max_size: None,
220            extra: serde_json::Map::new(),
221        }
222    }
223}
224
225impl std::fmt::Display for SetError {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        match &self.description {
228            Some(desc) => write!(f, "{}: {}", self.error_type, desc),
229            None => write!(f, "{}", self.error_type),
230        }
231    }
232}
233
234/// RFC 8620 §5.3 — `Foo/set` response shape.
235///
236/// Wire shape per RFC 8620 §5.3 (rfc8620.txt §5.3 around line 2033):
237///
238/// ```text
239/// created       Id[Foo]      | null
240/// updated       Id[Foo|null] | null   ← inner null is REQUIRED
241/// destroyed     Id[]         | null
242/// notCreated    Id[SetError] | null
243/// notUpdated    Id[SetError] | null
244/// notDestroyed  Id[SetError] | null
245/// ```
246///
247/// The inner `null` in `updated` is the server's signal that the patch was
248/// applied verbatim with no server-set property deltas to report; a typed
249/// `SetResponse<Foo>` MUST accept this rather than failing because `null`
250/// cannot become `Foo`.
251///
252/// `created` and `not_created` keys are caller-supplied creation ids
253/// (`String`); `updated`, `not_updated`, `not_destroyed` keys are
254/// server-assigned record ids ([`Id`]) — typed differently so callers can
255/// use `updated`/`destroyed` keys interchangeably with ids from any
256/// `/get` response.
257#[non_exhaustive]
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260#[serde(bound(
261    deserialize = "T: serde::de::DeserializeOwned",
262    serialize = "T: Serialize"
263))]
264pub struct SetResponse<T = serde_json::Value> {
265    /// The account the response refers to.
266    pub account_id: Id,
267    /// State token before this `/set` was applied. Optional because some
268    /// servers omit it on no-op responses (per RFC 8620 §5.3 the field is
269    /// nullable).
270    pub old_state: Option<State>,
271    /// State token after this `/set`.
272    pub new_state: State,
273    /// Successfully created objects, keyed by caller-supplied creation id.
274    pub created: Option<HashMap<String, T>>,
275    /// Successfully updated objects, keyed by record id. The value is
276    /// `Some(T)` when the server reports server-set property deltas, or
277    /// `None` when the patch was applied verbatim with nothing to echo.
278    pub updated: Option<HashMap<Id, Option<T>>>,
279    /// Ids of successfully destroyed objects.
280    pub destroyed: Option<Vec<Id>>,
281    /// Failed creates, keyed by caller-supplied creation id.
282    pub not_created: Option<HashMap<String, SetError>>,
283    /// Failed updates, keyed by record id.
284    pub not_updated: Option<HashMap<Id, SetError>>,
285    /// Failed destroys, keyed by record id.
286    pub not_destroyed: Option<HashMap<Id, SetError>>,
287    /// Catch-all for vendor / site / private extension fields not covered
288    /// by the typed fields above. Preserves unknown fields across
289    /// deserialize/serialize round-trip per workspace extras-preservation
290    /// policy (see workspace AGENTS.md).
291    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
292    pub extra: serde_json::Map<String, serde_json::Value>,
293}
294
295// ---------------------------------------------------------------------------
296// /query
297// ---------------------------------------------------------------------------
298
299/// RFC 8620 §5.5 — `Foo/query` response shape.
300///
301/// Returns the ids of objects matching a filter, in sort order. The
302/// `query_state` token can be passed to `Foo/queryChanges` to retrieve only
303/// the delta against this snapshot.
304#[non_exhaustive]
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(rename_all = "camelCase")]
307pub struct QueryResponse {
308    /// The account the response refers to.
309    pub account_id: Id,
310    /// Opaque state token for this query result; pass to `/queryChanges`.
311    pub query_state: State,
312    /// `true` if `/queryChanges` will give incremental updates against this
313    /// `query_state`; `false` if the client must re-run `/query` to refresh.
314    pub can_calculate_changes: bool,
315    /// Zero-based offset within the full result set of the first id in
316    /// `ids`. Per RFC 8620 §5.5, may differ from the requested `position`
317    /// when the requested offset exceeds the result count.
318    pub position: u64,
319    /// The matching ids in sort order.
320    pub ids: Vec<Id>,
321    /// Total number of matching objects, or `None` when the request did not
322    /// set `calculateTotal: true`.
323    pub total: Option<u64>,
324    /// Server's max page size; `None` when not advertised.
325    pub limit: Option<u64>,
326    /// Catch-all for vendor / site / private extension fields not covered
327    /// by the typed fields above. Preserves unknown fields across
328    /// deserialize/serialize round-trip per workspace extras-preservation
329    /// policy (see workspace AGENTS.md).
330    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
331    pub extra: serde_json::Map<String, serde_json::Value>,
332}
333
334// ---------------------------------------------------------------------------
335// /queryChanges
336// ---------------------------------------------------------------------------
337
338/// A single item added to a query result set (RFC 8620 §5.6).
339///
340/// The `index` is the position the new item occupies in the post-change
341/// result set, accounting for items also added in this batch.
342#[non_exhaustive]
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct AddedItem {
346    /// The id of the new item in the result set.
347    pub id: Id,
348    /// Zero-based position of the new item in the post-change result set.
349    pub index: u64,
350    /// Catch-all for vendor / site / private extension fields not covered
351    /// by the typed fields above. Preserves unknown fields across
352    /// deserialize/serialize round-trip per workspace extras-preservation
353    /// policy (see workspace AGENTS.md).
354    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
355    pub extra: serde_json::Map<String, serde_json::Value>,
356}
357
358/// RFC 8620 §5.6 — `Foo/queryChanges` response shape.
359///
360/// Reports the ids removed from and added to a query result set since
361/// `old_query_state`. Combined with the previous result, the client can
362/// reconstruct the new result without re-fetching all ids.
363#[non_exhaustive]
364#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct QueryChangesResponse {
367    /// The account the response refers to.
368    pub account_id: Id,
369    /// The state token the client passed in.
370    pub old_query_state: State,
371    /// The current state token.
372    pub new_query_state: State,
373    /// Total number of matching objects (only when
374    /// `calculateTotal: true` was set in the request).
375    pub total: Option<u64>,
376    /// Ids removed from the result set since `old_query_state`.
377    pub removed: Vec<Id>,
378    /// Items added to the result set, with their new positions.
379    pub added: Vec<AddedItem>,
380    /// Catch-all for vendor / site / private extension fields not covered
381    /// by the typed fields above. Preserves unknown fields across
382    /// deserialize/serialize round-trip per workspace extras-preservation
383    /// policy (see workspace AGENTS.md).
384    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
385    pub extra: serde_json::Map<String, serde_json::Value>,
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use serde_json::json;
392
393    // Independent oracles: hand-written JSON shapes from the RFC 8620
394    // examples and prose descriptions, NOT derived from the types being
395    // tested. Wire round-trips ensure the serde rename rules and field
396    // shapes match the spec.
397
398    #[test]
399    fn get_response_round_trips() {
400        let raw = json!({
401            "accountId": "A1",
402            "state": "s42",
403            "list": [{"id": "x", "name": "First"}],
404            "notFound": ["missing1"]
405        });
406        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
407        assert_eq!(resp.account_id.as_ref(), "A1");
408        assert_eq!(resp.state, "s42");
409        assert_eq!(resp.list.len(), 1);
410        assert_eq!(resp.list[0]["name"], "First");
411        let nf = resp.not_found.as_ref().unwrap();
412        assert_eq!(nf.len(), 1);
413        assert_eq!(nf[0].as_ref(), "missing1");
414        // Round-trip back to JSON and confirm the camelCase keys.
415        let back = serde_json::to_value(&resp).unwrap();
416        assert_eq!(back["accountId"], "A1");
417        assert_eq!(back["notFound"][0], "missing1");
418    }
419
420    #[test]
421    fn get_response_null_not_found() {
422        // §5.1 allows notFound to be null when the request did not specify
423        // ids (null is treated as the empty list).
424        let raw = json!({
425            "accountId": "A1",
426            "state": "s1",
427            "list": [],
428            "notFound": null
429        });
430        let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
431        assert!(resp.not_found.is_none());
432    }
433
434    #[test]
435    fn changes_response_round_trips() {
436        let raw = json!({
437            "accountId": "A1",
438            "oldState": "s0",
439            "newState": "s1",
440            "hasMoreChanges": false,
441            "created": ["a"],
442            "updated": ["b"],
443            "destroyed": ["c"]
444        });
445        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
446        assert_eq!(resp.old_state, "s0");
447        assert_eq!(resp.new_state, "s1");
448        assert!(!resp.has_more_changes);
449        assert_eq!(resp.created[0].as_ref(), "a");
450        assert_eq!(resp.updated[0].as_ref(), "b");
451        assert_eq!(resp.destroyed[0].as_ref(), "c");
452        // RFC 8620 §5.2 base `/changes` does not define `updatedProperties`;
453        // the field must default to `None` when absent from the wire.
454        assert!(resp.updated_properties.is_none());
455    }
456
457    /// Oracle: RFC 8621 §2.2 example response (lines 1015-1031 of rfc8621.txt
458    /// in this repo) — `Mailbox/changes` carries `updatedProperties` listing
459    /// `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`.
460    #[test]
461    fn changes_response_deserializes_mailbox_updated_properties() {
462        let raw = json!({
463            "accountId": "A1",
464            "oldState": "78541",
465            "newState": "78542",
466            "hasMoreChanges": false,
467            "updatedProperties": [
468                "totalEmails", "unreadEmails",
469                "totalThreads", "unreadThreads"
470            ],
471            "created": [],
472            "updated": ["B"],
473            "destroyed": []
474        });
475        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
476        let props = resp
477            .updated_properties
478            .expect("updatedProperties must be present");
479        assert_eq!(
480            props,
481            vec![
482                "totalEmails".to_string(),
483                "unreadEmails".to_string(),
484                "totalThreads".to_string(),
485                "unreadThreads".to_string()
486            ]
487        );
488    }
489
490    /// Oracle: RFC 9425 §5 example response — `Quota/changes` carries
491    /// `updatedProperties: ["used"]` when only quota usage changed.
492    #[test]
493    fn changes_response_deserializes_quota_updated_properties() {
494        let raw = json!({
495            "accountId": "A1",
496            "oldState": "78541",
497            "newState": "78542",
498            "hasMoreChanges": false,
499            "updatedProperties": ["used"],
500            "created": [],
501            "updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
502            "destroyed": []
503        });
504        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
505        let props = resp
506            .updated_properties
507            .expect("updatedProperties must be present");
508        assert_eq!(props, vec!["used".to_string()]);
509    }
510
511    /// `updatedProperties: null` on the wire (RFC 8621 §2.2: "If the server
512    /// is unable to tell if only counts have changed, it MUST just be null")
513    /// must also deserialize as `None` — distinct from omitted but
514    /// semantically equivalent on the typed side.
515    #[test]
516    fn changes_response_accepts_explicit_null_updated_properties() {
517        let raw = json!({
518            "accountId": "A1",
519            "oldState": "s0",
520            "newState": "s1",
521            "hasMoreChanges": false,
522            "updatedProperties": null,
523            "created": [],
524            "updated": ["B"],
525            "destroyed": []
526        });
527        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
528        assert!(resp.updated_properties.is_none());
529    }
530
531    /// Serializing a `ChangesResponse` without `updated_properties` must
532    /// NOT emit a `"updatedProperties": null` key — the
533    /// `skip_serializing_if = "Option::is_none"` attribute keeps the wire
534    /// shape minimal and matches the RFC 8620 §5.2 base envelope for
535    /// methods that don't define the extension field.
536    #[test]
537    fn changes_response_omits_updated_properties_when_none() {
538        let resp = ChangesResponse {
539            account_id: Id::from("A1"),
540            old_state: "s0".into(),
541            new_state: "s1".into(),
542            has_more_changes: false,
543            created: vec![],
544            updated: vec![],
545            destroyed: vec![],
546            updated_properties: None,
547            extra: serde_json::Map::new(),
548        };
549        let serialized = serde_json::to_value(&resp).expect("must serialize");
550        assert!(
551            serialized.get("updatedProperties").is_none(),
552            "updatedProperties must be omitted when None"
553        );
554    }
555
556    #[test]
557    fn set_response_updated_accepts_null_value() {
558        // §5.3 wire type: updated is Id[Foo|null]|null. The inner null
559        // signals "patch applied verbatim, no server-set fields to echo".
560        let raw = json!({
561            "accountId": "A1",
562            "oldState": "s1",
563            "newState": "s2",
564            "updated": { "ev1": null, "ev2": null }
565        });
566        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
567        let upd = resp.updated.unwrap();
568        assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
569        assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
570    }
571
572    #[test]
573    fn set_response_updated_accepts_object_value() {
574        let raw = json!({
575            "accountId": "A1",
576            "oldState": "s1",
577            "newState": "s2",
578            "updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
579        });
580        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
581        let upd = resp.updated.unwrap();
582        let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
583        assert_eq!(ev1["title"], "Meeting");
584    }
585
586    #[test]
587    fn set_response_not_updated_keys_are_ids() {
588        // §5.3: notUpdated is Id[SetError]|null. Keys are server-assigned
589        // ids, not creation ids — typing them as Id (not String) lets
590        // callers use the keys interchangeably with /get response ids.
591        let raw = json!({
592            "accountId": "A1",
593            "oldState": "s1",
594            "newState": "s1",
595            "notUpdated": {
596                "ev1": { "type": "stateMismatch" }
597            }
598        });
599        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
600        let nu = resp.not_updated.unwrap();
601        assert_eq!(
602            nu.get(&Id::from("ev1")).unwrap().error_type,
603            "stateMismatch"
604        );
605    }
606
607    #[test]
608    fn set_error_full_8_fields_round_trip() {
609        // §5.3 + RFC 8621 §5.5/§5.7/§7.5: SetError carries up to 8 fields
610        // depending on the error type. The client side must preserve all
611        // of them on deserialize, otherwise callers cannot recover the
612        // existingId / blobNotFound / recipient-list payload that the
613        // server relied on to make the error actionable.
614        let raw = json!({
615            "type": "alreadyExists",
616            "description": "conflict",
617            "properties": ["name"],
618            "existingId": "obj-7",
619            "notFound": ["blob-1", "blob-2"],
620            "maxRecipients": 50,
621            "invalidRecipients": ["bad@", "no@no"],
622            "maxSize": 10485760
623        });
624        let err = SetError::deserialize(&raw).unwrap();
625        assert_eq!(err.error_type, "alreadyExists");
626        assert_eq!(err.description.as_deref(), Some("conflict"));
627        assert_eq!(err.properties.as_ref().unwrap()[0], "name");
628        assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
629        assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
630        assert_eq!(err.max_recipients, Some(50));
631        assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
632        assert_eq!(err.max_size, Some(10_485_760));
633        // Round-trip preserves wire field names.
634        let back = serde_json::to_value(&err).unwrap();
635        assert_eq!(back, raw);
636    }
637
638    #[test]
639    fn set_error_minimal_omits_optional_fields_on_serialize() {
640        // §5.3: only `type` is required. Optional fields MUST be omitted
641        // (not serialized as `null`) so the wire matches the server's
642        // construction shape exactly.
643        let err = SetError::new("forbidden");
644        let json = serde_json::to_value(&err).unwrap();
645        assert_eq!(json["type"], "forbidden");
646        assert!(json.get("description").is_none());
647        assert!(json.get("properties").is_none());
648        assert!(json.get("existingId").is_none());
649        assert!(json.get("notFound").is_none());
650        assert!(json.get("maxRecipients").is_none());
651        assert!(json.get("invalidRecipients").is_none());
652        assert!(json.get("maxSize").is_none());
653        // Empty extra map must not appear at all in the wire output.
654        let obj = json.as_object().unwrap();
655        assert_eq!(
656            obj.len(),
657            1,
658            "minimal SetError must serialize to exactly {{type}}: {json}"
659        );
660    }
661
662    #[test]
663    fn set_error_extension_fields_round_trip_via_extra() {
664        // JMAP Chat's serverRetryAfter is a per-extension SetError field
665        // that must round-trip losslessly through extra without the base
666        // type knowing about it. Pin both directions.
667        let raw = json!({
668            "type": "rateLimited",
669            "description": "slow-mode active",
670            "serverRetryAfter": "2026-01-01T00:00:00Z"
671        });
672        let err = SetError::deserialize(&raw).unwrap();
673        assert_eq!(err.error_type, "rateLimited");
674        assert_eq!(err.description.as_deref(), Some("slow-mode active"));
675        assert_eq!(
676            err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
677            Some("2026-01-01T00:00:00Z"),
678            "extension field must land in extra map: {err:?}"
679        );
680        let back = serde_json::to_value(&err).unwrap();
681        assert_eq!(back, raw, "round-trip must preserve extension field");
682    }
683
684    #[test]
685    fn set_error_extension_type_round_trips() {
686        // Extension errors (e.g. calendars draft §10.7.2) MUST round-trip
687        // through the String error_type without a new variant being added.
688        let err = SetError::new("noSupportedScheduleMethods");
689        let json = serde_json::to_value(&err).unwrap();
690        assert_eq!(json["type"], "noSupportedScheduleMethods");
691        let back: SetError = serde_json::from_value(json).unwrap();
692        assert_eq!(back.error_type, "noSupportedScheduleMethods");
693    }
694
695    #[test]
696    fn set_error_display_with_description() {
697        let err = SetError {
698            error_type: "forbidden".to_owned(),
699            description: Some("not your calendar".to_owned()),
700            ..SetError::new("forbidden")
701        };
702        assert_eq!(err.to_string(), "forbidden: not your calendar");
703    }
704
705    #[test]
706    fn set_error_display_without_description() {
707        let err = SetError::new("forbidden");
708        assert_eq!(err.to_string(), "forbidden");
709    }
710
711    #[test]
712    fn query_response_round_trips() {
713        let raw = json!({
714            "accountId": "A1",
715            "queryState": "qs1",
716            "canCalculateChanges": true,
717            "position": 0,
718            "ids": ["a", "b", "c"],
719            "total": 3,
720            "limit": 100
721        });
722        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
723        assert_eq!(resp.query_state, "qs1");
724        assert!(resp.can_calculate_changes);
725        assert_eq!(resp.ids.len(), 3);
726        assert_eq!(resp.total, Some(3));
727        assert_eq!(resp.limit, Some(100));
728    }
729
730    #[test]
731    fn query_response_omits_optional_total_and_limit() {
732        let raw = json!({
733            "accountId": "A1",
734            "queryState": "qs1",
735            "canCalculateChanges": false,
736            "position": 0,
737            "ids": [],
738            "total": null,
739            "limit": null
740        });
741        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
742        assert!(resp.total.is_none());
743        assert!(resp.limit.is_none());
744    }
745
746    #[test]
747    fn query_changes_response_round_trips() {
748        let raw = json!({
749            "accountId": "A1",
750            "oldQueryState": "qs0",
751            "newQueryState": "qs1",
752            "total": 5,
753            "removed": ["x"],
754            "added": [
755                {"id": "y", "index": 2}
756            ]
757        });
758        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
759        assert_eq!(resp.old_query_state, "qs0");
760        assert_eq!(resp.new_query_state, "qs1");
761        assert_eq!(resp.total, Some(5));
762        assert_eq!(resp.removed[0].as_ref(), "x");
763        assert_eq!(resp.added.len(), 1);
764        assert_eq!(resp.added[0].id.as_ref(), "y");
765        assert_eq!(resp.added[0].index, 2);
766    }
767
768    #[test]
769    fn added_item_round_trips() {
770        let raw = json!({"id": "foo", "index": 7});
771        let item = AddedItem::deserialize(&raw).unwrap();
772        assert_eq!(item.id.as_ref(), "foo");
773        assert_eq!(item.index, 7);
774        assert_eq!(serde_json::to_value(&item).unwrap(), raw);
775    }
776
777    // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
778    //
779    // One round-trip preservation test per migrated type. Each test
780    // asserts that an unknown vendor / site / private-extension field
781    // survives deserialize/serialize unchanged. Per workspace
782    // AGENTS.md "Extras-preservation policy for vendor/site fields".
783
784    /// `GetResponse.extra` captures vendor fields and preserves them on
785    /// re-serialize.
786    #[test]
787    fn get_response_preserves_vendor_extras() {
788        let raw = json!({
789            "accountId": "A1",
790            "state": "s1",
791            "list": [],
792            "notFound": null,
793            "acmeCorpAuditTrail": {"sequence": 42}
794        });
795        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
796        assert_eq!(
797            resp.extra
798                .get("acmeCorpAuditTrail")
799                .and_then(|v| v["sequence"].as_u64()),
800            Some(42),
801            "vendor field must land in extra: {:?}",
802            resp.extra
803        );
804        let back = serde_json::to_value(&resp).unwrap();
805        assert_eq!(
806            back["acmeCorpAuditTrail"]["sequence"], 42,
807            "vendor field must survive serialize: {back}"
808        );
809    }
810
811    /// `ChangesResponse.extra` captures vendor fields and preserves them.
812    #[test]
813    fn changes_response_preserves_vendor_extras() {
814        let raw = json!({
815            "accountId": "A1",
816            "oldState": "s0",
817            "newState": "s1",
818            "hasMoreChanges": false,
819            "created": [],
820            "updated": [],
821            "destroyed": [],
822            "acmeCorpReplayToken": "rt-99"
823        });
824        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
825        assert_eq!(
826            resp.extra
827                .get("acmeCorpReplayToken")
828                .and_then(|v| v.as_str()),
829            Some("rt-99")
830        );
831        let back = serde_json::to_value(&resp).unwrap();
832        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
833    }
834
835    /// `SetResponse.extra` captures vendor fields and preserves them.
836    #[test]
837    fn set_response_preserves_vendor_extras() {
838        let raw = json!({
839            "accountId": "A1",
840            "oldState": "s1",
841            "newState": "s2",
842            "acmeCorpTransactionId": "txn-abc"
843        });
844        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
845        assert_eq!(
846            resp.extra
847                .get("acmeCorpTransactionId")
848                .and_then(|v| v.as_str()),
849            Some("txn-abc")
850        );
851        let back = serde_json::to_value(&resp).unwrap();
852        assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
853    }
854
855    /// `QueryResponse.extra` captures vendor fields and preserves them.
856    #[test]
857    fn query_response_preserves_vendor_extras() {
858        let raw = json!({
859            "accountId": "A1",
860            "queryState": "qs1",
861            "canCalculateChanges": false,
862            "position": 0,
863            "ids": [],
864            "total": null,
865            "limit": null,
866            "acmeCorpSearchTimingMs": 17
867        });
868        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
869        assert_eq!(
870            resp.extra
871                .get("acmeCorpSearchTimingMs")
872                .and_then(|v| v.as_u64()),
873            Some(17)
874        );
875        let back = serde_json::to_value(&resp).unwrap();
876        assert_eq!(back["acmeCorpSearchTimingMs"], 17);
877    }
878
879    /// `QueryChangesResponse.extra` captures vendor fields and preserves them.
880    #[test]
881    fn query_changes_response_preserves_vendor_extras() {
882        let raw = json!({
883            "accountId": "A1",
884            "oldQueryState": "qs0",
885            "newQueryState": "qs1",
886            "total": null,
887            "removed": [],
888            "added": [],
889            "acmeCorpDeltaToken": "dt-2"
890        });
891        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
892        assert_eq!(
893            resp.extra
894                .get("acmeCorpDeltaToken")
895                .and_then(|v| v.as_str()),
896            Some("dt-2")
897        );
898        let back = serde_json::to_value(&resp).unwrap();
899        assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
900    }
901
902    /// `AddedItem.extra` captures vendor fields and preserves them.
903    #[test]
904    fn added_item_preserves_vendor_extras() {
905        let raw = json!({
906            "id": "x",
907            "index": 0,
908            "acmeCorpHighlight": true
909        });
910        let item = AddedItem::deserialize(&raw).unwrap();
911        assert_eq!(
912            item.extra
913                .get("acmeCorpHighlight")
914                .and_then(|v| v.as_bool()),
915            Some(true)
916        );
917        let back = serde_json::to_value(&item).unwrap();
918        assert_eq!(back["acmeCorpHighlight"], true);
919    }
920
921    /// Empty extras must NOT serialize as a key on the wire — the
922    /// `skip_serializing_if = "serde_json::Map::is_empty"` attribute keeps
923    /// the wire shape byte-identical to the pre-migration form when no
924    /// vendor fields are present.
925    #[test]
926    fn empty_extras_omitted_from_wire() {
927        let resp = AddedItem {
928            id: Id::from("z"),
929            index: 1,
930            extra: serde_json::Map::new(),
931        };
932        let serialized = serde_json::to_value(&resp).expect("must serialize");
933        let obj = serialized.as_object().expect("must be object");
934        assert_eq!(
935            obj.len(),
936            2,
937            "empty extras must not add any wire keys; got {serialized}"
938        );
939        assert!(obj.contains_key("id"));
940        assert!(obj.contains_key("index"));
941    }
942}