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 #[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 #[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 #[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 #[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 #[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 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}