open-standards/openpay/crates/op-fraud/tests/lifecycle.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

187 lines
6.5 KiB
Rust

//! End-to-end tests for op-fraud.
//!
//! Exercise the full pipeline: build a `PaymentDescriptor`, run feature
//! extraction, score with the heuristic, and convert to a `FraudDecision`.
//!
//! No ONNX dependency — these tests work in any build.
use op_core::{A2aKey, Currency, Money, PaymentMethod, RailKind, VaultRef};
use op_fraud::features::PaymentDescriptor;
use op_fraud::{
FraudDecision, HeuristicScorer, Scorer, ScoringContext, Thresholds, extract_features,
};
use time::macros::datetime;
fn vault() -> PaymentMethod {
PaymentMethod::Vault(VaultRef::new("tok_x"))
}
fn descriptor<'a>(method: &'a PaymentMethod, amt: Money, rail: RailKind) -> PaymentDescriptor<'a> {
PaymentDescriptor {
amount: amt,
method,
rail,
creditor_account: Some("creditor_acct"),
creditor_name: Some("Recipient Inc"),
debtor_account: Some("debtor_acct"),
has_remittance: false,
}
}
#[test]
fn normal_card_payment_approves() {
let m = vault();
let p = descriptor(&m, Money::from_minor(2_499, Currency::USD), RailKind::Card);
let ctx = ScoringContext {
timestamp: Some(datetime!(2026-05-20 12:00:00 UTC)),
velocity_1h: Some(1),
velocity_24h: Some(4),
is_new_customer: Some(false),
geo_matches_history: Some(true),
..Default::default()
};
let features = extract_features(&p, &ctx).unwrap();
let score = HeuristicScorer::new().score(&features).unwrap();
let decision = Thresholds::default().decide(score).unwrap();
assert_eq!(decision, FraudDecision::Approve);
}
#[test]
fn large_a2a_to_new_unknown_recipient_reviews_or_declines() {
let m = vault();
let mut p = descriptor(
&m,
Money::from_minor(2_500_000, Currency::USD),
RailKind::A2a,
);
p.creditor_account = Some("never_seen_before_account");
p.creditor_name = Some("New Recipient");
let ctx = ScoringContext {
timestamp: Some(datetime!(2026-05-20 02:30:00 UTC)), // night
velocity_1h: Some(0),
is_new_customer: Some(true),
geo_matches_history: Some(false),
..Default::default()
};
let features = extract_features(&p, &ctx).unwrap();
let score = HeuristicScorer::new().score(&features).unwrap();
let decision = Thresholds::default().decide(score).unwrap();
assert!(
decision == FraudDecision::Review
|| decision == FraudDecision::Decline
|| decision == FraudDecision::Freeze,
"expected non-approve for risky A2A; got {decision:?} (score {score})"
);
}
#[test]
fn velocity_spike_triggers_review() {
let m = vault();
let p = descriptor(&m, Money::from_minor(50_000, Currency::USD), RailKind::Card);
let ctx = ScoringContext {
timestamp: Some(datetime!(2026-05-20 12:00:00 UTC)),
velocity_1h: Some(15),
velocity_24h: Some(40),
device_velocity_1h: Some(12),
is_new_customer: Some(false),
geo_matches_history: Some(true),
..Default::default()
};
let score = HeuristicScorer::new()
.score(&extract_features(&p, &ctx).unwrap())
.unwrap();
// Score should reflect velocity contribution (~0.20 from velocity alone)
// plus round-amount signal (~0.05).
assert!(score >= 0.20, "got {score}");
}
#[test]
fn first_pix_transfer_at_3am_with_geo_mismatch_does_not_silently_approve() {
let m = vault();
// 5000 BRL — over the $1K-equivalent threshold (f[6] = 1.0).
let mut p = descriptor(&m, Money::from_minor(500_000, Currency::BRL), RailKind::A2a);
p.creditor_account = Some("new_brazilian_account");
let ctx = ScoringContext {
timestamp: Some(datetime!(2026-05-20 03:00:00 UTC)),
is_new_customer: Some(true),
geo_matches_history: Some(false),
..Default::default()
};
let features = extract_features(&p, &ctx).unwrap();
let score = HeuristicScorer::new().score(&features).unwrap();
let decision = Thresholds::default().decide(score).unwrap();
assert_ne!(decision, FraudDecision::Approve);
}
#[test]
fn scorer_is_pluggable_via_trait() {
// The orchestrator holds Box<dyn Scorer> and doesn't know which
// implementation. This test proves the trait works.
let scorers: Vec<Box<dyn Scorer>> = vec![Box::new(HeuristicScorer::new())];
let f = [0.0_f32; op_fraud::FEATURES];
for s in scorers {
let _ = s.score(&f).unwrap();
}
}
#[test]
fn features_omit_pii_text_completely() {
let m = PaymentMethod::A2a(A2aKey::Pix("[email protected]".into()));
let p = PaymentDescriptor {
amount: Money::from_minor(10_000, Currency::BRL),
method: &m,
rail: RailKind::A2a,
creditor_account: Some("VERY_SENSITIVE_ACCOUNT_12345"),
creditor_name: Some("João da Silva"),
debtor_account: Some("ANOTHER_SENSITIVE_ACCOUNT_67890"),
has_remittance: true,
};
let features = extract_features(&p, &ScoringContext::empty()).unwrap();
// No string slot exists in the FeatureVector by construction (it's
// [f32; 32]). The compile-time guarantee is the test. Here we
// additionally verify that hashing distinguishes the inputs.
let mut p2 = p.clone();
p2.creditor_account = Some("DIFFERENT_ACCOUNT_99999");
let features2 = extract_features(&p2, &ScoringContext::empty()).unwrap();
assert_ne!(
features[28], features2[28],
"different creditor accounts must hash differently"
);
// Everything else should be identical (we only changed creditor_account).
for i in 0..op_fraud::FEATURES {
if i == 28 {
continue;
}
assert_eq!(features[i], features2[i], "feature {i} should match");
}
}
#[test]
fn score_below_review_threshold_means_approve() {
let t = Thresholds::default();
assert_eq!(t.decide(0.49).unwrap(), FraudDecision::Approve);
assert_eq!(t.decide(0.0).unwrap(), FraudDecision::Approve);
}
#[test]
fn full_pipeline_is_deterministic() {
let m = vault();
let p = descriptor(&m, Money::from_minor(12_345, Currency::USD), RailKind::Card);
let ctx = ScoringContext {
timestamp: Some(datetime!(2026-05-20 12:00:00 UTC)),
velocity_1h: Some(2),
device_id: Some("dev_xyz".into()),
..Default::default()
};
let s = HeuristicScorer::new();
let f1 = extract_features(&p, &ctx).unwrap();
let f2 = extract_features(&p, &ctx).unwrap();
assert_eq!(f1, f2);
let d1 = Thresholds::default().decide(s.score(&f1).unwrap()).unwrap();
let d2 = Thresholds::default().decide(s.score(&f2).unwrap()).unwrap();
assert_eq!(d1, d2);
}