snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! Integration tests for the Payments API using wiremock.

use serde_json::json;
use snippe::models::common::Customer;
use snippe::models::payment::{
    CreatePaymentRequest, HostedPaymentDetails, ListPaymentsParams, MobilePayment,
    PaymentStatus, PaymentType, QrPayment,
};
use snippe::{Client, ErrorCode, IdempotencyKey};
use wiremock::matchers::{body_json, header, header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn client_for(server: &MockServer) -> Client {
    Client::builder()
        .api_key("snp_test_key")
        .base_url(server.uri())
        .build()
        .unwrap()
}

#[tokio::test]
async fn create_mobile_payment_round_trip() {
    let server = MockServer::start().await;

    let response = json!({
        "status": "success",
        "code": 201,
        "data": {
            "reference": "9015c155-9e29-4e8e-8fe6-d5d81553c8e6",
            "status": "pending",
            "payment_type": "mobile",
            "amount": {"value": 500, "currency": "TZS"},
            "expires_at": "2026-01-25T05:04:54Z",
            "api_version": "2026-01-25"
        }
    });

    let expected_body = json!({
        "payment_type": "mobile",
        "details": {"amount": 500, "currency": "TZS"},
        "phone_number": "255781000000",
        "customer": {
            "firstname": "Jane",
            "lastname": "Doe",
            "email": "jane@example.com"
        }
    });

    Mock::given(method("POST"))
        .and(path("/v1/payments"))
        .and(header("Authorization", "Bearer snp_test_key"))
        .and(header("Idempotency-Key", "ord-12345"))
        .and(header("Snippe-Version", "2026-01-25"))
        .and(body_json(expected_body))
        .respond_with(ResponseTemplate::new(201).set_body_json(response))
        .expect(1)
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = CreatePaymentRequest::Mobile(MobilePayment::new(
        500,
        "255781000000",
        Customer::new("Jane", "Doe", "jane@example.com"),
    ));
    let key = IdempotencyKey::new("ord-12345").unwrap();

    let payment = client
        .payments()
        .create(&req, Some(&key))
        .await
        .expect("create succeeds");

    assert_eq!(payment.reference, "9015c155-9e29-4e8e-8fe6-d5d81553c8e6");
    assert_eq!(payment.status, PaymentStatus::Pending);
    assert_eq!(payment.payment_type, PaymentType::Mobile);
    assert_eq!(payment.amount.value, 500);
}

#[tokio::test]
async fn create_qr_payment_returns_qr_code() {
    let server = MockServer::start().await;

    let response = json!({
        "status": "success",
        "code": 201,
        "data": {
            "reference": "6a490816-799b-4fc9-b9b6-2ec67c54e17e",
            "status": "pending",
            "payment_type": "dynamic-qr",
            "amount": {"value": 500, "currency": "TZS"},
            "expires_at": "2026-01-25T04:47:50Z",
            "payment_qr_code": "000201010212041552545429990002026390014tz.go.bot.tips",
            "payment_token": "63890400",
            "payment_url": "https://tz.selcom.online/paymentgw/checkout/x"
        }
    });

    Mock::given(method("POST"))
        .and(path("/v1/payments"))
        .respond_with(ResponseTemplate::new(201).set_body_json(response))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = CreatePaymentRequest::DynamicQr(QrPayment::new(HostedPaymentDetails::tzs(
        500,
        "https://x/d",
        "https://x/c",
    )));

    let payment = client.payments().create(&req, None).await.unwrap();
    assert_eq!(payment.payment_type, PaymentType::DynamicQr);
    assert!(payment.payment_qr_code.is_some());
    assert!(payment.payment_url.is_some());
    assert_eq!(payment.payment_token.as_deref(), Some("63890400"));
}

#[tokio::test]
async fn get_payment_by_reference() {
    let server = MockServer::start().await;
    let response = json!({
        "code": 200,
        "data": {
            "reference": "ref-1",
            "status": "completed",
            "payment_type": "mobile",
            "amount": {"value": 1000, "currency": "TZS"},
            "expires_at": "2026-01-25T05:04:54Z"
        }
    });

    Mock::given(method("GET"))
        .and(path("/v1/payments/ref-1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(response))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let payment = client.payments().get("ref-1").await.unwrap();
    assert_eq!(payment.status, PaymentStatus::Completed);
    assert!(payment.status.is_terminal());
}

#[tokio::test]
async fn list_payments_passes_filters_as_query_params() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/payments"))
        .and(query_param("limit", "20"))
        .and(query_param("page", "1"))
        .and(query_param("status", "completed"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let params = ListPaymentsParams::new()
        .limit(20)
        .page(1)
        .status(PaymentStatus::Completed);
    let payments = client.payments().list(&params).await.unwrap();
    assert!(payments.is_empty());
}

#[tokio::test]
async fn balance_endpoint() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/payments/balance"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "data": {
                "available": {"value": 6943, "currency": "TZS"},
                "balance":   {"value": 6943, "currency": "TZS"}
            }
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let balance = client.payments().balance().await.unwrap();
    assert_eq!(balance.available.value, 6943);
}

#[tokio::test]
async fn validation_error_response() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments"))
        .respond_with(ResponseTemplate::new(400).set_body_json(json!({
            "status": "error",
            "code": 400,
            "error_code": "validation_error",
            "message": "amount is required"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = CreatePaymentRequest::Mobile(MobilePayment::new(
        500,
        "255781000000",
        Customer::new("J", "D", "j@d.com"),
    ));
    let err = client.payments().create(&req, None).await.unwrap_err();

    match err {
        snippe::Error::Api(e) => {
            assert_eq!(e.status, 400);
            assert_eq!(e.error_code, ErrorCode::ValidationError);
            assert_eq!(e.message, "amount is required");
            assert!(!e.is_retryable());
        }
        other => panic!("expected Api error, got {other:?}"),
    }
}

#[tokio::test]
async fn pay001_is_retryable() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments"))
        .respond_with(ResponseTemplate::new(500).set_body_json(json!({
            "status": "error",
            "code": 500,
            "error_code": "PAY_001",
            "message": "failed to initiate payment"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = CreatePaymentRequest::Mobile(MobilePayment::new(
        500, "255781000000", Customer::new("J", "D", "j@d.com")));
    let err = client.payments().create(&req, None).await.unwrap_err();
    if let snippe::Error::Api(e) = err {
        assert_eq!(e.error_code, ErrorCode::Pay001);
        assert!(e.is_retryable(), "PAY_001 must be retryable");
    } else {
        panic!("expected Api error");
    }
}

#[tokio::test]
async fn rate_limit_extracts_retry_after() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/payments/balance"))
        .respond_with(
            ResponseTemplate::new(429)
                .insert_header("X-Ratelimit-Reset", "30")
                .set_body_json(json!({
                    "status": "error",
                    "code": 429,
                    "error_code": "rate_limit_exceeded",
                    "message": "Too many requests"
                })),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let err = client.payments().balance().await.unwrap_err();
    if let snippe::Error::Api(e) = err {
        assert_eq!(e.status, 429);
        assert_eq!(e.error_code, ErrorCode::RateLimitExceeded);
        assert_eq!(e.retry_after, Some(30));
    } else {
        panic!("expected Api error");
    }
}

#[tokio::test]
async fn idempotency_key_is_sent_when_provided() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments"))
        .and(header_exists("Idempotency-Key"))
        .respond_with(
            ResponseTemplate::new(201).set_body_json(json!({
                "data": {
                    "reference": "r",
                    "status": "pending",
                    "payment_type": "mobile",
                    "amount": {"value": 500, "currency": "TZS"},
                    "expires_at": "2026-01-25T05:04:54Z"
                }
            })),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = CreatePaymentRequest::Mobile(MobilePayment::new(
        500, "255781000000", Customer::new("J", "D", "j@d.com")));
    let key = IdempotencyKey::new("ord-1").unwrap();
    client.payments().create(&req, Some(&key)).await.unwrap();
}

#[tokio::test]
async fn trigger_push_endpoint() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments/ref-1/push"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "data": {
                "reference": "ref-1",
                "status": "pending",
                "payment_type": "mobile",
                "amount": {"value": 500, "currency": "TZS"},
                "expires_at": "2026-01-25T05:04:54Z"
            }
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let p = client.payments().trigger_push("ref-1").await.unwrap();
    assert_eq!(p.reference, "ref-1");
}