Lines
100 %
Functions
Branches
//! Tests for the typed transaction builder (PhysicalSplit / PriceRow / LogicalSplit).
use chrono::Utc;
use num_rational::Rational64;
use sqlx::types::Uuid;
use crate::{
CommandError,
eval::{
build_create_transaction_form_dated,
transaction::{LogicalSplit, build_create_transaction_multi},
},
};
#[test]
fn build_create_transaction_form_dated_same_currency_no_prices() {
let from = Uuid::new_v4();
let to = Uuid::new_v4();
let currency = Uuid::new_v4();
let ls = LogicalSplit {
from,
to,
from_commodity: currency,
to_commodity: currency,
value: Rational64::new(50, 1),
to_amount: None,
let form = build_create_transaction_form_dated(&ls, None, Utc::now()).expect("dated build");
assert!(form.starts_with("(create-transaction "));
assert!(form.contains(":splits"));
assert!(
!form.contains(":prices"),
"same currency must not emit :prices"
);
}
fn build_create_transaction_multi_single_currency_no_prices() {
let splits = [LogicalSplit {
value: Rational64::new(100, 1),
}];
let form = build_create_transaction_multi(&splits, Some("groceries"), Utc::now())
.expect("single-currency multi build");
form.contains(":note \\\"groceries\\\""),
"note must be embedded"
assert!(form.contains(":value -100"), "from-split must be negative");
assert!(form.contains(":value 100"), "to-split must be positive");
fn build_create_transaction_multi_cross_currency_emits_prices() {
let usd = Uuid::new_v4();
let jpy = Uuid::new_v4();
from_commodity: usd,
to_commodity: jpy,
to_amount: Some(Rational64::new(15000, 1)),
let form = build_create_transaction_multi(&splits, None, Utc::now())
.expect("cross-currency multi build");
assert!(form.contains(":prices"), "cross-currency must emit :prices");
// 100/15000 reduces to canonical 1/150 (value/to_amount, gcd-divided).
form.contains(":value-num 1 :value-denom 150"),
"reduced FX rate must be 1/150, got: {form}"
fn build_create_transaction_multi_price_links_split_ids() {
let form =
build_create_transaction_multi(&splits, None, Utc::now()).expect("cross-currency build");
form.contains(":commodity-split"),
"must have commodity-split"
assert!(form.contains(":currency-split"), "must have currency-split");
fn build_create_transaction_multi_two_rows_produces_four_splits() {
let (a, b, c, d) = (
Uuid::new_v4(),
let curr = Uuid::new_v4();
let splits = [
LogicalSplit {
from: a,
to: b,
from_commodity: curr,
to_commodity: curr,
from: c,
to: d,
value: Rational64::new(30, 1),
];
build_create_transaction_multi(&splits, None, Utc::now()).expect("two-row multi build");
assert_eq!(
form.matches(":account-id").count(),
4,
"two rows → four splits"
fn build_create_transaction_multi_cross_currency_missing_to_amount_errors() {
let err = build_create_transaction_multi(&splits, None, Utc::now())
.expect_err("cross-currency without to_amount must error");
matches!(err, CommandError::Argument(ref m) if m.contains("to_amount")),
"expected to_amount argument error, got: {err:?}"
fn cross_row(value: Rational64, to_amount: Rational64) -> [LogicalSplit; 1] {
[LogicalSplit {
from: Uuid::new_v4(),
to: Uuid::new_v4(),
from_commodity: Uuid::new_v4(),
to_commodity: Uuid::new_v4(),
value,
to_amount: Some(to_amount),
}]
fn fx_zero_to_amount_is_error() {
let splits = cross_row(Rational64::new(100, 1), Rational64::new(0, 1));
.expect_err("zero to_amount must error");
matches!(err, CommandError::Argument(ref m) if m.contains("to_amount must be positive")),
"expected positive-to_amount error, got: {err:?}"
fn fx_zero_value_is_error() {
let splits = cross_row(Rational64::new(0, 1), Rational64::new(100, 1));
.expect_err("zero value must error");
matches!(err, CommandError::Argument(ref m) if m.contains("value must be positive")),
"expected positive-value error, got: {err:?}"
fn fx_reducible_rate_is_canonicalized() {
// value/to_amount = 100/200 → reduced 1/2.
let splits = cross_row(Rational64::new(100, 1), Rational64::new(200, 1));
build_create_transaction_multi(&splits, None, Utc::now()).expect("reducible rate build");
form.contains(":value-num 1 :value-denom 2"),
"100/200 must reduce to 1/2, got: {form}"
fn fx_overflow_prone_input_reduces_to_fit() {
// value.numer * to_amount.denom overflows i64, but gcd-reduction yields 2/1.
let splits = cross_row(Rational64::new(i64::MAX, 1), Rational64::new(i64::MAX, 2));
.expect("overflow-prone input must reduce, not wrap");
form.contains(":value-num 2 :value-denom 1"),
"MAX / (MAX/2) = 2/1, got: {form}"
fn fx_unreducible_overflow_is_error() {
// 4 * i64::MAX cannot fit i64 after reduction → CommandError, not a wrap/panic.
let splits = cross_row(Rational64::new(i64::MAX, 1), Rational64::new(1, 4));
.expect_err("unreducible overflow must error");
matches!(err, CommandError::Argument(ref m) if m.contains("overflow")),
"expected overflow error, got: {err:?}"
fn empty_splits_is_error() {
let err = build_create_transaction_multi(&[], None, Utc::now())
.expect_err("empty splits must error before emitting a payload");
matches!(err, CommandError::Argument(ref m) if m.contains("at least one")),
"expected non-empty splits error, got: {err:?}"