1
use super::super::validators::{
2
    LogicalSplitInput, amount_token, now_template, parse_amount, row_complete, step_date,
3
    validate_date,
4
};
5
use chrono::Utc;
6
use num_rational::Rational64;
7

            
8
#[test]
9
1
fn amount_token_integer_and_fraction() {
10
1
    assert_eq!(amount_token(&Rational64::new(90, 1)), "90");
11
1
    assert_eq!(amount_token(&Rational64::new(9, 2)), "9/2");
12
1
    assert_eq!(amount_token(&Rational64::new(-9, 2)), "-9/2");
13
    // Reduction is reflected: 6/4 → 3/2.
14
1
    assert_eq!(amount_token(&Rational64::new(6, 4)), "3/2");
15
1
}
16

            
17
#[test]
18
1
fn parse_amount_integer() {
19
1
    assert_eq!(parse_amount("100").unwrap(), Rational64::new(100, 1));
20
1
}
21

            
22
#[test]
23
1
fn parse_amount_negative() {
24
1
    assert_eq!(parse_amount("-7").unwrap(), Rational64::new(-7, 1));
25
1
}
26

            
27
#[test]
28
1
fn parse_amount_fraction() {
29
1
    assert_eq!(parse_amount("1/3").unwrap(), Rational64::new(1, 3));
30
1
    assert_eq!(parse_amount("3/4").unwrap(), Rational64::new(3, 4));
31
1
}
32

            
33
#[test]
34
1
fn parse_amount_decimal() {
35
1
    assert_eq!(parse_amount("153.81").unwrap(), Rational64::new(15381, 100));
36
1
}
37

            
38
#[test]
39
1
fn parse_amount_negative_decimal() {
40
1
    assert_eq!(parse_amount("-2.5").unwrap(), Rational64::new(-5, 2));
41
1
}
42

            
43
#[test]
44
1
fn parse_amount_zero_denominator_is_error() {
45
1
    assert!(parse_amount("1/0").is_err());
46
1
}
47

            
48
#[test]
49
1
fn parse_amount_i64_min_operand_is_error_not_panic() {
50
    // `Rational64::new` would overflow normalizing these (negating i64::MIN).
51
1
    assert!(parse_amount("1/-9223372036854775808").is_err());
52
1
    assert!(parse_amount("-9223372036854775808/1").is_err());
53
1
    assert!(parse_amount("-9223372036854775808/2").is_err());
54
1
}
55

            
56
#[test]
57
1
fn parse_amount_garbage_is_error() {
58
1
    assert!(parse_amount("abc").is_err());
59
1
}
60

            
61
#[test]
62
1
fn parse_amount_empty_is_error() {
63
1
    assert!(parse_amount("").is_err());
64
1
    assert!(parse_amount("   ").is_err());
65
1
}
66

            
67
#[test]
68
1
fn parse_amount_multiple_dots_is_error() {
69
1
    assert!(parse_amount("1.2.3").is_err());
70
1
}
71

            
72
#[test]
73
1
fn validate_date_rfc3339_passthrough() {
74
1
    let ts = "2026-06-01T00:00:00+00:00";
75
1
    let dt = validate_date(ts).unwrap();
76
1
    assert_eq!(dt.to_rfc3339(), ts);
77
1
}
78

            
79
#[test]
80
1
fn validate_date_yyyy_mm_dd_midnight_utc() {
81
1
    let dt = validate_date("2026-03-15").unwrap();
82
1
    assert_eq!(dt.date_naive().to_string(), "2026-03-15");
83
1
    assert_eq!(dt.time().to_string(), "00:00:00");
84
    // Must be UTC
85
1
    assert_eq!(dt.timezone(), Utc);
86
1
}
87

            
88
#[test]
89
1
fn validate_date_t_format_no_seconds() {
90
1
    let dt = validate_date("2025-11-30T14:05").unwrap();
91
1
    assert_eq!(dt.date_naive().to_string(), "2025-11-30");
92
1
    assert_eq!(dt.time().to_string(), "14:05:00");
93
1
}
94

            
95
#[test]
96
1
fn validate_date_invalid_is_error() {
97
1
    assert!(validate_date("not-a-date").is_err());
98
1
    assert!(validate_date("2026-13-01").is_err());
99
1
}
100

            
101
#[test]
102
1
fn now_template_is_accepted_by_validate_date() {
103
    // Verify that the template format round-trips through validate_date.
104
    // rpc/src/natives/transaction/parse.rs::parse_flexible_date also explicitly
105
    // lists "%Y-%m-%dT%H:%M" in its format loop, so this format is server-accepted.
106
1
    let tmpl = now_template();
107
1
    assert_eq!(tmpl.len(), 16, "YYYY-MM-DDTHH:MM is 16 chars, got: {tmpl}");
108
1
    assert!(tmpl.contains('T'), "must have T separator: {tmpl}");
109
1
    assert!(validate_date(&tmpl).is_ok(), "must parse: {tmpl}");
110
1
}
111

            
112
#[test]
113
1
fn step_date_forward_across_month_boundary_preserves_time() {
114
1
    let result = step_date("2024-01-31T10:30", 1).unwrap();
115
1
    assert_eq!(result, "2024-02-01T10:30");
116
1
}
117

            
118
#[test]
119
1
fn step_date_back_across_month_boundary_preserves_time() {
120
1
    let result = step_date("2024-03-01T08:15", -1).unwrap();
121
1
    assert_eq!(result, "2024-02-29T08:15");
122
1
}
123

            
124
#[test]
125
1
fn step_date_invalid_input_returns_none() {
126
    // Stepping an unparseable buffer must NOT replace it — return None so the
127
    // caller leaves the user's in-progress text untouched.
128
1
    assert!(step_date("not-a-date", 1).is_none());
129
1
    assert!(step_date("", -1).is_none());
130
1
}
131

            
132
#[test]
133
1
fn row_complete_same_commodity_no_to_amount_ok() {
134
1
    let c = "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string();
135
1
    let row = LogicalSplitInput {
136
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
137
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
138
1
        from_commodity: c.clone(),
139
1
        to_commodity: c.clone(),
140
1
        amount: "100".to_string(),
141
1
        to_amount: None,
142
1
    };
143
1
    assert!(row_complete(&row).is_ok());
144
1
}
145

            
146
#[test]
147
1
fn row_complete_cross_commodity_requires_to_amount() {
148
1
    let row = LogicalSplitInput {
149
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
150
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
151
1
        from_commodity: "aaaa".to_string(),
152
1
        to_commodity: "bbbb".to_string(),
153
1
        amount: "100".to_string(),
154
1
        to_amount: None,
155
1
    };
156
1
    let err = row_complete(&row).unwrap_err();
157
1
    assert!(err.contains("to_amount"), "got: {err}");
158
1
}
159

            
160
#[test]
161
1
fn row_complete_cross_commodity_with_to_amount_ok() {
162
1
    let row = LogicalSplitInput {
163
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
164
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
165
1
        from_commodity: "aaaa".to_string(),
166
1
        to_commodity: "bbbb".to_string(),
167
1
        amount: "100".to_string(),
168
1
        to_amount: Some("15000".to_string()),
169
1
    };
170
1
    assert!(row_complete(&row).is_ok());
171
1
}
172

            
173
#[test]
174
1
fn row_complete_zero_value_is_error() {
175
1
    let c = "c".to_string();
176
1
    let row = LogicalSplitInput {
177
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
178
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
179
1
        from_commodity: c.clone(),
180
1
        to_commodity: c,
181
1
        amount: "0".to_string(),
182
1
        to_amount: None,
183
1
    };
184
1
    let err = row_complete(&row).unwrap_err();
185
1
    assert!(err.contains("positive"), "got: {err}");
186
1
}
187

            
188
#[test]
189
1
fn row_complete_negative_value_is_error() {
190
1
    let c = "c".to_string();
191
1
    let row = LogicalSplitInput {
192
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
193
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
194
1
        from_commodity: c.clone(),
195
1
        to_commodity: c,
196
1
        amount: "-5".to_string(),
197
1
        to_amount: None,
198
1
    };
199
1
    let err = row_complete(&row).unwrap_err();
200
1
    assert!(err.contains("positive"), "got: {err}");
201
1
}
202

            
203
#[test]
204
1
fn row_complete_cross_commodity_nonpositive_to_amount_is_error() {
205
1
    let row = LogicalSplitInput {
206
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
207
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
208
1
        from_commodity: "aaaa".to_string(),
209
1
        to_commodity: "bbbb".to_string(),
210
1
        amount: "100".to_string(),
211
1
        to_amount: Some("0".to_string()),
212
1
    };
213
1
    let err = row_complete(&row).unwrap_err();
214
1
    assert!(err.contains("to_amount must be positive"), "got: {err}");
215
1
}
216

            
217
#[test]
218
1
fn row_complete_same_commodity_with_to_amount_is_error() {
219
1
    let c = "c".to_string();
220
1
    let row = LogicalSplitInput {
221
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
222
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
223
1
        from_commodity: c.clone(),
224
1
        to_commodity: c,
225
1
        amount: "100".to_string(),
226
1
        to_amount: Some("100".to_string()),
227
1
    };
228
1
    let err = row_complete(&row).unwrap_err();
229
1
    assert!(err.contains("only valid for cross-currency"), "got: {err}");
230
1
}
231

            
232
#[test]
233
1
fn row_complete_empty_from_is_error() {
234
1
    let row = LogicalSplitInput {
235
1
        from: String::new(),
236
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
237
1
        from_commodity: "c".to_string(),
238
1
        to_commodity: "c".to_string(),
239
1
        amount: "1".to_string(),
240
1
        to_amount: None,
241
1
    };
242
1
    let err = row_complete(&row).unwrap_err();
243
1
    assert!(err.contains("from"), "got: {err}");
244
1
}
245

            
246
#[test]
247
1
fn row_complete_empty_to_is_error() {
248
1
    let row = LogicalSplitInput {
249
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
250
1
        to: String::new(),
251
1
        from_commodity: "c".to_string(),
252
1
        to_commodity: "c".to_string(),
253
1
        amount: "1".to_string(),
254
1
        to_amount: None,
255
1
    };
256
1
    let err = row_complete(&row).unwrap_err();
257
1
    assert!(err.contains("to"), "got: {err}");
258
1
}
259

            
260
#[test]
261
1
fn row_complete_bad_amount_is_error() {
262
1
    let row = LogicalSplitInput {
263
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
264
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
265
1
        from_commodity: "c".to_string(),
266
1
        to_commodity: "c".to_string(),
267
1
        amount: "abc".to_string(),
268
1
        to_amount: None,
269
1
    };
270
1
    assert!(row_complete(&row).is_err());
271
1
}
272

            
273
#[test]
274
1
fn row_complete_empty_commodities_is_error() {
275
    // Two empty commodity fields must NOT be treated as same-commodity.
276
1
    let row = LogicalSplitInput {
277
1
        from: "00000000-0000-0000-0000-000000000001".to_string(),
278
1
        to: "00000000-0000-0000-0000-000000000002".to_string(),
279
1
        from_commodity: String::new(),
280
1
        to_commodity: String::new(),
281
1
        amount: "100".to_string(),
282
1
        to_amount: None,
283
1
    };
284
1
    let err = row_complete(&row).unwrap_err();
285
1
    assert!(err.contains("commodity"), "got: {err}");
286
1
}