use std::collections::HashMap;
use std::time::Duration;
use reevit::{
Client, ConnectionAuditEntry, ConnectionRequest, Error, FraudPolicy, PaymentIntent,
PaymentIntentRequest, PaymentLinkStats, PaymentStats, PaymentStatsBreakdown,
PaymentStatsTotals, RequestOptions, RoutingHints, WebhookConfig,
};
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn create_payment_intent_sends_auth_and_idempotency_headers() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/payments/intents"))
.and(header("x-reevit-key", "pfk_test_example.secret"))
.and(header("x-org-id", "org_123"))
.and(header("x-reevit-client", "@reevit/rust"))
.and(header("user-agent", "reevit-rust/0.1.0"))
.and(header("idempotency-key", "order_123"))
.and(body_json(json!({
"amount": 5000,
"currency": "GHS",
"country": "GH",
"reference": "order_123"
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "pay_123",
"connection_id": "conn_123",
"provider": "paystack",
"status": "pending",
"amount": 5000,
"currency": "GHS",
"fee_amount": 100,
"fee_currency": "GHS",
"net_amount": 4900
})))
.mount(&server)
.await;
let client = Client::builder("pfk_test_example.secret", "org_123")
.base_url(server.uri())
.http_client(reqwest::Client::new())
.build()
.expect("client configuration should be valid");
let request = PaymentIntentRequest {
amount: 5000,
currency: "GHS".into(),
country: "GH".into(),
reference: Some("order_123".into()),
..PaymentIntentRequest::default()
};
let payment = client
.payments()
.create_intent(
&request,
RequestOptions::default().idempotency_key("order_123"),
)
.await
.expect("payment intent should be created");
assert_eq!(payment.id, "pay_123");
assert_eq!(payment.amount, 5000);
}
#[tokio::test]
async fn api_errors_preserve_status_code_details_and_request_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/payments/intents"))
.respond_with(
ResponseTemplate::new(422)
.insert_header("x-request-id", "req_123")
.set_body_json(json!({
"code": "invalid_currency",
"message": "currency is not supported",
"details": {"currency": "ZZZ"}
})),
)
.mount(&server)
.await;
let client = Client::builder("pfk_test_example.secret", "org_123")
.base_url(server.uri())
.build()
.unwrap();
let error = client
.payments()
.create_intent(
&PaymentIntentRequest {
amount: 5000,
currency: "ZZZ".into(),
country: "GH".into(),
..PaymentIntentRequest::default()
},
RequestOptions::default(),
)
.await
.unwrap_err();
let Error::Api(api_error) = error else {
panic!("expected a structured API error");
};
assert_eq!(api_error.status.as_u16(), 422);
assert_eq!(api_error.code.as_deref(), Some("invalid_currency"));
assert_eq!(api_error.request_id.as_deref(), Some("req_123"));
assert_eq!(api_error.details, Some(json!({"currency": "ZZZ"})));
assert!(!api_error.is_recoverable());
}
#[test]
fn debug_output_redacts_credentials_and_signing_secrets() {
let client = Client::builder("pfk_test_super_secret", "org_123")
.build()
.unwrap();
let connection = ConnectionRequest {
provider: "paystack".into(),
mode: "test".into(),
credentials: HashMap::from([("secret_key".into(), json!("sk_super_secret"))]),
..ConnectionRequest::default()
};
let intent = PaymentIntent {
client_secret: Some("client_super_secret".into()),
psp_credentials: HashMap::from([("private".into(), json!("psp_super_secret"))]),
..PaymentIntent::default()
};
let webhook = WebhookConfig {
signing_secret: Some("whsec_super_secret".into()),
..WebhookConfig::default()
};
let output = format!("{client:?} {connection:?} {intent:?} {webhook:?}");
assert!(!output.contains("pfk_test_super_secret"));
assert!(!output.contains("sk_super_secret"));
assert!(!output.contains("client_super_secret"));
assert!(!output.contains("psp_super_secret"));
assert!(!output.contains("whsec_super_secret"));
assert!(output.contains("[REDACTED]"));
}
#[test]
fn response_models_do_not_accept_empty_objects() {
assert!(serde_json::from_str::<PaymentIntent>("{}").is_err());
assert!(serde_json::from_str::<PaymentStats>("{}").is_err());
assert!(serde_json::from_str::<PaymentStatsTotals>("{}").is_err());
assert!(serde_json::from_str::<PaymentStatsBreakdown>("{}").is_err());
assert!(serde_json::from_str::<ConnectionAuditEntry>("{}").is_err());
assert!(serde_json::from_str::<RoutingHints>("{}").is_err());
assert!(serde_json::from_str::<FraudPolicy>("{}").is_err());
assert!(serde_json::from_str::<PaymentLinkStats>("{}").is_err());
}
#[tokio::test]
async fn successful_but_malformed_resource_responses_are_decode_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/payments/intents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let client = Client::builder("pfk_test_example.secret", "org_123")
.base_url(server.uri())
.build()
.unwrap();
let error = client
.payments()
.create_intent(
&PaymentIntentRequest {
amount: 5000,
currency: "GHS".into(),
country: "GH".into(),
..PaymentIntentRequest::default()
},
RequestOptions::default(),
)
.await
.unwrap_err();
assert!(matches!(error, Error::Decode { .. }));
}
#[tokio::test]
async fn injected_clients_honor_sdk_timeout_without_leaking_url_secrets() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/payments/pay_123/confirm-intent"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1)))
.mount(&server)
.await;
let client = Client::builder("pfk_test_example.secret", "org_123")
.base_url(server.uri())
.timeout(Duration::from_millis(20))
.http_client(reqwest::Client::new())
.build()
.unwrap();
let error = client
.payments()
.confirm_intent("pay_123", "client_super_secret", RequestOptions::default())
.await
.unwrap_err();
let Error::Transport(transport) = &error else {
panic!("expected a transport error");
};
assert!(transport.is_timeout());
let output = format!("{error:?} {error}");
assert!(!output.contains("client_super_secret"));
assert!(!output.contains("client_secret="));
}