use jmap_base_client::auth::NoneAuth;
use jmap_base_client::client::JmapClient;
use jmap_base_client::error::ClientError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn session_fixture() -> serde_json::Value {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/jmap/session.json"),
)
.expect("cannot read session.json fixture");
serde_json::from_str(&text).expect("session.json must be valid JSON")
}
fn call_response_fixture() -> serde_json::Value {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/jmap/call_response.json"),
)
.expect("cannot read call_response.json fixture");
serde_json::from_str(&text).expect("call_response.json must be valid JSON")
}
fn minimal_request() -> jmap_types::JmapRequest {
jmap_types::JmapRequest::new(
vec!["urn:ietf:params:jmap:core".to_owned()],
vec![(
"Mailbox/get".to_owned(),
serde_json::json!({"accountId": "A13824", "ids": null}),
"r1".to_owned(),
)],
None,
)
}
#[test]
fn test_new_rejects_empty_url() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"empty base_url must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_ftp_scheme() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"ftp://example.com",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"ftp scheme must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_url_with_path() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com/jmap",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"base_url with path must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_accepts_https_origin() {
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
jmap_base_client::client::ClientConfig::default(),
)
.map(|_| ());
assert!(result.is_ok(), "valid https origin must be accepted");
}
#[test]
fn test_new_rejects_zero_request_timeout() {
let mut config = jmap_base_client::client::ClientConfig::default();
config.request_timeout = std::time::Duration::ZERO;
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
config,
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"request_timeout == Duration::ZERO must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_zero_max_call_body() {
let mut config = jmap_base_client::client::ClientConfig::default();
config.max_call_body = 0;
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
config,
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"max_call_body == 0 must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_new_rejects_zero_max_ws_message() {
let mut config = jmap_base_client::client::ClientConfig::default();
config.max_ws_message = 0;
let result = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
"https://example.com",
config,
)
.map(|_| ());
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"max_ws_message == 0 must return InvalidArgument, got {result:?}"
);
}
#[test]
fn test_default_max_ws_message_is_1mib() {
let cfg = jmap_base_client::client::ClientConfig::default();
assert_eq!(cfg.max_ws_message, 1024 * 1024);
assert_eq!(cfg.max_sse_frame, 1024 * 1024);
}
#[tokio::test]
async fn test_connect_ws_with_limit_rejects_zero_max_message() {
let result = jmap_base_client::ws::connect_ws_with_limit("ws://localhost/", None, 0).await;
match result {
Err(jmap_base_client::ClientError::InvalidArgument(msg)) => {
assert!(
msg.contains("max_message_bytes"),
"error message must mention 'max_message_bytes': {msg}"
);
}
other => panic!("expected InvalidArgument(\"...max_message_bytes...\"), got {other:?}"),
}
}
#[tokio::test]
async fn test_fetch_session_returns_session() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(session_fixture()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let session = client
.fetch_session()
.await
.expect("fetch_session must succeed");
assert_eq!(session.username, "john@example.com");
assert_eq!(session.api_url, "https://jmap.example.com/api/");
assert_eq!(
session.upload_url,
"https://jmap.example.com/upload/{accountId}/"
);
assert_eq!(
session.download_url,
"https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
);
assert_eq!(
session.event_source_url,
"https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
);
assert_eq!(session.state, "75128aab4b1b");
assert!(
session.accounts.contains_key("A13824"),
"accounts must contain A13824"
);
}
#[tokio::test]
async fn test_fetch_session_size_cap() {
let oversized_body = "x".repeat(1024 * 1024 + 1);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client
.fetch_session()
.await
.expect_err("oversized response must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_fetch_session_401_returns_auth_failed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client.fetch_session().await.expect_err("401 must fail");
assert!(
matches!(err, ClientError::AuthFailed(401)),
"expected AuthFailed(401), got {err:?}"
);
}
#[tokio::test]
async fn test_fetch_session_rejects_non_http_api_url() {
let server = MockServer::start().await;
let mut body = session_fixture();
body["apiUrl"] = serde_json::Value::String("ftp://example.com/api/".to_owned());
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client
.fetch_session()
.await
.expect_err("ftp apiUrl must fail");
assert!(
matches!(err, ClientError::InvalidSession(_)),
"expected InvalidSession for ftp apiUrl, got {err:?}"
);
}
#[tokio::test]
async fn test_fetch_session_rejects_non_http_other_urls() {
for field in &["uploadUrl", "downloadUrl", "eventSourceUrl"] {
let server = MockServer::start().await;
let mut body = session_fixture();
body[*field] = serde_json::Value::String("ftp://example.com/bad".to_owned());
Mock::given(method("GET"))
.and(path("/.well-known/jmap"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let err = client
.fetch_session()
.await
.expect_err(&format!("ftp {field} must fail"));
assert!(
matches!(err, ClientError::InvalidSession(_)),
"expected InvalidSession for ftp {field}, got {err:?}"
);
}
}
#[tokio::test]
async fn test_call_round_trip() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(200).set_body_json(call_response_fixture()))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let resp = client
.call(&api_url, &minimal_request())
.await
.expect("call must succeed");
assert_eq!(resp.session_state, "sess1");
assert_eq!(resp.method_responses.len(), 1);
assert_eq!(resp.method_responses[0].0, "Mailbox/get");
assert_eq!(resp.method_responses[0].2, "r1");
}
#[tokio::test]
async fn test_call_size_cap() {
let oversized_body = "x".repeat(8 * 1024 * 1024 + 1);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let err = client
.call(&api_url, &minimal_request())
.await
.expect_err("oversized response must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_call_401_returns_auth_failed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let api_url = format!("{}/api/", server.uri());
let err = client
.call(&api_url, &minimal_request())
.await
.expect_err("401 must fail");
assert!(
matches!(err, ClientError::AuthFailed(401)),
"expected AuthFailed(401), got {err:?}"
);
}
#[tokio::test]
async fn test_upload_blob_response_size_cap() {
let oversized_body = "x".repeat(1024 * 1024 + 1);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/upload/account1/"))
.respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template = format!("{}/upload/{{accountId}}/", server.uri());
let err = client
.upload_blob(
&template,
"account1",
bytes::Bytes::from(b"hello".to_vec()),
"application/octet-stream",
)
.await
.expect_err("oversized upload response must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_upload_blob_rejects_size_mismatch() {
let server = MockServer::start().await;
let buggy_resp =
r#"{"accountId":"account1","blobId":"B1","type":"application/octet-stream","size":0}"#;
Mock::given(method("POST"))
.and(path("/upload/account1/"))
.respond_with(ResponseTemplate::new(200).set_body_string(buggy_resp))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template = format!("{}/upload/{{accountId}}/", server.uri());
let err = client
.upload_blob(
&template,
"account1",
bytes::Bytes::from(b"hello".to_vec()), "application/octet-stream",
)
.await
.expect_err("size mismatch must surface as an error");
match err {
ClientError::UnexpectedResponse(msg) => {
assert!(
msg.contains("size mismatch"),
"error must mention 'size mismatch': {msg}"
);
assert!(msg.contains("5"), "error must mention client size 5: {msg}");
assert!(msg.contains("0"), "error must mention server size 0: {msg}");
}
other => panic!("expected UnexpectedResponse, got {other:?}"),
}
}
#[tokio::test]
async fn test_download_blob_size_cap() {
let oversized_body = vec![b'x'; 64 * 1024 * 1024 + 1];
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/download/account1/blob-abc/file.bin"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(oversized_body))
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let template = format!(
"{}/download/{{accountId}}/{{blobId}}/{{name}}",
server.uri()
);
let err = client
.download_blob(jmap_base_client::DownloadBlobParams {
download_url_template: &template,
account_id: "account1",
blob_id: "blob-abc",
name: "file.bin",
accept_type: None,
expected_sha256: None,
})
.await
.expect_err("oversized download must fail");
assert!(
matches!(err, ClientError::ResponseTooLarge { .. }),
"expected ResponseTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn test_subscribe_events_crlf_line_endings() {
use futures::StreamExt as _;
use jmap_base_client::sse::SseEvent;
let crlf_body = "event: state\r\ndata: {\"changed\":{}}\r\n\r\n";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(crlf_body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let mut stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must succeed");
let frame = stream
.next()
.await
.expect("stream must yield at least one frame")
.expect("frame must not be an error");
assert!(
matches!(frame.event, SseEvent::StateChange(_)),
"CRLF-terminated state event must parse as StateChange, got {:?}",
frame.event
);
}
#[tokio::test]
async fn test_subscribe_events_lf_crlf_frame_delimiter() {
use futures::StreamExt as _;
use jmap_base_client::sse::SseEvent;
let body = "event: state\ndata: {\"changed\":{}}\n\r\n";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let mut stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must succeed");
let frame = stream
.next()
.await
.expect("stream must yield at least one frame")
.expect("frame must not be an error");
assert!(
matches!(frame.event, SseEvent::StateChange(_)),
"LF+CRLF-terminated state event must parse as StateChange, got {:?}",
frame.event
);
}
#[tokio::test]
async fn test_subscribe_events_cr_line_endings() {
use futures::StreamExt as _;
use jmap_base_client::sse::SseEvent;
let cr_body = "event: state\rdata: {\"changed\":{}}\r\r";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_bytes(cr_body.as_bytes().to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let mut stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must succeed");
let frame = stream
.next()
.await
.expect("stream must yield at least one frame")
.expect("frame must not be an error");
assert!(
matches!(frame.event, SseEvent::StateChange(_)),
"CR-terminated state event must parse as StateChange, got {:?}",
frame.event
);
}
#[tokio::test]
async fn test_subscribe_events_rejects_wrong_content_type() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "application/json")
.set_body_bytes(b"{}".to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let result = client.subscribe_events(&event_url, None).await;
match result {
Ok(_) => panic!("wrong Content-Type must fail before streaming starts"),
Err(ref e) => assert!(
matches!(e, ClientError::UnexpectedResponse(_)),
"expected UnexpectedResponse for wrong Content-Type, got {e:?}"
),
}
}
#[tokio::test]
async fn test_subscribe_events_rejects_event_stream_suffix() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-streamish")
.set_body_bytes(b"data: hi\n\n".to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let result = client.subscribe_events(&event_url, None).await;
match result {
Ok(_) => panic!("text/event-streamish must be rejected, not silently accepted"),
Err(ref e) => assert!(
matches!(e, ClientError::UnexpectedResponse(_)),
"expected UnexpectedResponse for streamish suffix, got {e:?}"
),
}
}
#[tokio::test]
async fn test_subscribe_events_accepts_charset_parameter() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/events"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream; charset=utf-8")
.set_body_bytes(b"data: hi\n\n".to_vec()),
)
.mount(&server)
.await;
let client = JmapClient::new(
jmap_base_client::auth::DefaultTransport,
NoneAuth,
&server.uri(),
jmap_base_client::client::ClientConfig::default(),
)
.expect("client construction must succeed");
let event_url = format!("{}/events", server.uri());
let _stream = client
.subscribe_events(&event_url, None)
.await
.expect("subscribe_events must accept text/event-stream; charset=utf-8");
}
#[test]
fn test_extract_response_success() {
let resp = jmap_types::JmapResponse::new(
vec![(
"Mailbox/get".to_owned(),
serde_json::json!({"accountId": "A13824", "state": "s1", "list": [], "notFound": []}),
"r1".to_owned(),
)],
"sess1".into(),
None,
);
let val = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1");
assert!(val.is_ok(), "extract_response must succeed: {val:?}");
}
#[test]
fn test_extract_response_not_found() {
let resp = jmap_types::JmapResponse::new(
vec![(
"Mailbox/get".to_owned(),
serde_json::json!({}),
"r1".to_owned(),
)],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r99")
.expect_err("wrong call_id must fail");
assert!(
matches!(err, ClientError::MethodNotFound(_)),
"expected MethodNotFound, got {err:?}"
);
}
#[test]
fn test_extract_response_method_error() {
let resp = jmap_types::JmapResponse::new(
vec![(
"error".to_owned(),
serde_json::json!({"type": "serverFail", "description": "oops"}),
"r1".to_owned(),
)],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1")
.expect_err("error invocation must fail");
assert!(
matches!(
&err,
ClientError::MethodError { error_type, description }
if error_type == "serverFail" && description.as_deref() == Some("oops")
),
"expected MethodError{{serverFail, Some(\"oops\")}}, got {err:?}"
);
}
#[test]
fn test_extract_response_error_after_success_takes_precedence() {
let resp = jmap_types::JmapResponse::new(
vec![
(
"Mailbox/get".to_owned(),
serde_json::json!({
"accountId": "A1",
"state": "s1",
"list": [],
"notFound": []
}),
"r1".to_owned(),
),
(
"error".to_owned(),
serde_json::json!({"type": "serverFail", "description": "implicit op failed"}),
"r1".to_owned(),
),
],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1")
.expect_err("error sibling must surface even with a success present");
assert!(
matches!(
&err,
ClientError::MethodError { error_type, .. } if error_type == "serverFail"
),
"expected MethodError{{serverFail}}, got {err:?}"
);
}
#[test]
fn test_extract_response_first_success_when_no_error() {
let resp = jmap_types::JmapResponse::new(
vec![
(
"Todo/copy".to_owned(),
serde_json::json!({
"fromAccountId": "x",
"accountId": "y",
"created": {"k5122": {"id": "DAf97"}},
"oldState": "c1d64ecb038c",
"newState": "33844835152b"
}),
"0".to_owned(),
),
(
"Todo/set".to_owned(),
serde_json::json!({
"accountId": "x",
"oldState": "871903",
"newState": "871909",
"destroyed": ["a"]
}),
"0".to_owned(),
),
],
"sess1".into(),
None,
);
let v = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "0")
.expect("must succeed when all matches are successes");
assert_eq!(
v["fromAccountId"], "x",
"primary (first) response must be the Todo/copy result, got {v}"
);
assert!(
v.get("destroyed").is_none(),
"must NOT be the Todo/set result (which has 'destroyed' but no 'fromAccountId')"
);
}
#[test]
fn test_extract_response_error_after_multiple_successes() {
let resp = jmap_types::JmapResponse::new(
vec![
(
"Todo/copy".to_owned(),
serde_json::json!({"fromAccountId": "x"}),
"r1".to_owned(),
),
(
"Todo/set".to_owned(),
serde_json::json!({"accountId": "x"}),
"r1".to_owned(),
),
(
"error".to_owned(),
serde_json::json!({"type": "rateLimit"}),
"r1".to_owned(),
),
],
"sess1".into(),
None,
);
let err = jmap_base_client::client::extract_response::<serde_json::Value>(&resp, "r1")
.expect_err("trailing error must take precedence over earlier successes");
assert!(
matches!(
&err,
ClientError::MethodError { error_type, .. } if error_type == "rateLimit"
),
"expected MethodError{{rateLimit}}, got {err:?}"
);
}