Lines
88.89 %
Functions
62.5 %
Branches
100 %
//! Integration tests for `update-transaction-logical` and the prices-forward
//! fix in physical `update-transaction`.
//!
//! Each test receives an isolated Postgres DB from `sqlx::test`, installs it
//! as the server-side test pool, seeds fixtures, and exercises the native.
use chrono::{DateTime, TimeZone, Utc};
use server::command::account::CreateAccount;
use server::command::commodity::CreateCommodity;
use server::command::transaction::CreateTransaction;
use server::command::{CmdResult, FinanceEntity};
use sqlx::PgPool;
use uuid::Uuid;
use super::run::run_update_transaction;
use super::run_logical::{run_create_transaction_logical, run_update_transaction_logical};
fn install_pool(pool: &PgPool) {
server::db::DB_POOL.with(|c| c.set(pool as *const _));
}
async fn seed_user(pool: &PgPool, user_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, 'Test', 'test@test.com', 'x', false, 'pw', 'user', 'db', NOW())",
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
async fn seed_commodity(user_id: Uuid, symbol: &str, name: &str) -> anyhow::Result<Uuid> {
match CreateCommodity::new()
.symbol(symbol.to_string())
.name(name.to_string())
.user_id(user_id)
.run()
.await?
{
Some(CmdResult::String(id)) => Ok(Uuid::parse_str(&id)?),
other => anyhow::bail!("unexpected CreateCommodity result: {other:?}"),
async fn seed_account(user_id: Uuid, name: &str) -> anyhow::Result<Uuid> {
match CreateAccount::new()
Some(CmdResult::Entity(FinanceEntity::Account(a))) => Ok(a.id),
other => anyhow::bail!("unexpected CreateAccount result: {other:?}"),
/// Create a minimal two-split transaction with the given post_date, return its UUID.
async fn seed_transaction(
user_id: Uuid,
a_id: Uuid,
b_id: Uuid,
c_id: Uuid,
post_date: DateTime<Utc>,
) -> anyhow::Result<Uuid> {
let tx_id = Uuid::new_v4();
let split1 = finance::split::Split {
id: Uuid::new_v4(),
tx_id,
account_id: a_id,
commodity_id: c_id,
value_num: -100,
value_denom: 1,
reconcile_state: None,
reconcile_date: None,
lot_id: None,
};
let split2 = finance::split::Split {
account_id: b_id,
value_num: 100,
let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
match CreateTransaction::new()
.id(tx_id)
.post_date(post_date)
.enter_date(Utc::now())
.splits(splits)
Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(tx.id),
other => anyhow::bail!("unexpected CreateTransaction result: {other:?}"),
// ── Deliverable 1 regression: physical update-transaction retains prices ──
#[sqlx::test(migrator = "server::db::MIGRATOR")]
async fn physical_update_transaction_retains_prices(pool: PgPool) -> anyhow::Result<()> {
install_pool(&pool);
let user_id = Uuid::new_v4();
seed_user(&pool, user_id).await?;
let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
let a_id = seed_account(user_id, "Assets").await?;
let b_id = seed_account(user_id, "Expenses").await?;
// Create with logical to get a proper cross-currency transaction
let create_payload = format!(
"(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
:from-commodity \"{usd_id}\" :to-commodity \"{eur_id}\" \
:value 100 :to-amount 92)))"
);
let tx_id_str = run_create_transaction_logical(user_id, Some(create_payload))
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let tx_id = Uuid::parse_str(&tx_id_str)?;
let split1_id = Uuid::new_v4();
let split2_id = Uuid::new_v4();
// Now update via physical payload, supplying new splits AND prices
let update_payload = format!(
r#"(:transaction-id "{tx_id}"
:splits ((:id "{split1_id}"
:account-id "{a_id}"
:commodity-id "{usd_id}"
:value -10000/100)
(:id "{split2_id}"
:account-id "{b_id}"
:commodity-id "{eur_id}"
:value 9200/100))
:prices ((:commodity-id "{eur_id}"
:currency-id "{usd_id}"
:commodity-split "{split2_id}"
:currency-split "{split1_id}"
:value-num 9200
:value-denom 10000)))"#
run_update_transaction(user_id, Some(update_payload))
let price_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM prices")
.fetch_one(&pool)
assert_eq!(
price_count, 1,
"prices must be retained after a physical update-transaction with :prices"
// ── Deliverable 2: update-transaction-logical ──
async fn logical_update_single_currency_edits_splits(pool: PgPool) -> anyhow::Result<()> {
let c_id = seed_account(user_id, "Income").await?;
let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, Utc::now()).await?;
// Update: redirect the flow from Assets→Income instead
:splits ((:from "{a_id}" :to "{c_id}"
:from-commodity "{usd_id}" :to-commodity "{usd_id}"
:value 50)))"#
let returned_id = run_update_transaction_logical(user_id, Some(update_payload))
assert_eq!(returned_id, tx_id.to_string(), "must return the same tx id");
let split_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
.bind(tx_id)
assert_eq!(split_count, 2, "single logical split → 2 physical splits");
let income_split_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM splits WHERE tx_id = $1 AND account_id = $2",
.bind(c_id)
income_split_count, 1,
"new split must target Income account"
async fn logical_update_cross_currency_retains_price(pool: PgPool) -> anyhow::Result<()> {
:splits ((:from "{a_id}" :to "{b_id}"
:from-commodity "{usd_id}" :to-commodity "{eur_id}"
:value 100 :to-amount 92)))"#
run_update_transaction_logical(user_id, Some(update_payload))
"cross-currency logical update must record a price row"
async fn logical_update_unknown_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
:splits ((:from "NoSuchAccount" :to "{b_id}"
:value 10)))"#
let err = run_update_transaction_logical(user_id, Some(update_payload))
.unwrap_err();
assert!(
err.to_string().contains("unknown account"),
"expected unknown-account error, got: {err}"
assert_eq!(split_count, 2, "transaction must be unchanged on error");
async fn logical_update_missing_transaction_id_returns_error(pool: PgPool) -> anyhow::Result<()> {
let bad_payload = format!(
r#"(:splits ((:from "{a_id}" :to "{b_id}"
let err = run_update_transaction_logical(user_id, Some(bad_payload))
err.to_string().contains("transaction-id"),
"expected missing-transaction-id error, got: {err}"
async fn logical_update_ambiguous_account_returns_error(pool: PgPool) -> anyhow::Result<()> {
seed_account(user_id, "Cash").await?;
:splits ((:from "Cash" :to "{b_id}"
:value 5)))"#
err.to_string().contains("ambiguous account name"),
"expected ambiguous-account error, got: {err}"
// ── FIX 1 (MAJOR): physical update rejects a price referencing a foreign split ──
async fn physical_update_rejects_price_with_foreign_split(pool: PgPool) -> anyhow::Result<()> {
// currency_split references a uuid that is NOT in the replacement split set.
let foreign_split = Uuid::new_v4();
:currency-split "{foreign_split}"
let err = run_update_transaction(user_id, Some(update_payload))
err.to_string().contains("not part of this transaction"),
"expected price-split-membership error, got: {err}"
// Atomic rollback: the original two splits survive, no prices, no foreign split.
assert_eq!(split_count, 2, "original splits must be unchanged on error");
let replacement_exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE id = $1")
.bind(split1_id)
assert_eq!(replacement_exists, 0, "replacement split must not persist");
assert_eq!(price_count, 0, "no price may persist on a rejected update");
// ── FIX 2 (MINOR): logical update preserves dates unless :date is given ──
async fn logical_update_preserves_post_date_without_date(pool: PgPool) -> anyhow::Result<()> {
let original = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).single().unwrap();
let tx_id = seed_transaction(user_id, a_id, b_id, usd_id, original).await?;
// Split-only edit — no :date — must leave the stored post_date intact.
let no_date_payload = format!(
run_update_transaction_logical(user_id, Some(no_date_payload))
let after_no_date =
sqlx::query_scalar::<_, DateTime<Utc>>("SELECT post_date FROM transactions WHERE id = $1")
after_no_date, original,
"a split-only edit must not rewrite post_date"
// Now supply :date — the stored post_date must change to it.
let with_date_payload = format!(
:date "2021-06-15"
run_update_transaction_logical(user_id, Some(with_date_payload))
let after_with_date =
let expected = Utc.with_ymd_and_hms(2021, 6, 15, 0, 0, 0).single().unwrap();
after_with_date, expected,
"an explicit :date must rewrite post_date"