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//! `/copy`, `/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. [`SetResponse`] carries [`SetError`] values,
8//! defined in `set_error`.
9//!
10//! All types use camelCase JSON via `#[serde(rename_all = "camelCase")]` and
11//! are marked `#[non_exhaustive]` so future RFC errata or extensions can add
12//! fields without a SemVer break.
13//!
14//! # Spec references
15//!
16//! | Type | Spec |
17//! |---|---|
18//! | [`GetResponse`] | RFC 8620 §5.1 |
19//! | [`ChangesResponse`] | RFC 8620 §5.2 |
20//! | [`SetResponse`], [`PartialObject`] | RFC 8620 §5.3 |
21//! | [`CopyResponse`] | RFC 8620 §5.4 |
22//! | [`QueryResponse`] | RFC 8620 §5.5 |
23//! | [`QueryChangesResponse`], [`AddedItem`] | RFC 8620 §5.6 |
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29use crate::{Id, SetError, State};
30
31// ---------------------------------------------------------------------------
32// /get
33// ---------------------------------------------------------------------------
34
35/// RFC 8620 §5.1 — `Foo/get` response shape.
36///
37/// `T` is the type of object being fetched (e.g. `Mailbox`, `CalendarEvent`).
38/// `state` is the opaque state token the server returns alongside the result;
39/// it advances every time any object of the requested type changes in the
40/// account. `not_found` lists ids the client requested that the server could
41/// not find — `null` is treated as an empty list per RFC 8620 §5.1.
42#[non_exhaustive]
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct GetResponse<T> {
46 /// The account the response refers to.
47 pub account_id: Id,
48 /// Opaque state token for this object type at the time of the response.
49 pub state: State,
50 /// The fetched objects, one per id that was found.
51 pub list: Vec<T>,
52 /// Ids that were requested but not found (RFC 8620 §5.1 `notFound:
53 /// Id[]`). A peer that sends `null` or omits it decodes as empty.
54 #[serde(default, deserialize_with = "null_as_empty")]
55 pub not_found: 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/// Deserialize a `null` collection as empty: for fields RFC 8620 types as
65/// a plain array that lenient peers still send as `null`, and for the
66/// `/set` and `/copy` result fields it defines as "null if none".
67fn null_as_empty<'de, D, C>(d: D) -> Result<C, D::Error>
68where
69 D: serde::Deserializer<'de>,
70 C: Deserialize<'de> + Default,
71{
72 Ok(Option::<C>::deserialize(d)?.unwrap_or_default())
73}
74
75/// Serialize an empty collection as `null`, the RFC 8620 §5.3 / §5.4 wire
76/// form for a `/set` or `/copy` result field with no entries.
77fn empty_as_null<S, C>(c: &C, s: S) -> Result<S::Ok, S::Error>
78where
79 S: serde::Serializer,
80 C: Serialize,
81 for<'a> &'a C: IntoIterator,
82{
83 if c.into_iter().next().is_none() {
84 s.serialize_none()
85 } else {
86 s.serialize_some(c)
87 }
88}
89
90// ---------------------------------------------------------------------------
91// /changes
92// ---------------------------------------------------------------------------
93
94/// RFC 8620 §5.2 — `Foo/changes` response shape.
95///
96/// Reports the ids of objects created, updated, or destroyed since
97/// `old_state`. If `has_more_changes` is `true`, the client should call
98/// `/changes` again with `since_state = new_state` to retrieve the next
99/// page; otherwise `new_state` is the current state.
100///
101/// # Extension fields and the foundation-coupling trade-off
102///
103/// Some JMAP data-type extensions add an `updatedProperties` field to
104/// their `/changes` response shape:
105///
106/// - RFC 8621 §2.2 (`Mailbox/changes`): set when only `totalEmails` /
107/// `unreadEmails` / `totalThreads` / `unreadThreads` changed.
108/// - RFC 9425 §5 (`Quota/changes`): set when only the `used` property
109/// changed.
110///
111/// - draft-ietf-jmap-metadata-04 §3.3: every data type the metadata
112/// capability lists, when the request's `using` includes
113/// `urn:ietf:params:jmap:metadata`. The field is then always present,
114/// `null` unless every updated id changed only in `metadata` and/or
115/// `privateMetadata` (plus the Mailbox counts).
116///
117/// For all other `/changes` methods (RFC 8621 §3.2 `Thread/changes`,
118/// §4.3 `Email/changes`, plus every extension `/changes` method not
119/// listed above) the server omits the field, and clients deserialize
120/// it as `None`.
121///
122/// **Architectural decision (bd:JMAP-6xs8.5).** Carrying
123/// `updatedProperties` on this base type, rather than on per-extension
124/// `MailboxChangesResponse` / `QuotaChangesResponse` newtypes, is a
125/// deliberate workspace trade-off:
126///
127/// - **Chosen shape**: one foundation `ChangesResponse` with an
128/// `Option<Option<Vec<String>>>` field that extensions populate.
129/// - **Alternative (a)**: `ChangesResponse<Ext = ()>` generic with a
130/// per-type extension struct. Rejected: would force every
131/// `/changes` handler in every extension server to thread the `Ext`
132/// type parameter, and the 30-crate canonical-template family would
133/// have to settle on a single way to express "no extension" vs
134/// "Mailbox extension" vs "Quota extension".
135/// - **Alternative (b)**: `MailboxChangesResponse` and
136/// `QuotaChangesResponse` as separate types in `jmap-mail-types` and
137/// a future `jmap-quota-types`, delegating to a shared base via
138/// composition. Rejected: would duplicate the seven base fields
139/// (`accountId`, `oldState`, `newState`, `hasMoreChanges`, three
140/// id vectors) at every extension type, and require parallel
141/// handler / parse / dispatch code.
142/// - **Alternative (c)**: leave `updatedProperties` as an extras
143/// flatten entry that mail/quota types explicitly read via
144/// `resp.extra.get("updatedProperties")`. Rejected: loses typed
145/// access, and the field is RFC-defined (not vendor / site), so the
146/// extras pattern (workspace AGENTS.md "Extras-preservation policy")
147/// is the wrong tool.
148///
149/// **Drift risk acknowledged**: every future `/changes` extension
150/// (`jmap-sharing`, `jmap-tasks`, etc.) will face pressure to add its
151/// own typed field here "for parity". Each such field would extend
152/// this foundation type by one more extension-specific column.
153/// Cautionary precedent: bd:JMAP-kt5k removed eight cap-advertising
154/// fields from the chat capability after they accreted there for the
155/// same parity-pressure reason. New `/changes` extension fields land
156/// here only when the field is RFC-defined and the alternative shapes
157/// above have been re-evaluated against the new use case; a vendor
158/// extension belongs in the extras catch-all, not on the typed
159/// surface.
160#[non_exhaustive]
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct ChangesResponse {
164 /// The account the response refers to.
165 pub account_id: Id,
166 /// The state token the client passed in.
167 pub old_state: State,
168 /// The current (or next-page) state token.
169 pub new_state: State,
170 /// `true` if there are more changes the client must page through.
171 pub has_more_changes: bool,
172 /// Ids of objects created since `old_state`.
173 pub created: Vec<Id>,
174 /// Ids of objects updated since `old_state`.
175 pub updated: Vec<Id>,
176 /// Ids of objects destroyed since `old_state`.
177 pub destroyed: Vec<Id>,
178 /// Property names that changed (RFC 8621 §2.2, RFC 9425 §5,
179 /// draft-ietf-jmap-metadata-04 §3.3), in three states: `None` when
180 /// absent, `Some(None)` for `null` and `Some(Some(names))` for a
181 /// list. Each state round-trips.
182 ///
183 /// `Mailbox/changes` and `Quota/changes` may list the properties
184 /// that changed, and clients can back-reference
185 /// `/updatedProperties` into a follow-up `/get`. When the request's
186 /// `using` includes `urn:ietf:params:jmap:metadata`, -04 §3.3 says
187 /// for every listed data type that the argument "is always present,
188 /// and is null when updated is empty", so a server sets `Some(None)`
189 /// rather than `None` there. Other responses leave it `None` unless
190 /// their type defines it, and it is then omitted.
191 #[serde(
192 default,
193 deserialize_with = "present",
194 skip_serializing_if = "Option::is_none"
195 )]
196 pub updated_properties: Option<Option<Vec<String>>>,
197 /// Catch-all for vendor / site / private extension fields not covered
198 /// by the typed fields above. Preserves unknown fields across
199 /// deserialize/serialize round-trip per workspace extras-preservation
200 /// policy (see workspace AGENTS.md).
201 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
202 pub extra: serde_json::Map<String, serde_json::Value>,
203}
204
205/// Deserialize a property that may be absent, `null` or a value into
206/// `Option<Option<T>>`: absent is `None` (via `#[serde(default)]`), `null`
207/// is `Some(None)` and a value is `Some(Some(v))`.
208fn present<'de, D, T>(d: D) -> Result<Option<Option<T>>, D::Error>
209where
210 D: serde::Deserializer<'de>,
211 T: Deserialize<'de>,
212{
213 Option::<T>::deserialize(d).map(Some)
214}
215
216// ---------------------------------------------------------------------------
217// /set
218// ---------------------------------------------------------------------------
219
220/// RFC 8620 §5.3 — `Foo/set` response shape.
221///
222/// Wire shape per RFC 8620 §5.3 (rfc8620.txt §5.3 around line 2033):
223///
224/// ```text
225/// created Id[Foo] | null
226/// updated Id[Foo|null] | null ← inner null is REQUIRED
227/// destroyed Id[] | null
228/// notCreated Id[SetError] | null
229/// notUpdated Id[SetError] | null
230/// notDestroyed Id[SetError] | null
231/// ```
232///
233/// The inner `null` in `updated` is the server's signal that the patch was
234/// applied verbatim with no server-set property deltas to report; a typed
235/// `SetResponse<Foo>` MUST accept this rather than failing because `null`
236/// cannot become `Foo`.
237///
238/// `created` and `not_created` keys are caller-supplied creation ids
239/// (`String`); `updated`, `not_updated`, `not_destroyed` keys are
240/// server-assigned record ids ([`Id`]) — typed differently so callers can
241/// use `updated`/`destroyed` keys interchangeably with ids from any
242/// `/get` response.
243///
244/// The result maps and lists are plain collections, not `Option`s: RFC 8620
245/// defines each as "null if none", so an empty collection means none. A
246/// `null` or absent field decodes as empty and an empty one encodes as
247/// `null`.
248#[non_exhaustive]
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251#[serde(bound(
252 deserialize = "T: serde::de::DeserializeOwned",
253 serialize = "T: Serialize"
254))]
255pub struct SetResponse<T> {
256 /// The account the response refers to.
257 pub account_id: Id,
258 /// State token before this `/set` was applied. Optional because some
259 /// servers omit it on no-op responses (per RFC 8620 §5.3 the field is
260 /// nullable).
261 pub old_state: Option<State>,
262 /// State token after this `/set`.
263 pub new_state: State,
264 /// Successfully created objects, keyed by caller-supplied creation id.
265 #[serde(
266 default,
267 deserialize_with = "null_as_empty",
268 serialize_with = "empty_as_null"
269 )]
270 pub created: BTreeMap<String, T>,
271 /// Successfully updated objects, keyed by record id. The value is
272 /// `Some(T)` when the server reports server-set property deltas, or
273 /// `None` when the patch was applied verbatim with nothing to echo.
274 #[serde(
275 default,
276 deserialize_with = "null_as_empty",
277 serialize_with = "empty_as_null"
278 )]
279 pub updated: BTreeMap<Id, Option<T>>,
280 /// Ids of successfully destroyed objects.
281 #[serde(
282 default,
283 deserialize_with = "null_as_empty",
284 serialize_with = "empty_as_null"
285 )]
286 pub destroyed: Vec<Id>,
287 /// Failed creates, keyed by caller-supplied creation id.
288 #[serde(
289 default,
290 deserialize_with = "null_as_empty",
291 serialize_with = "empty_as_null"
292 )]
293 pub not_created: BTreeMap<String, SetError>,
294 /// Failed updates, keyed by record id.
295 #[serde(
296 default,
297 deserialize_with = "null_as_empty",
298 serialize_with = "empty_as_null"
299 )]
300 pub not_updated: BTreeMap<Id, SetError>,
301 /// Failed destroys, keyed by record id.
302 #[serde(
303 default,
304 deserialize_with = "null_as_empty",
305 serialize_with = "empty_as_null"
306 )]
307 pub not_destroyed: BTreeMap<Id, SetError>,
308 /// Catch-all for vendor / site / private extension fields not covered
309 /// by the typed fields above. Preserves unknown fields across
310 /// deserialize/serialize round-trip per workspace extras-preservation
311 /// policy (see workspace AGENTS.md).
312 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
313 pub extra: serde_json::Map<String, serde_json::Value>,
314}
315
316/// The partial object a server echoes in `/set` `created` / `updated` and
317/// `/copy` `created` (RFC 8620 §5.3, §5.4).
318///
319/// RFC 8620 §5.3 defines each `created` value as "an object containing any
320/// properties of the created Foo object that were not sent by the client",
321/// and §5.4 defines each `/copy` `created` value as the "properties of the
322/// copied Foo object that are set by the server". Neither is a full `Foo`,
323/// so a full typed `Foo` cannot hold them: required client-sent properties
324/// are absent. This type keeps the one property nearly every object type
325/// reports, `id`, and every other returned property in [`Self::extra`].
326///
327/// Deserialize a property out of `extra` with `serde_json::from_value`
328/// when it is needed (e.g. `blobId` / `threadId` / `size` for an Email,
329/// RFC 8621 §4.6).
330#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
331pub struct PartialObject {
332 /// The server-assigned id, when the server reported one. `None` for an
333 /// `updated` entry (the id is the map key) or an object type whose id
334 /// is not server-set.
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub id: Option<Id>,
337 /// Every other property the server returned, keyed by wire name.
338 #[serde(flatten, default)]
339 pub extra: serde_json::Map<String, serde_json::Value>,
340}
341
342// ---------------------------------------------------------------------------
343// /copy
344// ---------------------------------------------------------------------------
345
346/// RFC 8620 §5.4 — `Foo/copy` response shape.
347///
348/// Unlike [`SetResponse`] it carries `fromAccountId` and has no `updated`
349/// / `destroyed` maps. When `onSuccessDestroyOriginal` is set the server
350/// sends a separate implicit `Foo/set` response with the same call id; that
351/// one is a [`SetResponse`].
352///
353/// The result maps and lists are plain collections, not `Option`s: RFC 8620
354/// defines each as "null if none", so an empty collection means none. A
355/// `null` or absent field decodes as empty and an empty one encodes as
356/// `null`.
357#[non_exhaustive]
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360#[serde(bound(
361 deserialize = "T: serde::de::DeserializeOwned",
362 serialize = "T: Serialize"
363))]
364pub struct CopyResponse<T> {
365 /// The account the records were copied from.
366 pub from_account_id: Id,
367 /// The account the records were copied to.
368 pub account_id: Id,
369 /// State of the destination account before the copy, or `None` when
370 /// the server does not know it.
371 pub old_state: Option<State>,
372 /// State of the destination account after the copy.
373 pub new_state: State,
374 /// Successfully copied objects, keyed by caller-supplied creation id.
375 #[serde(
376 default,
377 deserialize_with = "null_as_empty",
378 serialize_with = "empty_as_null"
379 )]
380 pub created: BTreeMap<String, T>,
381 /// Failed copies, keyed by caller-supplied creation id.
382 #[serde(
383 default,
384 deserialize_with = "null_as_empty",
385 serialize_with = "empty_as_null"
386 )]
387 pub not_created: BTreeMap<String, SetError>,
388 /// Catch-all for vendor / site / private extension fields not covered
389 /// by the typed fields above. Preserves unknown fields across
390 /// deserialize/serialize round-trip per workspace extras-preservation
391 /// policy (see workspace AGENTS.md).
392 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
393 pub extra: serde_json::Map<String, serde_json::Value>,
394}
395
396// ---------------------------------------------------------------------------
397// /query
398// ---------------------------------------------------------------------------
399
400/// RFC 8620 §5.5 — `Foo/query` response shape.
401///
402/// Returns the ids of objects matching a filter, in sort order. The
403/// `query_state` token can be passed to `Foo/queryChanges` to retrieve only
404/// the delta against this snapshot.
405#[non_exhaustive]
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct QueryResponse {
409 /// The account the response refers to.
410 pub account_id: Id,
411 /// Opaque state token for this query result; pass to `/queryChanges`.
412 pub query_state: State,
413 /// `true` if `/queryChanges` will give incremental updates against this
414 /// `query_state`; `false` if the client must re-run `/query` to refresh.
415 pub can_calculate_changes: bool,
416 /// Zero-based offset within the full result set of the first id in
417 /// `ids`. Per RFC 8620 §5.5, may differ from the requested `position`
418 /// when the requested offset exceeds the result count.
419 pub position: u64,
420 /// The matching ids in sort order.
421 pub ids: Vec<Id>,
422 /// Total number of matching objects, or `None` when the request did not
423 /// set `calculateTotal: true`.
424 pub total: Option<u64>,
425 /// Server's max page size; `None` when not advertised.
426 pub limit: Option<u64>,
427 /// Catch-all for vendor / site / private extension fields not covered
428 /// by the typed fields above. Preserves unknown fields across
429 /// deserialize/serialize round-trip per workspace extras-preservation
430 /// policy (see workspace AGENTS.md).
431 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
432 pub extra: serde_json::Map<String, serde_json::Value>,
433}
434
435// ---------------------------------------------------------------------------
436// /queryChanges
437// ---------------------------------------------------------------------------
438
439/// A single item added to a query result set (RFC 8620 §5.6).
440///
441/// The `index` is the position the new item occupies in the post-change
442/// result set, accounting for items also added in this batch.
443#[non_exhaustive]
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct AddedItem {
447 /// The id of the new item in the result set.
448 pub id: Id,
449 /// Zero-based position of the new item in the post-change result set.
450 pub index: u64,
451 /// Catch-all for vendor / site / private extension fields not covered
452 /// by the typed fields above. Preserves unknown fields across
453 /// deserialize/serialize round-trip per workspace extras-preservation
454 /// policy (see workspace AGENTS.md).
455 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
456 pub extra: serde_json::Map<String, serde_json::Value>,
457}
458
459/// RFC 8620 §5.6 — `Foo/queryChanges` response shape.
460///
461/// Reports the ids removed from and added to a query result set since
462/// `old_query_state`. Combined with the previous result, the client can
463/// reconstruct the new result without re-fetching all ids.
464#[non_exhaustive]
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct QueryChangesResponse {
468 /// The account the response refers to.
469 pub account_id: Id,
470 /// The state token the client passed in.
471 pub old_query_state: State,
472 /// The current state token.
473 pub new_query_state: State,
474 /// Total number of matching objects (only when
475 /// `calculateTotal: true` was set in the request).
476 pub total: Option<u64>,
477 /// Ids removed from the result set since `old_query_state`.
478 pub removed: Vec<Id>,
479 /// Items added to the result set, with their new positions.
480 pub added: Vec<AddedItem>,
481 /// Catch-all for vendor / site / private extension fields not covered
482 /// by the typed fields above. Preserves unknown fields across
483 /// deserialize/serialize round-trip per workspace extras-preservation
484 /// policy (see workspace AGENTS.md).
485 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
486 pub extra: serde_json::Map<String, serde_json::Value>,
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::SetErrorType;
493 use serde_json::json;
494
495 // Independent oracles: hand-written JSON shapes from the RFC 8620
496 // examples and prose descriptions, NOT derived from the types being
497 // tested. Wire round-trips ensure the serde rename rules and field
498 // shapes match the spec.
499
500 #[test]
501 fn get_response_round_trips() {
502 let raw = json!({
503 "accountId": "A1",
504 "state": "s42",
505 "list": [{"id": "x", "name": "First"}],
506 "notFound": ["missing1"]
507 });
508 let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
509 assert_eq!(resp.account_id.as_ref(), "A1");
510 assert_eq!(resp.state, "s42");
511 assert_eq!(resp.list.len(), 1);
512 assert_eq!(resp.list[0]["name"], "First");
513 assert_eq!(resp.not_found, [Id::from("missing1")]);
514 // Round-trip back to JSON and confirm the camelCase keys.
515 let back = serde_json::to_value(&resp).unwrap();
516 assert_eq!(back["accountId"], "A1");
517 assert_eq!(back["notFound"][0], "missing1");
518 }
519
520 #[test]
521 fn get_response_null_not_found() {
522 // RFC 8620 §5.1 types notFound as "Id[]": "The array is empty if all
523 // requested ids were found or if the "ids" argument passed in was
524 // either null or an empty array." A lenient peer's null decodes as
525 // that empty array and re-encodes as `[]`, never `null`.
526 let raw = json!({
527 "accountId": "A1",
528 "state": "s1",
529 "list": [],
530 "notFound": null
531 });
532 let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
533 assert!(resp.not_found.is_empty());
534 assert_eq!(serde_json::to_value(&resp).unwrap()["notFound"], json!([]));
535 }
536
537 #[test]
538 fn changes_response_round_trips() {
539 let raw = json!({
540 "accountId": "A1",
541 "oldState": "s0",
542 "newState": "s1",
543 "hasMoreChanges": false,
544 "created": ["a"],
545 "updated": ["b"],
546 "destroyed": ["c"]
547 });
548 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
549 assert_eq!(resp.old_state, "s0");
550 assert_eq!(resp.new_state, "s1");
551 assert!(!resp.has_more_changes);
552 assert_eq!(resp.created[0].as_ref(), "a");
553 assert_eq!(resp.updated[0].as_ref(), "b");
554 assert_eq!(resp.destroyed[0].as_ref(), "c");
555 // RFC 8620 §5.2 base `/changes` does not define `updatedProperties`;
556 // the field must default to `None` when absent from the wire.
557 assert!(resp.updated_properties.is_none());
558 }
559
560 /// Oracle: RFC 8621 §2.2 example response (lines 1015-1031 of rfc8621.txt
561 /// in this repo) — `Mailbox/changes` carries `updatedProperties` listing
562 /// `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`.
563 #[test]
564 fn changes_response_deserializes_mailbox_updated_properties() {
565 let raw = json!({
566 "accountId": "A1",
567 "oldState": "78541",
568 "newState": "78542",
569 "hasMoreChanges": false,
570 "updatedProperties": [
571 "totalEmails", "unreadEmails",
572 "totalThreads", "unreadThreads"
573 ],
574 "created": [],
575 "updated": ["B"],
576 "destroyed": []
577 });
578 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
579 let props = resp
580 .updated_properties
581 .expect("updatedProperties must be present")
582 .expect("updatedProperties must be a list");
583 assert_eq!(
584 props,
585 vec![
586 "totalEmails".to_string(),
587 "unreadEmails".to_string(),
588 "totalThreads".to_string(),
589 "unreadThreads".to_string()
590 ]
591 );
592 }
593
594 /// Oracle: RFC 9425 §5 example response — `Quota/changes` carries
595 /// `updatedProperties: ["used"]` when only quota usage changed.
596 #[test]
597 fn changes_response_deserializes_quota_updated_properties() {
598 let raw = json!({
599 "accountId": "A1",
600 "oldState": "78541",
601 "newState": "78542",
602 "hasMoreChanges": false,
603 "updatedProperties": ["used"],
604 "created": [],
605 "updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
606 "destroyed": []
607 });
608 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
609 let props = resp
610 .updated_properties
611 .expect("updatedProperties must be present")
612 .expect("updatedProperties must be a list");
613 assert_eq!(props, vec!["used".to_string()]);
614 }
615
616 /// `updatedProperties: null` on the wire (RFC 8621 §2.2: "If the server
617 /// is unable to tell if only counts have changed, it MUST just be null")
618 /// deserializes as `Some(None)`, distinct from omitted, so it can be
619 /// re-emitted (draft-ietf-jmap-metadata-04 §3.3: "It is always
620 /// present, and is null when updated is empty").
621 #[test]
622 fn changes_response_accepts_explicit_null_updated_properties() {
623 let raw = json!({
624 "accountId": "A1",
625 "oldState": "s0",
626 "newState": "s1",
627 "hasMoreChanges": false,
628 "updatedProperties": null,
629 "created": [],
630 "updated": ["B"],
631 "destroyed": []
632 });
633 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
634 assert_eq!(resp.updated_properties, Some(None));
635 }
636
637 /// Serializing a `ChangesResponse` without `updated_properties` must
638 /// NOT emit a `"updatedProperties": null` key — the
639 /// `skip_serializing_if = "Option::is_none"` attribute keeps the wire
640 /// shape minimal and matches the RFC 8620 §5.2 base envelope for
641 /// methods that don't define the extension field.
642 #[test]
643 fn changes_response_omits_updated_properties_when_none() {
644 let resp = ChangesResponse {
645 account_id: Id::from("A1"),
646 old_state: "s0".into(),
647 new_state: "s1".into(),
648 has_more_changes: false,
649 created: vec![],
650 updated: vec![],
651 destroyed: vec![],
652 updated_properties: None,
653 extra: serde_json::Map::new(),
654 };
655 let serialized = serde_json::to_value(&resp).expect("must serialize");
656 assert!(
657 serialized.get("updatedProperties").is_none(),
658 "updatedProperties must be omitted when None"
659 );
660 }
661
662 /// draft-ietf-jmap-metadata-04 §3.3: when the request's `using`
663 /// includes the metadata capability, updatedProperties "is always
664 /// present, and is null when updated is empty". `Some(None)` must
665 /// therefore serialize as an explicit `null`.
666 #[test]
667 fn changes_response_emits_null_updated_properties_when_some_none() {
668 let resp = ChangesResponse {
669 account_id: Id::from("A1"),
670 old_state: "s0".into(),
671 new_state: "s1".into(),
672 has_more_changes: false,
673 created: vec![],
674 updated: vec![],
675 destroyed: vec![],
676 updated_properties: Some(None),
677 extra: serde_json::Map::new(),
678 };
679 let serialized = serde_json::to_value(&resp).expect("must serialize");
680 assert_eq!(
681 serialized,
682 json!({
683 "accountId": "A1",
684 "oldState": "s0",
685 "newState": "s1",
686 "hasMoreChanges": false,
687 "created": [],
688 "updated": [],
689 "destroyed": [],
690 "updatedProperties": null
691 })
692 );
693 }
694
695 /// Oracle: draft-ietf-jmap-metadata-04 §6.8 example `Email/changes`
696 /// response, which reports a metadata-only change with
697 /// `"updatedProperties": ["metadata"]`. It must decode and re-encode
698 /// unchanged.
699 #[test]
700 fn changes_response_round_trips_metadata_updated_properties() {
701 let raw = json!({
702 "accountId": "A1",
703 "oldState": "e-100",
704 "newState": "e-103",
705 "hasMoreChanges": false,
706 "created": [],
707 "updated": ["EM1", "EM2"],
708 "destroyed": [],
709 "updatedProperties": ["metadata"]
710 });
711 let resp: ChangesResponse = serde_json::from_value(raw.clone()).unwrap();
712 assert_eq!(
713 resp.updated_properties,
714 Some(Some(vec!["metadata".to_string()]))
715 );
716 assert_eq!(serde_json::to_value(&resp).unwrap(), raw);
717 }
718
719 #[test]
720 fn set_response_updated_accepts_null_value() {
721 // §5.3 wire type: updated is Id[Foo|null]|null. The inner null
722 // signals "patch applied verbatim, no server-set fields to echo".
723 let raw = json!({
724 "accountId": "A1",
725 "oldState": "s1",
726 "newState": "s2",
727 "updated": { "ev1": null, "ev2": null }
728 });
729 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
730 let upd = resp.updated;
731 assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
732 assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
733 }
734
735 #[test]
736 fn set_response_updated_accepts_object_value() {
737 let raw = json!({
738 "accountId": "A1",
739 "oldState": "s1",
740 "newState": "s2",
741 "updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
742 });
743 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
744 let upd = resp.updated;
745 let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
746 assert_eq!(ev1["title"], "Meeting");
747 }
748
749 #[test]
750 fn set_response_not_updated_keys_are_ids() {
751 // §5.3: notUpdated is Id[SetError]|null. Keys are server-assigned
752 // ids, not creation ids — typing them as Id (not String) lets
753 // callers use the keys interchangeably with /get response ids.
754 let raw = json!({
755 "accountId": "A1",
756 "oldState": "s1",
757 "newState": "s1",
758 "notUpdated": {
759 "ev1": { "type": "stateMismatch" }
760 }
761 });
762 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
763 let nu = resp.not_updated;
764 assert_eq!(
765 nu.get(&Id::from("ev1")).unwrap().error_type.to_string(),
766 "stateMismatch"
767 );
768 }
769
770 #[test]
771 fn set_error_full_8_fields_round_trip() {
772 // §5.3 + RFC 8621 §5.5/§5.7/§7.5: SetError carries up to 8 fields
773 // depending on the error type. The client side must preserve all
774 // of them on deserialize, otherwise callers cannot recover the
775 // existingId / blobNotFound / recipient-list payload that the
776 // server relied on to make the error actionable.
777 let raw = json!({
778 "type": "alreadyExists",
779 "description": "conflict",
780 "properties": ["name"],
781 "existingId": "obj-7",
782 "notFound": ["blob-1", "blob-2"],
783 "maxRecipients": 50,
784 "invalidRecipients": ["bad@", "no@no"],
785 "maxSize": 10485760
786 });
787 let err = SetError::deserialize(&raw).unwrap();
788 assert_eq!(err.error_type, SetErrorType::AlreadyExists);
789 assert_eq!(err.description.as_deref(), Some("conflict"));
790 assert_eq!(err.properties.as_ref().unwrap()[0], "name");
791 assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
792 assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
793 assert_eq!(err.max_recipients, Some(50));
794 assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
795 assert_eq!(err.max_size, Some(10_485_760));
796 // Round-trip preserves wire field names.
797 let back = serde_json::to_value(&err).unwrap();
798 assert_eq!(back, raw);
799 }
800
801 #[test]
802 fn set_error_minimal_omits_optional_fields_on_serialize() {
803 // §5.3: only `type` is required. Optional fields MUST be omitted
804 // (not serialized as `null`) so the wire matches the server's
805 // construction shape exactly.
806 let err = SetError::new("forbidden");
807 let json = serde_json::to_value(&err).unwrap();
808 assert_eq!(json["type"], "forbidden");
809 assert!(json.get("description").is_none());
810 assert!(json.get("properties").is_none());
811 assert!(json.get("existingId").is_none());
812 assert!(json.get("notFound").is_none());
813 assert!(json.get("maxRecipients").is_none());
814 assert!(json.get("invalidRecipients").is_none());
815 assert!(json.get("maxSize").is_none());
816 // Empty extra map must not appear at all in the wire output.
817 let obj = json.as_object().unwrap();
818 assert_eq!(
819 obj.len(),
820 1,
821 "minimal SetError must serialize to exactly {{type}}: {json}"
822 );
823 }
824
825 #[test]
826 fn set_error_extension_fields_round_trip_via_extra() {
827 // JMAP Chat's serverRetryAfter is a per-extension SetError field
828 // that must round-trip losslessly through extra without the base
829 // type knowing about it. Pin both directions.
830 let raw = json!({
831 "type": "rateLimited",
832 "description": "slow-mode active",
833 "serverRetryAfter": "2026-01-01T00:00:00Z"
834 });
835 let err = SetError::deserialize(&raw).unwrap();
836 assert_eq!(err.error_type.to_string(), "rateLimited");
837 assert_eq!(err.description.as_deref(), Some("slow-mode active"));
838 assert_eq!(
839 err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
840 Some("2026-01-01T00:00:00Z"),
841 "extension field must land in extra map: {err:?}"
842 );
843 let back = serde_json::to_value(&err).unwrap();
844 assert_eq!(back, raw, "round-trip must preserve extension field");
845 }
846
847 #[test]
848 fn set_error_extension_type_round_trips() {
849 // Extension errors (e.g. calendars draft §10.7.2) MUST round-trip
850 // through SetErrorType::Other without a new variant being added.
851 let err = SetError::new("noSupportedScheduleMethods");
852 let json = serde_json::to_value(&err).unwrap();
853 assert_eq!(json["type"], "noSupportedScheduleMethods");
854 let back: SetError = serde_json::from_value(json).unwrap();
855 assert_eq!(
856 back.error_type,
857 SetErrorType::Other("noSupportedScheduleMethods".to_owned())
858 );
859 }
860
861 #[test]
862 fn set_error_display_with_description() {
863 let err = SetError {
864 error_type: SetErrorType::Forbidden,
865 description: Some("not your calendar".to_owned()),
866 ..SetError::new("forbidden")
867 };
868 assert_eq!(err.to_string(), "forbidden: not your calendar");
869 }
870
871 #[test]
872 fn set_error_display_without_description() {
873 let err = SetError::new("forbidden");
874 assert_eq!(err.to_string(), "forbidden");
875 }
876
877 #[test]
878 fn query_response_round_trips() {
879 let raw = json!({
880 "accountId": "A1",
881 "queryState": "qs1",
882 "canCalculateChanges": true,
883 "position": 0,
884 "ids": ["a", "b", "c"],
885 "total": 3,
886 "limit": 100
887 });
888 let resp: QueryResponse = serde_json::from_value(raw).unwrap();
889 assert_eq!(resp.query_state, "qs1");
890 assert!(resp.can_calculate_changes);
891 assert_eq!(resp.ids.len(), 3);
892 assert_eq!(resp.total, Some(3));
893 assert_eq!(resp.limit, Some(100));
894 }
895
896 #[test]
897 fn query_response_omits_optional_total_and_limit() {
898 let raw = json!({
899 "accountId": "A1",
900 "queryState": "qs1",
901 "canCalculateChanges": false,
902 "position": 0,
903 "ids": [],
904 "total": null,
905 "limit": null
906 });
907 let resp: QueryResponse = serde_json::from_value(raw).unwrap();
908 assert!(resp.total.is_none());
909 assert!(resp.limit.is_none());
910 }
911
912 #[test]
913 fn query_changes_response_round_trips() {
914 let raw = json!({
915 "accountId": "A1",
916 "oldQueryState": "qs0",
917 "newQueryState": "qs1",
918 "total": 5,
919 "removed": ["x"],
920 "added": [
921 {"id": "y", "index": 2}
922 ]
923 });
924 let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
925 assert_eq!(resp.old_query_state, "qs0");
926 assert_eq!(resp.new_query_state, "qs1");
927 assert_eq!(resp.total, Some(5));
928 assert_eq!(resp.removed[0].as_ref(), "x");
929 assert_eq!(resp.added.len(), 1);
930 assert_eq!(resp.added[0].id.as_ref(), "y");
931 assert_eq!(resp.added[0].index, 2);
932 }
933
934 #[test]
935 fn added_item_round_trips() {
936 let raw = json!({"id": "foo", "index": 7});
937 let item = AddedItem::deserialize(&raw).unwrap();
938 assert_eq!(item.id.as_ref(), "foo");
939 assert_eq!(item.index, 7);
940 assert_eq!(serde_json::to_value(&item).unwrap(), raw);
941 }
942
943 // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
944 //
945 // One round-trip preservation test per migrated type. Each test
946 // asserts that an unknown vendor / site / private-extension field
947 // survives deserialize/serialize unchanged. Per workspace
948 // AGENTS.md "Extras-preservation policy for vendor/site fields".
949
950 /// `GetResponse.extra` captures vendor fields and preserves them on
951 /// re-serialize.
952 #[test]
953 fn get_response_preserves_vendor_extras() {
954 let raw = json!({
955 "accountId": "A1",
956 "state": "s1",
957 "list": [],
958 "notFound": null,
959 "acmeCorpAuditTrail": {"sequence": 42}
960 });
961 let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
962 assert_eq!(
963 resp.extra
964 .get("acmeCorpAuditTrail")
965 .and_then(|v| v["sequence"].as_u64()),
966 Some(42),
967 "vendor field must land in extra: {:?}",
968 resp.extra
969 );
970 let back = serde_json::to_value(&resp).unwrap();
971 assert_eq!(
972 back["acmeCorpAuditTrail"]["sequence"], 42,
973 "vendor field must survive serialize: {back}"
974 );
975 }
976
977 /// `ChangesResponse.extra` captures vendor fields and preserves them.
978 #[test]
979 fn changes_response_preserves_vendor_extras() {
980 let raw = json!({
981 "accountId": "A1",
982 "oldState": "s0",
983 "newState": "s1",
984 "hasMoreChanges": false,
985 "created": [],
986 "updated": [],
987 "destroyed": [],
988 "acmeCorpReplayToken": "rt-99"
989 });
990 let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
991 assert_eq!(
992 resp.extra
993 .get("acmeCorpReplayToken")
994 .and_then(|v| v.as_str()),
995 Some("rt-99")
996 );
997 let back = serde_json::to_value(&resp).unwrap();
998 assert_eq!(back["acmeCorpReplayToken"], "rt-99");
999 }
1000
1001 /// Pins the `#[serde(flatten)] extra` interaction with the typed
1002 /// `updated_properties: Option<Vec<String>>` field on `ChangesResponse`:
1003 ///
1004 /// - Wire `"updatedProperties": null` MUST be consumed by the typed
1005 /// field (deserialized as `None`) and MUST NOT leak into `extra`.
1006 /// - Vendor fields at the top level — including ones whose value is
1007 /// a nested object — MUST land in `extra` and survive round-trip.
1008 ///
1009 /// Without this regression test, a future serde or `#[serde(flatten)]`
1010 /// behavior change could silently move the null-valued typed key into
1011 /// `extra` (or fail to consume it from `extra` on the serialize side),
1012 /// breaking the wire-format contract for any caller relying on either
1013 /// the absence of `updatedProperties` from `extra` after parse or on
1014 /// vendor extras being preserved alongside an explicit null. Filed
1015 /// under bd:JMAP-6xs8.8.
1016 #[test]
1017 fn changes_response_null_updated_properties_and_extras_coexist() {
1018 let raw = json!({
1019 "accountId": "A1",
1020 "oldState": "s0",
1021 "newState": "s1",
1022 "hasMoreChanges": false,
1023 "created": [],
1024 "updated": ["B"],
1025 "destroyed": [],
1026 "updatedProperties": null,
1027 "acmeCorpReplayToken": "rt-99",
1028 "acmeCorpMetadata": { "requestId": "r1", "trace": "x" }
1029 });
1030
1031 let resp: ChangesResponse = serde_json::from_value(raw.clone()).expect("must deserialize");
1032
1033 // The typed field consumed the explicit null.
1034 assert_eq!(
1035 resp.updated_properties,
1036 Some(None),
1037 "explicit null updatedProperties must deserialize as Some(None)"
1038 );
1039
1040 // The typed key MUST NOT have leaked into `extra` — flatten is
1041 // supposed to visit only the keys the named fields did not
1042 // consume, regardless of whether the consumed value was null.
1043 assert!(
1044 !resp.extra.contains_key("updatedProperties"),
1045 "updatedProperties must not appear in extra after the typed \
1046 field consumed it (was: {:?})",
1047 resp.extra
1048 );
1049
1050 // Top-level vendor fields land in `extra`, including one whose
1051 // value is itself a nested object.
1052 assert_eq!(
1053 resp.extra
1054 .get("acmeCorpReplayToken")
1055 .and_then(|v| v.as_str()),
1056 Some("rt-99")
1057 );
1058 let nested = resp
1059 .extra
1060 .get("acmeCorpMetadata")
1061 .and_then(|v| v.as_object())
1062 .expect("acmeCorpMetadata must be a nested object in extra");
1063 assert_eq!(nested.get("requestId").and_then(|v| v.as_str()), Some("r1"));
1064 assert_eq!(nested.get("trace").and_then(|v| v.as_str()), Some("x"));
1065
1066 // Round-trip: serialize and reparse, all three properties
1067 // (null updatedProperties, two vendor extras) must survive.
1068 let back = serde_json::to_value(&resp).expect("must serialize");
1069
1070 // draft-ietf-jmap-metadata-04 §3.3: updatedProperties "is always
1071 // present, and is null when updated is empty", so an explicit
1072 // null is re-emitted rather than dropped.
1073 assert_eq!(
1074 back.get("updatedProperties"),
1075 Some(&serde_json::Value::Null),
1076 "explicit null updatedProperties must serialize as null: {back}"
1077 );
1078 assert_eq!(back["acmeCorpReplayToken"], "rt-99");
1079 assert_eq!(back["acmeCorpMetadata"]["requestId"], "r1");
1080 assert_eq!(back["acmeCorpMetadata"]["trace"], "x");
1081
1082 // Reparse the serialized form and confirm equivalence on the
1083 // typed surface + extras.
1084 let resp2: ChangesResponse = serde_json::from_value(back).expect("reparse must succeed");
1085 assert_eq!(resp2.updated_properties, Some(None));
1086 assert_eq!(resp2.extra, resp.extra);
1087 }
1088
1089 /// `SetResponse.extra` captures vendor fields and preserves them.
1090 #[test]
1091 fn set_response_preserves_vendor_extras() {
1092 let raw = json!({
1093 "accountId": "A1",
1094 "oldState": "s1",
1095 "newState": "s2",
1096 "acmeCorpTransactionId": "txn-abc"
1097 });
1098 let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
1099 assert_eq!(
1100 resp.extra
1101 .get("acmeCorpTransactionId")
1102 .and_then(|v| v.as_str()),
1103 Some("txn-abc")
1104 );
1105 let back = serde_json::to_value(&resp).unwrap();
1106 assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
1107 }
1108
1109 /// `QueryResponse.extra` captures vendor fields and preserves them.
1110 #[test]
1111 fn query_response_preserves_vendor_extras() {
1112 let raw = json!({
1113 "accountId": "A1",
1114 "queryState": "qs1",
1115 "canCalculateChanges": false,
1116 "position": 0,
1117 "ids": [],
1118 "total": null,
1119 "limit": null,
1120 "acmeCorpSearchTimingMs": 17
1121 });
1122 let resp: QueryResponse = serde_json::from_value(raw).unwrap();
1123 assert_eq!(
1124 resp.extra
1125 .get("acmeCorpSearchTimingMs")
1126 .and_then(|v| v.as_u64()),
1127 Some(17)
1128 );
1129 let back = serde_json::to_value(&resp).unwrap();
1130 assert_eq!(back["acmeCorpSearchTimingMs"], 17);
1131 }
1132
1133 /// `QueryChangesResponse.extra` captures vendor fields and preserves them.
1134 #[test]
1135 fn query_changes_response_preserves_vendor_extras() {
1136 let raw = json!({
1137 "accountId": "A1",
1138 "oldQueryState": "qs0",
1139 "newQueryState": "qs1",
1140 "total": null,
1141 "removed": [],
1142 "added": [],
1143 "acmeCorpDeltaToken": "dt-2"
1144 });
1145 let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
1146 assert_eq!(
1147 resp.extra
1148 .get("acmeCorpDeltaToken")
1149 .and_then(|v| v.as_str()),
1150 Some("dt-2")
1151 );
1152 let back = serde_json::to_value(&resp).unwrap();
1153 assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
1154 }
1155
1156 /// `AddedItem.extra` captures vendor fields and preserves them.
1157 #[test]
1158 fn added_item_preserves_vendor_extras() {
1159 let raw = json!({
1160 "id": "x",
1161 "index": 0,
1162 "acmeCorpHighlight": true
1163 });
1164 let item = AddedItem::deserialize(&raw).unwrap();
1165 assert_eq!(
1166 item.extra
1167 .get("acmeCorpHighlight")
1168 .and_then(|v| v.as_bool()),
1169 Some(true)
1170 );
1171 let back = serde_json::to_value(&item).unwrap();
1172 assert_eq!(back["acmeCorpHighlight"], true);
1173 }
1174
1175 /// Empty extras must NOT serialize as a key on the wire — the
1176 /// `skip_serializing_if = "serde_json::Map::is_empty"` attribute keeps
1177 /// the wire shape byte-identical to the pre-migration form when no
1178 /// vendor fields are present.
1179 #[test]
1180 fn empty_extras_omitted_from_wire() {
1181 let resp = AddedItem {
1182 id: Id::from("z"),
1183 index: 1,
1184 extra: serde_json::Map::new(),
1185 };
1186 let serialized = serde_json::to_value(&resp).expect("must serialize");
1187 let obj = serialized.as_object().expect("must be object");
1188 assert_eq!(
1189 obj.len(),
1190 2,
1191 "empty extras must not add any wire keys; got {serialized}"
1192 );
1193 assert!(obj.contains_key("id"));
1194 assert!(obj.contains_key("index"));
1195 }
1196
1197 // Oracle: workspace map-ordering policy (bd:JMAP-pdux) — wire-format
1198 // maps serialize in sorted key order. Input keys are written out of
1199 // order; expected bytes are hand-written.
1200 #[test]
1201 fn set_response_maps_serialize_in_sorted_key_order() {
1202 let raw = r#"{
1203 "accountId": "a1",
1204 "newState": "s2",
1205 "created": {"k2": {"id": "id2"}, "k1": {"id": "id1"}},
1206 "notUpdated": {"id9": {"type": "notFound"}, "id8": {"type": "notFound"}}
1207 }"#;
1208 let resp: SetResponse<serde_json::Value> = serde_json::from_str(raw).expect("deserialize");
1209 let j = serde_json::to_string(&resp).expect("serialize");
1210 assert!(
1211 j.contains(r#""created":{"k1":{"id":"id1"},"k2":{"id":"id2"}}"#),
1212 "created keys must be sorted: {j}"
1213 );
1214 assert!(
1215 j.contains(r#""notUpdated":{"id8":{"type":"notFound"},"id9":{"type":"notFound"}}"#),
1216 "notUpdated keys must be sorted: {j}"
1217 );
1218 }
1219
1220 /// RFC 8620 §5.3: `created` holds only "properties of the created Foo
1221 /// object that were not sent by the client". Oracle: the Email/set
1222 /// response in RFC 8621 §7 (page 66), `...` elision removed. Its
1223 /// `created` entry has no `mailboxIds`, `keywords` etc., so it would
1224 /// fail to parse as a full Email; it must parse as [`PartialObject`].
1225 #[test]
1226 fn set_response_partial_object_parses_rfc8621_created() {
1227 let raw = json!({
1228 "accountId": "ue150411c",
1229 "oldState": "780839",
1230 "newState": "780842",
1231 "created": {
1232 "k1546": {
1233 "id": "Md45b47b4877521042cec0938",
1234 "blobId": "Ge8de6c9f6de198239b982ea214e0f3a704e4af74",
1235 "threadId": "Td957e72e89f516dc",
1236 "size": 11721
1237 }
1238 },
1239 "updated": { "Mabc": null, "Mdef": { "size": 12 } },
1240 "destroyed": ["Mf40b5f831efa7233b9eb1c7f"]
1241 });
1242 let resp: SetResponse<PartialObject> = serde_json::from_value(raw).unwrap();
1243 let created = &resp.created;
1244 let k = &created["k1546"];
1245 assert_eq!(
1246 k.id.as_ref().map(|i| i.as_ref()),
1247 Some("Md45b47b4877521042cec0938")
1248 );
1249 assert_eq!(k.extra["threadId"], "Td957e72e89f516dc");
1250 assert_eq!(k.extra["size"], 11721);
1251 assert!(
1252 !k.extra.contains_key("id"),
1253 "id must not be duplicated in extra"
1254 );
1255 let updated = &resp.updated;
1256 assert_eq!(updated[&Id::from("Mabc")], None);
1257 let def = updated[&Id::from("Mdef")].as_ref().unwrap();
1258 assert_eq!(def.id, None);
1259 assert_eq!(def.extra["size"], 12);
1260 }
1261
1262 /// A created entry with no `id` (RFC 8620 §5.3: "such as the id in
1263 /// most object types", so not all) and with only client-omitted
1264 /// defaults must still parse, and serialize back without an `id` key.
1265 #[test]
1266 fn partial_object_without_id_round_trips() {
1267 let raw = json!({ "isSubscribed": false, "sortOrder": 0 });
1268 let p: PartialObject = serde_json::from_value(raw.clone()).unwrap();
1269 assert_eq!(p.id, None);
1270 assert_eq!(serde_json::to_value(&p).unwrap(), raw);
1271 }
1272
1273 /// RFC 8620 §5.4 response shape. Oracle: the Email/copy response in
1274 /// RFC 8621 §7 (page 67), verbatim.
1275 #[test]
1276 fn copy_response_parses_rfc8621_example() {
1277 let raw = json!({
1278 "fromAccountId": "ue150411c",
1279 "accountId": "u6c6c41ac",
1280 "oldState": "7ee7e9263a6d",
1281 "newState": "5a0d2447ed26",
1282 "created": {
1283 "k45": {
1284 "id": "M138f9954a5cd2423daeafa55",
1285 "blobId": "G6b9fb047cba722c48c611e79233d057c6b0b74e8",
1286 "threadId": "T2f242ea424a4079a",
1287 "size": 11721
1288 }
1289 },
1290 "notCreated": null
1291 });
1292 let resp: CopyResponse<PartialObject> = serde_json::from_value(raw).unwrap();
1293 assert_eq!(resp.from_account_id.as_ref(), "ue150411c");
1294 assert_eq!(resp.account_id.as_ref(), "u6c6c41ac");
1295 assert_eq!(
1296 resp.old_state.as_ref().map(|s| s.as_ref()),
1297 Some("7ee7e9263a6d")
1298 );
1299 assert_eq!(resp.new_state.as_ref(), "5a0d2447ed26");
1300 let k45 = &resp.created["k45"];
1301 assert_eq!(
1302 k45.id.as_ref().map(|i| i.as_ref()),
1303 Some("M138f9954a5cd2423daeafa55")
1304 );
1305 assert_eq!(
1306 k45.extra["blobId"],
1307 "G6b9fb047cba722c48c611e79233d057c6b0b74e8"
1308 );
1309 assert!(resp.not_created.is_empty());
1310 assert!(resp.extra.is_empty());
1311 }
1312
1313 /// RFC 8620 §5.3: `created` "is null if no Foo objects were
1314 /// successfully created", `destroyed` is "null if none", and the
1315 /// `not*` maps are "null if none". Null and absent both mean none.
1316 #[test]
1317 fn set_response_null_and_absent_results_decode_empty() {
1318 let raw = json!({
1319 "accountId": "A1", "oldState": null, "newState": "s2",
1320 "created": null, "updated": null, "destroyed": null,
1321 "notCreated": null
1322 });
1323 let resp: SetResponse<PartialObject> = serde_json::from_value(raw).unwrap();
1324 assert!(resp.created.is_empty());
1325 assert!(resp.updated.is_empty());
1326 assert!(resp.destroyed.is_empty());
1327 assert!(resp.not_created.is_empty());
1328 assert!(resp.not_updated.is_empty());
1329 assert!(resp.not_destroyed.is_empty());
1330 }
1331
1332 /// RFC 8620 §5.3 / §5.4: a result field with no entries is `null` on
1333 /// the wire, never `{}` or `[]`.
1334 #[test]
1335 fn set_and_copy_responses_encode_empty_results_as_null() {
1336 let set = json!({
1337 "accountId": "A1", "oldState": "s1", "newState": "s2",
1338 "created": null, "updated": null, "destroyed": ["X1"],
1339 "notCreated": null, "notUpdated": null, "notDestroyed": null
1340 });
1341 let resp: SetResponse<PartialObject> = serde_json::from_value(set.clone()).unwrap();
1342 assert_eq!(serde_json::to_value(&resp).unwrap(), set);
1343
1344 let copy = json!({
1345 "fromAccountId": "A0", "accountId": "A1",
1346 "oldState": null, "newState": "s2",
1347 "created": null, "notCreated": null
1348 });
1349 let resp: CopyResponse<PartialObject> = serde_json::from_value(copy.clone()).unwrap();
1350 assert_eq!(serde_json::to_value(&resp).unwrap(), copy);
1351 }
1352
1353 /// RFC 8620 §5.4: `oldState` is "String|null" and `fromAccountId` is
1354 /// required; a response without it is not a /copy response.
1355 #[test]
1356 fn copy_response_requires_from_account_id() {
1357 let ok = json!({
1358 "fromAccountId": "A0", "accountId": "A1",
1359 "oldState": null, "newState": "s2",
1360 "notCreated": { "c1": { "type": "alreadyExists" } }
1361 });
1362 let resp: CopyResponse<PartialObject> = serde_json::from_value(ok).unwrap();
1363 assert!(resp.old_state.is_none());
1364 assert_eq!(
1365 resp.not_created["c1"].error_type,
1366 SetErrorType::AlreadyExists
1367 );
1368 let missing = json!({ "accountId": "A1", "newState": "s2" });
1369 assert!(serde_json::from_value::<CopyResponse<PartialObject>>(missing).is_err());
1370 }
1371}