use std::time::Duration;
use httpmock::{Method::POST, MockServer};
use serde_json::json;
use uni_sdk::{
SendMessageRequest, SendOtpRequest, UniClient, UniError, VerifyOtpRequest, USER_AGENT,
};
fn unsigned_client(server: &MockServer) -> UniClient {
UniClient::builder()
.access_key_id("test-access-key")
.unsigned()
.endpoint(server.base_url())
.build()
.unwrap()
}
#[tokio::test]
async fn sends_a_text_message_and_decodes_typed_data() {
let server = MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(POST)
.path("/")
.query_param("action", "sms.message.send")
.query_param("accessKeyId", "test-access-key")
.header("user-agent", USER_AGENT)
.header("content-type", "application/json;charset=utf-8")
.json_body(json!({
"to": ["+12068800001", "+12068800002"],
"text": "Your verification code is 2048."
}));
then.status(200)
.header("content-type", "application/json")
.header("x-uni-request-id", "req-message")
.json_body(json!({
"code": "0",
"message": "OK",
"data": {
"recipients": 2,
"messageCount": 2,
"currency": "USD",
"totalAmount": "0.02",
"messages": [{
"id": "message-1",
"to": "+12068800001",
"iso": "US",
"cc": "1",
"count": 1,
"price": "0.01"
}]
}
}));
})
.await;
let request = SendMessageRequest::text(
["+12068800001", "+12068800002"],
"Your verification code is 2048.",
);
let response = unsigned_client(&server)
.messages()
.send(&request)
.await
.unwrap();
mock.assert_async().await;
assert_eq!(response.request_id.as_deref(), Some("req-message"));
let data = response.into_data().unwrap();
assert_eq!(data.recipients, 2);
assert_eq!(data.messages[0].id, "message-1");
}
#[tokio::test]
async fn sends_and_verifies_an_otp() {
let server = MockServer::start_async().await;
let send = server
.mock_async(|when, then| {
when.method(POST)
.query_param("action", "otp.send")
.json_body(json!({
"to": "+12068800000",
"digits": 6,
"channel": "sms"
}));
then.status(200).json_body(json!({
"code": 0,
"message": "OK",
"data": {
"id": "otp-message",
"to": "+12068800000",
"iso": "US",
"cc": "1",
"count": 1,
"price": "0.01"
}
}));
})
.await;
let verify = server
.mock_async(|when, then| {
when.method(POST)
.query_param("action", "otp.verify")
.json_body(json!({"to": "+12068800000", "code": "123456"}));
then.status(200).json_body(json!({
"code": "0",
"message": "OK",
"data": {"to": "+12068800000", "valid": true}
}));
})
.await;
let client = unsigned_client(&server);
let sent = client
.otp()
.send(
&SendOtpRequest::new("+12068800000")
.with_digits(6)
.with_channel(uni_sdk::OtpChannel::Sms),
)
.await
.unwrap()
.into_data()
.unwrap();
let verified = client
.otp()
.verify(&VerifyOtpRequest::new("+12068800000", "123456"))
.await
.unwrap()
.into_data()
.unwrap();
send.assert_async().await;
verify.assert_async().await;
assert_eq!(sent.id, "otp-message");
assert!(verified.valid);
}
#[tokio::test]
async fn exposes_api_error_context() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(POST);
then.status(400)
.header("x-uni-request-id", "req-error")
.json_body(json!({"code": "InvalidPhone", "message": "invalid phone"}));
})
.await;
let error = unsigned_client(&server)
.otp()
.send(&SendOtpRequest::new("invalid"))
.await
.unwrap_err();
assert_eq!(error.code(), Some("InvalidPhone"));
assert_eq!(error.request_id(), Some("req-error"));
assert_eq!(error.status().unwrap().as_u16(), 400);
assert_eq!(
error.to_string(),
"[InvalidPhone] invalid phone, RequestId: req-error"
);
}
#[tokio::test]
async fn distinguishes_http_and_invalid_response_errors() {
let server = MockServer::start_async().await;
let http_mock = server
.mock_async(|when, then| {
when.method(POST).query_param("action", "http.error");
then.status(503)
.json_body(json!({"code": "0", "message": "maintenance"}));
})
.await;
let invalid_mock = server
.mock_async(|when, then| {
when.method(POST).query_param("action", "invalid.response");
then.status(200).body("not-json");
})
.await;
let client = unsigned_client(&server);
let http = client
.request::<serde_json::Value, _>("http.error", &json!({}))
.await
.unwrap_err();
let invalid = client
.request::<serde_json::Value, _>("invalid.response", &json!({}))
.await
.unwrap_err();
http_mock.assert_async().await;
invalid_mock.assert_async().await;
assert!(matches!(http, UniError::Http { .. }));
assert!(matches!(invalid, UniError::InvalidResponse { .. }));
}
#[tokio::test]
async fn transport_errors_do_not_expose_signed_request_urls() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(POST);
then.delay(Duration::from_millis(100));
})
.await;
let client = UniClient::builder()
.access_key_id("sensitive-access-key")
.access_key_secret("sensitive-access-key-secret")
.endpoint(server.base_url())
.timeout(Duration::from_millis(1))
.build()
.unwrap();
let error = client
.otp()
.send(&SendOtpRequest::new("+12068800000"))
.await
.unwrap_err();
assert!(std::error::Error::source(&error).is_some());
match error {
UniError::Transport(source) => {
assert!(source.url().is_none());
assert!(!source.to_string().contains("sensitive-access-key"));
assert!(!source.to_string().contains("signature"));
}
other => panic!("expected a transport error, got {other:?}"),
}
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_client_sends_and_decodes_a_message() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.query_param("action", "sms.message.send")
.json_body(json!({"to": "+12068800000", "text": "hello"}));
then.status(200).json_body(json!({
"code": "0",
"message": "OK",
"data": {
"recipients": 1,
"messageCount": 1,
"currency": "USD",
"totalAmount": "0.01",
"messages": [{
"id": "message-1",
"to": "+12068800000",
"iso": "US",
"cc": "1",
"count": 1,
"price": "0.01"
}]
}
}));
});
let client = uni_sdk::blocking::UniClient::builder()
.access_key_id("test-access-key")
.unsigned()
.endpoint(server.base_url())
.build()
.unwrap();
let response = client
.messages()
.send(&SendMessageRequest::text("+12068800000", "hello"))
.unwrap();
mock.assert();
assert_eq!(response.into_data().unwrap().recipients, 1);
}
#[test]
fn validates_required_configuration_and_endpoint() {
let missing_key = UniClient::builder()
.access_key_id("")
.endpoint("https://api.unimtx.com")
.build()
.unwrap_err();
assert!(matches!(missing_key, UniError::Configuration(_)));
let bad_endpoint = UniClient::builder()
.access_key_id("key")
.endpoint("ftp://api.unimtx.com")
.build()
.unwrap_err();
assert!(matches!(bad_endpoint, UniError::Configuration(_)));
}
#[test]
fn debug_output_never_exposes_the_access_key_secret() {
let client = UniClient::new("access-key", "super-secret").unwrap();
let builder = UniClient::builder()
.access_key_id("access-key")
.access_key_secret("super-secret");
assert!(!format!("{client:?}").contains("super-secret"));
assert!(!format!("{builder:?}").contains("super-secret"));
}