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

217 lines
8.1 KiB
Rust

//! Persistent-backend tests for `op-graph`.
//!
//! These prove that a `GraphHandle::new_persistent(path)` survives a
//! handle drop and reopen — the same single-file durability story
//! SQLite gives you, applied to property-graph data. Each test
//! opens a fresh tempdir, writes through one of the typed stores,
//! drops the handle, reopens the same path, and verifies the data
//! is still there.
use std::sync::Arc;
use op_core::{Currency, Money};
use op_ledger::{Account, AccountClass, Entry, Ledger, LedgerStore, Transaction};
use op_reconciliation::sources::{SETTLEMENT_EVENT_TYPE, WebhookEventSource};
use op_reconciliation::{Reconciler, ReconciliationStore};
use op_webhook::{DeliveryAttempt, Endpoint, WebhookEvent, WebhookStore};
use op_graph::{GraphHandle, GraphLedgerStore, GraphReconciliationStore, GraphWebhookStore};
#[test]
fn graph_handle_persists_vertex_and_edge_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("openpay.graph");
let v_a = uuid::Uuid::new_v4();
let v_b = uuid::Uuid::new_v4();
let edge_id;
{
let h = GraphHandle::new_persistent(&path).unwrap();
h.create_vertex(op_graph::graph::vtypes::LEDGER_ACCOUNT, v_a)
.unwrap();
h.create_vertex(op_graph::graph::vtypes::LEDGER_ACCOUNT, v_b)
.unwrap();
h.set_vertex_property(v_a, "label", serde_json::Value::String("acct-a".into()))
.unwrap();
let e = h
.create_edge(v_a, op_graph::graph::etypes::LEDGER_IN_LEDGER, v_b)
.unwrap();
edge_id = e.id;
// Handle drops here — file flushes.
}
let h2 = GraphHandle::new_persistent(&path).unwrap();
let recovered = h2
.get_typed_vertex(v_a, op_graph::graph::vtypes::LEDGER_ACCOUNT)
.unwrap();
assert_eq!(recovered.id, v_a);
let props = h2.get_vertex_properties(v_a).unwrap();
assert_eq!(
props.get("label"),
Some(&serde_json::Value::String("acct-a".into()))
);
let out = h2
.out_edges(v_a, op_graph::graph::etypes::LEDGER_IN_LEDGER)
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].to, v_b);
assert_eq!(out[0].id, edge_id);
}
#[test]
fn ledger_transaction_round_trips_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.graph");
let (tid, lid, cid) = {
let store = GraphLedgerStore::with_handle(GraphHandle::new_persistent(&path).unwrap());
let l = Ledger::new("FY26").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();
let t = Transaction::new_posted(
lid,
1_700_000_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-PERSIST-1");
let tid = store.post_transaction(t).unwrap();
(tid, lid, cid)
};
// Reopen the same file; the ledger is intact.
let store2 = GraphLedgerStore::with_handle(GraphHandle::new_persistent(&path).unwrap());
let recovered = store2.get_transaction(tid).unwrap();
assert_eq!(recovered.external_id.as_deref(), Some("ORD-PERSIST-1"));
assert_eq!(recovered.ledger_id, lid);
// Balances are derived from the entries, which are stored as
// edges — proving the edge graph survived.
let bal = store2.balance(cid).unwrap();
assert_eq!(bal.posted.minor_units, 525);
}
#[test]
fn webhook_event_and_attempt_round_trip_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("webhooks.graph");
let (event_id, attempt_id, endpoint_id) = {
let store = GraphWebhookStore::with_handle(GraphHandle::new_persistent(&path).unwrap());
let endpoint = Endpoint::new(
"https://merchant/h",
b"whsec".to_vec(),
vec!["*".to_string()],
)
.unwrap();
let eid = endpoint.id;
store.put_endpoint(endpoint).unwrap();
let event = WebhookEvent::new("ledger.tx.posted", b"{\"x\":1}".to_vec(), 1_700_000_000);
let evid = event.id;
store.put_event(event).unwrap();
let a = DeliveryAttempt::new_pending(evid, eid, 0, 1_700_000_001);
let aid = a.id;
store.put_attempt(a).unwrap();
(evid, aid, eid)
};
let store2 = GraphWebhookStore::with_handle(GraphHandle::new_persistent(&path).unwrap());
let attempt = store2.get_attempt(attempt_id).unwrap();
assert_eq!(attempt.event_id, event_id);
assert_eq!(attempt.endpoint_id, endpoint_id);
}
#[test]
fn reconciliation_tasks_survive_restart_and_re_record_stays_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("recon.graph");
let _shared: Arc<()> = Arc::new(());
{
let handle = GraphHandle::new_persistent(&path).unwrap();
let ledger = GraphLedgerStore::with_handle(handle.clone());
let recon = GraphReconciliationStore::with_handle(handle.clone());
let l = Ledger::new("Acme").unwrap();
let lid = l.id;
ledger.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;
ledger.create_account(cash).unwrap();
ledger.create_account(rev).unwrap();
let pending = Transaction::new_pending(
lid,
1_000,
vec![
Entry::debit(cid, Money::from_minor(900, Currency::USD)),
Entry::credit(rid, Money::from_minor(900, Currency::USD)),
],
)
.unwrap()
.with_external_id("ORD-DURABLE");
let tid = ledger.post_transaction(pending).unwrap();
let stored = vec![ledger.get_transaction(tid).unwrap()];
let events = vec![WebhookEvent::new(
SETTLEMENT_EVENT_TYPE,
serde_json::to_vec(&serde_json::json!({
"source_id": "psp-durable", "external_id": "ORD-DURABLE",
"amount_minor": 900, "currency": "USD",
"direction": "credit", "posted_at_unix_secs": 1_000
}))
.unwrap(),
1_000,
)];
let report = Reconciler::new(0, 10_000)
.unwrap()
.reconcile(&WebhookEventSource::new(&events), &stored)
.unwrap();
let touched = recon.record_report(&report).unwrap();
assert_eq!(touched.len(), 1);
// Drop happens at end of scope -> file flushes.
}
// Reopen; the task is still there.
let handle2 = GraphHandle::new_persistent(&path).unwrap();
let ledger2 = GraphLedgerStore::with_handle(handle2.clone());
let recon2 = GraphReconciliationStore::with_handle(handle2);
let tasks = recon2.list_tasks().unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].kind, "status_mismatch");
// Re-recording the same report after the restart is still
// idempotent: the deterministic task_id index hits the persisted
// vertex, so no duplicate task is created.
let stored = ledger2
.find_by_external_id("ORD-DURABLE")
.unwrap()
.map(|t| vec![t])
.unwrap();
let events = vec![WebhookEvent::new(
SETTLEMENT_EVENT_TYPE,
serde_json::to_vec(&serde_json::json!({
"source_id": "psp-durable", "external_id": "ORD-DURABLE",
"amount_minor": 900, "currency": "USD",
"direction": "credit", "posted_at_unix_secs": 1_000
}))
.unwrap(),
1_000,
)];
let report = Reconciler::new(0, 10_000)
.unwrap()
.reconcile(&WebhookEventSource::new(&events), &stored)
.unwrap();
let _ = recon2.record_report(&report).unwrap();
assert_eq!(recon2.list_tasks().unwrap().len(), 1);
}