aelf-client 0.1.0-alpha.1

HTTP client and DTOs for AElf Rust SDK.
Documentation
use crate::dto::CreateRawTransactionInput;
use crate::provider::{MockCallKind, MockProvider, MockRecordedRequest, MockResponse};
use crate::{AElfClient, AElfError};
use http::Method;
use serde_json::{json, Value};

const VALID_TX_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

fn mock_client(responses: Vec<MockResponse>) -> (AElfClient, MockProvider) {
    let provider = MockProvider::new(responses);
    let client = AElfClient::with_provider(provider.clone()).expect("client");
    (client, provider)
}

fn chain_status_json() -> Value {
    json!({
        "ChainId": "AELF",
        "Branches": {},
        "NotLinkedBlocks": {},
        "LongestChainHeight": 1,
        "LongestChainHash": "0x01",
        "GenesisBlockHash": "0x02",
        "GenesisContractAddress": "genesis-address",
        "LastIrreversibleBlockHash": "0x03",
        "LastIrreversibleBlockHeight": 1,
        "BestChainHash": "04050607",
        "BestChainHeight": 2
    })
}

fn assert_single_request(provider: &MockProvider) -> MockRecordedRequest {
    let requests = provider.requests();
    assert_eq!(requests.len(), 1);
    requests.into_iter().next().expect("request")
}

#[tokio::test]
async fn send_transaction_accepts_object_response() {
    let (client, provider) = mock_client(vec![MockResponse::text(format!(
        r#"{{"TransactionId":"{VALID_TX_ID}"}}"#
    ))]);

    let output = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect("send transaction");

    assert_eq!(output.transaction_id, VALID_TX_ID);

    let request = assert_single_request(&provider);
    assert_eq!(request.kind, MockCallKind::Text);
    assert_eq!(request.method, Method::POST);
    assert_eq!(request.path, "api/blockChain/sendTransaction");
    assert_eq!(
        request.body,
        Some(json!({ "RawTransaction": "raw-transaction" }))
    );
}

