use crate::Cirrus;
use crate::error::CirrusResult;
use crate::response::{
BatchResponse, CompositeResponse, CompositeTreeResponse, SObjectCollectionResult,
};
use reqwest::header::HeaderMap;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
impl Cirrus {
pub fn composite(&self) -> CompositeHandler<'_> {
CompositeHandler { client: self }
}
}
#[derive(Debug)]
pub struct CompositeHandler<'a> {
client: &'a Cirrus,
}
impl CompositeHandler<'_> {
pub async fn batch<B>(&self, body: &B) -> CirrusResult<BatchResponse>
where
B: Serialize + ?Sized,
{
self.client.post("composite/batch", body).await
}
pub async fn tree<B>(&self, sobject: &str, body: &B) -> CirrusResult<CompositeTreeResponse>
where
B: Serialize + ?Sized,
{
let url = self
.client
.versioned_segments(&["composite", "tree", sobject])?;
self.client
.send_at(reqwest::Method::POST, &url, None::<&()>, Some(body))
.await
}
pub fn sobjects(&self) -> CompositeSObjectsHandler<'_> {
CompositeSObjectsHandler {
client: self.client,
}
}
pub async fn execute<B>(&self, body: &B) -> CirrusResult<CompositeResponse>
where
B: Serialize + ?Sized,
{
self.client.post("composite", body).await
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CompositeRequest {
#[serde(rename = "compositeRequest")]
pub composite_request: Vec<CompositeSubrequest>,
#[serde(rename = "allOrNone", skip_serializing_if = "Option::is_none")]
pub all_or_none: Option<bool>,
#[serde(rename = "collateSubrequests", skip_serializing_if = "Option::is_none")]
pub collate_subrequests: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CompositeSubrequest {
pub method: String,
pub url: String,
#[serde(rename = "referenceId")]
pub reference_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
#[serde(
rename = "httpHeaders",
with = "http_serde::option::header_map",
skip_serializing_if = "Option::is_none",
default
)]
pub http_headers: Option<HeaderMap>,
}
#[derive(Debug)]
pub struct CompositeSObjectsHandler<'a> {
client: &'a Cirrus,
}
impl CompositeSObjectsHandler<'_> {
pub async fn create<B>(&self, body: &B) -> CirrusResult<Vec<SObjectCollectionResult>>
where
B: Serialize + ?Sized,
{
self.client.post("composite/sobjects", body).await
}
pub async fn update<B>(&self, body: &B) -> CirrusResult<Vec<SObjectCollectionResult>>
where
B: Serialize + ?Sized,
{
self.client.patch("composite/sobjects", body).await
}
pub async fn upsert<B>(
&self,
sobject: &str,
external_id_field: &str,
body: &B,
) -> CirrusResult<Vec<SObjectCollectionResult>>
where
B: Serialize + ?Sized,
{
let url = self.client.versioned_segments(&[
"composite",
"sobjects",
sobject,
external_id_field,
])?;
self.client
.send_at(reqwest::Method::PATCH, &url, None::<&()>, Some(body))
.await
}
pub async fn delete(
&self,
ids: &[&str],
all_or_none: bool,
) -> CirrusResult<Vec<SObjectCollectionResult>> {
let joined = ids.join(",");
let all = if all_or_none { "true" } else { "false" };
let url = self.client.versioned_segments(&["composite", "sobjects"])?;
self.client
.send_at::<_, _, ()>(
reqwest::Method::DELETE,
&url,
Some(&[("ids", joined.as_str()), ("allOrNone", all)]),
None,
)
.await
}
pub async fn retrieve(
&self,
sobject: &str,
ids: &[&str],
fields: &[&str],
) -> CirrusResult<Vec<Value>> {
self.retrieve_as(sobject, ids, fields).await
}
pub async fn retrieve_as<R: DeserializeOwned>(
&self,
sobject: &str,
ids: &[&str],
fields: &[&str],
) -> CirrusResult<Vec<R>> {
let url = self
.client
.versioned_segments(&["composite", "sobjects", sobject])?;
let joined_ids = ids.join(",");
let joined_fields = fields.join(",");
self.client
.send_at::<_, _, ()>(
reqwest::Method::GET,
&url,
Some(&[
("ids", joined_ids.as_str()),
("fields", joined_fields.as_str()),
]),
None,
)
.await
}
pub async fn retrieve_with_body(
&self,
sobject: &str,
ids: &[&str],
fields: &[&str],
) -> CirrusResult<Vec<Value>> {
self.retrieve_with_body_as(sobject, ids, fields).await
}
pub async fn retrieve_with_body_as<R: DeserializeOwned>(
&self,
sobject: &str,
ids: &[&str],
fields: &[&str],
) -> CirrusResult<Vec<R>> {
let url = self
.client
.versioned_segments(&["composite", "sobjects", sobject])?;
let body = serde_json::json!({
"ids": ids,
"fields": fields,
});
self.client
.send_at::<_, (), _>(reqwest::Method::POST, &url, None, Some(&body))
.await
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BatchRequest {
#[serde(rename = "batchRequests")]
pub batch_requests: Vec<BatchSubrequest>,
#[serde(rename = "haltOnError", skip_serializing_if = "Option::is_none")]
pub halt_on_error: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchSubrequest {
pub method: String,
pub url: String,
#[serde(rename = "richInput", skip_serializing_if = "Option::is_none")]
pub rich_input: Option<serde_json::Value>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::Cirrus;
use crate::auth::StaticTokenAuth;
use serde_json::json;
use std::sync::Arc;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn fixture(uri: String) -> Cirrus {
let auth = Arc::new(StaticTokenAuth::new("tok", uri));
Cirrus::builder().auth(auth).build().unwrap()
}
#[tokio::test]
async fn batch_posts_and_parses_mixed_subresults() {
let server = MockServer::start().await;
let request_body = json!({
"batchRequests": [
{
"method": "PATCH",
"url": "v66.0/sobjects/Account/001xx",
"richInput": {"Name": "NewName"}
},
{
"method": "GET",
"url": "v66.0/sobjects/Account/001xx?fields=Name"
}
]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/batch"))
.and(header("authorization", "Bearer tok"))
.and(body_json(request_body.clone()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hasErrors": false,
"results": [
{"statusCode": 204, "result": null},
{"statusCode": 200, "result": {
"attributes": {"type": "Account"},
"Name": "NewName",
"Id": "001xx"
}}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf.composite().batch(&request_body).await.unwrap();
assert!(!resp.has_errors);
assert_eq!(resp.results.len(), 2);
assert_eq!(resp.results[0].status_code, 204);
assert!(resp.results[0].result.is_null());
assert_eq!(resp.results[1].result["Name"], "NewName");
}
#[tokio::test]
async fn batch_typed_request_matches_documented_wire_shape() {
let server = MockServer::start().await;
let expected_body = json!({
"batchRequests": [
{
"method": "PATCH",
"url": "v66.0/sobjects/account/001D000000K0fXOIAZ",
"richInput": {"Name": "NewName"}
},
{
"method": "GET",
"url": "v66.0/sobjects/account/001D000000K0fXOIAZ?fields=Name,BillingPostalCode"
}
]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/batch"))
.and(body_json(expected_body))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hasErrors": false,
"results": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = BatchRequest {
batch_requests: vec![
BatchSubrequest {
method: "PATCH".into(),
url: "v66.0/sobjects/account/001D000000K0fXOIAZ".into(),
rich_input: Some(json!({"Name": "NewName"})),
},
BatchSubrequest {
method: "GET".into(),
url: "v66.0/sobjects/account/001D000000K0fXOIAZ?fields=Name,BillingPostalCode"
.into(),
rich_input: None,
},
],
halt_on_error: None,
};
let resp = sf.composite().batch(&req).await.unwrap();
assert!(!resp.has_errors);
}
#[tokio::test]
async fn batch_serializes_halt_on_error_when_set() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/batch"))
.and(body_json(json!({
"batchRequests": [
{"method": "GET", "url": "v66.0/limits"}
],
"haltOnError": true
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hasErrors": false,
"results": [{"statusCode": 200, "result": {}}]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = BatchRequest {
batch_requests: vec![BatchSubrequest {
method: "GET".into(),
url: "v66.0/limits".into(),
rich_input: None,
}],
halt_on_error: Some(true),
};
sf.composite().batch(&req).await.unwrap();
}
#[tokio::test]
async fn batch_omits_halt_on_error_when_none() {
let server = MockServer::start().await;
let body_without_halt = json!({
"batchRequests": [
{"method": "GET", "url": "v66.0/limits"}
]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/batch"))
.and(body_json(body_without_halt))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hasErrors": false,
"results": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = BatchRequest {
batch_requests: vec![BatchSubrequest {
method: "GET".into(),
url: "v66.0/limits".into(),
rich_input: None,
}],
halt_on_error: None,
};
sf.composite().batch(&req).await.unwrap();
}
#[tokio::test]
async fn batch_surfaces_subrequest_errors_via_has_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/batch"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hasErrors": true,
"results": [
{"statusCode": 200, "result": {"Id": "001xx"}},
{"statusCode": 400, "result": [
{"message": "Required fields are missing: [Name]",
"errorCode": "REQUIRED_FIELD_MISSING",
"fields": ["Name"]}
]}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf
.composite()
.batch(&json!({"batchRequests": []}))
.await
.unwrap();
assert!(resp.has_errors);
assert!(resp.results[0].is_success());
assert!(!resp.results[1].is_success());
assert_eq!(resp.results[1].status_code, 400);
assert_eq!(
resp.results[1].result[0]["errorCode"],
"REQUIRED_FIELD_MISSING"
);
}
#[tokio::test]
async fn tree_creates_nested_records_and_returns_id_map() {
let server = MockServer::start().await;
let request_body = json!({
"records": [{
"attributes": {"type": "Account", "referenceId": "ref1"},
"Name": "Acme",
"Contacts": {
"records": [{
"attributes": {"type": "Contact", "referenceId": "ref2"},
"LastName": "Smith"
}, {
"attributes": {"type": "Contact", "referenceId": "ref3"},
"LastName": "Evans"
}]
}
}]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/tree/Account"))
.and(header("authorization", "Bearer tok"))
.and(body_json(request_body.clone()))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"hasErrors": false,
"results": [
{"referenceId": "ref1", "id": "001D000000K0fXOIAZ"},
{"referenceId": "ref2", "id": "003D000000QV9n2IAD"},
{"referenceId": "ref3", "id": "003D000000QV9n3IAD"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf.composite().tree("Account", &request_body).await.unwrap();
assert!(!resp.has_errors);
assert_eq!(resp.results.len(), 3);
assert_eq!(resp.results[0].reference_id, "ref1");
assert_eq!(resp.results[0].id.as_deref(), Some("001D000000K0fXOIAZ"));
assert!(resp.results.iter().all(|r| r.is_success()));
}
#[tokio::test]
async fn tree_surfaces_per_record_failure() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/tree/Account"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"hasErrors": true,
"results": [{
"referenceId": "ref2",
"errors": [{
"statusCode": "INVALID_EMAIL_ADDRESS",
"message": "Email: invalid email address: 123",
"fields": ["Email"]
}]
}]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf
.composite()
.tree("Account", &json!({"records": []}))
.await
.unwrap();
assert!(resp.has_errors);
let result = &resp.results[0];
assert!(!result.is_success());
assert!(result.id.is_none());
let errors = result.errors.as_ref().unwrap();
assert_eq!(errors[0].status_code, "INVALID_EMAIL_ADDRESS");
assert_eq!(errors[0].fields, vec!["Email".to_string()]);
}
#[tokio::test]
async fn tree_percent_encodes_sobject_name() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/tree/My_Custom__c"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"hasErrors": false,
"results": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf
.composite()
.tree("My_Custom__c", &json!({"records": []}))
.await
.unwrap();
assert!(!resp.has_errors);
}
#[tokio::test]
async fn tree_top_level_400_surfaces_as_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/tree/Account"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"message": "Invalid request: missing 'records'",
"errorCode": "INVALID_REQUEST"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.composite()
.tree("Account", &json!({}))
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "INVALID_REQUEST");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn batch_top_level_400_surfaces_as_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/batch"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"message": "Invalid HTTP method: BOGUS",
"errorCode": "JSON_PARSER_ERROR"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.composite()
.batch(&json!({"batchRequests": [{"method": "BOGUS", "url": "v66.0/limits"}]}))
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "JSON_PARSER_ERROR");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn composite_executes_chained_subrequests_and_returns_subresponses() {
let server = MockServer::start().await;
let request_body = json!({
"compositeRequest": [
{
"method": "POST",
"url": "/services/data/v66.0/sobjects/Account",
"referenceId": "NewAccount",
"body": {"Name": "Acme"}
},
{
"method": "POST",
"url": "/services/data/v66.0/sobjects/Contact",
"referenceId": "NewContact",
"body": {"AccountId": "@{NewAccount.id}", "LastName": "Doe"}
}
]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite"))
.and(header("authorization", "Bearer tok"))
.and(body_json(request_body.clone()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compositeResponse": [
{
"body": {"id": "001xx", "success": true, "errors": []},
"httpHeaders": {"Location": "/services/data/v66.0/sobjects/Account/001xx"},
"httpStatusCode": 201,
"referenceId": "NewAccount"
},
{
"body": {"id": "003yy", "success": true, "errors": []},
"httpHeaders": {"Location": "/services/data/v66.0/sobjects/Contact/003yy"},
"httpStatusCode": 201,
"referenceId": "NewContact"
}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf.composite().execute(&request_body).await.unwrap();
assert_eq!(resp.composite_response.len(), 2);
assert!(resp.composite_response.iter().all(|s| s.is_success()));
assert_eq!(resp.composite_response[0].reference_id, "NewAccount");
assert_eq!(resp.composite_response[0].body["id"], "001xx");
assert_eq!(
resp.composite_response[0]
.http_headers
.get("Location")
.and_then(|v| v.to_str().ok()),
Some("/services/data/v66.0/sobjects/Account/001xx")
);
}
#[tokio::test]
async fn composite_typed_request_serializes_documented_shape() {
let server = MockServer::start().await;
let expected_body = json!({
"compositeRequest": [{
"method": "POST",
"url": "/services/data/v66.0/sobjects/Account",
"referenceId": "refAccount",
"body": {"Name": "Sample Account"}
}],
"allOrNone": true
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite"))
.and(body_json(expected_body))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compositeResponse": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = CompositeRequest {
composite_request: vec![CompositeSubrequest {
method: "POST".into(),
url: "/services/data/v66.0/sobjects/Account".into(),
reference_id: "refAccount".into(),
body: Some(json!({"Name": "Sample Account"})),
http_headers: None,
}],
all_or_none: Some(true),
collate_subrequests: None,
};
sf.composite().execute(&req).await.unwrap();
}
#[tokio::test]
async fn composite_typed_request_omits_optional_flags_when_none() {
let server = MockServer::start().await;
let expected_body = json!({
"compositeRequest": [{
"method": "GET",
"url": "/services/data/v66.0/limits",
"referenceId": "L"
}]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite"))
.and(body_json(expected_body))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compositeResponse": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = CompositeRequest {
composite_request: vec![CompositeSubrequest {
method: "GET".into(),
url: "/services/data/v66.0/limits".into(),
reference_id: "L".into(),
body: None,
http_headers: None,
}],
all_or_none: None,
collate_subrequests: None,
};
sf.composite().execute(&req).await.unwrap();
}
#[tokio::test]
async fn composite_subrequest_failure_surfaces_via_http_status_code() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compositeResponse": [
{
"body": {"id": "001xx", "success": true, "errors": []},
"httpHeaders": {},
"httpStatusCode": 201,
"referenceId": "OK"
},
{
"body": [{
"message": "The requested resource does not exist",
"errorCode": "NOT_FOUND"
}],
"httpHeaders": {},
"httpStatusCode": 404,
"referenceId": "Missing"
}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let resp = sf
.composite()
.execute(&json!({"compositeRequest": []}))
.await
.unwrap();
assert!(resp.composite_response[0].is_success());
assert!(!resp.composite_response[1].is_success());
assert_eq!(resp.composite_response[1].body[0]["errorCode"], "NOT_FOUND");
}
#[tokio::test]
async fn composite_top_level_400_surfaces_as_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"message": "Duplicate referenceId: ref1",
"errorCode": "INVALID_INPUT"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.composite()
.execute(&json!({"compositeRequest": []}))
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "INVALID_INPUT");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn sobjects_create_returns_per_record_results() {
let server = MockServer::start().await;
let request_body = json!({
"allOrNone": false,
"records": [
{"attributes": {"type": "Account"}, "Name": "Acme"},
{"attributes": {"type": "Contact"}, "LastName": "Doe"}
]
});
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/sobjects"))
.and(header("authorization", "Bearer tok"))
.and(body_json(request_body.clone()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "001xx0000000001", "success": true, "errors": []},
{"id": "003yy0000000001", "success": true, "errors": []}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let results = sf
.composite()
.sobjects()
.create(&request_body)
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.success));
assert_eq!(results[0].id.as_deref(), Some("001xx0000000001"));
assert_eq!(results[1].id.as_deref(), Some("003yy0000000001"));
assert!(results[0].created.is_none());
}
#[tokio::test]
async fn sobjects_update_uses_patch_verb() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/services/data/v66.0/composite/sobjects"))
.and(body_json(json!({
"allOrNone": true,
"records": [
{"attributes": {"type": "Account"}, "id": "001xx", "Name": "Renamed"}
]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "001xx", "success": true, "errors": []}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let results = sf
.composite()
.sobjects()
.update(&json!({
"allOrNone": true,
"records": [
{"attributes": {"type": "Account"}, "id": "001xx", "Name": "Renamed"}
]
}))
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
}
#[tokio::test]
async fn sobjects_upsert_targets_external_id_path_and_returns_created_flag() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(
"/services/data/v66.0/composite/sobjects/Account/External_Id__c",
))
.and(body_json(json!({
"allOrNone": false,
"records": [
{"attributes": {"type": "Account"},
"External_Id__c": "EXT-1",
"Name": "Acme"},
{"attributes": {"type": "Account"},
"External_Id__c": "EXT-2",
"Name": "Existing"}
]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "001aa", "success": true, "errors": [], "created": true},
{"id": "001bb", "success": true, "errors": [], "created": false}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let results = sf
.composite()
.sobjects()
.upsert(
"Account",
"External_Id__c",
&json!({
"allOrNone": false,
"records": [
{"attributes": {"type": "Account"},
"External_Id__c": "EXT-1",
"Name": "Acme"},
{"attributes": {"type": "Account"},
"External_Id__c": "EXT-2",
"Name": "Existing"}
]
}),
)
.await
.unwrap();
assert_eq!(results[0].created, Some(true));
assert_eq!(results[1].created, Some(false));
}
#[tokio::test]
async fn sobjects_delete_passes_ids_and_all_or_none_query_params() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/services/data/v66.0/composite/sobjects"))
.and(wiremock::matchers::query_param("ids", "001xx,001yy"))
.and(wiremock::matchers::query_param("allOrNone", "false"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "001xx", "success": true, "errors": []},
{"id": "001yy", "success": true, "errors": []}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let results = sf
.composite()
.sobjects()
.delete(&["001xx", "001yy"], false)
.await
.unwrap();
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.success));
}
#[tokio::test]
async fn sobjects_retrieve_returns_record_array_aligned_with_ids() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/composite/sobjects/Account"))
.and(wiremock::matchers::query_param(
"ids",
"001xx,missing,001yy",
))
.and(wiremock::matchers::query_param("fields", "Id,Name"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"attributes": {"type": "Account"}, "Id": "001xx", "Name": "Acme"},
null,
{"attributes": {"type": "Account"}, "Id": "001yy", "Name": "Other"}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let records = sf
.composite()
.sobjects()
.retrieve("Account", &["001xx", "missing", "001yy"], &["Id", "Name"])
.await
.unwrap();
assert_eq!(records.len(), 3);
assert_eq!(records[0]["Name"], "Acme");
assert!(records[1].is_null());
assert_eq!(records[2]["Name"], "Other");
}
#[tokio::test]
async fn sobjects_retrieve_typed_into_optional_records() {
#[derive(serde::Deserialize)]
struct Acct {
#[serde(rename = "Id")]
id: String,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/composite/sobjects/Account"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"attributes": {"type": "Account"}, "Id": "001xx"},
null
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let records: Vec<Option<Acct>> = sf
.composite()
.sobjects()
.retrieve_as("Account", &["001xx", "missing"], &["Id"])
.await
.unwrap();
assert_eq!(records[0].as_ref().unwrap().id, "001xx");
assert!(records[1].is_none());
}
#[tokio::test]
async fn sobjects_retrieve_with_body_posts_ids_and_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/sobjects/Account"))
.and(body_json(json!({
"ids": ["001xx", "missing"],
"fields": ["Id", "Name"]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"attributes": {"type": "Account"}, "Id": "001xx", "Name": "Acme"},
null
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let records = sf
.composite()
.sobjects()
.retrieve_with_body("Account", &["001xx", "missing"], &["Id", "Name"])
.await
.unwrap();
assert_eq!(records[0]["Id"], "001xx");
assert!(records[1].is_null());
}
#[tokio::test]
async fn sobjects_create_surfaces_per_record_failures_with_diverged_error_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/composite/sobjects"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "001xx", "success": true, "errors": []},
{"success": false, "errors": [{
"statusCode": "DUPLICATE_VALUE",
"message": "duplicate value found: External_Id__c duplicates value on record with id: 001aa",
"fields": []
}]}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let results = sf
.composite()
.sobjects()
.create(&json!({"allOrNone": false, "records": []}))
.await
.unwrap();
assert!(results[0].success);
assert!(!results[1].success);
assert!(results[1].id.is_none());
assert_eq!(results[1].errors[0].status_code, "DUPLICATE_VALUE");
assert!(results[1].errors[0].fields.is_empty());
}
}