Lines
73.08 %
Functions
50 %
Branches
100 %
//! Integration tests for `get-transaction-detail` (B-edit phase).
//!
//! Verifies the full plist shape returned by `run_get_transaction_detail`:
//! - `:id`, `:post-date`, `:enter-date`, `:note`
//! - `:splits` list with `:id`, `:account-id`, `:commodity-id`, `:value`
//! - `:prices` list with split linkage and `:value-num`/`:value-denom`
//! Uses the same `sqlx::test` + `install_pool` fixture pattern as
//! `tests_logical`.
use server::command::account::CreateAccount;
use server::command::commodity::CreateCommodity;
use server::command::{CmdResult, FinanceEntity};
use sqlx::PgPool;
use uuid::Uuid;
use super::run::run_get_transaction_detail;
use super::run_logical::run_create_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:?}"),
#[sqlx::test(migrator = "server::db::MIGRATOR")]
async fn get_transaction_single_currency_contains_splits(pool: PgPool) -> anyhow::Result<()> {
install_pool(&pool);
let user_id = Uuid::new_v4();
seed_user(&pool, user_id).await?;
let c_id = seed_commodity(user_id, "USD", "US Dollar").await?;
let a_id = seed_account(user_id, "Assets").await?;
let b_id = seed_account(user_id, "Expenses").await?;
let payload = format!(
"(:note \"test tx\" :splits ((:from \"{a_id}\" :to \"{b_id}\" \
:from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 100)))"
);
let tx_id_str = run_create_transaction_logical(user_id, Some(payload))
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let tx_id = Uuid::parse_str(&tx_id_str)?;
let plist = run_get_transaction_detail(user_id, Some(tx_id_str))
.map_err(|e| anyhow::anyhow!("{e}"))?
.ok_or_else(|| anyhow::anyhow!("get-transaction returned None"))?;
assert!(
plist.contains(&format!(":id \"{tx_id}\"")),
"plist must contain :id; got: {plist}"
plist.contains(":post-date \""),
"plist must contain :post-date; got: {plist}"
plist.contains(":enter-date \""),
"plist must contain :enter-date; got: {plist}"
plist.contains(":note \"test tx\""),
"plist must contain :note; got: {plist}"
plist.contains(":splits ("),
"plist must have :splits key; got: {plist}"
plist.contains(&format!(":account-id \"{a_id}\"")),
"plist must reference account a; got: {plist}"
plist.contains(&format!(":account-id \"{b_id}\"")),
"plist must reference account b; got: {plist}"
plist.contains(&format!(":commodity-id \"{c_id}\"")),
"plist must reference commodity; got: {plist}"
plist.contains(":value -100") || plist.contains(":value 100"),
"plist must contain split values; got: {plist}"
plist.contains(":prices ()"),
"single-currency tx must have empty :prices; got: {plist}"
// Two physical splits were created; verify both ids appear in plist.
let split_id_count =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM splits WHERE tx_id = $1")
.bind(tx_id)
.fetch_one(&pool)
assert_eq!(split_id_count, 2);
let split_id_occurrences = plist.matches(":id \"").count();
// One ":id" for the transaction itself + two for splits
split_id_occurrences >= 3,
"expected at least 3 :id occurrences (tx + 2 splits), got {split_id_occurrences}; plist: {plist}"
async fn get_transaction_cross_currency_contains_prices(pool: PgPool) -> anyhow::Result<()> {
let usd_id = seed_commodity(user_id, "USD", "US Dollar").await?;
let eur_id = seed_commodity(user_id, "EUR", "Euro").await?;
// 100 USD → 92 EUR
"(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
:from-commodity \"{usd_id}\" :to-commodity \"{eur_id}\" \
:value 100 :to-amount 92)))"
// Splits present
"plist must have :splits; got: {plist}"
// Price row present and correctly linked
!plist.contains(":prices ()"),
"cross-currency tx must have non-empty :prices; got: {plist}"
plist.contains(":commodity-split \""),
"price must carry :commodity-split; got: {plist}"
plist.contains(":currency-split \""),
"price must carry :currency-split; got: {plist}"
plist.contains(":value-num "),
"price must carry :value-num; got: {plist}"
plist.contains(":value-denom "),
"price must carry :value-denom; got: {plist}"
plist.contains(":date \""),
"price must carry :date; got: {plist}"
// The commodity-split and currency-split UUIDs must match actual split ids.
let split_ids: Vec<Uuid> =
sqlx::query_scalar::<_, Uuid>("SELECT id FROM splits WHERE tx_id = $1 ORDER BY id")
.fetch_all(&pool)
assert_eq!(split_ids.len(), 2, "expected 2 physical splits");
for sid in &split_ids {
plist.contains(&sid.to_string()),
"price must reference split {sid}; got: {plist}"
async fn get_transaction_returns_none_for_missing_id(pool: PgPool) -> anyhow::Result<()> {
let result = run_get_transaction_detail(user_id, Some(Uuid::new_v4().to_string()))
result.is_none(),
"non-existent transaction must return None"
async fn list_transactions_compact_shape_unchanged(pool: PgPool) -> anyhow::Result<()> {
// Confirms list-transactions still returns the typed pair<transaction> chain
// (compact) and is unaffected by the get-transaction enrichment.
use super::run::run_list_transactions;
use server::command::CmdResult;
use server::command::transaction::ListTransactions;
:from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
run_create_transaction_logical(user_id, Some(payload))
// list-transactions via server: only Transaction entities in TaggedTransactions
let result = ListTransactions::new().user_id(user_id).run().await?;
if let Some(CmdResult::TaggedTransactions { entities, .. }) = result {
let tx_count = entities
.iter()
.filter(|(e, _, _)| matches!(e, FinanceEntity::Transaction(_)))
.count();
assert_eq!(
tx_count, 1,
"list-transactions must return 1 transaction entity"
// No splits or prices mixed into list result
let non_tx = entities
.filter(|(e, _, _)| !matches!(e, FinanceEntity::Transaction(_)))
non_tx, 0,
"list-transactions must NOT include split/price entities"
let amount = entities
.find_map(|(e, _, amt)| matches!(e, FinanceEntity::Transaction(_)).then_some(amt));
amount.and_then(Option::as_deref),
Some("50 USD"),
"list-transactions must carry the computed amount in its dedicated slot"
} else {
anyhow::bail!("expected TaggedTransactions from ListTransactions");
// rpc layer: list-transactions entries are (id, note, amount, post_date) tuples
let entries = run_list_transactions(user_id, None)
entries.len(),
1,
"rpc list-transactions must return 1 entry"
entries[0].2.as_deref(),
"rpc list-transactions entry must expose the computed amount"
async fn get_transaction_returns_computed_amount(pool: PgPool) -> anyhow::Result<()> {
use server::command::FinanceEntity;
use server::command::transaction::{GetTransaction, ListTransactions};
let tx_id = match ListTransactions::new().user_id(user_id).run().await? {
Some(CmdResult::TaggedTransactions { entities, .. }) => entities
.into_iter()
.find_map(|(e, _, _)| match e {
FinanceEntity::Transaction(tx) => Some(tx.id),
_ => None,
})
.ok_or_else(|| anyhow::anyhow!("no transaction listed"))?,
_ => anyhow::bail!("expected TaggedTransactions"),
};
// get-transaction must compute the same per-commodity amount as list.
match GetTransaction::new()
.transaction_id(tx_id)
Some(CmdResult::TaggedTransactions { entities, .. }) => {
let amount = entities.first().and_then(|(_, _, amt)| amt.as_deref());
amount,
"get-transaction must return the aggregated amount"
other => anyhow::bail!("expected TaggedTransactions, got {other:?}"),