Переглянути джерело

wip - bitreq conditional usage

lescuer97 1 місяць тому
батько
коміт
b554b07d56

+ 18 - 0
Cargo.lock

@@ -963,6 +963,22 @@ dependencies = [
 ]
 
 [[package]]
+name = "bitreq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bf88316bfce42d7db313826acfa4325857c085e26f163c3e8c1cfb38fef4007"
+dependencies = [
+ "base64 0.22.1",
+ "rustls 0.21.12",
+ "rustls-webpki 0.101.7",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tokio-rustls 0.24.1",
+ "webpki-roots 0.25.4",
+]
+
+[[package]]
 name = "bitvec"
 version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1386,11 +1402,13 @@ dependencies = [
 name = "cdk-http-client"
 version = "0.15.0"
 dependencies = [
+ "bitreq",
  "mockito",
  "regex",
  "reqwest",
  "serde",
  "serde_json",
+ "serde_urlencoded",
  "thiserror 2.0.18",
  "tokio",
  "url",

+ 2 - 1
crates/cdk-http-client/Cargo.toml

@@ -17,7 +17,8 @@ thiserror.workspace = true
 url.workspace = true
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-reqwest = { workspace = true }
+bitreq = { version = "0.3.1", features = ["async", "async-https-rustls", "json-using-serde", "proxy"] }
+serde_urlencoded = "0.7"
 regex = { workspace = true }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]

+ 230 - 75
crates/cdk-http-client/src/client.rs

@@ -3,22 +3,38 @@
 use serde::de::DeserializeOwned;
 use serde::Serialize;
 
+#[cfg(not(target_arch = "wasm32"))]
+use bitreq::RequestExt;
+
 use crate::error::HttpError;
 use crate::request::RequestBuilder;
 use crate::response::{RawResponse, Response};
 
 /// HTTP client wrapper
-#[derive(Debug, Clone)]
+#[derive(Clone)]
 pub struct HttpClient {
+    #[cfg(target_arch = "wasm32")]
     inner: reqwest::Client,
+    #[cfg(not(target_arch = "wasm32"))]
+    inner: bitreq::Client,
+    #[cfg(not(target_arch = "wasm32"))]
+    proxy_config: Option<ProxyConfig>,
 }
 
-impl Default for HttpClient {
-    fn default() -> Self {
-        Self::new()
+impl std::fmt::Debug for HttpClient {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("HttpClient").finish()
     }
 }
 
+// #[cfg(not(target_arch = "wasm32"))]
+// impl std::fmt::Debug for HttpClient {
+//     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+//         f.debug_struct("HttpClient").finish()
+//     }
+// }
+
+#[cfg(target_arch = "wasm32")]
 impl HttpClient {
     /// Create a new HTTP client with default settings
     pub fn new() -> Self {
@@ -27,23 +43,18 @@ impl HttpClient {
         }
     }
 
-    /// Create a new HTTP client builder
-    pub fn builder() -> HttpClientBuilder {
-        HttpClientBuilder::default()
-    }
-
     /// Create an HttpClient from a reqwest::Client
     pub fn from_reqwest(client: reqwest::Client) -> Self {
         Self { inner: client }
     }
 
-    // === Simple convenience methods ===
+    /// Create a new HTTP client builder
+    pub fn builder() -> HttpClientBuilder {
+        HttpClientBuilder::default()
+    }
 
     /// GET request, returns JSON deserialized to R
-    pub async fn fetch<R>(&self, url: &str) -> Response<R>
-    where
-        R: DeserializeOwned,
-    {
+    pub async fn fetch<R: DeserializeOwned>(&self, url: &str) -> Response<R> {
         let response = self.inner.get(url).send().await?;
         let status = response.status();
 
@@ -59,11 +70,11 @@ impl HttpClient {
     }
 
     /// POST with JSON body, returns JSON deserialized to R
-    pub async fn post_json<B, R>(&self, url: &str, body: &B) -> Response<R>
-    where
-        B: Serialize + ?Sized,
-        R: DeserializeOwned,
-    {
+    pub async fn post_json<B: Serialize + ?Sized, R: DeserializeOwned>(
+        &self,
+        url: &str,
+        body: &B,
+    ) -> Response<R> {
         let response = self.inner.post(url).json(body).send().await?;
         let status = response.status();
 
@@ -79,11 +90,11 @@ impl HttpClient {
     }
 
     /// POST with form data, returns JSON deserialized to R
-    pub async fn post_form<F, R>(&self, url: &str, form: &F) -> Response<R>
-    where
-        F: Serialize + ?Sized,
-        R: DeserializeOwned,
-    {
+    pub async fn post_form<F: Serialize + ?Sized, R: DeserializeOwned>(
+        &self,
+        url: &str,
+        form: &F,
+    ) -> Response<R> {
         let response = self.inner.post(url).form(form).send().await?;
         let status = response.status();
 
@@ -99,11 +110,11 @@ impl HttpClient {
     }
 
     /// PATCH with JSON body, returns JSON deserialized to R
-    pub async fn patch_json<B, R>(&self, url: &str, body: &B) -> Response<R>
-    where
-        B: Serialize + ?Sized,
-        R: DeserializeOwned,
-    {
+    pub async fn patch_json<B: Serialize + ?Sized, R: DeserializeOwned>(
+        &self,
+        url: &str,
+        body: &B,
+    ) -> Response<R> {
         let response = self.inner.patch(url).json(body).send().await?;
         let status = response.status();
 
@@ -118,32 +129,188 @@ impl HttpClient {
         response.json().await.map_err(HttpError::from)
     }
 
-    // === Raw request methods ===
-
     /// GET request returning raw response body
     pub async fn get_raw(&self, url: &str) -> Response<RawResponse> {
         let response = self.inner.get(url).send().await?;
         Ok(RawResponse::new(response))
     }
 
-    // === Request builder methods ===
-
-    /// POST request builder for complex cases (custom headers, form data, etc.)
+    /// POST request builder for complex cases
     pub fn post(&self, url: &str) -> RequestBuilder {
         RequestBuilder::new(self.inner.post(url))
     }
 
-    /// GET request builder for complex cases (custom headers, etc.)
+    /// GET request builder for complex cases
     pub fn get(&self, url: &str) -> RequestBuilder {
         RequestBuilder::new(self.inner.get(url))
     }
 
-    /// PATCH request builder for complex cases (custom headers, JSON body, etc.)
+    /// PATCH request builder for complex cases
     pub fn patch(&self, url: &str) -> RequestBuilder {
         RequestBuilder::new(self.inner.patch(url))
     }
 }
 
+#[cfg(not(target_arch = "wasm32"))]
+impl HttpClient {
+    /// Create a new HTTP client with default settings
+    pub fn new() -> Self {
+        Self {
+            inner: bitreq::Client::new(10), // Default capacity of 10
+            proxy_config: None,
+        }
+    }
+
+    /// Create a new HTTP client builder
+    pub fn builder() -> HttpClientBuilder {
+        HttpClientBuilder::default()
+    }
+
+    /// Helper method to apply proxy if URL matches the configured proxy rules
+    fn apply_proxy_if_needed(&self, request: bitreq::Request, url: &str) -> Response<bitreq::Request> {
+        if let Some(ref config) = self.proxy_config {
+            if let Some(ref matcher) = config.matcher {
+                // Check if URL host matches the regex pattern
+                if let Ok(parsed_url) = url::Url::parse(url) {
+                    if let Some(host) = parsed_url.host_str() {
+                        if matcher.is_match(host) {
+                            let proxy = bitreq::Proxy::new_http(&config.url.to_string())
+                                .map_err(|e| HttpError::Proxy(e.to_string()))?;
+                            return Ok(request.with_proxy(proxy));
+                        }
+                    }
+                }
+            } else {
+                // No matcher, apply proxy to all requests
+                let proxy = bitreq::Proxy::new_http(&config.url.to_string())
+                    .map_err(|e| HttpError::Proxy(e.to_string()))?;
+                return Ok(request.with_proxy(proxy));
+            }
+        }
+        Ok(request)
+    }
+
+    /// GET request, returns JSON deserialized to R
+    pub async fn fetch<R: DeserializeOwned>(&self, url: &str) -> Response<R> {
+        let request = bitreq::get(url);
+        let request = self.apply_proxy_if_needed(request, url)?;
+        let response = request.send_async_with_client(&self.inner).await.map_err(HttpError::from)?;
+        let status = response.status_code;
+
+        if !(200..300).contains(&status) {
+            let message = response.as_str().unwrap_or("").to_string();
+            return Err(HttpError::Status {
+                status: status as u16,
+                message,
+            });
+        }
+
+        response.json().map_err(HttpError::from)
+    }
+
+    /// POST with JSON body, returns JSON deserialized to R
+    pub async fn post_json<B: Serialize, R: DeserializeOwned>(
+        &self,
+        url: &str,
+        body: &B,
+    ) -> Response<R> {
+        let request = bitreq::post(url)
+            .with_json(body)
+            .map_err(HttpError::from)?;
+        let request = self.apply_proxy_if_needed(request, url)?;
+        let response: bitreq::Response = request.send_async_with_client(&self.inner).await.map_err(HttpError::from)?;
+        let status = response.status_code;
+
+        if !(200..300).contains(&status) {
+            let message = response.as_str().unwrap_or("").to_string();
+            return Err(HttpError::Status {
+                status: status as u16,
+                message,
+            });
+        }
+
+        response.json().map_err(HttpError::from)
+    }
+
+    /// POST with form data, returns JSON deserialized to R
+    pub async fn post_form<F: Serialize, R: DeserializeOwned>(
+        &self,
+        url: &str,
+        form: &F,
+    ) -> Response<R> {
+        let form_str = serde_urlencoded::to_string(form)
+            .map_err(|e| HttpError::Serialization(e.to_string()))?;
+        let request = bitreq::post(url).with_body(form_str.into_bytes());
+        let request = self.apply_proxy_if_needed(request, url)?;
+        let response: bitreq::Response = request.send_async_with_client(&self.inner).await.map_err(HttpError::from)?;
+        let status = response.status_code;
+
+        if !(200..300).contains(&status) {
+            let message = response.as_str().unwrap_or("").to_string();
+            return Err(HttpError::Status {
+                status: status as u16,
+                message,
+            });
+        }
+
+        response.json().map_err(HttpError::from)
+    }
+
+    /// PATCH with JSON body, returns JSON deserialized to R
+    pub async fn patch_json<B: Serialize, R: DeserializeOwned>(
+        &self,
+        url: &str,
+        body: &B,
+    ) -> Response<R> {
+        let request = bitreq::patch(url)
+            .with_json(body)
+            .map_err(HttpError::from)?;
+        let request = self.apply_proxy_if_needed(request, url)?;
+        let response: bitreq::Response = request.send_async_with_client(&self.inner).await.map_err(HttpError::from)?;
+        let status = response.status_code;
+
+        if !(200..300).contains(&status) {
+            let message = response.as_str().unwrap_or("").to_string();
+            return Err(HttpError::Status {
+                status: status as u16,
+                message,
+            });
+        }
+
+        response.json().map_err(HttpError::from)
+    }
+
+    /// GET request returning raw response body
+    pub async fn get_raw(&self, url: &str) -> Response<RawResponse> {
+        let request = bitreq::get(url);
+        let request = self.apply_proxy_if_needed(request, url)?;
+        let response = request.send_async_with_client(&self.inner).await.map_err(HttpError::from)?;
+        Ok(RawResponse::new(response))
+    }
+
+    /// POST request builder for complex cases
+    pub fn post(&self, url: &str) -> RequestBuilder {
+        // Note: Proxy will be applied when the request is sent
+        RequestBuilder::new(bitreq::post(url))
+    }
+
+    /// GET request builder for complex cases
+    pub fn get(&self, url: &str) -> RequestBuilder {
+        RequestBuilder::new(bitreq::get(url))
+    }
+
+    /// PATCH request builder for complex cases
+    pub fn patch(&self, url: &str) -> RequestBuilder {
+        RequestBuilder::new(bitreq::patch(url))
+    }
+}
+
+impl Default for HttpClient {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
 /// HTTP client builder for configuring proxy and TLS settings
 #[derive(Debug, Default)]
 pub struct HttpClientBuilder {
@@ -154,7 +321,7 @@ pub struct HttpClientBuilder {
 }
 
 #[cfg(not(target_arch = "wasm32"))]
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 struct ProxyConfig {
     url: url::Url,
     matcher: Option<regex::Regex>,
@@ -189,39 +356,31 @@ impl HttpClientBuilder {
 
     /// Build the HTTP client
     pub fn build(self) -> Response<HttpClient> {
-        #[cfg(not(target_arch = "wasm32"))]
+        #[cfg(target_arch = "wasm32")]
         {
-            let mut builder =
-                reqwest::Client::builder().danger_accept_invalid_certs(self.accept_invalid_certs);
-
-            if let Some(proxy_config) = self.proxy {
-                let proxy_url = proxy_config.url.to_string();
-                let proxy = if let Some(matcher) = proxy_config.matcher {
-                    reqwest::Proxy::custom(move |url| {
-                        if matcher.is_match(url.host_str().unwrap_or("")) {
-                            Some(proxy_url.clone())
-                        } else {
-                            None
-                        }
-                    })
-                } else {
-                    reqwest::Proxy::all(&proxy_url).map_err(|e| HttpError::Proxy(e.to_string()))?
-                };
-                builder = builder.proxy(proxy);
-            }
-
-            let client = builder.build().map_err(HttpError::from)?;
-            Ok(HttpClient { inner: client })
+            Ok(HttpClient {
+                inner: reqwest::Client::new(),
+            })
         }
 
-        #[cfg(target_arch = "wasm32")]
+        #[cfg(not(target_arch = "wasm32"))]
         {
-            Ok(HttpClient::new())
+            // Return error if danger_accept_invalid_certs was set on non-wasm32
+            if self.accept_invalid_certs {
+                return Err(HttpError::Build(
+                    "danger_accept_invalid_certs is not supported on non-WASM targets".to_string(),
+                ));
+            }
+
+            Ok(HttpClient {
+                inner: bitreq::Client::new(10), // Default capacity of 10
+                proxy_config: self.proxy,
+            })
         }
     }
 }
 
-/// Convenience function for simple GET requests (replaces reqwest::get)
+/// Convenience function for simple GET requests
 pub async fn fetch<R: DeserializeOwned>(url: &str) -> Response<R> {
     HttpClient::new().fetch(url).await
 }
@@ -256,6 +415,7 @@ mod tests {
         assert!(result.is_ok());
     }
 
+    #[cfg(target_arch = "wasm32")]
     #[test]
     fn test_from_reqwest() {
         let reqwest_client = reqwest::Client::new();
@@ -268,15 +428,20 @@ mod tests {
         use super::*;
 
         #[test]
-        fn test_builder_accept_invalid_certs() {
+        fn test_builder_accept_invalid_certs_returns_error() {
             let result = HttpClientBuilder::default()
                 .danger_accept_invalid_certs(true)
                 .build();
-            assert!(result.is_ok());
+            assert!(result.is_err());
+            if let Err(HttpError::Build(msg)) = result {
+                assert!(msg.contains("danger_accept_invalid_certs"));
+            } else {
+                panic!("Expected HttpError::Build");
+            }
         }
 
         #[test]
-        fn test_builder_accept_invalid_certs_false() {
+        fn test_builder_accept_invalid_certs_false_ok() {
             let result = HttpClientBuilder::default()
                 .danger_accept_invalid_certs(false)
                 .build();
@@ -315,15 +480,5 @@ mod tests {
                 panic!("Expected HttpError::Proxy");
             }
         }
-
-        #[test]
-        fn test_builder_chained_config() {
-            let proxy_url = url::Url::parse("http://localhost:8080").expect("Valid proxy URL");
-            let result = HttpClientBuilder::default()
-                .danger_accept_invalid_certs(true)
-                .proxy(proxy_url)
-                .build();
-            assert!(result.is_ok());
-        }
     }
 }

+ 29 - 5
crates/cdk-http-client/src/error.rs

@@ -33,6 +33,7 @@ pub enum HttpError {
     Other(String),
 }
 
+#[cfg(target_arch = "wasm32")]
 impl From<reqwest::Error> for HttpError {
     fn from(err: reqwest::Error) -> Self {
         if err.is_timeout() {
@@ -45,16 +46,39 @@ impl From<reqwest::Error> for HttpError {
                 message: err.to_string(),
             }
         } else {
-            // is_connect() is not available on wasm32
-            #[cfg(not(target_arch = "wasm32"))]
-            if err.is_connect() {
-                return HttpError::Connection(err.to_string());
-            }
             HttpError::Other(err.to_string())
         }
     }
 }
 
+#[cfg(not(target_arch = "wasm32"))]
+impl From<bitreq::Error> for HttpError {
+    fn from(err: bitreq::Error) -> Self {
+        use bitreq::Error;
+        use std::io;
+
+        match err {
+            Error::InvalidUtf8InBody(_) => HttpError::Serialization(err.to_string()),
+            Error::InvalidUtf8InResponse => HttpError::Serialization(err.to_string()),
+            Error::IoError(io_err) => {
+                if io_err.kind() == io::ErrorKind::TimedOut {
+                    HttpError::Timeout
+                } else if io_err.kind() == io::ErrorKind::ConnectionRefused
+                    || io_err.kind() == io::ErrorKind::ConnectionReset
+                    || io_err.kind() == io::ErrorKind::ConnectionAborted
+                    || io_err.kind() == io::ErrorKind::NotConnected
+                {
+                    HttpError::Connection(io_err.to_string())
+                } else {
+                    HttpError::Other(io_err.to_string())
+                }
+            }
+            Error::AddressNotFound => HttpError::Connection(err.to_string()),
+            _ => HttpError::Other(err.to_string()),
+        }
+    }
+}
+
 impl From<serde_json::Error> for HttpError {
     fn from(err: serde_json::Error) -> Self {
         HttpError::Serialization(err.to_string())

+ 87 - 2
crates/cdk-http-client/src/request.rs

@@ -9,9 +9,15 @@ use crate::response::{RawResponse, Response};
 /// HTTP request builder for complex requests
 #[derive(Debug)]
 pub struct RequestBuilder {
+    #[cfg(target_arch = "wasm32")]
     inner: reqwest::RequestBuilder,
+    #[cfg(not(target_arch = "wasm32"))]
+    inner: Option<bitreq::Request>,
+    #[cfg(not(target_arch = "wasm32"))]
+    error: Option<HttpError>,
 }
 
+#[cfg(target_arch = "wasm32")]
 impl RequestBuilder {
     /// Create a new RequestBuilder from a reqwest::RequestBuilder
     pub(crate) fn new(inner: reqwest::RequestBuilder) -> Self {
@@ -26,14 +32,14 @@ impl RequestBuilder {
     }
 
     /// Set the request body as JSON
-    pub fn json<T: Serialize + ?Sized>(self, body: &T) -> Self {
+    pub fn json<T: Serialize>(self, body: &T) -> Self {
         Self {
             inner: self.inner.json(body),
         }
     }
 
     /// Set the request body as form data
-    pub fn form<T: Serialize + ?Sized>(self, body: &T) -> Self {
+    pub fn form<T: Serialize>(self, body: &T) -> Self {
         Self {
             inner: self.inner.form(body),
         }
@@ -61,3 +67,82 @@ impl RequestBuilder {
         response.json().await.map_err(HttpError::from)
     }
 }
+
+#[cfg(not(target_arch = "wasm32"))]
+impl RequestBuilder {
+    /// Create a new RequestBuilder from a bitreq::Request
+    pub(crate) fn new(inner: bitreq::Request) -> Self {
+        Self {
+            inner: Some(inner),
+            error: None,
+        }
+    }
+
+    /// Add a header to the request
+    pub fn header(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
+        if let Some(req) = self.inner.take() {
+            self.inner = Some(req.with_header(key.as_ref(), value.as_ref()));
+        }
+        self
+    }
+
+    /// Set the request body as JSON
+    pub fn json<T: Serialize>(mut self, body: &T) -> Self {
+        if let Some(req) = self.inner.take() {
+            match req.with_json(body) {
+                Ok(req) => self.inner = Some(req),
+                Err(e) => self.error = Some(HttpError::from(e)),
+            }
+        }
+        self
+    }
+
+    /// Set the request body as form data
+    pub fn form<T: Serialize>(mut self, body: &T) -> Self {
+        match serde_urlencoded::to_string(body) {
+            Ok(form_str) => {
+                if let Some(req) = self.inner.take() {
+                    self.inner = Some(req.with_body(form_str.into_bytes()));
+                }
+            }
+            Err(e) => self.error = Some(HttpError::Serialization(e.to_string())),
+        }
+        self
+    }
+
+    /// Send the request and return a raw response
+    pub async fn send(mut self) -> Response<RawResponse> {
+        if let Some(err) = self.error {
+            return Err(err);
+        }
+        let req = self
+            .inner
+            .take()
+            .ok_or_else(|| HttpError::Other("Request already consumed".to_string()))?;
+        let response = req.send_async().await.map_err(HttpError::from)?;
+        Ok(RawResponse::new(response))
+    }
+
+    /// Send the request and deserialize the response as JSON
+    pub async fn send_json<R: DeserializeOwned>(mut self) -> Response<R> {
+        if let Some(err) = self.error {
+            return Err(err);
+        }
+        let req = self
+            .inner
+            .take()
+            .ok_or_else(|| HttpError::Other("Request already consumed".to_string()))?;
+        let response = req.send_async().await.map_err(HttpError::from)?;
+        let status = response.status_code;
+
+        if !(200..300).contains(&status) {
+            let message = response.as_str().unwrap_or("").to_string();
+            return Err(HttpError::Status {
+                status: status as u16,
+                message,
+            });
+        }
+
+        response.json().map_err(HttpError::from)
+    }
+}

+ 54 - 1
crates/cdk-http-client/src/response.rs

@@ -12,9 +12,13 @@ pub type Response<R, E = HttpError> = Result<R, E>;
 #[derive(Debug)]
 pub struct RawResponse {
     status: u16,
+    #[cfg(target_arch = "wasm32")]
     inner: reqwest::Response,
+    #[cfg(not(target_arch = "wasm32"))]
+    inner: bitreq::Response,
 }
 
+#[cfg(target_arch = "wasm32")]
 impl RawResponse {
     /// Create a new RawResponse from a reqwest::Response
     pub(crate) fn new(response: reqwest::Response) -> Self {
@@ -64,11 +68,60 @@ impl RawResponse {
     }
 }
 
+#[cfg(not(target_arch = "wasm32"))]
+impl RawResponse {
+    /// Create a new RawResponse from a bitreq::Response
+    pub(crate) fn new(response: bitreq::Response) -> Self {
+        Self {
+            status: response.status_code as u16,
+            inner: response,
+        }
+    }
+
+    /// Get the HTTP status code
+    pub fn status(&self) -> u16 {
+        self.status
+    }
+
+    /// Check if the response status is a success (2xx)
+    pub fn is_success(&self) -> bool {
+        (200..300).contains(&self.status)
+    }
+
+    /// Check if the response status is a client error (4xx)
+    pub fn is_client_error(&self) -> bool {
+        (400..500).contains(&self.status)
+    }
+
+    /// Check if the response status is a server error (5xx)
+    pub fn is_server_error(&self) -> bool {
+        (500..600).contains(&self.status)
+    }
+
+    /// Get the response body as text
+    pub async fn text(self) -> Response<String> {
+        self.inner
+            .as_str()
+            .map(|s| s.to_string())
+            .map_err(HttpError::from)
+    }
+
+    /// Get the response body as JSON
+    pub async fn json<T: DeserializeOwned>(self) -> Response<T> {
+        self.inner.json().map_err(HttpError::from)
+    }
+
+    /// Get the response body as bytes
+    pub async fn bytes(self) -> Response<Vec<u8>> {
+        Ok(self.inner.into_bytes())
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    // Note: RawResponse tests require a real reqwest::Response,
+    // Note: RawResponse tests require a real response,
     // so they are in tests/integration.rs using mockito.
 
     #[test]