uni-sdk 0.3.0

The official Unimatrix SDK for Rust
Documentation
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"));
}