Skip to main content

busbar_sf_rest/client/
quick_actions.rs

1use tracing::instrument;
2
3use busbar_sf_client::security::soql;
4
5use crate::error::{Error, ErrorKind, Result};
6use crate::quick_actions::{QuickAction, QuickActionDescribe, QuickActionResult};
7
8impl super::SalesforceRestClient {
9    /// List all global quick actions.
10    #[instrument(skip(self))]
11    pub async fn list_global_quick_actions(&self) -> Result<Vec<QuickAction>> {
12        self.client
13            .rest_get("quickActions")
14            .await
15            .map_err(Into::into)
16    }
17
18    /// Describe a global quick action.
19    #[instrument(skip(self))]
20    pub async fn describe_global_quick_action(
21        &self,
22        action_name: &str,
23    ) -> Result<QuickActionDescribe> {
24        if !soql::is_safe_action_name(action_name) {
25            return Err(Error::new(ErrorKind::Salesforce {
26                error_code: "INVALID_ACTION".to_string(),
27                message: "Invalid action name".to_string(),
28            }));
29        }
30        let path = format!("quickActions/{}", action_name);
31        self.client.rest_get(&path).await.map_err(Into::into)
32    }
33
34    /// List all quick actions available for an SObject.
35    #[instrument(skip(self))]
36    pub async fn list_quick_actions(&self, sobject: &str) -> Result<Vec<QuickAction>> {
37        if !soql::is_safe_sobject_name(sobject) {
38            return Err(Error::new(ErrorKind::Salesforce {
39                error_code: "INVALID_SOBJECT".to_string(),
40                message: "Invalid SObject name".to_string(),
41            }));
42        }
43        let path = format!("sobjects/{}/quickActions", sobject);
44        self.client.rest_get(&path).await.map_err(Into::into)
45    }
46
47    /// Describe a specific quick action.
48    ///
49    /// Action names can contain dots for SObject-scoped actions
50    /// (e.g., `FeedItem.TextPost`, `FeedItem.ContentPost`).
51    #[instrument(skip(self))]
52    pub async fn describe_quick_action(
53        &self,
54        sobject: &str,
55        action_name: &str,
56    ) -> Result<QuickActionDescribe> {
57        if !soql::is_safe_sobject_name(sobject) {
58            return Err(Error::new(ErrorKind::Salesforce {
59                error_code: "INVALID_SOBJECT".to_string(),
60                message: "Invalid SObject name".to_string(),
61            }));
62        }
63        if !soql::is_safe_action_name(action_name) {
64            return Err(Error::new(ErrorKind::Salesforce {
65                error_code: "INVALID_ACTION".to_string(),
66                message: "Invalid action name".to_string(),
67            }));
68        }
69        let path = format!("sobjects/{}/quickActions/{}", sobject, action_name);
70        self.client.rest_get(&path).await.map_err(Into::into)
71    }
72
73    /// Invoke a quick action on an SObject.
74    ///
75    /// Action names can contain dots for SObject-scoped actions
76    /// (e.g., `FeedItem.TextPost`, `FeedItem.ContentPost`).
77    #[instrument(skip(self, body))]
78    pub async fn invoke_quick_action(
79        &self,
80        sobject: &str,
81        action_name: &str,
82        body: &serde_json::Value,
83    ) -> Result<QuickActionResult> {
84        if !soql::is_safe_sobject_name(sobject) {
85            return Err(Error::new(ErrorKind::Salesforce {
86                error_code: "INVALID_SOBJECT".to_string(),
87                message: "Invalid SObject name".to_string(),
88            }));
89        }
90        if !soql::is_safe_action_name(action_name) {
91            return Err(Error::new(ErrorKind::Salesforce {
92                error_code: "INVALID_ACTION".to_string(),
93                message: "Invalid action name".to_string(),
94            }));
95        }
96        let path = format!("sobjects/{}/quickActions/{}", sobject, action_name);
97        self.client.rest_post(&path, body).await.map_err(Into::into)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::super::SalesforceRestClient;
104
105    #[tokio::test]
106    async fn test_list_quick_actions_invalid_sobject() {
107        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
108        let result = client.list_quick_actions("Bad'; DROP--").await;
109        assert!(result.is_err());
110        assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
111    }
112
113    #[tokio::test]
114    async fn test_describe_quick_action_invalid_sobject() {
115        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
116        let result = client
117            .describe_quick_action("Bad'; DROP--", "NewCase")
118            .await;
119        assert!(result.is_err());
120        assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
121    }
122
123    #[tokio::test]
124    async fn test_describe_quick_action_invalid_action() {
125        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
126        let result = client
127            .describe_quick_action("Account", "Bad'; DROP--")
128            .await;
129        assert!(result.is_err());
130        assert!(result.unwrap_err().to_string().contains("INVALID_ACTION"));
131    }
132
133    #[tokio::test]
134    async fn test_describe_quick_action_dotted_name_allowed() {
135        // Salesforce quick action names can contain dots (e.g., FeedItem.TextPost)
136        use wiremock::matchers::{method, path_regex};
137        use wiremock::{Mock, MockServer, ResponseTemplate};
138
139        let mock_server = MockServer::start().await;
140
141        let body = serde_json::json!({
142            "name": "FeedItem.TextPost",
143            "label": "Post",
144            "type": "Create",
145            "targetSobjectType": "FeedItem",
146            "targetRecordTypeId": null,
147            "layout": null,
148            "defaultValues": null,
149            "icons": []
150        });
151
152        Mock::given(method("GET"))
153            .and(path_regex(
154                ".*/sobjects/Account/quickActions/FeedItem.TextPost$",
155            ))
156            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
157            .mount(&mock_server)
158            .await;
159
160        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
161        let result = client
162            .describe_quick_action("Account", "FeedItem.TextPost")
163            .await
164            .expect("describe_quick_action should accept dotted action names");
165        assert_eq!(result.name, "FeedItem.TextPost");
166    }
167
168    #[tokio::test]
169    async fn test_describe_global_quick_action_invalid_name() {
170        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
171        let result = client.describe_global_quick_action("Bad'; DROP--").await;
172        assert!(result.is_err());
173        assert!(result.unwrap_err().to_string().contains("INVALID_ACTION"));
174    }
175
176    #[tokio::test]
177    async fn test_invoke_quick_action_invalid_action() {
178        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
179        let result = client
180            .invoke_quick_action("Account", "Bad'; DROP--", &serde_json::json!({}))
181            .await;
182        assert!(result.is_err());
183        assert!(result.unwrap_err().to_string().contains("INVALID_ACTION"));
184    }
185
186    #[tokio::test]
187    async fn test_invoke_quick_action_invalid_sobject() {
188        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
189        let result = client
190            .invoke_quick_action("Bad'; DROP--", "NewCase", &serde_json::json!({}))
191            .await;
192        assert!(result.is_err());
193        assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
194    }
195
196    #[tokio::test]
197    async fn test_list_global_quick_actions_wiremock() {
198        use wiremock::matchers::{method, path_regex};
199        use wiremock::{Mock, MockServer, ResponseTemplate};
200
201        let mock_server = MockServer::start().await;
202
203        let body = serde_json::json!([
204            {"name": "NewCase", "label": "New Case", "type": "Create"},
205            {"name": "LogACall", "label": "Log a Call", "type": "LogACall"}
206        ]);
207
208        Mock::given(method("GET"))
209            .and(path_regex(".*/quickActions$"))
210            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
211            .mount(&mock_server)
212            .await;
213
214        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
215        let result = client
216            .list_global_quick_actions()
217            .await
218            .expect("list_global_quick_actions should succeed");
219        assert_eq!(result.len(), 2);
220        assert_eq!(result[0].name, "NewCase");
221    }
222
223    #[tokio::test]
224    async fn test_describe_global_quick_action_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            "name": "LogACall",
232            "label": "Log a Call",
233            "type": "LogACall",
234            "targetSobjectType": "Task",
235            "targetRecordTypeId": null,
236            "layout": null,
237            "defaultValues": null,
238            "icons": []
239        });
240
241        Mock::given(method("GET"))
242            .and(path_regex(".*/quickActions/LogACall$"))
243            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
244            .mount(&mock_server)
245            .await;
246
247        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
248        let result = client
249            .describe_global_quick_action("LogACall")
250            .await
251            .expect("describe_global_quick_action should succeed");
252        assert_eq!(result.name, "LogACall");
253        assert_eq!(result.target_sobject_type.as_deref(), Some("Task"));
254    }
255
256    #[tokio::test]
257    async fn test_list_quick_actions_wiremock() {
258        use wiremock::matchers::{method, path_regex};
259        use wiremock::{Mock, MockServer, ResponseTemplate};
260
261        let mock_server = MockServer::start().await;
262
263        let body = serde_json::json!([
264            {"name": "NewCase", "label": "New Case", "type": "Create"}
265        ]);
266
267        Mock::given(method("GET"))
268            .and(path_regex(".*/sobjects/Account/quickActions$"))
269            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
270            .mount(&mock_server)
271            .await;
272
273        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
274        let result = client
275            .list_quick_actions("Account")
276            .await
277            .expect("list_quick_actions should succeed");
278        assert_eq!(result.len(), 1);
279        assert_eq!(result[0].name, "NewCase");
280    }
281
282    #[tokio::test]
283    async fn test_describe_quick_action_wiremock() {
284        use wiremock::matchers::{method, path_regex};
285        use wiremock::{Mock, MockServer, ResponseTemplate};
286
287        let mock_server = MockServer::start().await;
288
289        let body = serde_json::json!({
290            "name": "NewCase",
291            "label": "New Case",
292            "type": "Create",
293            "targetSobjectType": "Case",
294            "targetRecordTypeId": "012000000000000AAA",
295            "layout": null,
296            "defaultValues": null,
297            "icons": []
298        });
299
300        Mock::given(method("GET"))
301            .and(path_regex(".*/sobjects/Account/quickActions/NewCase$"))
302            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
303            .mount(&mock_server)
304            .await;
305
306        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
307        let result = client
308            .describe_quick_action("Account", "NewCase")
309            .await
310            .expect("describe_quick_action should succeed");
311        assert_eq!(result.name, "NewCase");
312        assert_eq!(result.target_sobject_type.as_deref(), Some("Case"));
313    }
314
315    #[tokio::test]
316    async fn test_invoke_quick_action_wiremock() {
317        use wiremock::matchers::{method, path_regex};
318        use wiremock::{Mock, MockServer, ResponseTemplate};
319
320        let mock_server = MockServer::start().await;
321
322        let body = serde_json::json!({
323            "id": "500xx000000bZKQAA2",
324            "success": true,
325            "errors": [],
326            "contextId": "001xx000003DgAAAS",
327            "feedItemId": null
328        });
329
330        Mock::given(method("POST"))
331            .and(path_regex(".*/sobjects/Account/quickActions/NewCase$"))
332            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
333            .mount(&mock_server)
334            .await;
335
336        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
337        let result = client
338            .invoke_quick_action(
339                "Account",
340                "NewCase",
341                &serde_json::json!({"Subject": "Test"}),
342            )
343            .await
344            .expect("invoke_quick_action should succeed");
345        assert!(result.success);
346        assert_eq!(result.id.unwrap(), "500xx000000bZKQAA2");
347    }
348}