open-standards/openpay/crates/op-iso20022/tests/conformance.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

137 lines
5.2 KiB
Rust

//! Conformance tests: parse the sample XML vectors and verify our
//! understanding of the message structure.
//!
//! These run against `vectors/*.xml`. Each sample must:
//! 1. Parse via `op_iso20022::from_xml`
//! 2. Re-serialize via `op_iso20022::to_xml`
//! 3. Round-trip a second time to the same canonical form
//!
//! If any step fails, either the sample is wrong or the upstream crate
//! has changed its wire shape.
use op_iso20022::status::{StatusReason, TransactionStatus};
const PACS008_MINIMAL: &str = include_str!("../vectors/fednow_pacs008_v08_minimal.xml");
const PACS002_ACSC: &str = include_str!("../vectors/fednow_pacs002_v10_acsc.xml");
const PACS002_RJCT: &str = include_str!("../vectors/fednow_pacs002_v10_rjct.xml");
#[test]
fn pacs008_minimal_contains_expected_elements() {
// We don't enforce a specific upstream parser API yet (it varies by
// version); instead we test that the sample contains the elements
// our profile validator and builder expect. This is the layer-0
// smoke test: if these substrings disappear, our schema model is
// out of date.
let xml = PACS008_MINIMAL;
assert!(xml.contains("pacs.008.001.08"), "expected v08 namespace");
assert!(xml.contains("<UETR>"), "expected UETR element");
assert!(
xml.contains("550e8400-e29b-41d4-a716-446655440000"),
"expected UETR value"
);
assert!(xml.contains("<IntrBkSttlmAmt Ccy=\"USD\">100.00</IntrBkSttlmAmt>"));
assert!(
xml.contains("<ChrgBr>SLEV</ChrgBr>"),
"FedNow charge bearer must be SLEV"
);
assert!(xml.contains("<MmbId>021000021</MmbId>"), "debtor agent ABA");
assert!(
xml.contains("<MmbId>026009593</MmbId>"),
"creditor agent ABA"
);
}
#[test]
fn pacs002_acsc_indicates_settlement() {
let xml = PACS002_ACSC;
assert!(xml.contains("<TxSts>ACSC</TxSts>"));
// No reject reason on a settled message.
assert!(
!xml.contains("<StsRsnInf>"),
"ACSC must not carry a reject reason"
);
let status = TransactionStatus::from_code("ACSC").unwrap();
assert!(status.is_terminal());
assert_eq!(status, TransactionStatus::AcceptedSettled);
}
#[test]
fn pacs002_rjct_carries_reason_code() {
let xml = PACS002_RJCT;
assert!(xml.contains("<TxSts>RJCT</TxSts>"));
assert!(xml.contains("<Cd>AC04</Cd>"));
let status = TransactionStatus::from_code("RJCT").unwrap();
let reason = StatusReason::from_code("AC04");
assert_eq!(status, TransactionStatus::Rejected);
assert_eq!(reason, StatusReason::ClosedAccountNumber);
assert!(status.is_terminal());
}
#[test]
fn all_sample_uetrs_match_format() {
// Every value-message UETR in our samples must be a lowercase v4 UUID.
for xml in [PACS008_MINIMAL, PACS002_ACSC, PACS002_RJCT] {
// Extract the UETR(s).
let mut count = 0;
let mut tail = xml;
while let Some(start) = tail.find("UETR>") {
let after = &tail[start + 5..];
if let Some(end) = after.find('<') {
let uetr = &after[..end];
if uetr.len() == 36 {
// Position 14 should be '4' (UUID v4).
assert_eq!(uetr.as_bytes()[14], b'4', "UETR {uetr} version nibble != 4");
assert!(
uetr.chars()
.all(|c| c == '-' || c.is_ascii_digit() || ('a'..='f').contains(&c)),
"UETR {uetr} contains non-lowercase-hex"
);
count += 1;
}
tail = &after[end..];
} else {
break;
}
}
assert!(count > 0, "no UETRs found in sample");
}
}
const CAMT053_MINIMAL: &str = include_str!("../vectors/camt053_v12_minimal.xml");
#[test]
fn camt053_minimal_contains_expected_elements() {
// Substring conformance, same pattern as the pacs.008/002
// tests above — verify the shape an operator-side parser will
// see. If these substrings disappear, downstream reconciliation
// can't extract the fields it needs.
let xml = CAMT053_MINIMAL;
assert!(
xml.contains("camt.053.001.12"),
"expected camt.053 v12 namespace"
);
assert!(xml.contains("<NtryRef>NTRY-1</NtryRef>"));
assert!(xml.contains("<Amt Ccy=\"USD\">52.50</Amt>"));
assert!(xml.contains("<CdtDbtInd>CRDT</CdtDbtInd>"));
assert!(xml.contains("<EndToEndId>ORD-77</EndToEndId>"));
}
#[test]
fn camt053_minimal_parses_into_message_camt053() {
// The serde round-trip across the upstream Bank-to-Customer
// Statement type. If this regresses we surface the upstream
// change immediately; the codebase's neutral statement view
// (`camt053_entries`) is what reconciliation consumes, but the
// parse layer has to work or that view is empty.
let parsed = op_iso20022::Message::parse_camt053(CAMT053_MINIMAL)
.expect("camt.053 v12 vector should parse");
let entries = parsed.camt053_entries();
assert_eq!(entries.len(), 1, "expected one Ntry in the vector");
let e = &entries[0];
assert_eq!(e.reference.as_deref(), Some("NTRY-1"));
assert_eq!(e.end_to_end_id.as_deref(), Some("ORD-77"));
assert_eq!(e.currency, "USD");
assert!(e.is_credit);
}