mod types;
use std::collections::BTreeMap;
use std::fmt;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::header::{HeaderValue, AUTHORIZATION, RETRY_AFTER};
use reqwest::Method;
use serde::de::DeserializeOwned;
use crate::error::Error;
use crate::money::{Currency, Money};
use crate::payment::{CreatePayment, Payment, PaymentStatus, Refund, RefundRequest, RefundStatus};
use crate::provider::PaymentProvider;
use types::{
CreatePaymentBody, CreateRefundBody, MollieAmount, MollieErrorBody, MolliePayment, MollieRefund,
};
pub mod webhook;
const DEFAULT_BASE_URL: &str = "https://api.mollie.com/v2";
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 4;
const REFERENCE_METADATA_KEY: &str = "paykit_reference";
#[derive(Clone)]
pub struct MollieProvider {
base_url: String,
client: reqwest::Client,
auth_header: HeaderValue,
}
impl fmt::Debug for MollieProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MollieProvider")
.field("base_url", &self.base_url)
.field("api_key", &"***")
.finish_non_exhaustive()
}
}
impl MollieProvider {
#[must_use]
pub fn builder(api_key: impl Into<String>) -> MollieProviderBuilder {
MollieProviderBuilder::new(api_key.into())
}
pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
Self::builder(api_key).build()
}
pub fn with_client(api_key: impl Into<String>, client: reqwest::Client) -> Result<Self, Error> {
Self::builder(api_key).client(client).build()
}
fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}{path}", self.base_url);
self.client
.request(method, url)
.header(AUTHORIZATION, self.auth_header.clone())
}
async fn send(&self, builder: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
builder.send().await.map_err(map_transport_error)
}
async fn send_json<T: DeserializeOwned>(
&self,
builder: reqwest::RequestBuilder,
) -> Result<T, Error> {
let response = self.send(builder).await?;
if response.status().is_success() {
decode_json(response).await
} else {
Err(build_error_response(response).await)
}
}
}
#[async_trait]
impl PaymentProvider for MollieProvider {
async fn create_payment(&self, req: CreatePayment) -> Result<Payment, Error> {
req.validate()?;
let amount = req.amount();
let body = CreatePaymentBody {
amount: MollieAmount::from(amount),
description: req.description().to_string(),
redirect_url: req.redirect_url().to_string(),
webhook_url: req.webhook_url().map(str::to_string),
metadata: build_metadata(req.reference(), req.metadata())?,
};
let builder = self
.request(Method::POST, "/payments")
.header("Idempotency-Key", req.idempotency_key())
.json(&body);
let wire: MolliePayment = self.send_json(builder).await?;
let payment = payment_from_wire(wire)?;
if payment.amount != amount {
return Err(Error::decode(format!(
"created a payment for {amount} but the provider echoed back {}",
payment.amount
)));
}
Ok(payment)
}
async fn get_payment(&self, id: &str) -> Result<Payment, Error> {
let id = validate_payment_id(id)?;
let path = format!("/payments/{}", encode_path_segment(id));
let wire: MolliePayment = self.send_json(self.request(Method::GET, &path)).await?;
let payment = payment_from_wire(wire)?;
if payment.id != id {
return Err(Error::decode(format!(
"requested payment {id:?} but the provider returned payment {:?}",
payment.id
)));
}
Ok(payment)
}
async fn cancel_payment(&self, id: &str) -> Result<Payment, Error> {
let id = validate_payment_id(id)?;
let path = format!("/payments/{}", encode_path_segment(id));
let wire: MolliePayment = self.send_json(self.request(Method::DELETE, &path)).await?;
let payment = payment_from_wire(wire)?;
if payment.id != id {
return Err(Error::decode(format!(
"requested to cancel payment {id:?} but the provider returned payment {:?}",
payment.id
)));
}
Ok(payment)
}
async fn refund(&self, id: &str, req: RefundRequest) -> Result<Refund, Error> {
let id = validate_payment_id(id)?;
let path = format!("/payments/{}/refunds", encode_path_segment(id));
let body = CreateRefundBody {
amount: MollieAmount::from(req.amount()),
description: req.description().map(str::to_string),
};
let builder = self
.request(Method::POST, &path)
.header("Idempotency-Key", req.idempotency_key())
.json(&body);
let wire: MollieRefund = self.send_json(builder).await?;
refund_from_wire(wire, id)
}
}
pub struct MollieProviderBuilder {
api_key: String,
client: Option<reqwest::Client>,
base_url: Result<String, Error>,
timeout: Duration,
connect_timeout: Duration,
}
impl MollieProviderBuilder {
fn new(api_key: String) -> Self {
Self {
api_key,
client: None,
base_url: Ok(DEFAULT_BASE_URL.to_string()),
timeout: DEFAULT_TIMEOUT,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
}
}
#[must_use]
pub fn client(mut self, client: reqwest::Client) -> Self {
self.client = Some(client);
self
}
#[must_use]
pub fn base_url(mut self, url: impl Into<String>) -> Self {
let url = url.into();
self.base_url = if url.starts_with("https://") {
normalize_base_url(url)
} else {
Err(Error::InvalidRequest(format!(
"base_url must be an https:// URL, got {url:?}"
)))
};
self
}
#[doc(hidden)]
#[must_use]
pub fn insecure_base_url_for_testing(mut self, url: impl Into<String>) -> Self {
self.base_url = normalize_base_url(url.into());
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn build(self) -> Result<MollieProvider, Error> {
let base_url = self.base_url?;
let auth_header = build_auth_header(&self.api_key)?;
let client = match self.client {
Some(client) => client,
None => reqwest::Client::builder()
.connect_timeout(self.connect_timeout)
.timeout(self.timeout)
.pool_max_idle_per_host(DEFAULT_POOL_MAX_IDLE_PER_HOST)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
Error::InvalidRequest(format!("failed to build internal HTTP client: {err}"))
})?,
};
Ok(MollieProvider {
base_url,
client,
auth_header,
})
}
}
fn build_auth_header(api_key: &str) -> Result<HeaderValue, Error> {
let mut value = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|_| {
Error::InvalidRequest(
"api_key contains bytes that are not valid in an HTTP header value".to_string(),
)
})?;
value.set_sensitive(true);
Ok(value)
}
fn normalize_base_url(url: String) -> Result<String, Error> {
if url.contains('?') || url.contains('#') {
return Err(Error::InvalidRequest(format!(
"base_url must not contain a query string or fragment, got {url:?}"
)));
}
let after_scheme = url.split_once("://").map_or(url.as_str(), |(_, rest)| rest);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
if authority.contains('@') {
return Err(Error::InvalidRequest(format!(
"base_url must not contain userinfo (user:pass@host), got {url:?}"
)));
}
Ok(url.strip_suffix('/').map(str::to_string).unwrap_or(url))
}
fn validate_payment_id(id: &str) -> Result<&str, Error> {
if id.trim().is_empty() {
return Err(Error::InvalidRequest(
"payment id must not be empty".to_string(),
));
}
Ok(id)
}
fn encode_path_segment(input: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
_ => {
let _ = write!(out, "%{byte:02X}");
}
}
}
out
}
fn build_metadata(
reference: Option<&str>,
metadata: &BTreeMap<String, String>,
) -> Result<Option<serde_json::Value>, Error> {
if metadata.contains_key(REFERENCE_METADATA_KEY) {
return Err(Error::InvalidRequest(format!(
"metadata key {REFERENCE_METADATA_KEY:?} is reserved for CreatePayment::reference"
)));
}
if reference.is_none() && metadata.is_empty() {
return Ok(None);
}
let mut map = serde_json::Map::with_capacity(metadata.len() + 1);
for (key, value) in metadata {
map.insert(key.clone(), serde_json::Value::String(value.clone()));
}
if let Some(reference) = reference {
map.insert(
REFERENCE_METADATA_KEY.to_string(),
serde_json::Value::String(reference.to_string()),
);
}
Ok(Some(serde_json::Value::Object(map)))
}
fn parse_metadata(value: Option<serde_json::Value>) -> (Option<String>, BTreeMap<String, String>) {
let mut metadata = BTreeMap::new();
let mut reference = None;
if let Some(serde_json::Value::Object(map)) = value {
for (key, value) in map {
let value = match value {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
if key == REFERENCE_METADATA_KEY {
reference = Some(value);
} else {
metadata.insert(key, value);
}
}
}
(reference, metadata)
}
fn money_from_wire(amount: &MollieAmount) -> Result<Money, Error> {
let currency = Currency::new(&amount.currency).map_err(|err| {
Error::decode(format!(
"invalid currency {:?} in provider response: {err}",
amount.currency
))
})?;
Money::parse_decimal(&amount.value, currency).map_err(|err| {
Error::decode(format!(
"invalid amount {:?} in provider response: {err}",
amount.value
))
})
}
fn is_absolute_http_url(url: &str) -> bool {
url.starts_with("http://") || url.starts_with("https://")
}
fn payment_from_wire(wire: MolliePayment) -> Result<Payment, Error> {
let amount = money_from_wire(&wire.amount)?;
let status = payment_status(&wire.status);
let checkout_url = wire
.links
.and_then(|links| links.checkout)
.map(|link| link.href);
let (reference, metadata) = parse_metadata(wire.metadata);
let mut payment = Payment::new(wire.id, status, amount).with_metadata_map(metadata);
if let Some(url) = checkout_url {
if !is_absolute_http_url(&url) {
return Err(Error::decode(format!(
"provider returned a checkout URL with a non-http(s) scheme: {url:?}"
)));
}
payment = payment.with_checkout_url(url);
}
if let Some(reference) = reference {
payment = payment.with_reference(reference);
}
Ok(payment)
}
fn refund_from_wire(wire: MollieRefund, requested_payment_id: &str) -> Result<Refund, Error> {
let amount = money_from_wire(&wire.amount)?;
let payment_id = match wire.payment_id {
Some(payment_id) if payment_id == requested_payment_id => payment_id,
Some(payment_id) => {
return Err(Error::decode(format!(
"refunded payment {requested_payment_id:?} but the provider returned a refund \
for payment {payment_id:?}"
)));
}
None => requested_payment_id.to_string(),
};
Ok(Refund::new(
wire.id,
payment_id,
amount,
refund_status(&wire.status),
))
}
fn payment_status(raw: &str) -> PaymentStatus {
match raw {
"open" => PaymentStatus::Open,
"pending" => PaymentStatus::Pending,
"authorized" => PaymentStatus::Authorized,
"paid" => PaymentStatus::Paid,
"failed" => PaymentStatus::Failed,
"canceled" | "cancelled" => PaymentStatus::Cancelled,
"expired" => PaymentStatus::Expired,
other => PaymentStatus::Unknown(other.into()),
}
}
fn refund_status(raw: &str) -> RefundStatus {
match raw {
"queued" => RefundStatus::Queued,
"pending" => RefundStatus::Pending,
"processing" => RefundStatus::Processing,
"refunded" => RefundStatus::Refunded,
"failed" => RefundStatus::Failed,
other => RefundStatus::Unknown(other.into()),
}
}
fn map_transport_error(err: reqwest::Error) -> Error {
let err = err.without_url();
if err.is_builder() {
Error::InvalidRequest(format!("failed to build the request: {err}"))
} else if err.is_timeout() {
Error::Timeout
} else {
Error::Transport(Box::new(err))
}
}
async fn read_bounded(mut response: reqwest::Response) -> Result<Vec<u8>, Error> {
if let Some(len) = response.content_length() {
if len > MAX_RESPONSE_BYTES as u64 {
return Err(too_large_error(len));
}
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(map_transport_error)? {
if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
return Err(too_large_error(
(body.len().saturating_add(chunk.len())) as u64,
));
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
fn too_large_error(observed_bytes: u64) -> Error {
Error::decode(format!(
"response body of at least {observed_bytes} bytes exceeds the \
{MAX_RESPONSE_BYTES}-byte cap"
))
}
async fn decode_json<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, Error> {
let body = read_bounded(response).await?;
serde_json::from_slice(&body).map_err(|err| {
Error::decode(format!("failed to decode provider response as JSON: {err}"))
.with_raw_body(String::from_utf8_lossy(&body).into_owned())
})
}
const MAX_ERROR_FIELD_LEN: usize = 200;
fn sanitize_error_field(input: &str) -> String {
input
.chars()
.filter(|c| !c.is_control())
.take(MAX_ERROR_FIELD_LEN)
.collect()
}
async fn build_error_response(response: reqwest::Response) -> Error {
let status = response.status();
let retry_after = parse_retry_after(response.headers());
if status.as_u16() == 429 {
return Error::rate_limited(retry_after);
}
let raw_body = read_bounded(response)
.await
.ok()
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
let dedicated = match status.as_u16() {
401 | 403 => Some(Error::unauthorized()),
404 => Some(Error::not_found()),
_ => None,
};
if let Some(mut err) = dedicated {
if let Some(raw_body) = raw_body {
err = err.with_raw_body(raw_body);
}
return err;
}
let parsed: Option<MollieErrorBody> = raw_body
.as_deref()
.and_then(|body| serde_json::from_str(body).ok());
let title = parsed
.as_ref()
.and_then(|body| body.title.as_deref())
.map(sanitize_error_field)
.unwrap_or_else(|| {
status
.canonical_reason()
.unwrap_or("unknown provider error")
.to_string()
});
let mut err = Error::api(status.as_u16(), title);
if let Some(code) = parsed.as_ref().and_then(|body| body.field.clone()) {
err = err.with_code(code);
}
if let Some(detail) = parsed.and_then(|body| body.detail) {
err = err.with_detail(sanitize_error_field(&detail));
}
if let Some(raw_body) = raw_body {
err = err.with_raw_body(raw_body);
}
err
}
const MAX_RETRY_AFTER: Duration = Duration::from_secs(300);
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get(RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse::<u64>().ok())
.map(|secs| Duration::from_secs(secs).min(MAX_RETRY_AFTER))
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
use super::*;
use crate::payment::RefundStatus;
fn test_provider(server: &MockServer) -> MollieProvider {
MollieProvider::builder("test_key")
.insecure_base_url_for_testing(server.uri())
.build()
.unwrap()
}
fn eur(minor: i64) -> Money {
Money::from_minor(minor, Currency::EUR)
}
fn mollie_payment_json(
id: &str,
status: &str,
value: &str,
currency: &str,
) -> serde_json::Value {
serde_json::json!({
"id": id,
"status": status,
"amount": {"currency": currency, "value": value},
"_links": {"checkout": {"href": "https://mollie.example/pay/1"}},
})
}
#[test]
fn new_never_panics_and_defaults_to_the_mollie_api() {
let provider = MollieProvider::new("test_key").unwrap();
assert!(format!("{provider:?}").contains("api.mollie.com"));
}
#[test]
fn provider_is_cheaply_cloneable() {
let provider = MollieProvider::new("test_key").unwrap();
let cloned = provider.clone();
assert_eq!(format!("{provider:?}"), format!("{cloned:?}"));
}
#[test]
fn base_url_rejects_non_https_scheme() {
let err = MollieProvider::builder("key")
.base_url("http://api.mollie.com/v2")
.build()
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn base_url_accepts_https_scheme() {
let provider = MollieProvider::builder("key")
.base_url("https://api.mollie.com/v2")
.build();
assert!(provider.is_ok());
}
#[test]
fn base_url_rejects_userinfo() {
let err = MollieProvider::builder("key")
.base_url("https://api.mollie.com@evil.example/v2")
.build()
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn base_url_rejects_query_string() {
let err = MollieProvider::builder("key")
.base_url("https://api.mollie.com/v2?x=1")
.build()
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn base_url_rejects_fragment() {
let err = MollieProvider::builder("key")
.base_url("https://api.mollie.com/v2#frag")
.build()
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn base_url_trailing_slash_does_not_double_up_the_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_1", "open", "1.00", "EUR")),
)
.mount(&server)
.await;
let provider = MollieProvider::builder("test_key")
.insecure_base_url_for_testing(format!("{}/", server.uri()))
.build()
.unwrap();
let payment = provider.get_payment("tr_1").await.unwrap();
assert_eq!(payment.id, "tr_1");
}
#[test]
fn build_never_panics_on_an_api_key_with_invalid_header_bytes() {
let err = MollieProvider::new("bad\nkey").unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn debug_never_contains_the_api_key() {
let provider = MollieProvider::new("sk_live_super_secret_key").unwrap();
let debug = format!("{provider:?}");
assert!(!debug.contains("sk_live_super_secret_key"));
}
#[tokio::test]
async fn api_key_never_appears_in_an_error_from_an_unreachable_host() {
let provider = MollieProvider::builder("sk_live_super_secret_key")
.insecure_base_url_for_testing("http://127.0.0.1:1")
.build()
.unwrap();
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(!format!("{err:?}").contains("sk_live_super_secret_key"));
assert!(!format!("{err}").contains("sk_live_super_secret_key"));
}
#[tokio::test]
async fn redirects_are_not_followed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(302)
.insert_header("Location", "http://example.invalid/elsewhere"),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Api { status: 302, .. }));
}
#[tokio::test]
async fn oversized_response_body_is_rejected() {
let server = MockServer::start().await;
let huge = "x".repeat(2 * MAX_RESPONSE_BYTES);
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_string(huge))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[tokio::test]
async fn get_payment_rejects_an_empty_id_without_sending_a_request() {
let server = MockServer::start().await;
let provider = test_provider(&server);
let err = provider.get_payment("").await.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn get_payment_rejects_a_blank_id() {
let server = MockServer::start().await;
let provider = test_provider(&server);
let err = provider.get_payment(" ").await.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn cancel_payment_rejects_an_empty_id() {
let server = MockServer::start().await;
let provider = test_provider(&server);
let err = provider.cancel_payment("").await.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn refund_rejects_an_empty_id() {
let server = MockServer::start().await;
let provider = test_provider(&server);
let err = provider
.refund("", RefundRequest::new(eur(500)))
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn get_payment_maps_status_amount_checkout_url_reference_and_metadata() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": "tr_1",
"status": "paid",
"amount": {"currency": "EUR", "value": "12.34"},
"metadata": {"paykit_reference": "order-42", "channel": "web"},
"_links": {"checkout": {"href": "https://mollie.example/pay/tr_1"}},
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let payment = provider.get_payment("tr_1").await.unwrap();
assert_eq!(payment.id, "tr_1");
assert_eq!(payment.status, PaymentStatus::Paid);
assert_eq!(payment.amount, eur(1234));
assert_eq!(
payment.checkout_url.as_deref(),
Some("https://mollie.example/pay/tr_1")
);
assert_eq!(payment.reference.as_deref(), Some("order-42"));
assert_eq!(
payment.metadata.get("channel").map(String::as_str),
Some("web")
);
assert!(!payment.metadata.contains_key("paykit_reference"));
}
#[tokio::test]
async fn get_payment_rejects_a_checkout_url_with_a_non_http_scheme() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": "tr_1",
"status": "open",
"amount": {"currency": "EUR", "value": "1.00"},
"_links": {"checkout": {"href": "javascript:alert(1)"}},
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[tokio::test]
async fn get_payment_accepts_a_plain_http_checkout_url() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": "tr_1",
"status": "open",
"amount": {"currency": "EUR", "value": "1.00"},
"_links": {"checkout": {"href": "http://mollie.example/pay/tr_1"}},
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let payment = provider.get_payment("tr_1").await.unwrap();
assert_eq!(
payment.checkout_url.as_deref(),
Some("http://mollie.example/pay/tr_1")
);
}
#[tokio::test]
async fn absent_metadata_and_links_map_to_none_never_fabricated() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": "tr_1",
"status": "open",
"amount": {"currency": "EUR", "value": "1.00"},
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let payment = provider.get_payment("tr_1").await.unwrap();
assert_eq!(payment.reference, None);
assert_eq!(payment.checkout_url, None);
assert!(payment.metadata.is_empty());
}
#[tokio::test]
async fn get_payment_rejects_a_mismatched_id_in_the_response() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": "tr_other",
"status": "open",
"amount": {"currency": "EUR", "value": "1.00"},
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[tokio::test]
async fn unknown_payment_status_is_preserved_verbatim() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": "tr_1",
"status": "a_future_status_mollie_invented",
"amount": {"currency": "EUR", "value": "1.00"},
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let payment = provider.get_payment("tr_1").await.unwrap();
match payment.status {
PaymentStatus::Unknown(raw) => assert_eq!(&*raw, "a_future_status_mollie_invented"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[tokio::test]
async fn payment_id_is_percent_encoded_in_the_request_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/weird%20id%2Fwith%20slash"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mollie_payment_json(
"weird id/with slash",
"open",
"1.00",
"EUR",
)),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let payment = provider.get_payment("weird id/with slash").await.unwrap();
assert_eq!(payment.id, "weird id/with slash");
}
#[tokio::test]
async fn unauthorized_maps_to_the_unauthorized_variant_and_captures_the_body() {
let server = MockServer::start().await;
let body = serde_json::json!({
"title": "Unauthorized",
"detail": "test key used for a live payment",
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(401).set_body_json(&body))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Unauthorized { .. }));
assert!(!err.is_retriable());
assert!(err.raw_body().unwrap().contains("live payment"));
}
#[tokio::test]
async fn forbidden_also_maps_to_the_unauthorized_variant() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Unauthorized { .. }));
assert!(!err.is_retriable());
}
#[tokio::test]
async fn not_found_maps_to_the_not_found_variant_and_captures_the_body() {
let server = MockServer::start().await;
let body = serde_json::json!({"title": "Not Found", "detail": "No payment exists with token tr_1."});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(404).set_body_json(&body))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::NotFound { .. }));
assert!(!err.is_retriable());
assert!(err.raw_body().unwrap().contains("No payment exists"));
}
#[tokio::test]
async fn unprocessable_entity_maps_to_api_with_field_as_code_and_is_not_retriable() {
let server = MockServer::start().await;
let body = serde_json::json!({
"status": 422,
"title": "Unprocessable Entity",
"detail": "amount is required",
"field": "amount",
});
Mock::given(method("POST"))
.and(path("/payments"))
.respond_with(ResponseTemplate::new(422).set_body_json(body))
.mount(&server)
.await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order", "https://shop.example/return");
let err = provider.create_payment(req).await.unwrap_err();
match &err {
Error::Api {
status,
code,
title,
detail,
..
} => {
assert_eq!(*status, 422);
assert_eq!(code.as_deref(), Some("amount"));
assert_eq!(title, "Unprocessable Entity");
assert_eq!(detail.as_deref(), Some("amount is required"));
}
other => panic!("expected Api, got {other:?}"),
}
assert!(!err.is_retriable());
}
#[tokio::test]
async fn error_title_and_detail_are_sanitized_of_control_characters() {
let server = MockServer::start().await;
let body = serde_json::json!({
"title": "Bad\r\nRequest: forged log line",
"detail": "some\ndetail\twith control chars",
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(400).set_body_json(&body))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
match &err {
Error::Api { title, detail, .. } => {
assert!(!title.contains('\n') && !title.contains('\r'));
let detail = detail.as_deref().unwrap_or_default();
assert!(!detail.contains('\n') && !detail.contains('\t'));
}
other => panic!("expected Api, got {other:?}"),
}
}
#[tokio::test]
async fn rate_limited_parses_retry_after_seconds_and_is_retriable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "30"))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
match &err {
Error::RateLimited { retry_after, .. } => {
assert_eq!(*retry_after, Some(Duration::from_secs(30)));
}
other => panic!("expected RateLimited, got {other:?}"),
}
assert!(err.is_retriable());
}
#[tokio::test]
async fn rate_limited_clamps_an_absurd_retry_after_value() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "999999999999"))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
match &err {
Error::RateLimited { retry_after, .. } => {
assert_eq!(*retry_after, Some(MAX_RETRY_AFTER));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn rate_limited_never_captures_a_body_even_when_the_provider_sends_one() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("Retry-After", "5")
.set_body_json(serde_json::json!({"title": "Rate limit exceeded"})),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::RateLimited { .. }));
assert_eq!(err.raw_body(), None);
}
#[tokio::test]
async fn rate_limited_does_not_choke_on_an_oversized_body() {
let server = MockServer::start().await;
let huge = "x".repeat(2 * MAX_RESPONSE_BYTES);
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("Retry-After", "5")
.set_body_string(huge),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::RateLimited { .. }));
}
#[tokio::test]
async fn server_error_maps_to_api_and_is_retriable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Api { status: 503, .. }));
assert!(err.is_retriable());
}
#[tokio::test]
async fn api_error_raw_body_is_reachable_but_never_in_display() {
let server = MockServer::start().await;
let body = serde_json::json!({
"title": "Bad Request",
"detail": "contains a card_number that must never be logged by default",
});
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(ResponseTemplate::new(400).set_body_json(&body))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.get_payment("tr_1").await.unwrap_err();
assert!(err.raw_body().unwrap().contains("card_number"));
assert!(!format!("{err}").contains("card_number"));
}
#[tokio::test]
async fn create_payment_validates_before_sending_anything() {
let server = MockServer::start().await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order", "/relative-not-absolute");
let err = provider.create_payment(req).await.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn create_payment_sends_amount_redirect_url_and_metadata_with_reference_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/payments"))
.and(body_partial_json(serde_json::json!({
"amount": {"currency": "EUR", "value": "10.00"},
"redirectUrl": "https://shop.example/return",
"metadata": {"channel": "web", "paykit_reference": "order-42"},
})))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(mollie_payment_json("tr_1", "open", "10.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order #1", "https://shop.example/return")
.with_reference("order-42")
.with_metadata_entry("channel", "web");
let payment = provider.create_payment(req).await.unwrap();
assert_eq!(payment.status, PaymentStatus::Open);
}
#[tokio::test]
async fn create_payment_rejects_a_reserved_metadata_key() {
let server = MockServer::start().await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order", "https://shop.example/return")
.with_metadata_entry(REFERENCE_METADATA_KEY, "user-supplied");
let err = provider.create_payment(req).await.unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[tokio::test]
async fn create_payment_rejects_an_amount_mismatch_echoed_by_the_provider() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/payments"))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(mollie_payment_json("tr_1", "open", "5.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order", "https://shop.example/return");
let err = provider.create_payment(req).await.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[tokio::test]
async fn create_payment_encodes_zero_decimal_currencies_without_dividing_by_100() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/payments"))
.and(body_partial_json(
serde_json::json!({"amount": {"currency": "JPY", "value": "1000"}}),
))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(mollie_payment_json("tr_jpy", "open", "1000", "JPY")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let req = CreatePayment::new(
Money::from_minor(1000, Currency::JPY),
"order",
"https://shop.example/return",
);
let payment = provider.create_payment(req).await.unwrap();
assert_eq!(payment.amount, Money::from_minor(1000, Currency::JPY));
}
struct CaptureHeader {
name: &'static str,
seen: Arc<Mutex<Vec<String>>>,
}
impl Match for CaptureHeader {
fn matches(&self, request: &Request) -> bool {
if let Some(value) = request.headers.get(self.name).and_then(|v| v.to_str().ok()) {
self.seen.lock().unwrap().push(value.to_string());
}
true
}
}
#[tokio::test]
async fn create_payment_reuses_the_requests_idempotency_key_on_retry() {
let server = MockServer::start().await;
let seen = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/payments"))
.and(CaptureHeader {
name: "idempotency-key",
seen: Arc::clone(&seen),
})
.respond_with(
ResponseTemplate::new(201)
.set_body_json(mollie_payment_json("tr_1", "open", "10.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order", "https://shop.example/return");
provider.create_payment(req.clone()).await.unwrap();
provider.create_payment(req.clone()).await.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[0], seen[1],
"a retried request must reuse the same idempotency key, not a fresh one"
);
}
#[tokio::test]
async fn create_payment_uses_the_caller_supplied_idempotency_key_when_present() {
let server = MockServer::start().await;
let seen = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/payments"))
.and(CaptureHeader {
name: "idempotency-key",
seen: Arc::clone(&seen),
})
.respond_with(
ResponseTemplate::new(201)
.set_body_json(mollie_payment_json("tr_1", "open", "10.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let req = CreatePayment::new(eur(1000), "order", "https://shop.example/return")
.with_idempotency_key("caller-chosen-key");
provider.create_payment(req).await.unwrap();
assert_eq!(seen.lock().unwrap().as_slice(), ["caller-chosen-key"]);
}
#[tokio::test]
async fn cancel_payment_returns_the_decoded_payment_not_a_bare_ack() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_1", "canceled", "5.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let payment = provider.cancel_payment("tr_1").await.unwrap();
assert_eq!(payment.status, PaymentStatus::Cancelled);
assert_eq!(payment.amount, eur(500));
}
#[tokio::test]
async fn cancel_payment_rejects_a_mismatched_id_in_the_response() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_other", "canceled", "5.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider.cancel_payment("tr_1").await.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[tokio::test]
async fn refund_always_sends_amount_and_returns_the_decoded_refund() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/payments/tr_1/refunds"))
.and(body_partial_json(
serde_json::json!({"amount": {"currency": "EUR", "value": "5.00"}}),
))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "re_1",
"paymentId": "tr_1",
"amount": {"currency": "EUR", "value": "5.00"},
"status": "pending",
})))
.mount(&server)
.await;
let provider = test_provider(&server);
let refund = provider
.refund("tr_1", RefundRequest::new(eur(500)))
.await
.unwrap();
assert_eq!(refund.id, "re_1");
assert_eq!(refund.payment_id, "tr_1");
assert_eq!(refund.amount, eur(500));
assert_eq!(refund.status, RefundStatus::Pending);
}
#[tokio::test]
async fn refund_falls_back_to_the_requested_payment_id_when_the_response_omits_it() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/payments/tr_1/refunds"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "re_1",
"amount": {"currency": "EUR", "value": "5.00"},
"status": "queued",
})))
.mount(&server)
.await;
let provider = test_provider(&server);
let refund = provider
.refund("tr_1", RefundRequest::new(eur(500)))
.await
.unwrap();
assert_eq!(refund.payment_id, "tr_1");
}
#[tokio::test]
async fn refund_rejects_a_mismatched_payment_id_in_the_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/payments/tr_1/refunds"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "re_1",
"paymentId": "tr_other",
"amount": {"currency": "EUR", "value": "5.00"},
"status": "queued",
})))
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider
.refund("tr_1", RefundRequest::new(eur(500)))
.await
.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
fn mollie_refund_response() -> serde_json::Value {
serde_json::json!({
"id": "re_1",
"paymentId": "tr_1",
"amount": {"currency": "EUR", "value": "5.00"},
"status": "pending",
})
}
#[tokio::test]
async fn refund_always_sends_the_idempotency_key_header() {
let server = MockServer::start().await;
let seen = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/payments/tr_1/refunds"))
.and(CaptureHeader {
name: "idempotency-key",
seen: Arc::clone(&seen),
})
.respond_with(ResponseTemplate::new(201).set_body_json(mollie_refund_response()))
.mount(&server)
.await;
let provider = test_provider(&server);
provider
.refund("tr_1", RefundRequest::new(eur(500)))
.await
.unwrap();
assert_eq!(
seen.lock().unwrap().len(),
1,
"expected exactly one request, with the Idempotency-Key header present"
);
}
#[tokio::test]
async fn refund_reuses_the_same_requests_idempotency_key_across_retries() {
let server = MockServer::start().await;
let seen = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/payments/tr_1/refunds"))
.and(CaptureHeader {
name: "idempotency-key",
seen: Arc::clone(&seen),
})
.respond_with(ResponseTemplate::new(201).set_body_json(mollie_refund_response()))
.mount(&server)
.await;
let provider = test_provider(&server);
let req = RefundRequest::new(eur(500));
provider.refund("tr_1", req.clone()).await.unwrap();
provider.refund("tr_1", req).await.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[0], seen[1],
"retrying the same RefundRequest value must reuse the same idempotency key"
);
}
#[tokio::test]
async fn refund_uses_different_idempotency_keys_for_separately_constructed_requests() {
let server = MockServer::start().await;
let seen = Arc::new(Mutex::new(Vec::new()));
Mock::given(method("POST"))
.and(path("/payments/tr_1/refunds"))
.and(CaptureHeader {
name: "idempotency-key",
seen: Arc::clone(&seen),
})
.respond_with(ResponseTemplate::new(201).set_body_json(mollie_refund_response()))
.mount(&server)
.await;
let provider = test_provider(&server);
provider
.refund("tr_1", RefundRequest::new(eur(500)))
.await
.unwrap();
provider
.refund("tr_1", RefundRequest::new(eur(500)))
.await
.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert_ne!(
seen[0], seen[1],
"two separately constructed RefundRequests must not share an idempotency key"
);
}
#[tokio::test]
async fn fetch_verified_returns_verified_payment_on_matching_amount_and_paid_status() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_1", "paid", "10.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let verified = provider.fetch_verified("tr_1", eur(1000)).await.unwrap();
assert_eq!(verified.payment().status, PaymentStatus::Paid);
let payment = verified.into_payment();
assert_eq!(payment.amount, eur(1000));
}
#[tokio::test]
async fn fetch_verified_rejects_amount_mismatch() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_1", "paid", "9.99", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider
.fetch_verified("tr_1", eur(1000))
.await
.unwrap_err();
match err {
Error::AmountMismatch { expected, actual } => {
assert_eq!(expected, eur(1000));
assert_eq!(actual, eur(999));
}
other => panic!("expected AmountMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn fetch_verified_rejects_currency_mismatch_even_with_equal_minor_units() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_1", "paid", "10.00", "USD")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider
.fetch_verified("tr_1", eur(1000))
.await
.unwrap_err();
assert!(matches!(err, Error::AmountMismatch { .. }));
}
#[tokio::test]
async fn fetch_verified_rejects_a_matching_amount_that_is_not_paid() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/payments/tr_1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(mollie_payment_json("tr_1", "open", "10.00", "EUR")),
)
.mount(&server)
.await;
let provider = test_provider(&server);
let err = provider
.fetch_verified("tr_1", eur(1000))
.await
.unwrap_err();
assert!(matches!(err, Error::NotPaid { .. }));
}
#[test]
fn encode_path_segment_leaves_unreserved_chars_untouched() {
assert_eq!(encode_path_segment("tr_ABC123-._~"), "tr_ABC123-._~");
}
#[test]
fn encode_path_segment_escapes_everything_else() {
assert_eq!(encode_path_segment("a b/c"), "a%20b%2Fc");
}
#[test]
fn is_absolute_http_url_accepts_http_and_https() {
assert!(is_absolute_http_url("http://mollie.example/pay/1"));
assert!(is_absolute_http_url("https://mollie.example/pay/1"));
}
#[test]
fn is_absolute_http_url_rejects_other_schemes_and_relative_values() {
assert!(!is_absolute_http_url("javascript:alert(1)"));
assert!(!is_absolute_http_url(
"data:text/html,<script>alert(1)</script>"
));
assert!(!is_absolute_http_url("/relative/path"));
assert!(!is_absolute_http_url(""));
}
#[test]
fn map_transport_error_maps_a_builder_error_to_invalid_request_not_transport() {
let err = reqwest::Client::new()
.get("not a valid url")
.build()
.unwrap_err();
assert!(err.is_builder());
let mapped = map_transport_error(err);
assert!(matches!(mapped, Error::InvalidRequest(_)));
assert!(!mapped.is_retriable());
}
#[test]
fn build_and_parse_metadata_round_trips_reference_and_user_metadata() {
let mut metadata = BTreeMap::new();
metadata.insert("channel".to_string(), "web".to_string());
let value = build_metadata(Some("order-42"), &metadata)
.unwrap()
.unwrap();
let (reference, parsed) = parse_metadata(Some(value));
assert_eq!(reference.as_deref(), Some("order-42"));
assert_eq!(parsed.get("channel").map(String::as_str), Some("web"));
assert!(!parsed.contains_key(REFERENCE_METADATA_KEY));
}
#[test]
fn build_metadata_returns_none_when_there_is_nothing_to_send() {
assert_eq!(build_metadata(None, &BTreeMap::new()).unwrap(), None);
}
#[test]
fn build_metadata_rejects_a_caller_supplied_reserved_key() {
let mut metadata = BTreeMap::new();
metadata.insert(REFERENCE_METADATA_KEY.to_string(), "user-value".to_string());
let err = build_metadata(Some("order-42"), &metadata).unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn parse_metadata_of_none_is_empty() {
let (reference, metadata) = parse_metadata(None);
assert_eq!(reference, None);
assert!(metadata.is_empty());
}
#[test]
fn money_from_wire_rejects_invalid_currency() {
let err = money_from_wire(&MollieAmount {
currency: "EU".into(),
value: "1.00".into(),
})
.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[test]
fn money_from_wire_rejects_invalid_amount() {
let err = money_from_wire(&MollieAmount {
currency: "EUR".into(),
value: "abc".into(),
})
.unwrap_err();
assert!(matches!(err, Error::Decode { .. }));
}
#[test]
fn payment_status_preserves_unrecognized_values() {
match payment_status("a_future_status") {
PaymentStatus::Unknown(raw) => assert_eq!(&*raw, "a_future_status"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn payment_status_accepts_both_cancelled_spellings() {
assert_eq!(payment_status("canceled"), PaymentStatus::Cancelled);
assert_eq!(payment_status("cancelled"), PaymentStatus::Cancelled);
}
#[test]
fn refund_status_preserves_unrecognized_values() {
match refund_status("a_future_status") {
RefundStatus::Unknown(raw) => assert_eq!(&*raw, "a_future_status"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn sanitize_error_field_strips_control_characters() {
assert_eq!(sanitize_error_field("line1\r\nline2\ttab"), "line1line2tab");
}
#[test]
fn sanitize_error_field_truncates_long_input() {
let long = "a".repeat(500);
assert_eq!(
sanitize_error_field(&long).chars().count(),
MAX_ERROR_FIELD_LEN
);
}
#[test]
fn parse_retry_after_reads_delta_seconds() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(RETRY_AFTER, HeaderValue::from_static("120"));
assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(120)));
}
#[test]
fn parse_retry_after_clamps_to_the_maximum() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(RETRY_AFTER, HeaderValue::from_static("999999999999"));
assert_eq!(parse_retry_after(&headers), Some(MAX_RETRY_AFTER));
}
#[test]
fn parse_retry_after_ignores_non_numeric_values() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
RETRY_AFTER,
HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
);
assert_eq!(parse_retry_after(&headers), None);
}
#[test]
fn parse_retry_after_is_none_when_absent() {
let headers = reqwest::header::HeaderMap::new();
assert_eq!(parse_retry_after(&headers), None);
}
}