use chrono::TimeZone;
use super::*;
use crate::{CumulativeRefundCents, CurrencyCode, GatewayReferenceValueError};
#[test]
fn gateway_account_mode_storage_values_round_trip_exhaustively() {
for mode in [GatewayAccountMode::Live, GatewayAccountMode::Test] {
assert_eq!(mode.as_str().parse::<GatewayAccountMode>(), Ok(mode));
assert_eq!(mode.to_string(), mode.as_str());
}
assert!("LIVE".parse::<GatewayAccountMode>().is_err());
assert!("unknown".parse::<GatewayAccountMode>().is_err());
assert!("".parse::<GatewayAccountMode>().is_err());
}
#[test]
fn query_and_report_requests_reject_invalid_shapes() {
assert!(matches!(
GatewayQueryRequest::new(None, None),
Err(GatewayRequestError::MissingQuerySelector)
));
let start = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap();
assert!(matches!(
GatewayTransactionReportRequest::new(start, start, 100, 0),
Err(GatewayRequestError::InvalidReportWindow)
));
assert!(matches!(
GatewayTransactionReportRequest::new(start, start + Duration::minutes(1), 0, 0),
Err(GatewayRequestError::InvalidPageSize)
));
}
#[test]
fn sale_request_cannot_represent_zero_money() {
let usd = CurrencyCode::new("USD").unwrap();
let charge = ChargeAmount::new(100, usd).unwrap();
let attempt_id: PaymentAttemptId = "00000000-0000-0000-0000-000000000000".parse().unwrap();
let order = GatewayOrderId::from_generated_attempt(
"ck_order_00000000000000000000000000000000",
attempt_id,
)
.unwrap();
let token = PaymentToken::new("tok_safe").unwrap();
let request = GatewaySaleRequest::new(
charge,
order,
GatewaySaleIntent::OneTime {
payment_token: token,
},
None,
);
assert_eq!(request.charge().cents(), 100);
}
#[test]
fn card_brand_canonicalization_never_retains_unknown_provider_text() {
let aliases = [
("visa", PaymentCardBrand::Visa),
(" VISA ", PaymentCardBrand::Visa),
("mastercard", PaymentCardBrand::Mastercard),
("master card", PaymentCardBrand::Mastercard),
("american express", PaymentCardBrand::AmericanExpress),
("amex", PaymentCardBrand::AmericanExpress),
("discover", PaymentCardBrand::Discover),
("jcb", PaymentCardBrand::Jcb),
("diners", PaymentCardBrand::DinersClub),
("diners club", PaymentCardBrand::DinersClub),
("dinersclub", PaymentCardBrand::DinersClub),
("unionpay", PaymentCardBrand::UnionPay),
("union pay", PaymentCardBrand::UnionPay),
("maestro", PaymentCardBrand::Maestro),
];
for (provider_value, expected) in aliases {
assert_eq!(
PaymentCardBrand::from_provider(provider_value),
Some(expected)
);
}
assert_eq!(PaymentCardBrand::from_provider(" "), None);
let unknown = PaymentCardBrand::from_provider("private-provider-sentinel").unwrap();
assert_eq!(unknown, PaymentCardBrand::Other);
assert_eq!(unknown.as_str(), "other");
assert!(!format!("{unknown:?}").contains("private-provider-sentinel"));
assert_eq!(unknown.to_string(), "[redacted]");
}
#[test]
fn descriptor_drops_invalid_display_fields() {
let descriptor = GatewayPaymentDescriptor::from_provider_parts(
Some(GatewayDiagnostic::new("visa")),
Some(GatewayDiagnostic::new("brand")),
Some("12x4"),
Some(13),
Some(2101),
);
assert_eq!(
descriptor.canonical_card_brand(),
Some(PaymentCardBrand::Other)
);
assert_eq!(
descriptor.card_brand().map(GatewayDiagnostic::expose),
Some("brand")
);
assert!(descriptor.card_last_four().is_none());
assert_eq!(descriptor.card_exp_month(), None);
assert_eq!(descriptor.card_exp_year(), None);
}
#[test]
fn lifecycle_admission_prevents_invalid_locator_combinations() {
assert_eq!(
GatewayLifecycleEvidence::new(None, None, GatewayLifecycleState::Unknown, None, None, None,),
Err(GatewayLifecycleEvidenceError::MissingLocator)
);
assert!(
GatewayLifecycleQuarantine::new(
None,
None,
GatewayLifecycleQuarantineReason::MalformedReportStructure,
)
.is_ok()
);
assert_eq!(
GatewayLifecycleQuarantine::new(
None,
None,
GatewayLifecycleQuarantineReason::InvalidRefundEconomics,
),
Err(GatewayLifecycleQuarantineError::MissingLocator)
);
}
#[test]
fn only_full_lifecycle_states_derive_reversals() {
let refunded = CumulativeRefundCents::new(100).unwrap();
assert_eq!(
GatewayLifecycleState::Settled {
cumulative_refunded_cents: Some(refunded)
}
.full_reversal_kind(),
None
);
assert_eq!(
GatewayLifecycleState::Refunded {
cumulative_refunded_cents: refunded
}
.full_reversal_kind(),
Some(PaymentReversalKind::Refunded)
);
}
#[test]
fn query_policy_preserves_positive_overflow_safe_limits() {
let key = GatewayLifecycleCursorKey::new("nmi_approved_lifecycle").unwrap();
let policy =
GatewayLifecycleQueryPolicy::new(key, Duration::minutes(5), 100, 20, 12, 2_000).unwrap();
assert_eq!(policy.page_size().get(), 100);
assert_eq!(policy.narrow_window_drain_page_limit().get(), 2_000);
assert_eq!(
GatewayLifecycleQueryPolicy::new(
GatewayLifecycleCursorKey::new("cursor").unwrap(),
Duration::zero(),
100,
20,
12,
2_000,
),
Err(GatewayLifecycleQueryPolicyError::NonPositiveOverlap)
);
}
#[test]
fn sensitive_debug_output_is_value_free() {
let identifier = GatewayTransactionId::new("txn_sentinel").unwrap();
let debug = format!("{identifier:?}");
assert!(!debug.contains("txn_sentinel"));
assert!(debug.contains("redacted"));
let evidence = ProcessorEvidence::new(
Some(identifier),
None,
None,
None,
None,
None,
GatewayPaymentDescriptor::default(),
);
assert!(evidence.has_gateway_reference());
assert_eq!(ProcessorEvidence::default(), ProcessorEvidence::default());
assert!(!format!("{evidence:?}").contains("txn_sentinel"));
assert_eq!(
GatewayTransactionId::from_correlation("bad selector"),
Err(GatewayReferenceValueError::UnsupportedCorrelationCharacter)
);
}
#[test]
fn approved_payment_evidence_requires_identity_and_an_authoritative_decision() {
let approved =
|response: Option<&str>, response_code: Option<&str>, condition: Option<&str>| {
ProcessorEvidence::new(
Some(GatewayTransactionId::new("txn-approved-evidence").unwrap()),
None,
response.map(GatewayDiagnostic::new),
response_code.map(GatewayDiagnostic::new),
None,
condition.map(GatewayDiagnostic::new),
GatewayPaymentDescriptor::default(),
)
};
assert!(approved(Some("1"), None, None).indicates_approved_payment());
assert!(approved(None, Some("100"), None).indicates_approved_payment());
assert!(approved(None, None, Some("complete")).indicates_approved_payment());
assert!(!approved(Some("2"), Some("200"), Some("declined")).indicates_approved_payment());
assert!(!approved(None, None, None).indicates_approved_payment());
let missing_identity = ProcessorEvidence::new(
None,
None,
Some(GatewayDiagnostic::new("1")),
Some(GatewayDiagnostic::new("100")),
Some(GatewayDiagnostic::new("Approved")),
Some(GatewayDiagnostic::new("complete")),
GatewayPaymentDescriptor::default(),
);
assert!(!missing_identity.indicates_approved_payment());
let incomplete_but_authoritative =
GatewayPaymentOutcome::new(GatewayPaymentStatus::Approved, ProcessorEvidence::default());
assert!(incomplete_but_authoritative.approved_evidence().is_some());
let non_approved = GatewayPaymentOutcome::new(
GatewayPaymentStatus::Unknown,
approved(Some("1"), None, None),
);
assert!(non_approved.approved_evidence().is_none());
}
#[test]
fn gateway_outcome_carries_only_provider_neutral_payment_diagnostics() {
let outcome =
GatewayPaymentOutcome::new(GatewayPaymentStatus::Unknown, ProcessorEvidence::default())
.with_diagnostics(vec![GatewayPaymentDiagnostic::ProcessorReportedDuplicate]);
assert_eq!(
outcome.diagnostics(),
&[GatewayPaymentDiagnostic::ProcessorReportedDuplicate]
);
let (status, evidence, diagnostics) = outcome.into_parts_with_diagnostics();
assert_eq!(status, GatewayPaymentStatus::Unknown);
assert_eq!(evidence, ProcessorEvidence::default());
assert_eq!(
diagnostics,
&[GatewayPaymentDiagnostic::ProcessorReportedDuplicate]
);
assert!(
GatewayPaymentOutcome::new(GatewayPaymentStatus::Unknown, ProcessorEvidence::default())
.diagnostics()
.is_empty()
);
}
#[test]
fn quarantine_resolution_reason_is_normalized_bounded_and_card_safe() {
let reason = GatewayLifecycleQuarantineResolutionReason::new(" reviewed evidence ").unwrap();
assert_eq!(reason.expose(), "reviewed evidence");
assert!(!format!("{reason:?}").contains("reviewed evidence"));
assert_eq!(
GatewayLifecycleQuarantineResolutionReason::new(" "),
Err(GatewayLifecycleQuarantineResolutionReasonError::Empty)
);
assert_eq!(
GatewayLifecycleQuarantineResolutionReason::new("x".repeat(501)),
Err(GatewayLifecycleQuarantineResolutionReasonError::TooLong)
);
assert_eq!(
GatewayLifecycleQuarantineResolutionReason::new("card 4111111111111111"),
Err(GatewayLifecycleQuarantineResolutionReasonError::ContainsRawCardData)
);
}
#[test]
fn gateway_errors_preserve_value_free_debug_and_stable_messages() {
const SENTINEL: &str = "gateway-error-detail-sentinel";
let query_errors = [
(
GatewayError::RequestRejected(GatewayDiagnostic::new(SENTINEL)),
"RequestRejected",
"gateway rejected the request before processing",
),
(
GatewayError::Malformed(GatewayDiagnostic::new(SENTINEL)),
"Malformed",
"gateway response was malformed",
),
(
GatewayError::Configuration(GatewayDiagnostic::new(SENTINEL)),
"Configuration",
"gateway configuration is invalid",
),
(
GatewayError::Unavailable(GatewayDiagnostic::new(SENTINEL)),
"Unavailable",
"gateway is unavailable",
),
(
GatewayError::RateLimited(GatewayDiagnostic::new(SENTINEL)),
"RateLimited",
"gateway rate limit exceeded",
),
];
for (error, variant, message) in query_errors {
assert_eq!(error.to_string(), message);
let debug = format!("{error:?}");
assert!(debug.starts_with(variant));
assert!(debug.contains("has_detail: true"));
assert!(!debug.contains(SENTINEL));
}
let not_submitted = GatewayNotSubmittedError::Malformed(GatewayDiagnostic::new(SENTINEL));
let debug = format!("{not_submitted:?}");
assert!(debug.starts_with("Malformed"));
assert!(debug.contains("has_detail: true"));
assert!(!debug.contains(SENTINEL));
let mode_mismatch = GatewayNotSubmittedError::AccountModeMismatch {
required: GatewayAccountMode::Live,
observed: GatewayAccountMode::Test,
detail: GatewayDiagnostic::new(SENTINEL),
};
let debug = format!("{mode_mismatch:?}");
assert!(debug.starts_with("AccountModeMismatch"));
assert!(debug.contains("has_detail: true"));
assert!(!debug.contains(SENTINEL));
let mode_verification = GatewayNotSubmittedError::AccountModeVerification(
GatewayError::Unavailable(GatewayDiagnostic::new(SENTINEL)),
);
let debug = format!("{mode_verification:?}");
assert!(debug.starts_with("AccountModeVerification"));
assert!(!debug.contains(SENTINEL));
let mutation = GatewayMutationError::Indeterminate(GatewayDiagnostic::new(SENTINEL));
let debug = format!("{mutation:?}");
assert!(debug.starts_with("Indeterminate"));
assert!(debug.contains("has_detail: true"));
assert!(!debug.contains(SENTINEL));
}