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(¶ms).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");
}