Lines
88.89 %
Functions
100 %
Branches
//! Integration tests for ADR-0021 Phase 2b: account balance, transaction list,
//! and transaction create leaves migrated to the in-process rpc::Session path.
//!
//! Gated on the `db` feature. Run via:
//! DATABASE_URL=postgres://… cargo test -p tests-integration --features db
#![cfg(feature = "db")]
use cli_core::render::{WireValue, parse_wire, reparse_list};
use nomiscript::{Value, list_to_vec};
use rpc::{ScriptCtx, Session};
use server::db::DB_POOL;
use sqlx::PgPool;
use supp_macro::local_db_sqlx_test;
use uuid::Uuid;
async fn setup() {}
/// Read the formatted value following `key` in a structured plist `Value`,
/// tolerating a leading type tag (mirrors the CLI `plist_field` helper).
fn plist_str(value: &Value, key: &str) -> Option<String> {
let items = list_to_vec(value)?;
let pos = items
.iter()
.position(|v| matches!(v, Value::Symbol(s) if s == key))?;
match items.get(pos + 1)? {
Value::String(s) => Some(s.clone()),
Value::Symbol(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
/// Render the migrated `transaction list` output lines from a real wire reply,
/// reading fields directly off each structured `Value::Pair` element — the same
/// path `print_transaction_list` takes in the CLI binary.
fn render_transaction_lines(wire: &str) -> Vec<String> {
let WireValue::Value(Value::String(raw)) = parse_wire(wire).expect("parse_wire") else {
return Vec::new();
};
let outer = reparse_list(&raw).expect("reparse outer");
let Some(elements) = list_to_vec(&outer) else {
elements
.map(|el| {
let id = plist_str(el, ":id").expect(":id");
let post_date = plist_str(el, ":post-date").expect(":post-date");
let note = plist_str(el, ":note").unwrap_or_default();
let label = if note.is_empty() { id } else { note };
format!("{label} - {post_date}")
})
.collect()
async fn insert_test_user(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
sqlx::query!(
"INSERT INTO users (
id, user_name, email, photo, verified, user_password,
user_role, db_name, created_at
) VALUES (
$1, 'p2b-test-user', 'p2b-test@example.com', 'default.png',
FALSE, 'irrelevant', 'user', 'p2b-test', NOW()
)",
id
)
.execute(pool)
.await?;
Ok(())
fn extract_uuid(response: &str) -> Option<String> {
let start = response.find(":value \"")?;
let after = &response[start + 8..];
let end = after.find('"')?;
Some(after[..end].to_string())
#[local_db_sqlx_test]
async fn get_balances_returns_balance_plist_for_single_currency(
pool: PgPool,
) -> anyhow::Result<()> {
let user_id = Uuid::new_v4();
insert_test_user(&pool, user_id).await?;
let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
let resp = session
.handle_form("(:id 1 :form (create-commodity \"USD\" \"US Dollar\"))")
.await;
let usd = extract_uuid(&resp).expect("usd id");
.handle_form("(:id 2 :form (create-account \"Wallet\" \"\"))")
let wallet = extract_uuid(&resp).expect("wallet id");
.handle_form("(:id 3 :form (create-account \"Sink\" \"\"))")
let sink = extract_uuid(&resp).expect("sink id");
let tx = format!(
"(:id 4 :form (create-transaction \"(:post-date \\\"2026-01-01T00:00:00Z\\\" \
:splits ((:account-id \\\"{wallet}\\\" :commodity-id \\\"{usd}\\\" :value -50) \
(:account-id \\\"{sink}\\\" :commodity-id \\\"{usd}\\\" :value 50)))\"))"
);
session.handle_form(&tx).await;
.handle_form(&format!("(:id 5 :form (get-balances \"{wallet}\"))"))
assert!(!resp.contains(":error"), "got error: {resp}");
assert!(
resp.contains(":value-num"),
"expected balance plist, got: {resp}"
resp.contains("-50"),
"expected wallet balance -50, got: {resp}"
assert!(resp.contains("USD"), "expected USD symbol, got: {resp}");
async fn get_balances_empty_for_account_with_no_splits(pool: PgPool) -> anyhow::Result<()> {
.handle_form("(:id 6 :form (get-balances \"11111111-1111-1111-1111-111111111111\"))")
resp.contains(":value \"()\""),
"expected empty list, got: {resp}"
async fn list_transactions_shows_created_transaction(pool: PgPool) -> anyhow::Result<()> {
.handle_form("(:id 10 :form (create-commodity \"EUR\" \"Euro\"))")
let eur = extract_uuid(&resp).expect("eur id");
.handle_form("(:id 11 :form (create-account \"From\" \"\"))")
let from = extract_uuid(&resp).expect("from id");
.handle_form("(:id 12 :form (create-account \"To\" \"\"))")
let to = extract_uuid(&resp).expect("to id");
"(:id 13 :form (create-transaction \"(:post-date \\\"2026-03-10T00:00:00Z\\\" \
:note \\\"p2b-test-note\\\" \
:splits ((:account-id \\\"{from}\\\" :commodity-id \\\"{eur}\\\" :value -200) \
(:account-id \\\"{to}\\\" :commodity-id \\\"{eur}\\\" :value 200)))\"))"
.handle_form("(:id 14 :form (list-transactions \"\"))")
// Render the populated list the way the CLI does — each element is a
// structured `Value::Pair`, NOT a string; reading fields directly must
// produce the legacy `"{note} - {post_date}"` line (regression guard for
// the second-reparse bug that errored on any non-empty list).
let lines = render_transaction_lines(&resp);
assert_eq!(lines.len(), 1, "expected one rendered line, got: {lines:?}");
assert_eq!(
lines[0], "p2b-test-note - 2026-03-10T00:00:00+00:00",
"rendered transaction line must match legacy format"
resp.contains("p2b-test-note"),
"expected note in transaction entity, got: {resp}"
resp.contains("2026-03-10"),
"expected post-date in transaction entity, got: {resp}"
async fn create_transaction_single_currency_returns_uuid(pool: PgPool) -> anyhow::Result<()> {
.handle_form("(:id 20 :form (create-commodity \"GBP\" \"British Pound\"))")
let gbp = extract_uuid(&resp).expect("gbp id");
.handle_form("(:id 21 :form (create-account \"AccA\" \"\"))")
let acct_a = extract_uuid(&resp).expect("acct_a id");
.handle_form("(:id 22 :form (create-account \"AccB\" \"\"))")
let acct_b = extract_uuid(&resp).expect("acct_b id");
"(:id 23 :form (create-transaction \"(:post-date \\\"2026-04-01T00:00:00Z\\\" \
:splits ((:account-id \\\"{acct_a}\\\" :commodity-id \\\"{gbp}\\\" :value -75) \
(:account-id \\\"{acct_b}\\\" :commodity-id \\\"{gbp}\\\" :value 75)))\"))"
let resp = session.handle_form(&tx).await;
resp.contains(":value \""),
"expected uuid in :value, got: {resp}"
let tx_id = extract_uuid(&resp).expect("tx uuid");
assert_eq!(tx_id.len(), 36, "expected uuid format, got: {tx_id}");
async fn create_transaction_cross_currency_with_prices_returns_uuid(
.handle_form("(:id 30 :form (create-commodity \"USD\" \"US Dollar\"))")
.handle_form("(:id 31 :form (create-commodity \"JPY\" \"Japanese Yen\"))")
let jpy = extract_uuid(&resp).expect("jpy id");
.handle_form("(:id 32 :form (create-account \"UsdAcct\" \"\"))")
let usd_acct = extract_uuid(&resp).expect("usd_acct id");
.handle_form("(:id 33 :form (create-account \"JpyAcct\" \"\"))")
let jpy_acct = extract_uuid(&resp).expect("jpy_acct id");
let from_split_id = Uuid::new_v4();
let to_split_id = Uuid::new_v4();
"(:id 34 :form (create-transaction \"(:post-date \\\"2026-05-01T00:00:00Z\\\" \
:splits ((:account-id \\\"{usd_acct}\\\" :commodity-id \\\"{usd}\\\" \
:id \\\"{from_split_id}\\\" :value -100) \
(:account-id \\\"{jpy_acct}\\\" :commodity-id \\\"{jpy}\\\" \
:id \\\"{to_split_id}\\\" :value 15000)) \
:prices ((:commodity-id \\\"{jpy}\\\" :currency-id \\\"{usd}\\\" \
:commodity-split \\\"{to_split_id}\\\" :currency-split \\\"{from_split_id}\\\" \
:value-num 100 :value-denom 15000)))\" ))"
async fn get_balances_after_transaction_reflects_split(pool: PgPool) -> anyhow::Result<()> {
.handle_form("(:id 40 :form (create-commodity \"CHF\" \"Swiss Franc\"))")
let chf = extract_uuid(&resp).expect("chf id");
.handle_form("(:id 41 :form (create-account \"Source\" \"\"))")
let source = extract_uuid(&resp).expect("source id");
.handle_form("(:id 42 :form (create-account \"Dest\" \"\"))")
let dest = extract_uuid(&resp).expect("dest id");
"(:id 43 :form (create-transaction \"(:post-date \\\"2026-06-01T00:00:00Z\\\" \
:splits ((:account-id \\\"{source}\\\" :commodity-id \\\"{chf}\\\" :value -300) \
(:account-id \\\"{dest}\\\" :commodity-id \\\"{chf}\\\" :value 300)))\"))"
.handle_form(&format!("(:id 44 :form (get-balances \"{source}\"))"))
resp.contains("-300"),
"expected -300 balance for source, got: {resp}"
assert!(resp.contains("CHF"), "expected CHF symbol, got: {resp}");
let resp2 = session
.handle_form(&format!("(:id 45 :form (get-balances \"{dest}\"))"))
assert!(!resp2.contains(":error"), "got error: {resp2}");
resp2.contains("300"),
"expected 300 balance for dest, got: {resp2}"
/// Render the migrated `account balance` output from a real wire reply,
/// mirroring `print_balance_list`: empty list / single zero balance →
/// `0 NONE (No transaction yet)`; single non-zero → `{value} {symbol} ({name})`.
fn render_balance(wire: &str) -> String {
let WireValue::Value(value) = parse_wire(wire).expect("parse_wire");
let raw = match value {
Value::String(s) => s,
_ => return "0 NONE (No transaction yet)".to_string(),
let elements = list_to_vec(&outer).unwrap_or_default();
if elements.is_empty() {
return "0 NONE (No transaction yet)".to_string();
let first = &elements[0];
let inner_str = match first {
Value::String(s) => s.clone(),
_ => panic!("balance element must be a string, got: {first:?}"),
let inner = reparse_list(&inner_str).expect("reparse inner");
let num = plist_str(&inner, ":value-num").expect(":value-num");
let symbol = plist_str(&inner, ":symbol").expect(":symbol");
let name = plist_str(&inner, ":name").expect(":name");
if elements.len() == 1 && num == "0" {
"0 NONE (No transaction yet)".to_string()
} else {
format!("{num} {symbol} ({name})")
async fn account_balance_no_splits_prints_no_transaction_sentinel(
.handle_form("(:id 50 :form (create-account \"Fresh\" \"\"))")
let fresh = extract_uuid(&resp).expect("fresh id");
.handle_form(&format!("(:id 51 :form (get-balances \"{fresh}\"))"))
render_balance(&resp),
"0 NONE (No transaction yet)",
"no-splits account must render the legacy sentinel"
async fn account_balance_zero_single_currency_prints_sentinel(pool: PgPool) -> anyhow::Result<()> {
.handle_form("(:id 60 :form (create-commodity \"DKK\" \"Danish Krone\"))")
let dkk = extract_uuid(&resp).expect("dkk id");
.handle_form("(:id 61 :form (create-account \"Zeroed\" \"\"))")
let zeroed = extract_uuid(&resp).expect("zeroed id");
.handle_form("(:id 62 :form (create-account \"Other\" \"\"))")
let other = extract_uuid(&resp).expect("other id");
// Two offsetting transactions leave `zeroed` at a net zero single-currency
// balance — legacy prints the sentinel, not `0 DKK (Danish Krone)`.
let tx_in = format!(
"(:id 63 :form (create-transaction \"(:post-date \\\"2026-07-01T00:00:00Z\\\" \
:splits ((:account-id \\\"{zeroed}\\\" :commodity-id \\\"{dkk}\\\" :value 100) \
(:account-id \\\"{other}\\\" :commodity-id \\\"{dkk}\\\" :value -100)))\"))"
session.handle_form(&tx_in).await;
let tx_out = format!(
"(:id 64 :form (create-transaction \"(:post-date \\\"2026-07-02T00:00:00Z\\\" \
:splits ((:account-id \\\"{zeroed}\\\" :commodity-id \\\"{dkk}\\\" :value -100) \
(:account-id \\\"{other}\\\" :commodity-id \\\"{dkk}\\\" :value 100)))\"))"
session.handle_form(&tx_out).await;
.handle_form(&format!("(:id 65 :form (get-balances \"{zeroed}\"))"))
"zero single-currency balance must render the legacy sentinel"