Lines
100 %
Functions
Branches
//! End-to-end integration: a template (per-user nomiscript source) ->
//! rpc::render_template -> real Postgres -> TransactionDraft.
//!
//! Gated on the `db` feature (see list_accounts.rs). Run via:
//! DATABASE_URL=postgres://… cargo test -p tests-integration --features db
//! Proves the render surface works against a real per-user database: the
//! template resolves accounts/commodities via the allowlisted read natives and
//! accumulates a draft via the draft natives, all without touching any
//! mutating or secret-bearing native.
#![cfg(feature = "db")]
use rpc::{ScriptCtx, Session, render_template};
use server::db::DB_POOL;
use sqlx::PgPool;
use supp_macro::local_db_sqlx_test;
use uuid::Uuid;
async fn setup() {}
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, 'tmpl-test-user', 'tmpl-test@example.com', 'default.png',
FALSE, 'irrelevant', 'user', 'tmpl-test', NOW()
)",
id
)
.execute(pool)
.await?;
Ok(())
}
fn value_uuid(response: &str) -> String {
let needle = ":value \"";
let start = response.find(needle).expect("value uuid present") + needle.len();
let end = start + response[start..].find('"').expect("closing quote");
response[start..end].to_string()
#[local_db_sqlx_test]
async fn render_template_builds_draft_from_db_entities(pool: PgPool) -> anyhow::Result<()> {
let user_id = Uuid::new_v4();
insert_test_user(&pool, user_id).await?;
// Build the prerequisites the template will look up by name.
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 = value_uuid(&resp);
.handle_form("(:id 2 :form (create-account \"Assets:Checking\"))")
let checking = value_uuid(&resp);
.handle_form("(:id 3 :form (create-account \"Expenses:Food\"))")
let food = value_uuid(&resp);
// A template: resolve accounts by name and the commodity by SYMBOL, set
// note/date/tag, draft two balancing splits. Uses only allowlisted read
// natives + draft natives. (`get-account` accepts a name; `get-commodity`
// accepts a uuid or a symbol — here "USD", which must resolve to `usd`.)
let source = r#"
(set-draft-note "Groceries")
(set-draft-date "2026-06-15")
(draft-tag "category" "food")
(draft-split (get-account "Assets:Checking") (get-commodity "USD") -50)
(draft-split (get-account "Expenses:Food") (get-commodity "USD") 50)
"#
.to_string();
let draft = render_template(&ScriptCtx::new(user_id), &source)
.await
.expect("render must succeed");
assert_eq!(draft.note.as_deref(), Some("Groceries"));
assert_eq!(draft.date.as_deref(), Some("2026-06-15"));
assert_eq!(draft.tags.len(), 1);
assert_eq!(draft.tags[0].name, "category");
assert_eq!(draft.tags[0].value, "food");
assert_eq!(
draft.splits.len(),
2,
"two splits expected: {:?}",
draft.splits
);
// The template resolved entities to their canonical uuids.
assert_eq!(draft.splits[0].account_id, checking);
assert_eq!(draft.splits[0].commodity_id, usd);
assert_eq!(draft.splits[0].value_num, -50);
assert_eq!(draft.splits[0].value_denom, 1);
assert_eq!(draft.splits[1].account_id, food);
assert_eq!(draft.splits[1].commodity_id, usd);
assert_eq!(draft.splits[1].value_num, 50);
// The drafted splits sum to zero (balanced double entry).
let sum: i64 = draft.splits.iter().map(|s| s.value_num).sum();
assert_eq!(sum, 0, "drafted splits must balance");
async fn render_template_split_tag_attaches_to_the_returned_split(
pool: PgPool,
) -> anyhow::Result<()> {
// draft-split returns the split's index; draft-split-tag(index, ..) attaches
// a tag to THAT split. A second split with no tag stays untagged.
session
.handle_form("(:id 2 :form (create-account \"Checking\"))")
.handle_form("(:id 3 :form (create-account \"Food\"))")
(let ((from (draft-split (get-account "Checking") (get-commodity "USD") -50))
(to (draft-split (get-account "Food") (get-commodity "USD") 50)))
(draft-split-tag from "memo" "lunch"))
"#;
let draft = render_template(&ScriptCtx::new(user_id), source)
assert_eq!(draft.splits.len(), 2);
draft.splits[0].tags.len(),
1,
"first split should be tagged"
assert_eq!(draft.splits[0].tags[0].name, "memo");
assert_eq!(draft.splits[0].tags[0].value, "lunch");
assert!(
draft.splits[1].tags.is_empty(),
"second split must stay untagged"
async fn render_template_split_tag_bad_index_errors(pool: PgPool) -> anyhow::Result<()> {
// A draft-split-tag handle that no draft-split produced must fail loudly,
// not silently no-op.
let result = render_template(&ScriptCtx::new(user_id), "(draft-split-tag 7 \"k\" \"v\")").await;
assert!(result.is_err(), "stale split handle must surface an error");
async fn render_template_empty_source_yields_empty_draft(pool: PgPool) -> anyhow::Result<()> {
let draft = render_template(&ScriptCtx::new(user_id), "")
.expect("empty template renders");
assert!(draft.note.is_none());
assert!(draft.splits.is_empty());
assert!(draft.tags.is_empty());
async fn render_template_unknown_account_surfaces_error(pool: PgPool) -> anyhow::Result<()> {
// get-account of an unknown name returns nil; draft-split then has a null
// account ref and the native errors with a clean TemplateError::Runtime
// rather than panicking or recording a bogus split.
(draft-split (get-account "Nope") (get-commodity "Nope") 1)
let result = render_template(&ScriptCtx::new(user_id), source).await;
assert!(result.is_err(), "missing entity must surface an error");