jmap_metadata_client/methods/metadata.rs
1//! JMAP Metadata — Metadata/* method implementations on SessionClient
2//! (draft-ietf-jmap-metadata-01 §3).
3//!
4//! Each method follows the standard five-step pattern:
5//! 1. Validate arguments (defence-in-depth empty-state guards).
6//! 2. Call `self.session_parts()?` → `(api_url, account_id)`.
7//! 3. Build args JSON with `serde_json::json!({…})`.
8//! 4. Call `build_request(method_name, args, USING_METADATA)`.
9//! 5. Call `self.call_internal(api_url, &req).await?`.
10//! 6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
11
12use std::collections::HashMap;
13
14use jmap_types::{Id, PatchObject, State};
15
16use super::{
17 ChangesResponse, GetResponse, MetadataChangesParams, MetadataGetParams,
18 MetadataQueryChangesParams, MetadataQueryParams, MetadataSetParams, QueryChangesResponse,
19 QueryResponse, SetResponse,
20};
21
22/// Flatten extras from a params struct into the args object, preserving
23/// typed-field values on key collisions. Centralises the
24/// `Map::entry(...).or_insert(...)` pattern used by every method builder so
25/// the collision semantics (typed-field wins, extras lose silently) stay
26/// uniform across `Metadata/*`.
27///
28/// Expected `extras` size is small — vendor / site / private-extension args
29/// on a JMAP method call are typically zero (the common case) or a single-
30/// digit number of keys. The per-key hash lookup is O(1) average and the
31/// outer loop is bounded by the same small count; no allocation budget
32/// concern in practice. Callers that genuinely need to inject hundreds of
33/// extras keys per call should use the lower-level `build_request` directly.
34fn merge_extras(args: &mut serde_json::Value, extras: serde_json::Map<String, serde_json::Value>) {
35 let obj = args
36 .as_object_mut()
37 .expect("merge_extras: args is constructed as an Object");
38 for (k, v) in extras {
39 obj.entry(k).or_insert(v);
40 }
41}
42
43impl super::SessionClient {
44 /// Fetch Metadata objects by IDs (draft-ietf-jmap-metadata-01 §3.2).
45 ///
46 /// If `ids` is `None`, the server returns all Metadata objects for
47 /// the account, SUBJECT TO the server's `maxObjectsInGet` cap (RFC
48 /// 8620 §5.1). For production use, scope the result set via
49 /// [`Self::metadata_query`] first and pass explicit ids here to
50 /// avoid `requestTooLarge` errors when the account holds more
51 /// objects than the cap.
52 ///
53 /// Pass `properties: None` to return all fields. Pass
54 /// `params: Some(MetadataGetParams { extra: ... })` to inject
55 /// vendor / site / private method-level extension args; pass
56 /// `None` (or `Some(Default::default())`) for the standard wire
57 /// shape.
58 ///
59 /// # Errors
60 ///
61 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
62 /// if the bound session has no primary account for
63 /// `urn:ietf:params:jmap:metadata`.
64 /// - Any transport / protocol variant returned by
65 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
66 /// [`Http`](jmap_base_client::ClientError::Http),
67 /// [`Parse`](jmap_base_client::ClientError::Parse),
68 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
69 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
70 /// (wraps RFC 8620 §3.6.2 method-level errors such as
71 /// `accountNotFound`, `invalidArguments`, `serverFail`),
72 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
73 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
74 /// or
75 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
76 pub async fn metadata_get(
77 &self,
78 ids: Option<&[Id]>,
79 properties: Option<&[&str]>,
80 params: Option<MetadataGetParams>,
81 ) -> Result<GetResponse<jmap_metadata_types::Metadata>, jmap_base_client::ClientError> {
82 let (api_url, account_id) = self.session_parts()?;
83 // Omit `ids` / `properties` entirely when None rather than sending
84 // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
85 // crate's other builders (set/changes/query) consistently use the
86 // conditional-add idiom; matching it here keeps the wire request
87 // canonical and avoids "present-but-null vs absent" interop quirks
88 // in proxies / audit loggers.
89 let mut args = serde_json::json!({ "accountId": account_id });
90 if let Some(id_slice) = ids {
91 args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
92 }
93 if let Some(props) = properties {
94 args["properties"] =
95 serde_json::to_value(props).expect("&[&str] Serialize is infallible");
96 }
97 if let Some(p) = params {
98 merge_extras(&mut args, p.extra);
99 }
100 let req = super::build_request("Metadata/get", args, super::USING_METADATA);
101 let resp = self.call_internal(api_url, &req).await?;
102 jmap_base_client::extract_response(&resp, super::CALL_ID)
103 }
104
105 /// Fetch changes to Metadata objects since `since_state`
106 /// (draft-ietf-jmap-metadata-01 §3.3).
107 ///
108 /// If `has_more_changes` is true in the response, call again with
109 /// `new_state` as `since_state` until the flag is false.
110 ///
111 /// `params` carries the Metadata-specific optional filters
112 /// `filterRelatedType` and `filterMetadataType`. Pass `None` (or
113 /// `Some(Default::default())`) to fetch unfiltered changes. Per §3.3,
114 /// clients that use these filters MUST re-use the same filter values
115 /// across subsequent calls to ensure consistent synchronisation,
116 /// because the returned state token represents the complete account
117 /// state and is independent of the filter selection.
118 ///
119 /// # Errors
120 ///
121 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
122 /// if `since_state` is the empty string (defence-in-depth — `State`
123 /// constructed via [`State::from`](jmap_types::State::from) accepts
124 /// empty strings, but an empty `sinceState` is never useful and
125 /// would otherwise generate a wasted round-trip producing a
126 /// server-side `cannotCalculateChanges`).
127 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
128 /// if the bound session has no primary account for
129 /// `urn:ietf:params:jmap:metadata`.
130 /// - Any transport / protocol variant returned by
131 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
132 /// the matching error list on [`Self::metadata_get`].
133 pub async fn metadata_changes(
134 &self,
135 since_state: &State,
136 max_changes: Option<u64>,
137 params: Option<MetadataChangesParams>,
138 ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
139 // Defence-in-depth: even with the typed-`State` parameter (a transparent
140 // newtype around `String`), an empty state token is still a logically
141 // invalid value that should be caught client-side rather than producing
142 // a confusing server-side `cannotCalculateChanges` error.
143 if since_state.as_ref().is_empty() {
144 return Err(jmap_base_client::ClientError::InvalidArgument(
145 "metadata_changes: since_state may not be empty".into(),
146 ));
147 }
148 let (api_url, account_id) = self.session_parts()?;
149 let mut args = serde_json::json!({
150 "accountId": account_id,
151 "sinceState": since_state,
152 });
153 if let Some(mc) = max_changes {
154 args["maxChanges"] = mc.into();
155 }
156 if let Some(p) = params {
157 if let Some(v) = p.filter_related_type {
158 args["filterRelatedType"] = v.into();
159 }
160 if let Some(v) = p.filter_metadata_type {
161 args["filterMetadataType"] =
162 serde_json::to_value(&v).expect("Vec<String> Serialize is infallible");
163 }
164 // Vendor extras: flatten caller-supplied keys directly into the
165 // args object. Typed-field wire names (`filterRelatedType`,
166 // `filterMetadataType`, `accountId`, `sinceState`, `maxChanges`)
167 // win on collision — the helper uses `Map::entry(...).or_insert`.
168 merge_extras(&mut args, p.extra);
169 }
170 let req = super::build_request("Metadata/changes", args, super::USING_METADATA);
171 let resp = self.call_internal(api_url, &req).await?;
172 jmap_base_client::extract_response(&resp, super::CALL_ID)
173 }
174
175 /// Create, update, or destroy Metadata objects
176 /// (draft-ietf-jmap-metadata-01 §3.1).
177 ///
178 /// Pass `create`, `update`, and/or `destroy` as needed. All three are
179 /// optional; pass `None` to omit any operation from the request.
180 ///
181 /// `create` is `Option<serde_json::Value>` and MUST be a JSON object
182 /// whose keys are caller-chosen creation ids (RFC 8620 §5.3 — the
183 /// creation id strings may be referenced via the `#`-prefixed result-
184 /// reference syntax in RFC 8620 §3.7) and whose values are partial
185 /// Metadata objects per draft-ietf-jmap-metadata-01 §3.1 (one of the
186 /// three `@type` variants: `Annotation` per §2.1.1, `ImapMetadata`
187 /// per §2.1.2, `WebDavMetadata` per §2.1.3, each with at minimum
188 /// `relatedType`, `relatedId`, and an optional `isPrivate`). The
189 /// server assigns the final `id` and returns it under `created` in
190 /// the response. Wire-shape mismatches (e.g. passing an array or a
191 /// primitive) are rejected by the server as `invalidArguments` after
192 /// a network round-trip.
193 ///
194 /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
195 /// format is unchanged from a plain JSON object because [`PatchObject`]
196 /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
197 /// key + null-leaf removal contract to the type system.
198 ///
199 /// The asymmetry between typed `update` and untyped `create` is
200 /// deliberate: PatchObject is a stable workspace-defined type with one
201 /// JSON-Pointer-keyed shape, whereas a Metadata create object is one of
202 /// three `@type` variants and the spec accepts the full JSON wire
203 /// shape (including vendor-defined extras at every level). Forcing
204 /// `create` through a single Rust type would fight the spec's
205 /// extensibility model. Mirrors the canonical mail-client `email_set`
206 /// signature.
207 ///
208 /// Pass `if_in_state: Some(&state)` to use an optimistic-concurrency
209 /// guard (RFC 8620 §5.3): the server returns `stateMismatch` if the
210 /// current state does not match. Required for safe read-modify-write
211 /// given the `(relatedType, relatedId, @type, isPrivate)` uniqueness
212 /// invariant.
213 ///
214 /// Pass `params: Some(MetadataSetParams { extra: ... })` to inject
215 /// vendor / site / private method-level extension args; pass `None`
216 /// (or `Some(Default::default())`) for the standard wire shape.
217 ///
218 /// The server enforces uniqueness on the tuple
219 /// `(relatedType, relatedId, @type, isPrivate)` and may return
220 /// `alreadyExists`, `forbidden` (if `maySetPrivate: false` and
221 /// `isPrivate: true` is requested), `overQuota`, or
222 /// `invalidProperties` SetErrors per §3.1.
223 ///
224 /// # Errors
225 ///
226 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
227 /// if all three of `create`, `update`, and `destroy` are `None`
228 /// (an all-`None` /set is a no-op round-trip; `if_in_state` and
229 /// `params` alone are not sufficient to make the request useful,
230 /// so the client rejects it before sending).
231 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
232 /// if `serde_json::to_value` fails when serializing the `update`
233 /// map. In practice this can occur only under pathological
234 /// conditions (allocation failure on a very large `HashMap`; a
235 /// `PatchObject` whose internal JSON tree exceeds `serde_json`'s
236 /// recursion limit). The size of `update` is otherwise bounded
237 /// only by available memory; the wire request is buffered by the
238 /// HTTP client (reqwest's `RequestBuilder::json` serializes the
239 /// body upfront via `serde_json::to_vec`), so the transient peak
240 /// holds the source `HashMap`, the intermediate `serde_json::Value`
241 /// tree, and the serialized `Vec<u8>` body simultaneously —
242 /// roughly 3-4× the `HashMap`'s in-memory size. Callers dealing
243 /// with thousands of patches per call may prefer to batch across
244 /// multiple `metadata_set` invocations to bound that transient
245 /// peak.
246 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
247 /// if the bound session has no primary account for
248 /// `urn:ietf:params:jmap:metadata`.
249 /// - Any transport / protocol variant returned by
250 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
251 /// the matching error list on [`Self::metadata_get`]. Per-creation
252 /// /set failures (e.g. `alreadyExists`, `forbidden`, `overQuota`,
253 /// `invalidProperties`) appear in
254 /// [`SetResponse::not_created`](super::SetResponse::not_created) /
255 /// [`SetResponse::not_updated`](super::SetResponse::not_updated) /
256 /// [`SetResponse::not_destroyed`](super::SetResponse::not_destroyed)
257 /// as [`SetError`](super::SetError) values, not as `Err`. A
258 /// `stateMismatch` on `if_in_state` surfaces as
259 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
260 /// with `error_type == "stateMismatch"`.
261 pub async fn metadata_set(
262 &self,
263 create: Option<serde_json::Value>,
264 update: Option<HashMap<Id, PatchObject>>,
265 destroy: Option<Vec<Id>>,
266 if_in_state: Option<&State>,
267 params: Option<MetadataSetParams>,
268 ) -> Result<SetResponse<jmap_metadata_types::Metadata>, jmap_base_client::ClientError> {
269 if create.is_none() && update.is_none() && destroy.is_none() {
270 return Err(jmap_base_client::ClientError::InvalidArgument(
271 "metadata_set: at least one of create, update, destroy must be Some \
272 (an all-None /set is a no-op round-trip; if_in_state and params alone \
273 are not sufficient)"
274 .into(),
275 ));
276 }
277 let (api_url, account_id) = self.session_parts()?;
278 let mut args = serde_json::json!({
279 "accountId": account_id,
280 });
281 if let Some(s) = if_in_state {
282 args["ifInState"] = s.as_ref().into();
283 }
284 if let Some(c) = create {
285 args["create"] = c;
286 }
287 if let Some(u) = update {
288 args["update"] = serde_json::to_value(&u).map_err(|e| {
289 jmap_base_client::ClientError::InvalidArgument(format!(
290 "metadata_set: serializing update map failed: {e}"
291 ))
292 })?;
293 }
294 if let Some(d) = destroy {
295 args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
296 }
297 if let Some(p) = params {
298 merge_extras(&mut args, p.extra);
299 }
300 let req = super::build_request("Metadata/set", args, super::USING_METADATA);
301 let resp = self.call_internal(api_url, &req).await?;
302 jmap_base_client::extract_response(&resp, super::CALL_ID)
303 }
304
305 /// Query Metadata IDs with optional filter and sort
306 /// (draft-ietf-jmap-metadata-01 §3.4).
307 ///
308 /// Pass `filter: None` and `sort: None` to return all Metadata IDs with
309 /// server-default ordering. Use `position` and `limit` for pagination.
310 ///
311 /// The `filter` value is typed by the caller; the canonical type is
312 /// [`jmap_metadata_types::MetadataFilterCondition`] (§3.4.1) wrapped in
313 /// `jmap_types::query::Filter`. This builder accepts a `serde_json::Value`
314 /// so callers can mix filter conditions with `FilterOperator` algebra
315 /// without forcing a single type through the API.
316 ///
317 /// Construct a filter via `serde_json::to_value` on the typed shape, or
318 /// inline as a JSON literal:
319 ///
320 /// ```ignore
321 /// // Match Annotations on a specific Email.
322 /// let filter = serde_json::json!({
323 /// "relatedType": "Email",
324 /// "relatedId": "EM456",
325 /// "@type": "Annotation"
326 /// });
327 /// // Match Annotations on either of two Emails (FilterOperator algebra).
328 /// let filter = serde_json::json!({
329 /// "operator": "OR",
330 /// "conditions": [
331 /// { "relatedType": "Email", "relatedId": "EM456" },
332 /// { "relatedType": "Email", "relatedId": "EM789" }
333 /// ]
334 /// });
335 /// ```
336 ///
337 /// Sort comparators MUST set `property` to one of `id`, `@type`,
338 /// `relatedType`, `relatedId`, or `isPrivate` per §3.4.2; `id` MUST be
339 /// supported by every server, the others SHOULD be supported.
340 ///
341 /// Construct sort as an array of comparator objects:
342 ///
343 /// ```ignore
344 /// // Ascending by @type, then by id.
345 /// let sort = serde_json::json!([
346 /// { "property": "@type", "isAscending": true },
347 /// { "property": "id", "isAscending": true }
348 /// ]);
349 /// ```
350 ///
351 /// Pass `params: Some(MetadataQueryParams { extra: ... })` to inject
352 /// vendor / site / private method-level extension args; pass `None`
353 /// (or `Some(Default::default())`) for the standard wire shape.
354 ///
355 /// # Errors
356 ///
357 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
358 /// if the bound session has no primary account for
359 /// `urn:ietf:params:jmap:metadata`.
360 /// - Any transport / protocol variant returned by
361 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
362 /// the matching error list on [`Self::metadata_get`]. RFC 8620 §5.5
363 /// defines additional method-level errors specific to /query
364 /// (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
365 /// `tooManyChanges`); they surface here as
366 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
367 /// with the corresponding `error_type` string.
368 pub async fn metadata_query(
369 &self,
370 filter: Option<serde_json::Value>,
371 sort: Option<serde_json::Value>,
372 position: Option<u64>,
373 limit: Option<u64>,
374 params: Option<MetadataQueryParams>,
375 ) -> Result<QueryResponse, jmap_base_client::ClientError> {
376 let (api_url, account_id) = self.session_parts()?;
377 let mut args = serde_json::json!({
378 "accountId": account_id,
379 });
380 if let Some(f) = filter {
381 args["filter"] = f;
382 }
383 if let Some(s) = sort {
384 args["sort"] = s;
385 }
386 if let Some(p) = position {
387 args["position"] = p.into();
388 }
389 if let Some(l) = limit {
390 args["limit"] = l.into();
391 }
392 if let Some(p) = params {
393 merge_extras(&mut args, p.extra);
394 }
395 let req = super::build_request("Metadata/query", args, super::USING_METADATA);
396 let resp = self.call_internal(api_url, &req).await?;
397 jmap_base_client::extract_response(&resp, super::CALL_ID)
398 }
399
400 /// Fetch query-result changes for Metadata since `since_query_state`
401 /// (draft-ietf-jmap-metadata-01 §3.5).
402 ///
403 /// Returns which Metadata IDs were removed from or added to the query
404 /// result set since the given state. `max_changes` may be `None`.
405 ///
406 /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
407 /// original `Metadata/query` call that returned `since_query_state` —
408 /// RFC 8620 §5.6 is explicit that the server uses them to compute
409 /// which entries entered or left the result set.
410 ///
411 /// `up_to_id` is the highest-index id the client has cached;
412 /// `calculate_total` requests the new total result count.
413 ///
414 /// Pass `params: Some(MetadataQueryChangesParams { extra: ... })` to
415 /// inject vendor / site / private method-level extension args; pass
416 /// `None` (or `Some(Default::default())`) for the standard wire shape.
417 ///
418 /// # Errors
419 ///
420 /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
421 /// if `since_query_state` is the empty string (defence-in-depth
422 /// empty-state guard; see [`Self::metadata_changes`]).
423 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
424 /// if the bound session has no primary account for
425 /// `urn:ietf:params:jmap:metadata`.
426 /// - Any transport / protocol variant returned by
427 /// [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
428 /// the matching error list on [`Self::metadata_get`]. RFC 8620 §5.6
429 /// also defines `cannotCalculateChanges` (returned when the server
430 /// cannot honour the request given the supplied filter / sort);
431 /// it surfaces as
432 /// [`MethodError`](jmap_base_client::ClientError::MethodError).
433 #[allow(clippy::too_many_arguments)] // RFC 8620 §5.6 + extras struct
434 pub async fn metadata_query_changes(
435 &self,
436 since_query_state: &State,
437 max_changes: Option<u64>,
438 filter: Option<serde_json::Value>,
439 sort: Option<serde_json::Value>,
440 up_to_id: Option<&Id>,
441 calculate_total: Option<bool>,
442 params: Option<MetadataQueryChangesParams>,
443 ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
444 // Defence-in-depth: see `metadata_changes`.
445 if since_query_state.as_ref().is_empty() {
446 return Err(jmap_base_client::ClientError::InvalidArgument(
447 "metadata_query_changes: since_query_state may not be empty".into(),
448 ));
449 }
450 let (api_url, account_id) = self.session_parts()?;
451 let mut args = serde_json::json!({
452 "accountId": account_id,
453 "sinceQueryState": since_query_state,
454 });
455 if let Some(f) = filter {
456 args["filter"] = f;
457 }
458 if let Some(s) = sort {
459 args["sort"] = s;
460 }
461 if let Some(mc) = max_changes {
462 args["maxChanges"] = mc.into();
463 }
464 if let Some(uti) = up_to_id {
465 args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
466 }
467 if let Some(ct) = calculate_total {
468 args["calculateTotal"] = ct.into();
469 }
470 if let Some(p) = params {
471 merge_extras(&mut args, p.extra);
472 }
473 let req = super::build_request("Metadata/queryChanges", args, super::USING_METADATA);
474 let resp = self.call_internal(api_url, &req).await?;
475 jmap_base_client::extract_response(&resp, super::CALL_ID)
476 }
477}
478
479// ---------------------------------------------------------------------------
480// Tests
481// ---------------------------------------------------------------------------
482
483#[cfg(test)]
484mod tests {
485 use serde_json::json;
486
487 // Production-path coverage lives in tests/metadata_tests.rs
488 // (wiremock-backed end-to-end). The tests here exercise the typed
489 // wire-shape oracles for response types using JSON literals taken
490 // from draft-ietf-jmap-metadata-01 and RFC 8620.
491
492 /// Oracle: Annotation deserialization composed from
493 /// draft-ietf-jmap-metadata-01 §7.5 ("Creating an Email with Annotation
494 /// Atomically"). The `id`, `relatedType`, and `relatedId` fields are
495 /// verbatim from the server's `Metadata/set` response object at
496 /// draft §7.5 lines 1591-1597 (id=MD789, relatedType=Email,
497 /// relatedId=EM456). The `isPrivate: true` and
498 /// `acme.example.com:workflowState: "pending-review"` fields are
499 /// verbatim from the client's `onSuccessCreateMetadata` Annotation
500 /// object earlier in the same §7.5 example (lines 1540-1544). The
501 /// test merges the two into a single deserialization fixture for the
502 /// post-creation server view.
503 #[test]
504 fn annotation_deserializes_from_spec_example() {
505 let json = json!({
506 "@type": "Annotation",
507 "id": "MD789",
508 "relatedType": "Email",
509 "relatedId": "EM456",
510 "isPrivate": true,
511 "acme.example.com:workflowState": "pending-review"
512 });
513 let meta: jmap_metadata_types::Metadata =
514 serde_json::from_value(json).expect("Annotation must deserialize");
515 let jmap_metadata_types::Metadata::Annotation(a) = meta else {
516 panic!("expected Annotation variant");
517 };
518 assert_eq!(a.related_type.as_deref(), Some("Email"));
519 assert_eq!(a.related_id.as_ref(), "EM456");
520 assert_eq!(a.is_private, Some(true));
521 assert_eq!(
522 a.extra
523 .get("acme.example.com:workflowState")
524 .and_then(|v| v.as_str()),
525 Some("pending-review")
526 );
527 }
528
529 /// Oracle: GetResponse<Metadata> deserializes from RFC 8620 §5.1 shape.
530 #[test]
531 fn get_response_metadata_deserializes() {
532 use super::super::GetResponse;
533
534 let json = json!({
535 "accountId": "acc1",
536 "state": "s42",
537 "list": [
538 {
539 "@type": "Annotation",
540 "id": "MD1",
541 "relatedType": "Mailbox",
542 "relatedId": "MB1",
543 "isPrivate": false,
544 "acme.example.com:color": "blue"
545 }
546 ],
547 "notFound": []
548 });
549 let resp: GetResponse<jmap_metadata_types::Metadata> =
550 serde_json::from_value(json).expect("GetResponse<Metadata> must deserialize");
551 assert_eq!(resp.account_id, "acc1");
552 assert_eq!(resp.state, "s42");
553 assert_eq!(resp.list.len(), 1);
554 assert_eq!(resp.list[0].related_type(), Some("Mailbox"));
555 }
556
557 /// Oracle: SetResponse<Metadata> deserializes with a created entry.
558 #[test]
559 fn set_response_metadata_with_created_deserializes() {
560 use super::super::SetResponse;
561
562 let json = json!({
563 "accountId": "acc1",
564 "oldState": "s1",
565 "newState": "s2",
566 "created": {
567 "newMeta": {
568 "@type": "Annotation",
569 "id": "server-md-id",
570 "relatedType": "Email",
571 "relatedId": "EM1",
572 "isPrivate": false,
573 "acme.example.com:tag": "important"
574 }
575 },
576 "updated": null,
577 "destroyed": null,
578 "notCreated": null,
579 "notUpdated": null,
580 "notDestroyed": null
581 });
582 let resp: SetResponse<jmap_metadata_types::Metadata> =
583 serde_json::from_value(json).expect("SetResponse<Metadata> must deserialize");
584 assert_eq!(resp.new_state, "s2");
585 let created = resp.created.expect("created must be present");
586 assert!(
587 created.contains_key("newMeta"),
588 "created must contain 'newMeta' key"
589 );
590 let meta = &created["newMeta"];
591 assert_eq!(meta.id().map(|id| id.as_ref()), Some("server-md-id"));
592 assert_eq!(meta.related_type(), Some("Email"));
593 }
594}