Lines
96.2 %
Functions
94.44 %
Branches
100 %
//! FORM→NATIVE round-trip integration tests.
//!
//! Verifies that each `cli_core::eval::build_*` helper emits a nomiscript
//! string whose arity and argument shape the compiler + runtime accept.
//! A mis-shaped builder surfaces as `:error` in the wire response and trips
//! the assertion — catching the bug class where `build_create_account_form`
//! with no parent formerly emitted a 1-arg form while the native requires 2.
//! Pattern (mirrors `rpc::natives::transaction::tests_logical`):
//! `sqlx::test` → isolated Postgres DB → `install_pool` → seed fixtures
//! → `Session::handle_form(built_string)` → assert `:value` (no `:error`).
use chrono::Utc;
use cli_core::eval::{
LogicalSplit, build_create_account_form, build_create_commodity_form,
build_create_transaction_form_dated, build_delete_transaction_form, build_set_account_tag_form,
build_set_transaction_tag_form, escape_str,
};
use num_rational::Rational64;
use rpc::{ScriptCtx, Session};
use server::command::account::CreateAccount;
use server::command::commodity::CreateCommodity;
use server::command::{CmdResult, FinanceEntity};
use sqlx::PgPool;
use uuid::Uuid;
// ── Fixtures ──────────────────────────────────────────────────────────────────
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:?}"),
async fn seed_price(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
value_num: i64,
value_denom: i64,
) -> anyhow::Result<()> {
"INSERT INTO prices \
(id, commodity_id, currency_id, commodity_split_id, currency_split_id, price_date, value_num, value_denom) \
VALUES ($1, $2, $3, NULL, NULL, NOW(), $4, $5)",
.bind(Uuid::new_v4())
.bind(from_id)
.bind(to_id)
.bind(value_num)
.bind(value_denom)
// ── Eval helpers ──────────────────────────────────────────────────────────────
fn make_session(user_id: Uuid) -> Session {
Session::new(ScriptCtx::new(user_id)).expect("Session::new")
async fn eval(session: &mut Session, form: &str) -> String {
session.handle_form(&format!("(:id 1 :form {form})")).await
/// Asserts no `:error` in `resp` and returns the `:value` slice.
fn value_of(resp: &str) -> &str {
assert!(!resp.contains(":error"), "unexpected error: {resp}");
resp.split_once(":value ")
.map(|(_, rest)| rest.trim_end_matches(')').trim())
.unwrap_or(resp)
/// Creates a transaction via `create-transaction-logical` and returns its UUID.
async fn seed_transaction_via_eval(
session: &mut Session,
a_id: Uuid,
b_id: Uuid,
c_id: Uuid,
) -> anyhow::Result<Uuid> {
let payload = format!(
"(:splits ((:from \"{a_id}\" :to \"{b_id}\" \
:from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 100)))"
);
let form = format!("(create-transaction-logical {})", escape_str(&payload));
let resp = eval(session, &form).await;
let val = value_of(&resp);
Ok(Uuid::parse_str(val.trim().trim_matches('"'))?)
// ── Tests ─────────────────────────────────────────────────────────────────────
/// REGRESSION: `build_create_account_form` with no parent formerly emitted
/// `(create-account "name")` (1 arg); the native requires 2 → runtime error.
/// Now emits `(create-account "name" "")` which succeeds.
#[sqlx::test(migrator = "server::db::MIGRATOR")]
async fn create_root_account_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
install_pool(&pool);
let user_id = Uuid::new_v4();
seed_user(&pool, user_id).await?;
let mut session = make_session(user_id);
let form = build_create_account_form("Assets", None);
let resp = eval(&mut session, &form).await;
let id_str = value_of(&resp).trim().trim_matches('"');
Uuid::parse_str(id_str)?;
async fn create_child_account_form_evals_with_parent(pool: PgPool) -> anyhow::Result<()> {
let parent_id = seed_account(user_id, "Assets").await?;
let form = build_create_account_form("Cash", Some(&parent_id.to_string()));
let child_id = Uuid::parse_str(id_str)?;
assert!(!child_id.is_nil());
async fn create_commodity_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
let form = build_create_commodity_form("USD", "US Dollar");
async fn set_account_tag_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
let account_id = seed_account(user_id, "Assets").await?;
let form = build_set_account_tag_form(&account_id.to_string(), "name", "Renamed Assets");
value_of(&resp);
async fn set_transaction_tag_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
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 tx_id = seed_transaction_via_eval(&mut session, a_id, b_id, c_id).await?;
let form = build_set_transaction_tag_form(&tx_id.to_string(), "category", "groceries");
async fn delete_transaction_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
let form = build_delete_transaction_form(&tx_id.to_string());
/// Tests `build_create_transaction_form_dated` — the physical-path builder that
/// lowers logical splits to physical splits and emits `(create-transaction "...")`.
async fn create_transaction_physical_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
let ls = LogicalSplit {
from: a_id,
to: b_id,
from_commodity: c_id,
to_commodity: c_id,
value: Rational64::new(100, 1),
to_amount: None,
let form = build_create_transaction_form_dated(&ls, Some("test payment"), Utc::now())
.map_err(|e| anyhow::anyhow!("{e}"))?;
let tx_id = Uuid::parse_str(id_str)?;
assert!(!tx_id.is_nil());
/// Evals `(create-transaction-logical "...")` directly through the Session
/// compile + runtime path, asserting the native's 1-arg arity is accepted.
async fn create_transaction_logical_form_evals_to_uuid(pool: PgPool) -> anyhow::Result<()> {
/// Evals `(update-transaction-logical "...")` through the Session path.
async fn update_transaction_logical_form_evals_successfully(pool: PgPool) -> anyhow::Result<()> {
let c_acct = seed_account(user_id, "Income").await?;
"(:transaction-id \"{tx_id}\" \
:splits ((:from \"{a_id}\" :to \"{c_acct}\" \
:from-commodity \"{c_id}\" :to-commodity \"{c_id}\" :value 50)))"
let form = format!("(update-transaction-logical {})", escape_str(&payload));
let returned = value_of(&resp).trim().trim_matches('"');
assert_eq!(returned, tx_id.to_string());
/// `(convert-amount "num/denom" "<from>" "<to>")` — happy path: price row
/// exists so the conversion succeeds and returns the reduced ratio string.
/// 100 USD × 9/10 = 900/10, reduced to 90/1, returned as "90".
async fn convert_amount_with_price_returns_ratio_string(pool: PgPool) -> anyhow::Result<()> {
let from_id = seed_commodity(user_id, "USD", "US Dollar").await?;
let to_id = seed_commodity(user_id, "EUR", "Euro").await?;
seed_price(&pool, from_id, to_id, 9, 10).await?;
let form = format!(r#"(convert-amount "100/1" "{from_id}" "{to_id}")"#);
let val = value_of(&resp).trim().trim_matches('"');
assert_eq!(val, "90");
/// No price row → `convert-amount` surfaces an error (no conversion path).
async fn convert_amount_without_price_emits_error(pool: PgPool) -> anyhow::Result<()> {
assert!(
resp.contains(":error"),
"expected :error without price: {resp}"
/// Passing a non-numeric amount string → error before any DB access.
async fn convert_amount_invalid_amount_string_emits_error(pool: PgPool) -> anyhow::Result<()> {
let form = format!(r#"(convert-amount "not-a-number" "{from_id}" "{to_id}")"#);
"expected :error for bad amount: {resp}"
/// A huge amount × huge price overflows i64 — `convert-amount` must surface a
/// `:error` (conversion overflow), not panic under debug-assertions or wrap.
async fn convert_amount_overflow_emits_error(pool: PgPool) -> anyhow::Result<()> {
seed_price(&pool, from_id, to_id, i64::MAX, 1).await?;
let amount = format!("{}/1", i64::MAX);
let form = format!(r#"(convert-amount "{amount}" "{from_id}" "{to_id}")"#);
assert!(resp.contains(":error"), "expected overflow :error: {resp}");
/// A stored price row with `value_denom == 0` (no DB CHECK forbids it) must
/// surface a `:error`, not panic in `Rational64::new`.
async fn convert_amount_zero_denom_price_emits_error(pool: PgPool) -> anyhow::Result<()> {
seed_price(&pool, from_id, to_id, 9, 0).await?;
"expected zero-denom :error: {resp}"