Lines
100 %
Functions
Branches
use chrono::TimeZone;
use finance::tag::Tag;
use finance::transaction::Transaction;
use server::command::{CmdResult, FinanceEntity, PaginationInfo};
use std::collections::HashMap;
use uuid::Uuid;
use super::parse::{
parse_account_filter_arg, parse_create_transaction_payload, parse_transaction_id_arg,
parse_update_transaction_payload,
};
use super::render::{format_tagged_transactions, list_transaction_entries};
use super::run::{run_create_transaction, run_delete_transaction, run_update_transaction};
fn tx_entity(id: Uuid) -> FinanceEntity {
let post = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
let enter = chrono::Utc.with_ymd_and_hms(2026, 5, 2, 9, 30, 0).unwrap();
FinanceEntity::Transaction(Transaction {
id,
post_date: post,
enter_date: enter,
})
}
#[test]
fn format_empty_list_with_no_pagination() {
assert_eq!(
format_tagged_transactions(&[], None),
"(:transactions () :pagination nil)"
);
fn format_single_transaction_with_note_and_pagination() {
let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let mut tags = HashMap::new();
tags.insert(
"note".to_string(),
FinanceEntity::Tag(Tag {
id: Uuid::nil(),
tag_name: "note".into(),
tag_value: "groceries".into(),
description: None,
}),
let pagination = PaginationInfo {
total_count: 1,
limit: 20,
offset: 0,
has_more: false,
let out = format_tagged_transactions(&[(tx_entity(id), tags)], Some(&pagination));
assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
assert!(out.contains(":post-date \"2026-05-01T12:00:00+00:00\""));
assert!(out.contains(":enter-date \"2026-05-02T09:30:00+00:00\""));
assert!(out.contains(":note \"groceries\""));
assert!(out.contains(":pagination (:total 1 :limit 20 :offset 0 :has-more nil)"));
fn format_transaction_without_tags_omits_note() {
let id = Uuid::nil();
let out = format_tagged_transactions(&[(tx_entity(id), HashMap::new())], None);
assert!(out.contains(":id \"00000000-0000-0000-0000-000000000000\""));
assert!(!out.contains(":note"));
assert!(out.ends_with(":pagination nil)"));
fn parse_transaction_id_rejects_missing_arg() {
let err = parse_transaction_id_arg(None).unwrap_err();
assert!(err.to_string().contains("missing or empty"), "got: {err}");
fn parse_transaction_id_rejects_invalid_uuid() {
let err = parse_transaction_id_arg(Some("not-a-uuid".into())).unwrap_err();
assert!(err.to_string().contains("invalid uuid"), "got: {err}");
#[tokio::test]
async fn run_delete_transaction_with_no_arg_emits_error() {
let err = run_delete_transaction(Uuid::nil(), None).await.unwrap_err();
async fn run_delete_transaction_with_invalid_uuid_emits_error() {
let err = run_delete_transaction(Uuid::nil(), Some("not-uuid".into()))
.await
.unwrap_err();
fn parse_payload_split_id_preserved_when_given() {
let src = r#"(:post-date "2026-01-15T00:00:00Z"
:splits ((:id "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
:account-id "11111111-1111-1111-1111-111111111111"
:commodity-id "22222222-2222-2222-2222-222222222222"
:value -1)
(:account-id "33333333-3333-3333-3333-333333333333"
:value 1)))"#;
let parsed = parse_create_transaction_payload(src).expect("parse");
parsed.splits[0].id.to_string(),
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
assert_ne!(parsed.splits[1].id, Uuid::nil());
fn parse_payload_cross_currency_with_prices_and_split_ids() {
:value -10000/100)
(:id "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
:account-id "33333333-3333-3333-3333-333333333333"
:commodity-id "44444444-4444-4444-4444-444444444444"
:value 9000/100))
:prices ((:commodity-id "44444444-4444-4444-4444-444444444444"
:currency-id "22222222-2222-2222-2222-222222222222"
:commodity-split "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
:currency-split "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
:value-num 9000
:value-denom 10000)))"#;
assert_eq!(parsed.splits.len(), 2);
parsed.splits[1].id.to_string(),
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
assert_eq!(parsed.prices.len(), 1);
parsed.prices[0].commodity_id.to_string(),
"44444444-4444-4444-4444-444444444444"
parsed.prices[0].currency_id.to_string(),
"22222222-2222-2222-2222-222222222222"
parsed.prices[0].commodity_split,
Some(Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").expect("test uuid"))
parsed.prices[0].currency_split,
Some(Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").expect("test uuid"))
assert_eq!(parsed.prices[0].value_num, 9000);
assert_eq!(parsed.prices[0].value_denom, 10000);
parsed.prices[0].date.to_rfc3339(),
"2026-01-15T00:00:00+00:00"
fn parse_payload_price_with_explicit_date() {
:value 1))
:date "2026-03-01T00:00:00Z"
:value-num 100
:value-denom 90)))"#;
"2026-03-01T00:00:00+00:00"
fn parse_account_filter_none_and_empty_yield_no_filter() {
assert!(parse_account_filter_arg(None).expect("none").is_none());
assert!(
parse_account_filter_arg(Some(String::new()))
.expect("empty")
.is_none()
fn parse_account_filter_valid_uuid_is_passed_through() {
let raw = "550e8400-e29b-41d4-a716-446655440000";
let parsed = parse_account_filter_arg(Some(raw.into())).expect("valid uuid");
assert_eq!(parsed.map(|u| u.to_string()).as_deref(), Some(raw));
fn parse_account_filter_invalid_uuid_emits_error() {
let err = parse_account_filter_arg(Some("not-a-uuid".into())).unwrap_err();
err.to_string().contains("invalid account uuid"),
"got: {err}"
fn parse_payload_minimal_two_splits() {
:splits ((:account-id "11111111-1111-1111-1111-111111111111"
:value -5000/100)
:value 5000/100)))"#;
assert_eq!(parsed.splits[0].value_num, -50);
assert_eq!(parsed.splits[0].value_denom, 1);
assert_eq!(parsed.splits[1].value_num, 50);
fn parse_payload_picks_up_note() {
:note "groceries"
assert_eq!(parsed.note.as_deref(), Some("groceries"));
fn parse_payload_rejects_missing_post_date() {
let src = r#"(:splits ((:account-id "x" :commodity-id "y" :value 1)
(:account-id "x" :commodity-id "y" :value -1)))"#;
let err = parse_create_transaction_payload(src).unwrap_err();
assert!(err.contains(":post-date"), "got: {err}");
fn parse_payload_rejects_single_split() {
:splits ((:account-id "x" :commodity-id "y" :value 1)))"#;
assert!(err.contains("at least two"), "got: {err}");
async fn run_create_transaction_with_no_payload_emits_error() {
let err = run_create_transaction(Uuid::nil(), None).await.unwrap_err();
async fn run_create_transaction_with_garbage_payload_surfaces_parse_error() {
let err = run_create_transaction(Uuid::nil(), Some("not-an-sexpr".into()))
assert!(err.to_string().contains("create-transaction"), "got: {err}");
fn parse_update_payload_minimum_just_id() {
let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000")"#;
let parsed = parse_update_transaction_payload(src).expect("parse");
parsed.transaction_id.to_string(),
"550e8400-e29b-41d4-a716-446655440000"
assert!(parsed.note.is_none());
assert!(parsed.splits.is_none());
fn parse_update_payload_rejects_missing_transaction_id() {
let src = r#"(:note "x")"#;
let err = parse_update_transaction_payload(src).unwrap_err();
assert!(err.contains(":transaction-id"), "got: {err}");
fn parse_update_payload_carries_partial_fields() {
let src = r#"(:transaction-id "550e8400-e29b-41d4-a716-446655440000"
:post-date "2026-05-11T12:00:00Z"
:note "edited")"#;
assert!(parsed.post_date.is_some());
assert_eq!(parsed.note.as_deref(), Some("edited"));
async fn run_update_transaction_with_no_payload_emits_error() {
let err = run_update_transaction(Uuid::nil(), None).await.unwrap_err();
fn format_pagination_has_more_emits_t() {
total_count: 100,
has_more: true,
let out = format_tagged_transactions(&[], Some(&pagination));
assert!(out.contains(":has-more t)"));
fn user_tag(name: &str, value: &str) -> FinanceEntity {
tag_name: name.into(),
tag_value: value.into(),
fn list_transaction_entries_extracts_amount_from_dedicated_slot() {
let result = Ok(Some(CmdResult::TaggedTransactions {
entities: vec![(tx_entity(id), HashMap::new(), Some("42 EUR".to_string()))],
pagination: None,
}));
let entries = list_transaction_entries("test", result).unwrap();
assert_eq!(entries.len(), 1);
let (_, _, amount, _) = &entries[0];
assert_eq!(amount.as_deref(), Some("42 EUR"));
/// Regression for the amount/user-tag collision (review issue #3): a
/// transaction carrying a genuine user tag named `amount` must keep that
/// tag untouched while the computed summary rides its own slot.
fn computed_amount_is_independent_of_user_amount_tag() {
tags.insert("amount".to_string(), user_tag("amount", "user-private"));
tags.insert("note".to_string(), user_tag("note", "lunch"));
entities: vec![(tx_entity(id), tags, Some("100 USD".to_string()))],
let (_, note, amount, _) = &entries[0];
assert_eq!(note.as_deref(), Some("lunch"));
amount.as_deref(),
Some("100 USD"),
"computed amount must come from the dedicated slot, not the user tag"