1
use chrono::NaiveDate;
2
use finance::error::ReportError;
3
use num_rational::Rational64;
4
use sqlx::types::Uuid;
5

            
6
use super::super::{BreakdownSort, FilterEntity, PeriodGrouping, ReportFilter};
7
use super::filter::SqlParam;
8
use super::period::{
9
    generate_month_boundaries, generate_quarter_boundaries, generate_year_boundaries,
10
};
11
use super::tree::{
12
    AccountAmounts, AccountRow, ConversionTarget, accumulate_split_converted, build_tree,
13
};
14

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

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

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

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

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

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

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

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

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

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

            
128
#[test]
129
1
fn test_build_tree_single_account() {
130
1
    let id = Uuid::new_v4();
131
1
    let commodity_id = Uuid::new_v4();
132
1
    let accounts = vec![AccountRow {
133
1
        account_id: id,
134
1
        parent_id: None,
135
1
        account_name: "Assets".to_string(),
136
1
        account_type: None,
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
            account_type: None,
165
1
        },
166
1
        AccountRow {
167
1
            account_id: child_id,
168
1
            parent_id: Some(parent_id),
169
1
            account_name: "Bank".to_string(),
170
1
            account_type: None,
171
1
        },
172
    ];
173
1
    let mut amounts = AccountAmounts::new();
174
1
    amounts.insert(
175
1
        child_id,
176
1
        [(commodity_id, (Rational64::new(50, 1), "USD".to_string()))]
177
1
            .into_iter()
178
1
            .collect(),
179
    );
180
1
    let roots = build_tree(&accounts, &amounts);
181
1
    assert_eq!(roots.len(), 1);
182
1
    assert_eq!(roots[0].children.len(), 1);
183
1
    assert_eq!(roots[0].children[0].account_path, "Assets:Bank");
184
1
    assert_eq!(roots[0].children[0].depth, 1);
185
1
    assert_eq!(roots[0].amounts[0].amount, Rational64::new(50, 1));
186
1
    assert_eq!(
187
1
        roots[0].children[0].amounts[0].amount,
188
1
        Rational64::new(50, 1)
189
    );
190
1
}
191

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

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

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

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

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

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

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

            
371
#[cfg(feature = "scripting")]
372
mod sexpr_tests {
373
    use super::*;
374

            
375
    #[test]
376
1
    fn test_from_sexpr_account_eq() {
377
1
        let id = Uuid::new_v4();
378
1
        let input = format!("(account= \"{id}\")");
379
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
380
1
        assert!(matches!(filter, ReportFilter::AccountEq(v) if v == id));
381
1
    }
382

            
383
    #[test]
384
1
    fn test_from_sexpr_commodity_eq() {
385
1
        let id = Uuid::new_v4();
386
1
        let input = format!("(commodity= \"{id}\")");
387
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
388
1
        assert!(matches!(filter, ReportFilter::CommodityEq(v) if v == id));
389
1
    }
390

            
391
    #[test]
392
1
    fn test_from_sexpr_amount_gt() {
393
1
        let input = "(amount> 100)";
394
1
        let filter = ReportFilter::from_sexpr(input).unwrap();
395
1
        assert!(matches!(filter, ReportFilter::AmountGt(r) if r == Rational64::new(100, 1)));
396
1
    }
397

            
398
    #[test]
399
1
    fn test_from_sexpr_and() {
400
1
        let id1 = Uuid::new_v4();
401
1
        let id2 = Uuid::new_v4();
402
1
        let input = format!("(and (account= \"{id1}\") (commodity= \"{id2}\"))");
403
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
404
1
        assert!(matches!(filter, ReportFilter::And(v) if v.len() == 2));
405
1
    }
406

            
407
    #[test]
408
1
    fn test_from_sexpr_not() {
409
1
        let id = Uuid::new_v4();
410
1
        let input = format!("(not (account= \"{id}\"))");
411
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
412
1
        assert!(matches!(filter, ReportFilter::Not(_)));
413
1
    }
414

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

            
433
    #[test]
434
1
    fn test_from_sexpr_account_subtree() {
435
1
        let id = Uuid::new_v4();
436
1
        let input = format!("(account-subtree \"{id}\")");
437
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
438
1
        assert!(matches!(filter, ReportFilter::AccountSubtree(v) if v == id));
439
1
    }
440

            
441
    #[test]
442
1
    fn test_from_sexpr_invalid() {
443
1
        let result = ReportFilter::from_sexpr("invalid");
444
1
        assert!(result.is_err());
445
1
    }
446

            
447
    #[test]
448
1
    fn test_from_sexpr_counterparty() {
449
1
        let id = Uuid::new_v4();
450
1
        let input = format!("(counterparty= \"{id}\")");
451
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
452
1
        assert!(matches!(filter, ReportFilter::CounterpartyEq(v) if v == id));
453
1
    }
454

            
455
    #[test]
456
1
    fn test_from_sexpr_or() {
457
1
        let id1 = Uuid::new_v4();
458
1
        let id2 = Uuid::new_v4();
459
1
        let input = format!("(or (account= \"{id1}\") (account= \"{id2}\"))");
460
1
        let filter = ReportFilter::from_sexpr(&input).unwrap();
461
1
        assert!(matches!(filter, ReportFilter::Or(v) if v.len() == 2));
462
1
    }
463
}
464

            
465
#[test]
466
1
fn test_period_grouping_deserializes_from_web_forms() {
467
1
    let cases = [
468
1
        ("\"month\"", PeriodGrouping::Month),
469
1
        ("\"quarter\"", PeriodGrouping::Quarter),
470
1
        ("\"year\"", PeriodGrouping::Year),
471
1
    ];
472
3
    for (input, expected) in cases {
473
3
        let parsed: PeriodGrouping = serde_json::from_str(input).unwrap();
474
3
        assert_eq!(parsed, expected);
475
    }
476
1
}
477

            
478
#[test]
479
1
fn test_period_grouping_rejects_invalid() {
480
1
    assert!(serde_json::from_str::<PeriodGrouping>("\"weekly\"").is_err());
481
1
}
482

            
483
#[test]
484
1
fn test_breakdown_sort_deserializes_from_web_forms() {
485
1
    let cases = [
486
1
        ("\"amount_desc\"", BreakdownSort::AmountDesc),
487
1
        ("\"amount_asc\"", BreakdownSort::AmountAsc),
488
1
        ("\"name_asc\"", BreakdownSort::NameAsc),
489
1
        ("\"name_desc\"", BreakdownSort::NameDesc),
490
1
    ];
491
4
    for (input, expected) in cases {
492
4
        let parsed: BreakdownSort = serde_json::from_str(input).unwrap();
493
4
        assert_eq!(parsed, expected);
494
    }
495
1
}
496

            
497
#[test]
498
1
fn test_breakdown_sort_default_is_amount_desc() {
499
1
    assert_eq!(BreakdownSort::default(), BreakdownSort::AmountDesc);
500
1
}
501

            
502
#[test]
503
1
fn test_activity_group_serde_round_trip() {
504
    use super::super::ActivityGroup;
505
1
    let g = ActivityGroup {
506
1
        label: "Savings".into(),
507
1
        filter: ReportFilter::Tag {
508
1
            entity: FilterEntity::Account,
509
1
            name: "type".into(),
510
1
            value: "savings".into(),
511
1
        },
512
1
        flip_sign: true,
513
1
    };
514
1
    let json = serde_json::to_string(&g).unwrap();
515
1
    let parsed: ActivityGroup = serde_json::from_str(&json).unwrap();
516
1
    assert_eq!(parsed.label, g.label);
517
1
    assert_eq!(parsed.flip_sign, g.flip_sign);
518
    // filter equality: easier to compare via re-serialization
519
1
    let re_json = serde_json::to_string(&parsed).unwrap();
520
1
    assert_eq!(json, re_json);
521
1
}
522

            
523
#[test]
524
1
fn test_activity_group_flip_sign_defaults_false() {
525
    use super::super::ActivityGroup;
526
1
    let json = r#"{"label":"Expense","filter":{"op":"tag","args":{"entity":"account","name":"type","value":"expense"}}}"#;
527
1
    let parsed: ActivityGroup = serde_json::from_str(json).unwrap();
528
1
    assert!(!parsed.flip_sign);
529
1
}