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

208 lines
7 KiB
Rust

//! Bi-temporal time-travel tests for `LedgerHistory` on
//! `GraphLedgerStore`.
//!
//! These exercise Phase 17's core promise: the graph backend keeps
//! every prior fact, so an operator (or auditor) can ask "what did
//! the books look like at point X?" and get an answer.
use op_core::{Currency, Money};
use op_graph::{GraphHandle, GraphLedgerStore};
use op_ledger::{
Account, AccountClass, Entry, Ledger, LedgerHistory, LedgerStore, Status, Transaction,
};
fn fixture() -> (
GraphLedgerStore,
GraphHandle,
op_ledger::LedgerId,
op_ledger::AccountId,
op_ledger::AccountId,
) {
let handle = GraphHandle::new_in_memory();
let store = GraphLedgerStore::with_handle(handle.clone());
let l = Ledger::new("Acme").unwrap();
let lid = l.id;
store.create_ledger(l).unwrap();
let cash = Account::new(lid, "cash", AccountClass::Asset, Currency::USD);
let rev = Account::new(lid, "rev", AccountClass::Revenue, Currency::USD);
let cid = cash.id;
let rid = rev.id;
store.create_account(cash).unwrap();
store.create_account(rev).unwrap();
(store, handle, lid, cid, rid)
}
#[test]
fn balance_as_of_before_reversal_shows_pre_reversal_state() {
let (store, handle, lid, cid, rid) = fixture();
// Post an initial $5.00 transaction.
let tx = Transaction::new_posted(
lid,
1_000,
vec![
Entry::debit(cid, Money::from_minor(500, Currency::USD)),
Entry::credit(rid, Money::from_minor(500, Currency::USD)),
],
)
.unwrap()
.with_external_id("ORD-1");
store.post_transaction(tx).unwrap();
// Snapshot AFTER the initial post but BEFORE the reversal.
let snap = handle.tx_count();
// Now post a reversing entry of $5.00 (credit cash, debit rev).
let reversal = Transaction::new_posted(
lid,
2_000,
vec![
Entry::credit(cid, Money::from_minor(500, Currency::USD)),
Entry::debit(rid, Money::from_minor(500, Currency::USD)),
],
)
.unwrap()
.with_external_id("ORD-1-REVERSE");
store.post_transaction(reversal).unwrap();
// Present-time balance on cash is zero (the reversal cancelled it).
let now = store.balance(cid).unwrap();
assert_eq!(now.posted.minor_units, 0);
// Time-travel: at the snapshot, cash was up by $5 (debit-normal,
// posted only the first tx's debit).
let then = store.balance_as_of(cid, snap).unwrap();
assert_eq!(then.posted.minor_units, 500);
}
#[test]
fn transaction_as_of_returns_pending_status_before_mark_posted() {
let (store, handle, lid, cid, rid) = fixture();
let tx = Transaction::new_pending(
lid,
1_000,
vec![
Entry::debit(cid, Money::from_minor(525, Currency::USD)),
Entry::credit(rid, Money::from_minor(525, Currency::USD)),
],
)
.unwrap()
.with_external_id("ORD-PENDING");
let tid = store.post_transaction(tx).unwrap();
// Snapshot while the tx is still pending.
let snap = handle.tx_count();
// Then settle it.
store.mark_posted(tid).unwrap();
// Present: posted.
let now = store.get_transaction(tid).unwrap();
assert_eq!(now.status, Status::Posted);
// Then: pending — the historical view at `snap` predates the
// `mark_posted`.
let then = store.transaction_as_of(tid, snap).unwrap();
assert_eq!(then.status, Status::Pending);
assert_eq!(then.external_id.as_deref(), Some("ORD-PENDING"));
assert_eq!(then.entries.len(), 2);
}
#[test]
fn balance_as_of_through_pending_then_posted_transition() {
// Pending tx contributes to `pending` balance but not `posted`.
// After `mark_posted` it contributes to both. Snapshot in
// between and verify that.
let (store, handle, lid, cid, rid) = fixture();
let tx = Transaction::new_pending(
lid,
1_000,
vec![
Entry::debit(cid, Money::from_minor(1_000, Currency::USD)),
Entry::credit(rid, Money::from_minor(1_000, Currency::USD)),
],
)
.unwrap()
.with_external_id("ORD-FLOW");
let tid = store.post_transaction(tx).unwrap();
let snap_pending = handle.tx_count();
store.mark_posted(tid).unwrap();
let snap_posted = handle.tx_count();
let b_pending = store.balance_as_of(cid, snap_pending).unwrap();
assert_eq!(b_pending.posted.minor_units, 0);
assert_eq!(b_pending.pending.minor_units, 1_000);
let b_posted = store.balance_as_of(cid, snap_posted).unwrap();
assert_eq!(b_posted.posted.minor_units, 1_000);
assert_eq!(b_posted.pending.minor_units, 1_000);
}
#[test]
fn transaction_as_of_unknown_returns_not_found() {
let (store, handle, _, _, _) = fixture();
let snap = handle.tx_count();
let fake = op_ledger::TransactionId::from_uuid(uuid::Uuid::new_v4());
let err = store.transaction_as_of(fake, snap).unwrap_err();
assert!(matches!(err, op_ledger::Error::TransactionNotFound(_)));
}
#[test]
fn time_travel_survives_persistence_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.graph");
let (cid, snap) = {
let handle = GraphHandle::new_persistent(&path).unwrap();
let store = GraphLedgerStore::with_handle(handle.clone());
let l = Ledger::new("Acme").unwrap();
let lid = l.id;
store.create_ledger(l).unwrap();
let cash = Account::new(lid, "cash", AccountClass::Asset, Currency::USD);
let rev = Account::new(lid, "rev", AccountClass::Revenue, Currency::USD);
let cid = cash.id;
let rid = rev.id;
store.create_account(cash).unwrap();
store.create_account(rev).unwrap();
store
.post_transaction(
Transaction::new_posted(
lid,
1_000,
vec![
Entry::debit(cid, Money::from_minor(400, Currency::USD)),
Entry::credit(rid, Money::from_minor(400, Currency::USD)),
],
)
.unwrap()
.with_external_id("FIRST"),
)
.unwrap();
let snap = handle.tx_count();
store
.post_transaction(
Transaction::new_posted(
lid,
2_000,
vec![
Entry::debit(cid, Money::from_minor(300, Currency::USD)),
Entry::credit(rid, Money::from_minor(300, Currency::USD)),
],
)
.unwrap()
.with_external_id("SECOND"),
)
.unwrap();
(cid, snap)
};
// Reopen and time-travel: the saved snapshot still points at
// the same moment in the persisted history.
let store2 = GraphLedgerStore::with_handle(GraphHandle::new_persistent(&path).unwrap());
let now = store2.balance(cid).unwrap();
assert_eq!(now.posted.minor_units, 700);
let then = store2.balance_as_of(cid, snap).unwrap();
assert_eq!(then.posted.minor_units, 400);
}