#[tokio::test]
async fn send_transaction_accepts_json_string_response() {
    let (client, _) = mock_client(vec![MockResponse::text(format!(r#""{VALID_TX_ID}""#))]);

    let output = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect("send transaction");

    assert_eq!(output.transaction_id, VALID_TX_ID);
}

#[tokio::test]
async fn send_transaction_accepts_plain_text_response() {
    let (client, _) = mock_client(vec![MockResponse::text(VALID_TX_ID)]);

    let output = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect("send transaction");

    assert_eq!(output.transaction_id, VALID_TX_ID);
}

#[tokio::test]
async fn send_transaction_accepts_0x_prefixed_plain_text_response() {
    let (client, _) = mock_client(vec![MockResponse::text(format!("0x{VALID_TX_ID}"))]);

    let output = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect("send transaction");

    assert_eq!(output.transaction_id, format!("0x{VALID_TX_ID}"));
}

#[tokio::test]
async fn send_transaction_rejects_empty_response() {
    let (client, _) = mock_client(vec![MockResponse::text("")]);

    let error = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect_err("empty response should fail");

    assert!(matches!(error, AElfError::UnexpectedResponse(_)));
}

#[tokio::test]
async fn send_transaction_rejects_non_txid_plain_text_response() {
    let (client, _) = mock_client(vec![MockResponse::text("tx-plain")]);

    let error = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect_err("non-txid response should fail");

    assert!(matches!(error, AElfError::UnexpectedResponse(_)));
}

#[tokio::test]
async fn send_transaction_rejects_ok_plain_text_response() {
    let (client, _) = mock_client(vec![MockResponse::text("ok")]);

    let error = client
        .tx()
        .send_transaction("raw-transaction")
        .await
        .expect_err("ok response should fail");

    assert!(matches!(error, AElfError::UnexpectedResponse(_)));
}

#[tokio::test]
async fn get_block_height_parses_quoted_number() {
    let (client, provider) = mock_client(vec![MockResponse::text("\"42\"")]);

    let height = client
        .block()
        .get_block_height()
        .await
        .expect("block height");

    assert_eq!(height, 42);
    let request = assert_single_request(&provider);
    assert_eq!(request.kind, MockCallKind::Text);
    assert_eq!(request.method, Method::GET);
    assert_eq!(request.path, "api/blockChain/blockHeight");
    assert!(request.query.is_empty());
    assert_eq!(request.body, None);
}

#[tokio::test]
async fn get_block_height_rejects_invalid_text() {
    let (client, _) = mock_client(vec![MockResponse::text("\"not-a-number\"")]);

    let error = client
        .block()
        .get_block_height()
        .await
        .expect_err("invalid height should fail");

    match error {
        AElfError::InvalidConfig(message) => {
            assert!(message.contains("invalid block height"));
        }
        other => panic!("unexpected error: {other:?}"),
    }
}

#[tokio::test]
async fn create_raw_transaction_uses_pascal_case_body() {
    let (client, provider) = mock_client(vec![MockResponse::json(json!({
        "RawTransaction": "raw-transaction"
    }))]);

    let output = client
        .tx()
        .create_raw_transaction(&CreateRawTransactionInput {
            from: "from-address".to_owned(),
            to: "to-address".to_owned(),
            ref_block_number: 100,
            ref_block_hash: "abcd".to_owned(),
            method_name: "Transfer".to_owned(),
            params: "{\"amount\":1}".to_owned(),
        })
        .await
        .expect("raw transaction");

    assert_eq!(output.raw_transaction, "raw-transaction");
    let request = assert_single_request(&provider);
    assert_eq!(request.kind, MockCallKind::Json);
    assert_eq!(request.method, Method::POST);
    assert_eq!(request.path, "api/blockChain/rawTransaction");
    assert_eq!(
        request.body,
        Some(json!({
            "From": "from-address",
            "To": "to-address",
            "RefBlockNumber": 100,
            "RefBlockHash": "abcd",
            "MethodName": "Transfer",
            "Params": "{\"amount\":1}"
        }))
    );
}

#[tokio::test]
async fn get_transaction_result_passes_query_parameter() {
    let (client, provider) = mock_client(vec![MockResponse::json(json!({
        "TransactionId": "txid",
        "Status": "MINED"
    }))]);

    let output = client
        .tx()
        .get_transaction_result("txid")
        .await
        .expect("transaction result");

    assert_eq!(output.transaction_id, "txid");
    assert_eq!(output.status, "MINED");

    let request = assert_single_request(&provider);
    assert_eq!(request.kind, MockCallKind::Json);
    assert_eq!(request.method, Method::GET);
    assert_eq!(request.path, "api/blockChain/transactionResult");
    assert_eq!(
        request.query,
        vec![("transactionId".to_owned(), "txid".to_owned())]
    );
    assert_eq!(request.body, None);
}

#[tokio::test]
async fn is_connected_returns_true_when_chain_status_succeeds() {
    let (client, provider) = mock_client(vec![MockResponse::json(chain_status_json())]);

    assert!(client.utils().is_connected().await);
    let request = assert_single_request(&provider);
    assert_eq!(request.kind, MockCallKind::Json);
    assert_eq!(request.method, Method::GET);
    assert_eq!(request.path, "api/blockChain/chainStatus");
}

#[tokio::test]
async fn is_connected_returns_false_when_provider_fails() {
    let (client, provider) = mock_client(vec![MockResponse::json_error(AElfError::request(
        "offline", None,
    ))]);

    assert!(!client.utils().is_connected().await);
    let request = assert_single_request(&provider);
    assert_eq!(request.kind, MockCallKind::Json);
    assert_eq!(request.method, Method::GET);
    assert_eq!(request.path, "api/blockChain/chainStatus");
}