1
//! Tests for the typed transaction builder (PhysicalSplit / PriceRow / LogicalSplit).
2

            
3
use chrono::Utc;
4
use num_rational::Rational64;
5
use sqlx::types::Uuid;
6

            
7
use crate::{
8
    CommandError,
9
    eval::{
10
        build_create_transaction_form_dated,
11
        transaction::{LogicalSplit, build_create_transaction_multi},
12
    },
13
};
14

            
15
#[test]
16
1
fn build_create_transaction_form_dated_same_currency_no_prices() {
17
1
    let from = Uuid::new_v4();
18
1
    let to = Uuid::new_v4();
19
1
    let currency = Uuid::new_v4();
20
1
    let ls = LogicalSplit {
21
1
        from,
22
1
        to,
23
1
        from_commodity: currency,
24
1
        to_commodity: currency,
25
1
        value: Rational64::new(50, 1),
26
1
        to_amount: None,
27
1
    };
28
1
    let form = build_create_transaction_form_dated(&ls, None, Utc::now()).expect("dated build");
29
1
    assert!(form.starts_with("(create-transaction "));
30
1
    assert!(form.contains(":splits"));
31
1
    assert!(
32
1
        !form.contains(":prices"),
33
        "same currency must not emit :prices"
34
    );
35
1
}
36

            
37
#[test]
38
1
fn build_create_transaction_multi_single_currency_no_prices() {
39
1
    let from = Uuid::new_v4();
40
1
    let to = Uuid::new_v4();
41
1
    let currency = Uuid::new_v4();
42
1
    let splits = [LogicalSplit {
43
1
        from,
44
1
        to,
45
1
        from_commodity: currency,
46
1
        to_commodity: currency,
47
1
        value: Rational64::new(100, 1),
48
1
        to_amount: None,
49
1
    }];
50
1
    let form = build_create_transaction_multi(&splits, Some("groceries"), Utc::now())
51
1
        .expect("single-currency multi build");
52
1
    assert!(form.starts_with("(create-transaction "));
53
1
    assert!(form.contains(":splits"));
54
1
    assert!(
55
1
        !form.contains(":prices"),
56
        "same currency must not emit :prices"
57
    );
58
1
    assert!(
59
1
        form.contains(":note \\\"groceries\\\""),
60
        "note must be embedded"
61
    );
62
1
    assert!(form.contains(":value -100"), "from-split must be negative");
63
1
    assert!(form.contains(":value 100"), "to-split must be positive");
64
1
}
65

            
66
#[test]
67
1
fn build_create_transaction_multi_cross_currency_emits_prices() {
68
1
    let from = Uuid::new_v4();
69
1
    let to = Uuid::new_v4();
70
1
    let usd = Uuid::new_v4();
71
1
    let jpy = Uuid::new_v4();
72
1
    let splits = [LogicalSplit {
73
1
        from,
74
1
        to,
75
1
        from_commodity: usd,
76
1
        to_commodity: jpy,
77
1
        value: Rational64::new(100, 1),
78
1
        to_amount: Some(Rational64::new(15000, 1)),
79
1
    }];
80
1
    let form = build_create_transaction_multi(&splits, None, Utc::now())
81
1
        .expect("cross-currency multi build");
82
1
    assert!(form.contains(":prices"), "cross-currency must emit :prices");
83
    // 100/15000 reduces to canonical 1/150 (value/to_amount, gcd-divided).
84
1
    assert!(
85
1
        form.contains(":value-num 1 :value-denom 150"),
86
        "reduced FX rate must be 1/150, got: {form}"
87
    );
88
1
}
89

            
90
#[test]
91
1
fn build_create_transaction_multi_price_links_split_ids() {
92
1
    let from = Uuid::new_v4();
93
1
    let to = Uuid::new_v4();
94
1
    let usd = Uuid::new_v4();
95
1
    let jpy = Uuid::new_v4();
96
1
    let splits = [LogicalSplit {
97
1
        from,
98
1
        to,
99
1
        from_commodity: usd,
100
1
        to_commodity: jpy,
101
1
        value: Rational64::new(100, 1),
102
1
        to_amount: Some(Rational64::new(15000, 1)),
103
1
    }];
104
1
    let form =
105
1
        build_create_transaction_multi(&splits, None, Utc::now()).expect("cross-currency build");
106
1
    assert!(
107
1
        form.contains(":commodity-split"),
108
        "must have commodity-split"
109
    );
110
1
    assert!(form.contains(":currency-split"), "must have currency-split");
111
1
}
112

            
113
#[test]
114
1
fn build_create_transaction_multi_two_rows_produces_four_splits() {
115
1
    let (a, b, c, d) = (
116
1
        Uuid::new_v4(),
117
1
        Uuid::new_v4(),
118
1
        Uuid::new_v4(),
119
1
        Uuid::new_v4(),
120
1
    );
121
1
    let curr = Uuid::new_v4();
122
1
    let splits = [
123
1
        LogicalSplit {
124
1
            from: a,
125
1
            to: b,
126
1
            from_commodity: curr,
127
1
            to_commodity: curr,
128
1
            value: Rational64::new(50, 1),
129
1
            to_amount: None,
130
1
        },
131
1
        LogicalSplit {
132
1
            from: c,
133
1
            to: d,
134
1
            from_commodity: curr,
135
1
            to_commodity: curr,
136
1
            value: Rational64::new(30, 1),
137
1
            to_amount: None,
138
1
        },
139
1
    ];
140
1
    let form =
141
1
        build_create_transaction_multi(&splits, None, Utc::now()).expect("two-row multi build");
142
1
    assert_eq!(
143
1
        form.matches(":account-id").count(),
144
        4,
145
        "two rows → four splits"
146
    );
147
1
}
148

            
149
#[test]
150
1
fn build_create_transaction_multi_cross_currency_missing_to_amount_errors() {
151
1
    let from = Uuid::new_v4();
152
1
    let to = Uuid::new_v4();
153
1
    let usd = Uuid::new_v4();
154
1
    let jpy = Uuid::new_v4();
155
1
    let splits = [LogicalSplit {
156
1
        from,
157
1
        to,
158
1
        from_commodity: usd,
159
1
        to_commodity: jpy,
160
1
        value: Rational64::new(100, 1),
161
1
        to_amount: None,
162
1
    }];
163
1
    let err = build_create_transaction_multi(&splits, None, Utc::now())
164
1
        .expect_err("cross-currency without to_amount must error");
165
1
    assert!(
166
1
        matches!(err, CommandError::Argument(ref m) if m.contains("to_amount")),
167
        "expected to_amount argument error, got: {err:?}"
168
    );
169
1
}
170

            
171
5
fn cross_row(value: Rational64, to_amount: Rational64) -> [LogicalSplit; 1] {
172
5
    [LogicalSplit {
173
5
        from: Uuid::new_v4(),
174
5
        to: Uuid::new_v4(),
175
5
        from_commodity: Uuid::new_v4(),
176
5
        to_commodity: Uuid::new_v4(),
177
5
        value,
178
5
        to_amount: Some(to_amount),
179
5
    }]
180
5
}
181

            
182
#[test]
183
1
fn fx_zero_to_amount_is_error() {
184
1
    let splits = cross_row(Rational64::new(100, 1), Rational64::new(0, 1));
185
1
    let err = build_create_transaction_multi(&splits, None, Utc::now())
186
1
        .expect_err("zero to_amount must error");
187
1
    assert!(
188
1
        matches!(err, CommandError::Argument(ref m) if m.contains("to_amount must be positive")),
189
        "expected positive-to_amount error, got: {err:?}"
190
    );
191
1
}
192

            
193
#[test]
194
1
fn fx_zero_value_is_error() {
195
1
    let splits = cross_row(Rational64::new(0, 1), Rational64::new(100, 1));
196
1
    let err = build_create_transaction_multi(&splits, None, Utc::now())
197
1
        .expect_err("zero value must error");
198
1
    assert!(
199
1
        matches!(err, CommandError::Argument(ref m) if m.contains("value must be positive")),
200
        "expected positive-value error, got: {err:?}"
201
    );
202
1
}
203

            
204
#[test]
205
1
fn fx_reducible_rate_is_canonicalized() {
206
    // value/to_amount = 100/200 → reduced 1/2.
207
1
    let splits = cross_row(Rational64::new(100, 1), Rational64::new(200, 1));
208
1
    let form =
209
1
        build_create_transaction_multi(&splits, None, Utc::now()).expect("reducible rate build");
210
1
    assert!(
211
1
        form.contains(":value-num 1 :value-denom 2"),
212
        "100/200 must reduce to 1/2, got: {form}"
213
    );
214
1
}
215

            
216
#[test]
217
1
fn fx_overflow_prone_input_reduces_to_fit() {
218
    // value.numer * to_amount.denom overflows i64, but gcd-reduction yields 2/1.
219
1
    let splits = cross_row(Rational64::new(i64::MAX, 1), Rational64::new(i64::MAX, 2));
220
1
    let form = build_create_transaction_multi(&splits, None, Utc::now())
221
1
        .expect("overflow-prone input must reduce, not wrap");
222
1
    assert!(
223
1
        form.contains(":value-num 2 :value-denom 1"),
224
        "MAX / (MAX/2) = 2/1, got: {form}"
225
    );
226
1
}
227

            
228
#[test]
229
1
fn fx_unreducible_overflow_is_error() {
230
    // 4 * i64::MAX cannot fit i64 after reduction → CommandError, not a wrap/panic.
231
1
    let splits = cross_row(Rational64::new(i64::MAX, 1), Rational64::new(1, 4));
232
1
    let err = build_create_transaction_multi(&splits, None, Utc::now())
233
1
        .expect_err("unreducible overflow must error");
234
1
    assert!(
235
1
        matches!(err, CommandError::Argument(ref m) if m.contains("overflow")),
236
        "expected overflow error, got: {err:?}"
237
    );
238
1
}
239

            
240
#[test]
241
1
fn empty_splits_is_error() {
242
1
    let err = build_create_transaction_multi(&[], None, Utc::now())
243
1
        .expect_err("empty splits must error before emitting a payload");
244
1
    assert!(
245
1
        matches!(err, CommandError::Argument(ref m) if m.contains("at least one")),
246
        "expected non-empty splits error, got: {err:?}"
247
    );
248
1
}