1use std::collections::HashMap;
8use std::fmt;
9use std::path::Path;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use futures_util::Stream;
14use reqwest::Method;
15use serde::de::{Deserializer, Error as _};
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Map, Value};
18
19use crate::client::{CallOptions, Client};
20use crate::common::{string_enum, MessageResponse};
21use crate::error::{Error, ErrorKind, Result};
22use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
23use crate::query::QueryBuilder;
24use crate::resources::escape;
25
26const PATH: &str = "/v2/batch-calls";
27
28const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
30const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
31const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(120);
32
33fn resolve_poll(interval: Option<Duration>, timeout: Option<Duration>) -> (Duration, Duration) {
39 (
40 interval
41 .filter(|interval| !interval.is_zero())
42 .unwrap_or(DEFAULT_POLL_INTERVAL),
43 timeout
44 .filter(|timeout| !timeout.is_zero())
45 .unwrap_or(DEFAULT_POLL_TIMEOUT),
46 )
47}
48
49fn upload_content_type(extension: &str) -> Option<&'static str> {
51 match extension.to_ascii_lowercase().as_str() {
52 "csv" => Some("text/csv"),
53 "xls" => Some("application/vnd.ms-excel"),
54 "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
55 _ => None,
56 }
57}
58
59string_enum! {
60 BatchCallTiming {
62 IMMEDIATE => "immediate",
64 SCHEDULED => "scheduled",
66 }
67}
68
69string_enum! {
70 BatchCallStatus {
72 UPLOADED => "uploaded",
74 PARSING => "parsing",
76 PARSED => "parsed",
78 PARSE_FAILED => "parse_failed",
80 DRAFT => "draft",
82 SCHEDULED => "scheduled",
84 RUNNING => "running",
86 COMPLETED => "completed",
88 CANCELLING => "cancelling",
90 CANCELLED => "cancelled",
92 }
93}
94
95string_enum! {
96 BatchCallCancelMode {
98 GRACEFUL => "graceful",
100 IMMEDIATE => "immediate",
102 }
103}
104
105string_enum! {
106 BatchCallRetryStatus {
108 FAILED => "failed",
110 NOT_ANSWERED => "not-answered",
112 REJECTED => "rejected",
114 MISSED => "missed",
116 ABANDONED => "abandoned",
118 }
119}
120
121string_enum! {
122 BatchCallWebhookEvent {
124 BATCHCALL_STARTED => "batchcall-started",
126 BATCHCALL_COMPLETED => "batchcall-completed",
128 }
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct BatchCallRetryConfig {
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub enabled: Option<bool>,
138 #[serde(
140 default,
141 deserialize_with = "crate::common::null_to_default",
142 skip_serializing_if = "Vec::is_empty"
143 )]
144 pub retry_on: Vec<BatchCallRetryStatus>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub max_attempts: Option<u32>,
148}
149
150#[derive(Debug, Clone, Default, Serialize)]
152pub struct BatchCallWebhookConfig {
153 pub endpoint: String,
155 #[serde(skip_serializing_if = "Vec::is_empty")]
157 pub events: Vec<BatchCallWebhookEvent>,
158}
159
160#[derive(Debug, Clone, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct BatchCall {
164 pub batch_id: String,
166 pub batch_name: Option<String>,
168 pub description: Option<String>,
170 pub phone_number_id: Option<String>,
172 pub phone_number: Option<String>,
174 pub routing_rule_id: Option<String>,
176 pub timing: Option<BatchCallTiming>,
178 pub timezone: Option<String>,
180 pub scheduled_time: Option<String>,
182 pub scheduled_date: Option<String>,
184 pub allowed_from: Option<String>,
186 pub allowed_until: Option<String>,
188 #[serde(default, deserialize_with = "crate::common::null_to_default")]
190 pub days_of_week: Vec<u32>,
191 pub retry: Option<BatchCallRetryConfig>,
193 pub status: Option<BatchCallStatus>,
195 pub cdn_url: Option<String>,
197 pub records_uploaded: Option<u32>,
199 pub parse_error: Option<String>,
201 pub timestamp: Option<i64>,
204 pub progress: Option<f64>,
206 pub created_at: Option<String>,
208 pub updated_at: Option<String>,
210 #[serde(flatten)]
212 pub extra: Map<String, Value>,
213}
214
215#[derive(Debug, Clone, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct BatchCallRecord {
219 #[serde(default, deserialize_with = "crate::common::null_to_default")]
221 pub batch_id: String,
222 pub record_id: String,
224 pub phone_number: Option<String>,
226 pub metadata: Option<Map<String, Value>>,
228 pub status: Option<String>,
230 pub call_id: Option<String>,
232 pub reason: Option<String>,
234 pub attempts: Option<u32>,
236 pub recording_id: Option<String>,
238 pub recording: Option<Value>,
240 pub call_duration: Option<f64>,
242 pub created_at: Option<String>,
244 pub updated_at: Option<String>,
246 #[serde(flatten)]
248 pub extra: Map<String, Value>,
249}
250
251#[derive(Debug, Clone, Default)]
253pub struct BatchCallStats {
254 pub batch_id: String,
256 pub status: Option<BatchCallStatus>,
258 pub total: Option<i64>,
260 pub counts: HashMap<String, i64>,
263}
264
265impl<'de> Deserialize<'de> for BatchCallStats {
266 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
269 let raw = Map::deserialize(deserializer)?;
270 let mut stats = BatchCallStats {
271 batch_id: raw
272 .get("batchId")
273 .and_then(Value::as_str)
274 .unwrap_or_default()
275 .to_string(),
276 ..Default::default()
277 };
278
279 if let Some(status) = raw.get("status") {
280 stats.status = Some(serde_json::from_value(status.clone()).map_err(D::Error::custom)?);
281 }
282
283 for (key, value) in &raw {
284 if key == "batchId" || key == "status" {
285 continue;
286 }
287 if let Some(count) = value.as_i64() {
288 stats.counts.insert(key.clone(), count);
289 if key == "total" {
290 stats.total = Some(count);
291 }
292 }
293 }
294 Ok(stats)
295 }
296}
297
298#[derive(Debug, Deserialize)]
300#[serde(rename_all = "camelCase")]
301struct BatchPresignedUpload {
302 presigned_url: String,
303 batch_id: String,
304}
305
306#[derive(Debug, Clone, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct DeleteRecordsResult {
310 pub message: String,
312 #[serde(default, deserialize_with = "crate::common::null_to_default")]
314 pub deleted_count: u32,
315}
316
317#[derive(Default)]
321pub struct UploadBatchCallParams {
322 pub file_path: String,
325 pub batch_id: Option<String>,
327 pub wait_for_parse: Option<bool>,
332 pub poll_interval: Option<Duration>,
334 pub poll_timeout: Option<Duration>,
336 pub on_parsing: Option<Box<dyn FnOnce() + Send>>,
339}
340
341impl fmt::Debug for UploadBatchCallParams {
342 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343 f.debug_struct("UploadBatchCallParams")
344 .field("file_path", &self.file_path)
345 .field("batch_id", &self.batch_id)
346 .field("wait_for_parse", &self.wait_for_parse)
347 .field("poll_interval", &self.poll_interval)
348 .field("poll_timeout", &self.poll_timeout)
349 .field("on_parsing", &self.on_parsing.is_some())
350 .finish()
351 }
352}
353
354#[derive(Debug, Clone, Default, Serialize)]
356#[serde(rename_all = "camelCase")]
357pub struct CreateBatchCallParams {
358 pub batch_name: String,
360 pub phone_number_id: String,
362 pub routing_rule_id: String,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub description: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub timing: Option<BatchCallTiming>,
370 #[serde(skip_serializing_if = "Option::is_none")]
372 pub timezone: Option<String>,
373 #[serde(skip_serializing_if = "Option::is_none")]
375 pub scheduled_time: Option<String>,
376 #[serde(skip_serializing_if = "Option::is_none")]
378 pub scheduled_date: Option<String>,
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub allowed_from: Option<String>,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub allowed_until: Option<String>,
385 #[serde(skip_serializing_if = "Vec::is_empty")]
387 pub days_of_week: Vec<u32>,
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub retry: Option<BatchCallRetryConfig>,
391 #[serde(skip_serializing_if = "Option::is_none")]
393 pub webhook: Option<BatchCallWebhookConfig>,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub batch_id: Option<String>,
397 #[serde(skip_serializing_if = "Option::is_none")]
399 pub save_as_draft: Option<bool>,
400}
401
402#[derive(Debug, Clone, Default, Serialize)]
404#[serde(rename_all = "camelCase")]
405pub struct UpdateBatchCallParams {
406 #[serde(skip_serializing_if = "Option::is_none")]
408 pub batch_name: Option<String>,
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub phone_number_id: Option<String>,
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub routing_rule_id: Option<String>,
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub description: Option<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub timing: Option<BatchCallTiming>,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub timezone: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
426 pub scheduled_time: Option<String>,
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub scheduled_date: Option<String>,
430 #[serde(skip_serializing_if = "Option::is_none")]
432 pub allowed_from: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none")]
435 pub allowed_until: Option<String>,
436 #[serde(skip_serializing_if = "Vec::is_empty")]
438 pub days_of_week: Vec<u32>,
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub retry: Option<BatchCallRetryConfig>,
442 #[serde(skip_serializing_if = "Option::is_none")]
444 pub save_as_draft: Option<bool>,
445}
446
447#[derive(Debug, Clone, Default)]
449pub struct ListBatchCallsParams {
450 pub page: Option<u32>,
452 pub per_page: Option<u32>,
454 pub cursor: Option<String>,
456 pub status: Option<BatchCallStatus>,
458 pub start_date: Option<String>,
460 pub end_date: Option<String>,
462 pub min_value: Option<u32>,
464 pub max_value: Option<u32>,
466}
467
468#[derive(Debug, Clone, Default)]
470pub struct ListBatchCallRecordsParams {
471 pub page: Option<u32>,
473 pub per_page: Option<u32>,
475 pub cursor: Option<String>,
477 pub status: Option<String>,
479 pub search: Option<String>,
481}
482
483#[derive(Debug, Clone, Default, Serialize)]
485#[serde(rename_all = "camelCase")]
486pub struct UpdateBatchCallRecordParams {
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub phone_number: Option<String>,
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub metadata: Option<Map<String, Value>>,
493}
494
495#[derive(Debug, Clone, Default)]
497pub struct ExportBatchCallRecordsParams {
498 pub status: Option<String>,
500 pub search: Option<String>,
502}
503
504macro_rules! pagination {
505 ($name:ident) => {
506 impl $name {
507 fn pagination(&self) -> ListParams {
508 ListParams {
509 page: self.page,
510 per_page: self.per_page,
511 cursor: self.cursor.clone(),
512 }
513 }
514 }
515 };
516}
517
518pagination!(ListBatchCallsParams);
519pagination!(ListBatchCallRecordsParams);
520
521#[derive(Debug, Clone, Copy)]
525pub struct BatchCallsResource<'a> {
526 client: &'a Client,
527}
528
529impl<'a> BatchCallsResource<'a> {
530 pub(crate) fn new(client: &'a Client) -> Self {
531 Self { client }
532 }
533
534 pub fn records(&self) -> BatchCallRecordsResource<'a> {
536 BatchCallRecordsResource {
537 client: self.client,
538 }
539 }
540
541 pub async fn upload(&self, params: UploadBatchCallParams) -> Result<BatchCall> {
553 let path = Path::new(¶ms.file_path);
554 let file_name = path
555 .file_name()
556 .map(|name| name.to_string_lossy().into_owned())
557 .ok_or_else(|| {
558 Error::validation(format!(
559 "invalid batch-call file path {:?}",
560 params.file_path
561 ))
562 })?;
563
564 let extension = path
565 .extension()
566 .map(|e| e.to_string_lossy())
567 .unwrap_or_default();
568 let content_type = upload_content_type(&extension).ok_or_else(|| {
569 Error::validation(format!(
570 "unsupported batch-call file {file_name:?}; allowed types: .csv, .xls, .xlsx"
571 ))
572 })?;
573
574 let data = tokio::fs::read(¶ms.file_path).await.map_err(|e| {
575 Error::validation(format!(
576 "could not read batch-call file {:?}: {e}",
577 params.file_path
578 ))
579 })?;
580 if data.len() > MAX_UPLOAD_BYTES {
581 return Err(Error::validation(format!(
582 "batch-call file exceeds the {} MB limit",
583 MAX_UPLOAD_BYTES / 1024 / 1024
584 )));
585 }
586
587 let mut upload_body = Map::new();
588 upload_body.insert("fileName".into(), json!(file_name));
589 upload_body.insert("fileSize".into(), json!(data.len()));
590 if let Some(batch_id) = ¶ms.batch_id {
591 upload_body.insert("batchId".into(), json!(batch_id));
592 }
593
594 let upload_path = format!("{PATH}/upload");
595 let presigned: BatchPresignedUpload = self
596 .client
597 .json(Method::POST, &upload_path, CallOptions::json(&upload_body)?)
598 .await?;
599
600 self.client
601 .put_binary(&presigned.presigned_url, &data, content_type)
602 .await?;
603
604 let parse_path = format!("{PATH}/parse");
605 let parse_body = json!({ "batchId": presigned.batch_id });
606 self.client
607 .none(Method::POST, &parse_path, CallOptions::json(&parse_body)?)
608 .await?;
609
610 if let Some(on_parsing) = params.on_parsing {
611 on_parsing();
612 }
613
614 if params.wait_for_parse == Some(false) {
615 return self.get(&presigned.batch_id).await;
616 }
617 let (interval, timeout) = resolve_poll(params.poll_interval, params.poll_timeout);
618 self.poll_until_parsed(&presigned.batch_id, interval, timeout)
619 .await
620 }
621
622 async fn poll_until_parsed(
624 &self,
625 batch_id: &str,
626 interval: Duration,
627 timeout: Duration,
628 ) -> Result<BatchCall> {
629 let deadline = Instant::now() + timeout;
630 loop {
631 let batch = self.get(batch_id).await?;
632 match batch.status.as_ref().map(BatchCallStatus::as_str) {
633 Some("parsed") => return Ok(batch),
634 Some("parse_failed") => {
635 let mut message = "parsing the batch-call file failed".to_string();
636 if let Some(parse_error) = &batch.parse_error {
637 message.push_str(": ");
638 message.push_str(parse_error);
639 }
640 return Err(Error::operation(
641 ErrorKind::Generic,
642 "batchcall_parse_failed",
643 message,
644 ));
645 }
646 _ => {}
647 }
648
649 if Instant::now() >= deadline {
650 let status = batch
651 .status
652 .as_ref()
653 .map(BatchCallStatus::as_str)
654 .unwrap_or("unknown");
655 return Err(Error::operation(
656 ErrorKind::Timeout,
657 "batchcall_parse_timeout",
658 format!(
659 "timed out after {timeout:?} waiting for batch {batch_id:?} to finish \
660 parsing (last status: {status})"
661 ),
662 ));
663 }
664 tokio::time::sleep(interval).await;
665 }
666 }
667
668 pub async fn create(&self, params: CreateBatchCallParams) -> Result<BatchCall> {
670 self.client
671 .json(Method::POST, PATH, CallOptions::json(¶ms)?)
672 .await
673 }
674
675 pub async fn list(&self, params: ListBatchCallsParams) -> Result<Page<BatchCall>> {
677 paginate(
678 self.fetcher(¶ms),
679 ¶ms.pagination(),
680 "batchCalls",
681 None,
682 )
683 .await
684 }
685
686 pub fn list_stream(
688 &self,
689 params: ListBatchCallsParams,
690 ) -> impl Stream<Item = Result<BatchCall>> + Send {
691 auto_page(
692 self.fetcher(¶ms),
693 params.pagination(),
694 "batchCalls",
695 None,
696 )
697 }
698
699 pub async fn get(&self, batch_id: &str) -> Result<BatchCall> {
701 let path = format!("{PATH}/{}", escape(batch_id));
702 self.client
703 .json(Method::GET, &path, CallOptions::new())
704 .await
705 }
706
707 pub async fn update(&self, batch_id: &str, params: UpdateBatchCallParams) -> Result<BatchCall> {
709 let path = format!("{PATH}/{}", escape(batch_id));
710 self.client
711 .json(Method::PUT, &path, CallOptions::json(¶ms)?)
712 .await
713 }
714
715 pub async fn delete(&self, batch_id: &str) -> Result<MessageResponse> {
717 let path = format!("{PATH}/{}", escape(batch_id));
718 self.client
719 .json(Method::DELETE, &path, CallOptions::new())
720 .await
721 }
722
723 pub async fn cancel(&self, batch_id: &str, mode: BatchCallCancelMode) -> Result<BatchCall> {
725 let path = format!("{PATH}/{}/cancel", escape(batch_id));
726 let body = json!({ "mode": mode });
727 self.client
728 .json(Method::POST, &path, CallOptions::json(&body)?)
729 .await
730 }
731
732 pub async fn stats(&self, batch_id: &str) -> Result<BatchCallStats> {
734 let path = format!("{PATH}/{}/stats", escape(batch_id));
735 self.client
736 .json(Method::GET, &path, CallOptions::new())
737 .await
738 }
739
740 fn fetcher(&self, params: &ListBatchCallsParams) -> PageFetcher {
741 let client = self.client.clone();
742 let params = params.clone();
743 Arc::new(move |page, per_page| {
744 let client = client.clone();
745 let params = params.clone();
746 Box::pin(async move {
747 let query = QueryBuilder::new()
748 .opt("page", page)
749 .opt("perPage", per_page)
750 .opt_str(
751 "status",
752 params.status.as_ref().map(BatchCallStatus::as_str),
753 )
754 .opt_str("startDate", params.start_date.as_deref())
755 .opt_str("endDate", params.end_date.as_deref())
756 .opt("minValue", params.min_value)
757 .opt("maxValue", params.max_value)
758 .into_pairs();
759 client
760 .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
761 .await
762 })
763 })
764 }
765}
766
767#[derive(Debug, Clone, Copy)]
769pub struct BatchCallRecordsResource<'a> {
770 client: &'a Client,
771}
772
773impl<'a> BatchCallRecordsResource<'a> {
774 pub async fn list(
776 &self,
777 batch_id: &str,
778 params: ListBatchCallRecordsParams,
779 ) -> Result<Page<BatchCallRecord>> {
780 let fetcher = self.fetcher(batch_id, ¶ms);
781 paginate(fetcher, ¶ms.pagination(), "records", None).await
782 }
783
784 pub fn list_stream(
786 &self,
787 batch_id: &str,
788 params: ListBatchCallRecordsParams,
789 ) -> impl Stream<Item = Result<BatchCallRecord>> + Send {
790 let fetcher = self.fetcher(batch_id, ¶ms);
791 auto_page(fetcher, params.pagination(), "records", None)
792 }
793
794 pub async fn update(
796 &self,
797 batch_id: &str,
798 record_id: &str,
799 params: UpdateBatchCallRecordParams,
800 ) -> Result<BatchCallRecord> {
801 let path = format!("{PATH}/{}/records/{}", escape(batch_id), escape(record_id));
802 self.client
803 .json(Method::PUT, &path, CallOptions::json(¶ms)?)
804 .await
805 }
806
807 pub async fn delete(
809 &self,
810 batch_id: &str,
811 record_ids: &[String],
812 ) -> Result<DeleteRecordsResult> {
813 let path = format!("{PATH}/{}/records", escape(batch_id));
814 let body = json!({ "recordIds": record_ids });
815 self.client
816 .json(Method::DELETE, &path, CallOptions::json(&body)?)
817 .await
818 }
819
820 pub async fn export(
822 &self,
823 batch_id: &str,
824 params: ExportBatchCallRecordsParams,
825 ) -> Result<String> {
826 let path = format!("{PATH}/{}/records/export", escape(batch_id));
827 let query = QueryBuilder::new()
828 .opt_str("status", params.status.as_deref())
829 .opt_str("search", params.search.as_deref())
830 .into_pairs();
831 self.client
832 .text(Method::GET, &path, CallOptions::new().query(query))
833 .await
834 }
835
836 fn fetcher(&self, batch_id: &str, params: &ListBatchCallRecordsParams) -> PageFetcher {
837 let client = self.client.clone();
838 let params = params.clone();
839 let path = format!("{PATH}/{}/records", escape(batch_id));
840 Arc::new(move |page, per_page| {
841 let client = client.clone();
842 let params = params.clone();
843 let path = path.clone();
844 Box::pin(async move {
845 let query = QueryBuilder::new()
847 .opt("page", page)
848 .opt("perPage", per_page)
849 .opt_str("status", params.status.as_deref())
850 .opt_str("query", params.search.as_deref())
851 .into_pairs();
852 client
853 .json::<Value>(Method::GET, &path, CallOptions::new().query(query))
854 .await
855 })
856 })
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 #[test]
865 fn stats_collect_every_numeric_sibling_into_counts() {
866 let stats: BatchCallStats = serde_json::from_value(json!({
867 "batchId": "b-1",
868 "status": "running",
869 "total": 10,
870 "scheduled": 3,
871 "completed": 5,
872 "not connected": 2,
873 "someString": "ignored",
874 }))
875 .unwrap();
876
877 assert_eq!(stats.batch_id, "b-1");
878 assert_eq!(stats.status.unwrap().as_str(), "running");
879 assert_eq!(stats.total, Some(10));
880 assert_eq!(stats.counts["scheduled"], 3);
881 assert_eq!(stats.counts["not connected"], 2);
882 assert_eq!(stats.counts["total"], 10, "total is also a count");
883 assert!(!stats.counts.contains_key("someString"));
884 assert!(!stats.counts.contains_key("status"));
885 }
886
887 #[test]
888 fn a_zero_poll_interval_or_timeout_falls_back_to_the_default() {
889 let (interval, timeout) = resolve_poll(Some(Duration::ZERO), Some(Duration::ZERO));
891 assert_eq!(interval, DEFAULT_POLL_INTERVAL);
892 assert_eq!(timeout, DEFAULT_POLL_TIMEOUT);
893
894 let (interval, timeout) = resolve_poll(None, None);
895 assert_eq!(interval, DEFAULT_POLL_INTERVAL);
896 assert_eq!(timeout, DEFAULT_POLL_TIMEOUT);
897
898 let (interval, timeout) = resolve_poll(
900 Some(Duration::from_millis(5)),
901 Some(Duration::from_millis(50)),
902 );
903 assert_eq!(interval, Duration::from_millis(5));
904 assert_eq!(timeout, Duration::from_millis(50));
905 }
906
907 #[test]
908 fn upload_content_types_cover_the_allowed_extensions() {
909 assert_eq!(upload_content_type("csv"), Some("text/csv"));
910 assert_eq!(upload_content_type("CSV"), Some("text/csv"));
911 assert_eq!(upload_content_type("xls"), Some("application/vnd.ms-excel"));
912 assert!(upload_content_type("xlsx").is_some());
913 assert_eq!(upload_content_type("txt"), None);
914 assert_eq!(upload_content_type(""), None);
915 }
916}