Skip to main content

busbar_sf_rest/client/
process.rs

1use serde::Deserialize;
2use tracing::instrument;
3
4use busbar_sf_client::security::soql;
5
6use crate::error::{Error, ErrorKind, Result};
7use crate::process::{
8    ApprovalRequest, ApprovalResult, PendingApprovalCollection, ProcessRule, ProcessRuleCollection,
9    ProcessRuleRequest, ProcessRuleResult,
10};
11
12/// Per-SObject process rules response: `{"rules": [rule1, rule2, ...]}`
13/// Different from the top-level response which uses a map keyed by SObject type.
14#[derive(Deserialize)]
15struct PerSObjectProcessRules {
16    #[serde(default)]
17    rules: Vec<ProcessRule>,
18}
19
20impl super::SalesforceRestClient {
21    /// List all process rules.
22    #[instrument(skip(self))]
23    pub async fn list_process_rules(&self) -> Result<ProcessRuleCollection> {
24        self.client
25            .rest_get("process/rules")
26            .await
27            .map_err(Into::into)
28    }
29
30    /// List process rules for a specific SObject type.
31    ///
32    /// Returns the array of rules for that SObject.
33    #[instrument(skip(self))]
34    pub async fn list_process_rules_for_sobject(
35        &self,
36        sobject: &str,
37    ) -> Result<Vec<crate::process::ProcessRule>> {
38        if !soql::is_safe_sobject_name(sobject) {
39            return Err(Error::new(ErrorKind::Salesforce {
40                error_code: "INVALID_SOBJECT".to_string(),
41                message: "Invalid SObject name".to_string(),
42            }));
43        }
44        let path = format!("process/rules/{}", sobject);
45        // Per-SObject endpoint returns {"rules": [rule1, rule2, ...]} (array),
46        // unlike the top-level endpoint which uses a map keyed by SObject type.
47        let response: PerSObjectProcessRules = self.client.rest_get(&path).await?;
48        Ok(response.rules)
49    }
50
51    /// Trigger process rules for a record.
52    #[instrument(skip(self, request))]
53    pub async fn trigger_process_rules(
54        &self,
55        request: &ProcessRuleRequest,
56    ) -> Result<ProcessRuleResult> {
57        self.client
58            .rest_post("process/rules", request)
59            .await
60            .map_err(Into::into)
61    }
62
63    /// List pending approval work items.
64    #[instrument(skip(self))]
65    pub async fn list_pending_approvals(&self) -> Result<PendingApprovalCollection> {
66        self.client
67            .rest_get("process/approvals")
68            .await
69            .map_err(Into::into)
70    }
71
72    /// Submit, approve, or reject an approval request.
73    ///
74    /// The response is an array; this method returns the first element.
75    #[instrument(skip(self, request))]
76    pub async fn submit_approval(&self, request: &ApprovalRequest) -> Result<ApprovalResult> {
77        let wrapper = serde_json::json!({ "requests": [request] });
78        let results: Vec<ApprovalResult> =
79            self.client.rest_post("process/approvals", &wrapper).await?;
80        results.into_iter().next().ok_or_else(|| {
81            Error::new(ErrorKind::Salesforce {
82                error_code: "EMPTY_RESPONSE".to_string(),
83                message: "No approval result returned".to_string(),
84            })
85        })
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::super::SalesforceRestClient;
92
93    #[tokio::test]
94    async fn test_list_process_rules_for_sobject_invalid() {
95        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
96        let result = client.list_process_rules_for_sobject("Bad'; DROP--").await;
97        assert!(result.is_err());
98        assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
99    }
100
101    #[tokio::test]
102    async fn test_list_process_rules_wiremock() {
103        use wiremock::matchers::{method, path_regex};
104        use wiremock::{Mock, MockServer, ResponseTemplate};
105
106        let mock_server = MockServer::start().await;
107
108        let body = serde_json::json!({
109            "rules": {
110                "Account": [{
111                    "id": "01Qxx0000000001",
112                    "name": "My Rule",
113                    "sobjectType": "Account",
114                    "url": "/rules/Account/01Qxx0000000001"
115                }]
116            }
117        });
118
119        Mock::given(method("GET"))
120            .and(path_regex(".*/process/rules$"))
121            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
122            .mount(&mock_server)
123            .await;
124
125        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
126        let result = client
127            .list_process_rules()
128            .await
129            .expect("list_process_rules should succeed");
130        assert_eq!(result.rules.get("Account").unwrap().len(), 1);
131    }
132
133    #[tokio::test]
134    async fn test_list_process_rules_for_sobject_wiremock() {
135        use wiremock::matchers::{method, path_regex};
136        use wiremock::{Mock, MockServer, ResponseTemplate};
137
138        let mock_server = MockServer::start().await;
139
140        // Per-SObject endpoint returns {"rules": [...]} (array, not map)
141        let body = serde_json::json!({
142            "rules": [{
143                "id": "01Qxx0000000001",
144                "name": "My Rule",
145                "sobjectType": "Account"
146            }]
147        });
148
149        Mock::given(method("GET"))
150            .and(path_regex(".*/process/rules/Account$"))
151            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
152            .mount(&mock_server)
153            .await;
154
155        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
156        let rules = client
157            .list_process_rules_for_sobject("Account")
158            .await
159            .expect("list_process_rules_for_sobject should succeed");
160        assert_eq!(rules.len(), 1);
161        assert_eq!(rules[0].name, "My Rule");
162    }
163
164    #[tokio::test]
165    async fn test_trigger_process_rules_wiremock() {
166        use wiremock::matchers::{method, path_regex};
167        use wiremock::{Mock, MockServer, ResponseTemplate};
168
169        let mock_server = MockServer::start().await;
170
171        let body = serde_json::json!({"errors": [], "success": true});
172
173        Mock::given(method("POST"))
174            .and(path_regex(".*/process/rules$"))
175            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
176            .mount(&mock_server)
177            .await;
178
179        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
180        let request = crate::process::ProcessRuleRequest {
181            context_ids: vec!["001xx000003DgAAAS".to_string()],
182        };
183        let result = client
184            .trigger_process_rules(&request)
185            .await
186            .expect("trigger_process_rules should succeed");
187        assert!(result.success);
188    }
189
190    #[tokio::test]
191    async fn test_list_pending_approvals_wiremock() {
192        use wiremock::matchers::{method, path_regex};
193        use wiremock::{Mock, MockServer, ResponseTemplate};
194
195        let mock_server = MockServer::start().await;
196
197        let body = serde_json::json!({
198            "approvals": {
199                "Account": [{
200                    "id": "04axx0000000001",
201                    "name": "Account_Approval",
202                    "description": null,
203                    "object": "Account",
204                    "sortOrder": 1
205                }]
206            }
207        });
208
209        Mock::given(method("GET"))
210            .and(path_regex(".*/process/approvals$"))
211            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
212            .mount(&mock_server)
213            .await;
214
215        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
216        let result = client
217            .list_pending_approvals()
218            .await
219            .expect("list_pending_approvals should succeed");
220        assert_eq!(result.approvals.get("Account").unwrap().len(), 1);
221    }
222
223    #[tokio::test]
224    async fn test_submit_approval_wiremock() {
225        use wiremock::matchers::{method, path_regex};
226        use wiremock::{Mock, MockServer, ResponseTemplate};
227
228        let mock_server = MockServer::start().await;
229
230        let body = serde_json::json!([{
231            "actorIds": ["005xx000001Svf0AAC"],
232            "entityId": "001xx000003DgAAAS",
233            "errors": [],
234            "instanceId": "04gxx0000000001",
235            "instanceStatus": "Pending",
236            "newWorkitemIds": ["04ixx0000000002"],
237            "success": true
238        }]);
239
240        Mock::given(method("POST"))
241            .and(path_regex(".*/process/approvals$"))
242            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
243            .mount(&mock_server)
244            .await;
245
246        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
247        let request = crate::process::ApprovalRequest {
248            action_type: crate::process::ApprovalActionType::Submit,
249            context_id: "001xx000003DgAAAS".to_string(),
250            context_actor_id: None,
251            comments: Some("Please approve".to_string()),
252            next_approver_ids: None,
253            process_definition_name_or_id: None,
254            skip_entry_criteria: None,
255        };
256        let result = client
257            .submit_approval(&request)
258            .await
259            .expect("submit_approval should succeed");
260        assert!(result.success);
261        assert_eq!(result.instance_status, "Pending");
262    }
263}