1
use super::*;
2

            
3
use chrono::NaiveDate;
4
use finance::error::ReportError;
5
use num_rational::Rational64;
6

            
7
use super::super::FilterEntity;
8
use super::filter::SqlParam;
9
use super::tree::{AccountAmounts, AccountRow, ConversionTarget, accumulate_split_converted};
10

            
11
use super::period::{
12
    generate_month_boundaries, generate_quarter_boundaries, generate_year_boundaries,
13
};
14
use super::tree::build_tree;
15

            
16
#[test]
17
1
fn test_to_sql_account_eq() {
18
1
    let id = Uuid::new_v4();
19
1
    let filter = ReportFilter::AccountEq(id);
20
1
    let mut offset = 0;
21
1
    let (sql, params) = filter.to_sql(&mut offset);
22
1
    assert_eq!(sql, "s.account_id = $1");
23
1
    assert_eq!(offset, 1);
24
1
    assert!(matches!(&params[0], SqlParam::Uuid(v) if *v == id));
25
1
}
26

            
27
#[test]
28
1
fn test_to_sql_and_combinator() {
29
1
    let id1 = Uuid::new_v4();
30
1
    let id2 = Uuid::new_v4();
31
1
    let filter = ReportFilter::And(vec![
32
1
        ReportFilter::AccountEq(id1),
33
1
        ReportFilter::CommodityEq(id2),
34
1
    ]);
35
1
    let mut offset = 2;
36
1
    let (sql, params) = filter.to_sql(&mut offset);
37
1
    assert_eq!(sql, "(s.account_id = $3 AND s.commodity_id = $4)");
38
1
    assert_eq!(params.len(), 2);
39
1
    assert_eq!(offset, 4);
40
1
}
41

            
42
#[test]
43
1
fn test_to_sql_not() {
44
1
    let id = Uuid::new_v4();
45
1
    let filter = ReportFilter::Not(Box::new(ReportFilter::AccountEq(id)));
46
1
    let mut offset = 0;
47
1
    let (sql, _params) = filter.to_sql(&mut offset);
48
1
    assert_eq!(sql, "NOT (s.account_id = $1)");
49
1
}
50

            
51
#[test]
52
1
fn test_to_sql_amount_gt() {
53
1
    let r = Rational64::new(100, 1);
54
1
    let filter = ReportFilter::AmountGt(r);
55
1
    let mut offset = 0;
56
1
    let (sql, params) = filter.to_sql(&mut offset);
57
1
    assert_eq!(sql, "s.value_num * $2 > $1 * s.value_denom");
58
1
    assert_eq!(params.len(), 2);
59
1
    assert!(matches!(&params[0], SqlParam::I64(100)));
60
1
    assert!(matches!(&params[1], SqlParam::I64(1)));
61
1
}
62

            
63
#[test]
64
1
fn test_to_sql_counterparty_eq() {
65
1
    let id = Uuid::new_v4();
66
1
    let filter = ReportFilter::CounterpartyEq(id);
67
1
    let mut offset = 0;
68
1
    let (sql, _) = filter.to_sql(&mut offset);
69
1
    assert!(sql.contains("EXISTS"));
70
1
    assert!(sql.contains("o.account_id = $1"));
71
1
}
72

            
73
#[test]
74
1
fn test_to_sql_tag_filter() {
75
1
    let filter = ReportFilter::Tag {
76
1
        entity: FilterEntity::Account,
77
1
        name: "category".to_string(),
78
1
        value: "food".to_string(),
79
1
    };
80
1
    let mut offset = 0;
81
1
    let (sql, params) = filter.to_sql(&mut offset);
82
1
    assert!(sql.contains("account_tags"));
83
1
    assert!(sql.contains("$1"));
84
1
    assert!(sql.contains("$2"));
85
1
    assert_eq!(params.len(), 2);
86
1
}
87

            
88
#[test]
89
1
fn test_to_sql_or_combinator() {
90
1
    let filter = ReportFilter::Or(vec![
91
1
        ReportFilter::AccountEq(Uuid::new_v4()),
92
1
        ReportFilter::AccountEq(Uuid::new_v4()),
93
1
    ]);
94
1
    let mut offset = 0;
95
1
    let (sql, _) = filter.to_sql(&mut offset);
96
1
    assert!(sql.contains(" OR "));
97
1
}
98

            
99
#[test]
100
1
fn test_to_sql_account_subtree() {
101
1
    let id = Uuid::new_v4();
102
1
    let filter = ReportFilter::AccountSubtree(id);
103
1
    let mut offset = 0;
104
1
    let (sql, params) = filter.to_sql(&mut offset);
105
1
    assert!(sql.contains("WITH RECURSIVE descendants"));
106
1
    assert!(sql.contains("$1"));
107
1
    assert_eq!(offset, 1);
108
1
    assert!(matches!(&params[0], SqlParam::Uuid(v) if *v == id));
109
1
}
110

            
111
#[test]
112
1
fn test_to_sql_account_in() {
113
1
    let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
114
1
    let filter = ReportFilter::AccountIn(ids.clone());
115
1
    let mut offset = 0;
116
1
    let (sql, params) = filter.to_sql(&mut offset);
117
1
    assert_eq!(sql, "s.account_id = ANY($1)");
118
1
    assert!(matches!(&params[0], SqlParam::UuidVec(v) if v.len() == 2));
119
1
}
120

            
121
#[test]
122
1
fn test_build_tree_empty() {
123
1
    let accounts: Vec<AccountRow> = vec![];
124
1
    let amounts = AccountAmounts::new();
125
1
    let roots = build_tree(&accounts, &amounts);
126
1
    assert!(roots.is_empty());
127
1
}
128

            
129
#[test]
130
1
fn test_build_tree_single_account() {
131
1
    let id = Uuid::new_v4();
132
1
    let commodity_id = Uuid::new_v4();
133
1
    let accounts = vec![AccountRow {
134
1
        account_id: id,
135
1
        parent_id: None,
136
1
        account_name: "Assets".to_string(),
137
1
    }];
138
1
    let mut amounts = AccountAmounts::new();
139
1
    amounts.insert(
140
1
        id,
141
1
        [(commodity_id, (Rational64::new(100, 1), "USD".to_string()))]
142
1
            .into_iter()
143
1
            .collect(),
144
    );
145
1
    let roots = build_tree(&accounts, &amounts);
146
1
    assert_eq!(roots.len(), 1);
147
1
    assert_eq!(roots[0].account_name, "Assets");
148
1
    assert_eq!(roots[0].account_path, "Assets");
149
1
    assert_eq!(roots[0].depth, 0);
150
1
    assert_eq!(roots[0].amounts.len(), 1);
151
1
    assert_eq!(roots[0].amounts[0].amount, Rational64::new(100, 1));
152
1
}
153

            
154
#[test]
155
1
fn test_build_tree_hierarchy() {
156
1
    let parent_id = Uuid::new_v4();
157
1
    let child_id = Uuid::new_v4();
158
1
    let commodity_id = Uuid::new_v4();
159
1
    let accounts = vec![
160
1
        AccountRow {
161
1
            account_id: parent_id,
162
1
            parent_id: None,
163
1
            account_name: "Assets".to_string(),
164
1
        },
165
1
        AccountRow {
166
1
            account_id: child_id,
167
1
            parent_id: Some(parent_id),
168
1
            account_name: "Bank".to_string(),
169
1
        },
170
    ];
171
1
    let mut amounts = AccountAmounts::new();
172
1
    amounts.insert(
173
1
        child_id,
174
1
        [(commodity_id, (Rational64::new(50, 1), "USD".to_string()))]
175
1
            .into_iter()
176
1
            .collect(),
177
    );
178
1
    let roots = build_tree(&accounts, &amounts);
179
1
    assert_eq!(roots.len(), 1);
180
1
    assert_eq!(roots[0].children.len(), 1);
181
1
    assert_eq!(roots[0].children[0].account_path, "Assets:Bank");
182
1
    assert_eq!(roots[0].children[0].depth, 1);
183
1
    assert_eq!(roots[0].amounts[0].amount, Rational64::new(50, 1));
184
1
    assert_eq!(
185
1
        roots[0].children[0].amounts[0].amount,
186
1
        Rational64::new(50, 1)
187
    );
188
1
}
189

            
190
#[test]
191
1
fn test_build_tree_prunes_empty_accounts() {
192
1
    let parent_id = Uuid::new_v4();
193
1
    let child_with_data = Uuid::new_v4();
194
1
    let child_empty = Uuid::new_v4();
195
1
    let empty_root = Uuid::new_v4();
196
1
    let commodity_id = Uuid::new_v4();
197
1
    let accounts = vec![
198
1
        AccountRow {
199
1
            account_id: parent_id,
200
1
            parent_id: None,
201
1
            account_name: "Assets".to_string(),
202
1
        },
203
1
        AccountRow {
204
1
            account_id: child_with_data,
205
1
            parent_id: Some(parent_id),
206
1
            account_name: "Bank".to_string(),
207
1
        },
208
1
        AccountRow {
209
1
            account_id: child_empty,
210
1
            parent_id: Some(parent_id),
211
1
            account_name: "Empty".to_string(),
212
1
        },
213
1
        AccountRow {
214
1
            account_id: empty_root,
215
1
            parent_id: None,
216
1
            account_name: "Liabilities".to_string(),
217
1
        },
218
    ];
219
1
    let mut amounts = AccountAmounts::new();
220
1
    amounts.insert(
221
1
        child_with_data,
222
1
        [(commodity_id, (Rational64::new(100, 1), "USD".to_string()))]
223
1
            .into_iter()
224
1
            .collect(),
225
    );
226
1
    let roots = build_tree(&accounts, &amounts);
227
1
    assert_eq!(roots.len(), 1, "empty root Liabilities should be pruned");
228
1
    assert_eq!(roots[0].account_name, "Assets");
229
1
    assert_eq!(
230
1
        roots[0].children.len(),
231
        1,
232
        "empty child Empty should be pruned"
233
    );
234
1
    assert_eq!(roots[0].children[0].account_name, "Bank");
235
1
}
236

            
237
#[test]
238
1
fn test_generate_month_boundaries() {
239
1
    let from = NaiveDate::from_ymd_opt(2025, 1, 15)
240
1
        .unwrap()
241
1
        .and_hms_opt(0, 0, 0)
242
1
        .unwrap()
243
1
        .and_utc();
244
1
    let to = NaiveDate::from_ymd_opt(2025, 4, 1)
245
1
        .unwrap()
246
1
        .and_hms_opt(0, 0, 0)
247
1
        .unwrap()
248
1
        .and_utc();
249
1
    let boundaries = generate_month_boundaries(from, to);
250
1
    assert_eq!(boundaries.len(), 3);
251
1
    assert_eq!(boundaries[0].0, "2025-01");
252
1
    assert_eq!(boundaries[1].0, "2025-02");
253
1
    assert_eq!(boundaries[2].0, "2025-03");
254
1
}
255

            
256
#[test]
257
1
fn test_generate_quarter_boundaries() {
258
1
    let from = NaiveDate::from_ymd_opt(2025, 1, 1)
259
1
        .unwrap()
260
1
        .and_hms_opt(0, 0, 0)
261
1
        .unwrap()
262
1
        .and_utc();
263
1
    let to = NaiveDate::from_ymd_opt(2025, 7, 1)
264
1
        .unwrap()
265
1
        .and_hms_opt(0, 0, 0)
266
1
        .unwrap()
267
1
        .and_utc();
268
1
    let boundaries = generate_quarter_boundaries(from, to);
269
1
    assert_eq!(boundaries.len(), 2);
270
1
    assert_eq!(boundaries[0].0, "2025-Q1");
271
1
    assert_eq!(boundaries[1].0, "2025-Q2");
272
1
}
273

            
274
#[test]
275
1
fn test_generate_year_boundaries() {
276
1
    let from = NaiveDate::from_ymd_opt(2024, 6, 1)
277
1
        .unwrap()
278
1
        .and_hms_opt(0, 0, 0)
279
1
        .unwrap()
280
1
        .and_utc();
281
1
    let to = NaiveDate::from_ymd_opt(2026, 3, 1)
282
1
        .unwrap()
283
1
        .and_hms_opt(0, 0, 0)
284
1
        .unwrap()
285
1
        .and_utc();
286
1
    let boundaries = generate_year_boundaries(from, to);
287
1
    assert_eq!(boundaries.len(), 3);
288
1
    assert_eq!(boundaries[0].0, "2024");
289
1
    assert_eq!(boundaries[1].0, "2025");
290
1
    assert_eq!(boundaries[2].0, "2026");
291
1
}
292

            
293
#[test]
294
1
fn test_accumulate_split_converted_same_commodity() {
295
1
    let mut amounts = AccountAmounts::new();
296
1
    let account_id = Uuid::new_v4();
297
1
    let commodity_id = Uuid::new_v4();
298
1
    let target = ConversionTarget {
299
1
        commodity_id,
300
1
        symbol: "USD",
301
1
    };
302
1
    let result = accumulate_split_converted(
303
1
        &mut amounts,
304
1
        account_id,
305
1
        commodity_id,
306
1
        Rational64::new(100, 1),
307
1
        "USD",
308
1
        &target,
309
1
        (None, None),
310
    );
311
1
    assert!(result.is_ok());
312
1
    assert_eq!(
313
1
        amounts[&account_id][&commodity_id].0,
314
1
        Rational64::new(100, 1)
315
    );
316
1
}
317

            
318
#[test]
319
1
fn test_accumulate_split_converted_with_price() {
320
1
    let mut amounts = AccountAmounts::new();
321
1
    let account_id = Uuid::new_v4();
322
1
    let from_commodity = Uuid::new_v4();
323
1
    let target_commodity = Uuid::new_v4();
324
1
    let target = ConversionTarget {
325
1
        commodity_id: target_commodity,
326
1
        symbol: "USD",
327
1
    };
328
1
    let result = accumulate_split_converted(
329
1
        &mut amounts,
330
1
        account_id,
331
1
        from_commodity,
332
1
        Rational64::new(100, 1),
333
1
        "EUR",
334
1
        &target,
335
1
        (Some(1176), Some(1000)),
336
    );
337
1
    assert!(result.is_ok());
338
1
    assert_eq!(
339
1
        amounts[&account_id][&target_commodity].0,
340
1
        Rational64::new(100, 1) * Rational64::new(1176, 1000)
341
    );
342
1
}
343

            
344
#[test]
345
1
fn test_accumulate_split_converted_missing_price() {
346
1
    let mut amounts = AccountAmounts::new();
347
1
    let target = ConversionTarget {
348
1
        commodity_id: Uuid::new_v4(),
349
1
        symbol: "USD",
350
1
    };
351
1
    let result = accumulate_split_converted(
352
1
        &mut amounts,
353
1
        Uuid::new_v4(),
354
1
        Uuid::new_v4(),
355
1
        Rational64::new(100, 1),
356
1
        "EUR",
357
1
        &target,
358
1
        (None, None),
359
    );
360
1
    assert!(result.is_err());
361
1
    let err = result.unwrap_err();
362
1
    assert!(matches!(err, ReportError::MissingConversion { .. }));
363
1
}
364

            
365
#[cfg(feature = "scripting")]
366
mod sexpr_tests {
367
    use super::*;
368

            
369
    #[test]
370
1
    fn test_from_sexpr_account_eq() {
371
1
        let id = Uuid::new_v4();
372
1
        let input = format!("(account= \"{id}\")");
373
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
374
1
        assert!(matches!(filter, ReportFilter::AccountEq(v) if v == id));
375
1
    }
376

            
377
    #[test]
378
1
    fn test_from_sexpr_commodity_eq() {
379
1
        let id = Uuid::new_v4();
380
1
        let input = format!("(commodity= \"{id}\")");
381
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
382
1
        assert!(matches!(filter, ReportFilter::CommodityEq(v) if v == id));
383
1
    }
384

            
385
    #[test]
386
1
    fn test_from_sexpr_amount_gt() {
387
1
        let input = "(amount> 100)";
388
1
        let filter = ReportFilter::from_sexpr(input).unwrap();
389
1
        assert!(matches!(filter, ReportFilter::AmountGt(r) if r == Rational64::new(100, 1)));
390
1
    }
391

            
392
    #[test]
393
1
    fn test_from_sexpr_and() {
394
1
        let id1 = Uuid::new_v4();
395
1
        let id2 = Uuid::new_v4();
396
1
        let input = format!("(and (account= \"{id1}\") (commodity= \"{id2}\"))");
397
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
398
1
        assert!(matches!(filter, ReportFilter::And(v) if v.len() == 2));
399
1
    }
400

            
401
    #[test]
402
1
    fn test_from_sexpr_not() {
403
1
        let id = Uuid::new_v4();
404
1
        let input = format!("(not (account= \"{id}\"))");
405
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
406
1
        assert!(matches!(filter, ReportFilter::Not(_)));
407
1
    }
408

            
409
    #[test]
410
1
    fn test_from_sexpr_tag() {
411
1
        let input = "(account-tag= \"category\" \"food\")";
412
1
        let filter = ReportFilter::from_sexpr(input).unwrap();
413
1
        match filter {
414
            ReportFilter::Tag {
415
1
                entity,
416
1
                name,
417
1
                value,
418
            } => {
419
1
                assert!(matches!(entity, FilterEntity::Account));
420
1
                assert_eq!(name, "category");
421
1
                assert_eq!(value, "food");
422
            }
423
            _ => panic!("Expected Tag filter"),
424
        }
425
1
    }
426

            
427
    #[test]
428
1
    fn test_from_sexpr_account_subtree() {
429
1
        let id = Uuid::new_v4();
430
1
        let input = format!("(account-subtree \"{id}\")");
431
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
432
1
        assert!(matches!(filter, ReportFilter::AccountSubtree(v) if v == id));
433
1
    }
434

            
435
    #[test]
436
1
    fn test_from_sexpr_invalid() {
437
1
        let result = ReportFilter::from_sexpr("invalid");
438
1
        assert!(result.is_err());
439
1
    }
440

            
441
    #[test]
442
1
    fn test_from_sexpr_counterparty() {
443
1
        let id = Uuid::new_v4();
444
1
        let input = format!("(counterparty= \"{id}\")");
445
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
446
1
        assert!(matches!(filter, ReportFilter::CounterpartyEq(v) if v == id));
447
1
    }
448

            
449
    #[test]
450
1
    fn test_from_sexpr_or() {
451
1
        let id1 = Uuid::new_v4();
452
1
        let id2 = Uuid::new_v4();
453
1
        let input = format!("(or (account= \"{id1}\") (account= \"{id2}\"))");
454
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
455
1
        assert!(matches!(filter, ReportFilter::Or(v) if v.len() == 2));
456
1
    }
457
}