Lines
100 %
Functions
Branches
//! End-to-end runtime test for the Metro split-tagging flow.
//!
//! Drives the Metro tagging logic through `rpc::Session` against real
//! Postgres and asserts the resulting `split_tags` rows — the only way to
//! catch the silent no-op a compile-check can't see: `list-splits` filters
//! by account id, so `(list-splits (transaction-id tx))` matched nothing.
//! This test uses the transaction-keyed `list-splits-by-transaction` native.
//! Gated on the `db` feature. Run via:
//! DATABASE_URL=postgres://… cargo test -p tests-integration --features db -- metro
#![cfg(feature = "db")]
use rpc::{ScriptCtx, Session};
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, 'metro-test-user', 'metro-test@example.com', 'default.png',
FALSE, 'irrelevant', 'user', 'metro-test', NOW()
)",
id
)
.execute(pool)
.await?;
Ok(())
}
/// Single-record writes (create-account / create-commodity / create-transaction)
/// surface the server-assigned UUID as a bare `:value "<uuid>"` string.
fn extract_value_uuid(response: &str) -> Option<String> {
let needle = ":value \"";
let start = response.find(needle)? + needle.len();
let end = start + response[start..].find('"')?;
Some(response[start..end].to_string())
/// Builds a `create-transaction` form from an explicit list of
/// `(account_id, value)` legs (values must sum to zero). Drives both the
/// two-split fare and the three-split multi-leg cases through one builder, so
/// the leg count a test exercises is visible at the call site.
fn tx_with_splits(
id: u32,
post_date: &str,
note: &str,
commodity: &str,
legs: &[(&str, i64)],
) -> String {
let splits = legs
.iter()
.map(|(account, value)| {
format!(
"(:account-id \\\"{account}\\\" :commodity-id \\\"{commodity}\\\" :value {value})"
})
.collect::<Vec<_>>()
.join(" ");
"(:id {id} :form (create-transaction \"(:post-date \\\"{post_date}\\\" \
:note \\\"{note}\\\" :splits ({splits}))\"))"
/// The category tag value on the split belonging to `account_id` within
/// `tx_id`, or `None` if that split carries no `category` tag.
async fn split_category(
pool: &PgPool,
tx_id: &str,
account_id: &str,
) -> anyhow::Result<Option<String>> {
let tx = Uuid::parse_str(tx_id)?;
let account = Uuid::parse_str(account_id)?;
let row = sqlx::query!(
"SELECT t.tag_value \
FROM splits AS s \
INNER JOIN split_tags AS st ON st.split_id = s.id \
INNER JOIN tags AS t ON t.id = st.tag_id \
WHERE s.tx_id = $1 AND s.account_id = $2 AND t.tag_name = 'category'",
tx,
account
.fetch_optional(pool)
Ok(row.map(|r| r.tag_value))
/// The split id belonging to `account_id` within `tx_id` (for direct
/// pre-tagging in the "already categorized" case).
async fn split_id_for(pool: &PgPool, tx_id: &str, account_id: &str) -> anyhow::Result<Uuid> {
"SELECT id FROM splits WHERE tx_id = $1 AND account_id = $2",
.fetch_one(pool)
Ok(row.id)
// Mirrors the shipped tag-metro-splits.nms sample (host-prelude
// split:list-for-transaction loaded on the Session path), proving the full
// stack: namespaced reader → host-prelude resolution → list-splits-by-transaction
// native → server by_transaction query → split_tags. A Metro FARE is a clean
// two-split transaction; BOTH its splits get category=transportation. Metro
// transactions that aren't exactly two splits are skipped.
const METRO_COUNT_DEFUN: &str = "(:id 99 :form (defun metro-split-count (splits) \
(length (filter (lambda (s) \
(equal? (account-name (get-account (split-account-id s))) \"Metro\")) \
splits))))";
const TAG_METRO_DEFUN: &str = "(:id 100 :form (defun tag-metro-splits-in (tx) \
(let ((splits (split:list-for-transaction tx))) \
(when (and (= (length splits) 2) \
(> (metro-split-count splits) 0)) \
(dolist (s splits) \
(when (equal? (get-split-tag (split-id s) \"category\") \"\") \
(set-split-tag (split-id s) \"category\" \"transportation\")))))))";
const RUN_DRIVER: &str =
"(:id 101 :form (catch-each (list-transactions) tx (tag-metro-splits-in tx)))";
#[local_db_sqlx_test]
async fn metro_splits_get_transportation_category(pool: PgPool) -> anyhow::Result<()> {
setup().await;
let user_id = Uuid::new_v4();
insert_test_user(&pool, user_id).await?;
let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
let jpy = extract_value_uuid(
&session
.handle_form("(:id 1 :form (create-commodity \"JPY\" \"Yen\"))")
.await,
.expect("jpy commodity id");
let suica = extract_value_uuid(
.handle_form("(:id 2 :form (create-account \"Suica\"))")
.expect("suica account id");
let metro = extract_value_uuid(
.handle_form("(:id 3 :form (create-account \"Metro\"))")
.expect("metro account id");
let cafe = extract_value_uuid(
.handle_form("(:id 4 :form (create-account \"Cafe\"))")
.expect("cafe account id");
let bus = extract_value_uuid(
.handle_form("(:id 9 :form (create-account \"Bus\"))")
.expect("bus account id");
let metro_tx = extract_value_uuid(
.handle_form(&tx_with_splits(
5,
"2026-02-01T00:00:00Z",
"metro-ride",
&jpy,
&[(&suica, -1000), (&metro, 1000)],
))
.expect("metro tx id");
let cafe_tx = extract_value_uuid(
6,
"2026-02-02T00:00:00Z",
"coffee",
&[(&suica, -500), (&cafe, 500)],
.expect("cafe tx id");
let pretagged_tx = extract_value_uuid(
7,
"2026-02-03T00:00:00Z",
"metro-prepaid",
&[(&suica, -200), (&metro, 200)],
.expect("pretagged tx id");
// A three-split transaction that DOES touch Metro (Suica → Metro + Bus).
// It is not a clean two-split fare, so the `(= (length splits) 2)` guard
// must skip it entirely — no split gets tagged.
let multileg_tx = extract_value_uuid(
10,
"2026-02-04T00:00:00Z",
"metro-plus-bus",
&[(&suica, -800), (&metro, 500), (&bus, 300)],
.expect("multileg tx id");
// A two-split transaction where Metro is the SOURCE (negative) leg, not the
// target. The tagger is deliberately direction-agnostic — any Metro leg in a
// two-split tx qualifies — so this must still tag BOTH legs. Locks that the
// no-direction-check behavior is intentional, not an accidental omission.
let metro_refund_tx = extract_value_uuid(
11,
"2026-02-05T00:00:00Z",
"metro-refund",
&[(&metro, -300), (&suica, 300)],
.expect("metro refund tx id");
// Pre-tag the Metro split of the third transaction so the script's
// empty-category guard must skip it.
let pretagged_split = split_id_for(&pool, &pretagged_tx, &metro).await?;
let set_resp = session
.handle_form(&format!(
"(:id 8 :form (set-split-tag \"{pretagged_split}\" \"category\" \"groceries\"))"
.await;
assert!(
set_resp.contains(":value 1"),
"pre-tag set-split-tag failed: {set_resp}"
);
// Define + run the Metro tagging logic across forms on one session.
let count_resp = session.handle_form(METRO_COUNT_DEFUN).await;
!count_resp.contains(":error"),
"metro-split-count defun failed: {count_resp}"
let defun_resp = session.handle_form(TAG_METRO_DEFUN).await;
assert!(!defun_resp.contains(":error"), "defun failed: {defun_resp}");
let run_resp = session.handle_form(RUN_DRIVER).await;
assert!(!run_resp.contains(":error"), "driver failed: {run_resp}");
// Drive the three-split tx through the tagger DIRECTLY (no catch-each), so a
// runtime failure on the filter+lambda+length path would surface as a
// top-level `:error` rather than being swallowed as an `(err ...)` cell and
// mistaken for a clean guard-skip. A `:value` response proves the body ran
// to completion and chose not to tag (the `(= length 2)` guard), not that it
// threw.
let direct_resp = session
"(:id 102 :form (tag-metro-splits-in (get-transaction \"{multileg_tx}\")))"
direct_resp.contains(":value") && !direct_resp.contains(":error"),
"direct multileg run must complete cleanly (guard-skip, not error): {direct_resp}"
// The two-split Metro fare: BOTH splits are now transportation — the Metro
// expense leg AND the Suica counter-leg, because the category describes the
// whole movement.
assert_eq!(
split_category(&pool, &metro_tx, &metro).await?,
Some("transportation".to_string()),
"Metro split of the fare should be tagged transportation"
split_category(&pool, &metro_tx, &suica).await?,
"Suica counter-split of the fare should ALSO be tagged transportation"
// The Cafe transaction has no Metro split → nothing tagged (neither leg).
split_category(&pool, &cafe_tx, &cafe).await?,
None,
"Cafe split must not be tagged"
split_category(&pool, &cafe_tx, &suica).await?,
"Cafe counter-split must not be tagged"
// The three-split Metro transaction is not a clean fare → guard skips it,
// so NO leg is tagged (not the Metro leg, not the Bus leg, not the source).
split_category(&pool, &multileg_tx, &metro).await?,
"multi-leg Metro split must not be tagged (not a two-split fare)"
split_category(&pool, &multileg_tx, &bus).await?,
"multi-leg Bus split must not be tagged"
split_category(&pool, &multileg_tx, &suica).await?,
"multi-leg Suica source split must not be tagged"
// The Metro-as-source two-split refund: direction-agnostic, so BOTH legs
// tagged (the Metro source leg and the Suica target leg).
split_category(&pool, &metro_refund_tx, &metro).await?,
"Metro source leg of a two-split tx should be tagged transportation"
split_category(&pool, &metro_refund_tx, &suica).await?,
"Suica target leg of the Metro-source refund should ALSO be tagged"
// The pre-tagged fare: its Metro leg keeps groceries (empty-category guard
// skipped it), while the still-empty Suica leg gets transportation — the
// guard is per-split, so a partially-tagged fare tags only the blank legs.
split_category(&pool, &pretagged_tx, &metro).await?,
Some("groceries".to_string()),
"pre-tagged Metro split must keep groceries"
split_category(&pool, &pretagged_tx, &suica).await?,
"pre-tagged fare's blank Suica leg should still get transportation"