1use axum::http::HeaderMap;
2use axum::http::StatusCode;
3use chrono::{DateTime, Datelike, Utc};
4use num_rational::Rational64;
5use serde::{Deserialize, Deserializer};
6use server::command::{
7 CmdResult, FilterEntity, FinanceEntity, ReportFilter, commodity::ListCommodities,
8};
9use sqlx::types::{Uuid, chrono::NaiveDate};
10
11pub mod activity;
12pub mod balance;
13pub mod category_breakdown;
14
15use server::command::report::view;
16use view::AmountView;
17
18pub struct CommodityOption {
19 pub id: Uuid,
20 pub symbol: String,
21 pub name: String,
22}
23
24pub struct SummaryCard {
28 pub label: String,
29 pub amounts: Vec<AmountView>,
30 pub is_net: bool,
31 pub highlight: bool,
32}
33
34#[derive(Default, Deserialize)]
40pub struct TableControlParams {
41 #[serde(default, deserialize_with = "empty_string_as_none")]
42 pub collapsed_depth: Option<String>,
43 #[serde(default, deserialize_with = "empty_string_as_none")]
44 pub commodity_columns: Option<String>,
45}
46
47impl TableControlParams {
48 #[must_use]
49 pub fn commodity_columns_enabled(&self) -> bool {
50 self.commodity_columns
51 .as_deref()
52 .map(str::trim)
53 .is_some_and(|v| v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true"))
54 }
55}
56
57#[derive(Default, Deserialize)]
63pub struct ChartParams {
64 #[serde(default, deserialize_with = "empty_string_as_none")]
65 pub chart_kind: Option<String>,
66 #[serde(default, deserialize_with = "empty_string_as_none")]
67 pub chart_series: Option<String>,
68 #[serde(default, deserialize_with = "empty_string_as_none")]
69 pub renderer: Option<String>,
70}
71
72impl ChartParams {
73 #[must_use]
77 pub fn chart_kind_or_default(&self) -> plotting::ChartKind {
78 match self.chart_kind.as_deref().map(str::to_ascii_lowercase) {
79 Some(ref s) if s == "line" => plotting::ChartKind::Line,
80 Some(ref s) if s == "stacked" || s == "stackedbar" => plotting::ChartKind::StackedBar,
81 _ => plotting::ChartKind::Bar,
82 }
83 }
84
85 #[must_use]
89 pub fn chart_kind_str(&self) -> String {
90 match self.chart_kind_or_default() {
91 plotting::ChartKind::Bar => "bar".to_string(),
92 plotting::ChartKind::StackedBar => "stacked".to_string(),
93 plotting::ChartKind::Line => "line".to_string(),
94 }
95 }
96
97 #[must_use]
100 pub fn renderer_str(&self) -> String {
101 match self.renderer.as_deref().map(str::to_ascii_lowercase) {
102 Some(ref s) if s == "canvas" => "canvas".to_string(),
103 _ => "svg".to_string(),
104 }
105 }
106}
107
108#[must_use]
112pub fn encode_query(pairs: &[(&str, &str)]) -> String {
113 let mut out = String::new();
114 for (k, v) in pairs {
115 if v.is_empty() {
116 continue;
117 }
118 if !out.is_empty() {
119 out.push('&');
120 }
121 out.push_str(&percent_encode(k));
122 out.push('=');
123 out.push_str(&percent_encode(v));
124 }
125 if out.is_empty() {
126 out
127 } else {
128 format!("?{out}")
129 }
130}
131
132fn percent_encode(s: &str) -> String {
133 let mut out = String::with_capacity(s.len());
134 for b in s.bytes() {
135 let keep = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
136 if keep {
137 out.push(b as char);
138 } else {
139 out.push_str(&format!("%{b:02X}"));
140 }
141 }
142 out
143}
144
145pub async fn load_commodities(user_id: Uuid) -> Vec<CommodityOption> {
146 let Ok(Some(CmdResult::TaggedEntities { entities, .. })) =
147 ListCommodities::new().user_id(user_id).run().await
148 else {
149 return Vec::new();
150 };
151
152 let mut commodities = Vec::new();
153 for (entity, tags) in entities {
154 if let FinanceEntity::Commodity(commodity) = entity
155 && let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) = (&tags["symbol"], &tags["name"])
156 {
157 commodities.push(CommodityOption {
158 id: commodity.id,
159 symbol: s.tag_value.clone(),
160 name: n.tag_value.clone(),
161 });
162 }
163 }
164 commodities
165}
166
167#[must_use]
171pub fn parse_date_bound(raw: &str, end_of_day: bool) -> Option<DateTime<Utc>> {
172 let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
173 let time = if end_of_day {
174 date.and_hms_opt(23, 59, 59)
175 } else {
176 date.and_hms_opt(0, 0, 0)
177 }?;
178 Some(time.and_utc())
179}
180
181#[must_use]
182pub fn wants_json(headers: &HeaderMap) -> bool {
183 headers
184 .get("accept")
185 .and_then(|v| v.to_str().ok())
186 .is_some_and(|v| v.contains("application/json"))
187}
188
189#[must_use]
192pub fn sum_top_level_amounts(rows: &[view::ReportRowView]) -> Vec<AmountView> {
193 let mut out: Vec<AmountView> = Vec::new();
194 for row in rows.iter().filter(|r| r.depth == 0) {
195 for a in &row.amounts {
196 match out
197 .iter_mut()
198 .find(|x| x.commodity_symbol == a.commodity_symbol)
199 {
200 Some(existing) => existing.amount += a.amount,
201 None => out.push(AmountView {
202 commodity_symbol: a.commodity_symbol.clone(),
203 amount: a.amount,
204 }),
205 }
206 }
207 }
208 out
209}
210
211#[must_use]
216pub fn commodity_symbols_in_rows(rows: &[view::ReportRowView]) -> Vec<String> {
217 let mut seen = Vec::new();
218 for row in rows {
219 for a in &row.amounts {
220 if !seen.iter().any(|s: &String| s == &a.commodity_symbol) {
221 seen.push(a.commodity_symbol.clone());
222 }
223 }
224 }
225 seen
226}
227
228#[must_use]
231pub fn row_amount_by_symbol(row: &view::ReportRowView, symbol: &str) -> Option<Rational64> {
232 row.amounts
233 .iter()
234 .find(|a| a.commodity_symbol == symbol)
235 .map(|a| a.amount)
236}
237
238#[must_use]
243pub fn parse_sort_order_shared(raw: Option<&str>) -> SharedSort {
244 match raw.map(str::to_ascii_lowercase).as_deref() {
245 Some("amount_asc") => SharedSort::AmountAsc,
246 Some("name_asc") => SharedSort::NameAsc,
247 Some("name_desc") => SharedSort::NameDesc,
248 _ => SharedSort::AmountDesc,
249 }
250}
251
252#[derive(Debug, Clone, Copy, Default)]
253pub enum SharedSort {
254 #[default]
255 AmountDesc,
256 AmountAsc,
257 NameAsc,
258 NameDesc,
259}
260
261impl SharedSort {
262 #[must_use]
263 pub fn to_str(self) -> &'static str {
264 match self {
265 Self::AmountDesc => "amount_desc",
266 Self::AmountAsc => "amount_asc",
267 Self::NameAsc => "name_asc",
268 Self::NameDesc => "name_desc",
269 }
270 }
271
272 #[must_use]
274 pub fn into_plotting_balance(self) -> plotting::adapters::SortOrder {
275 match self {
276 Self::AmountDesc => plotting::adapters::SortOrder::MagnitudeDesc,
277 Self::AmountAsc => plotting::adapters::SortOrder::MagnitudeAsc,
278 Self::NameAsc => plotting::adapters::SortOrder::NameAsc,
279 Self::NameDesc => plotting::adapters::SortOrder::NameDesc,
280 }
281 }
282}
283
284fn abs_rational(r: Rational64) -> Rational64 {
285 if r < Rational64::new(0, 1) { -r } else { r }
286}
287
288pub fn sort_top_level_rows(rows: &mut Vec<view::ReportRowView>, order: SharedSort) {
293 let mut groups: Vec<Vec<view::ReportRowView>> = Vec::new();
297 for row in std::mem::take(rows) {
298 if row.depth == 0 || groups.is_empty() {
299 groups.push(vec![row]);
300 } else if let Some(last) = groups.last_mut() {
301 last.push(row);
302 }
303 }
304
305 groups.sort_by(|a, b| {
306 let (a_root, b_root) = (&a[0], &b[0]);
307 match order {
308 SharedSort::AmountDesc => {
309 let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
310 let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
311 kb.cmp(&ka)
312 }
313 SharedSort::AmountAsc => {
314 let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
315 let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
316 ka.cmp(&kb)
317 }
318 SharedSort::NameAsc => a_root.account_name.cmp(&b_root.account_name),
319 SharedSort::NameDesc => b_root.account_name.cmp(&a_root.account_name),
320 }
321 });
322
323 for group in groups {
324 rows.extend(group);
325 }
326}
327
328#[cfg(test)]
329mod sort_tests {
330 use super::*;
331 use num_rational::Rational64;
332 use view::{AmountView, ReportRowView};
333
334 fn mk_row(name: &str, depth: usize, amount: i64, parent: Option<Uuid>) -> ReportRowView {
335 let id = Uuid::new_v4();
336 ReportRowView {
337 account_id: id,
338 parent_id: parent,
339 account_name: name.to_string(),
340 depth,
341 has_children: false,
342 amounts: vec![AmountView {
343 commodity_symbol: "USD".to_string(),
344 amount: Rational64::new(amount, 1),
345 }],
346 }
347 }
348
349 #[test]
350 fn sort_keeps_children_with_parents() {
351 let parent_b = mk_row("Bank", 0, 500, None);
352 let parent_b_id = parent_b.account_id;
353 let parent_a = mk_row("Assets", 0, 1000, None);
354 let parent_c = mk_row("Cash", 0, 200, None);
355 let mut rows = vec![
356 parent_b,
357 mk_row("Checking", 1, 300, Some(parent_b_id)),
358 mk_row("Savings", 1, 200, Some(parent_b_id)),
359 parent_a,
360 parent_c,
361 ];
362
363 sort_top_level_rows(&mut rows, SharedSort::AmountDesc);
364 let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
365 assert_eq!(
366 names,
367 vec!["Assets", "Bank", "Checking", "Savings", "Cash"],
368 "Assets first (1000), Bank + children next (500), Cash last (200)"
369 );
370 }
371
372 #[test]
373 fn sort_by_name_ascending() {
374 let mut rows = vec![
375 mk_row("Zulu", 0, 1, None),
376 mk_row("Alpha", 0, 2, None),
377 mk_row("Mike", 0, 3, None),
378 ];
379 sort_top_level_rows(&mut rows, SharedSort::NameAsc);
380 let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
381 assert_eq!(names, vec!["Alpha", "Mike", "Zulu"]);
382 }
383}
384
385#[must_use]
388pub fn breakdown_row_amount_by_symbol(
389 row: &view::BreakdownRowView,
390 symbol: &str,
391) -> Option<Rational64> {
392 row.amounts
393 .iter()
394 .find(|a| a.commodity_symbol == symbol)
395 .map(|a| a.amount)
396}
397
398pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
399where
400 D: Deserializer<'de>,
401{
402 let opt = Option::<String>::deserialize(deserializer)?;
403 Ok(opt.filter(|s| !s.is_empty()))
404}
405
406#[must_use]
407pub fn today_string() -> String {
408 Utc::now().date_naive().format("%Y-%m-%d").to_string()
409}
410
411#[must_use]
412pub fn month_start_string() -> String {
413 let now = Utc::now().date_naive();
414 NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
415 .unwrap_or(now)
416 .format("%Y-%m-%d")
417 .to_string()
418}
419
420#[derive(Deserialize)]
421#[serde(tag = "type")]
422enum FilterItem {
423 #[serde(rename = "tag")]
424 Tag {
425 entities: Vec<String>,
426 name: String,
427 values: Vec<String>,
428 },
429 #[serde(rename = "account")]
430 Account {
431 account_id: String,
432 #[serde(default, rename = "display_name")]
433 _display_name: String,
434 include_subtree: bool,
435 },
436 #[serde(rename = "group")]
437 Group {
438 logic: String,
439 items: Vec<FilterItem>,
440 },
441}
442
443#[derive(Deserialize)]
444struct FilterGroup {
445 logic: String,
446 items: Vec<FilterItem>,
447}
448
449pub(super) fn report_status(err: server::command::CmdError) -> StatusCode {
454 match err {
455 server::command::CmdError::Args(msg) => {
456 log::warn!("report refused: {msg}");
457 StatusCode::BAD_REQUEST
458 }
459 _ => StatusCode::INTERNAL_SERVER_ERROR,
460 }
461}
462
463fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
464 match values.len() {
465 1 => ReportFilter::Tag {
466 entity,
467 name: name.to_string(),
468 value: values[0].clone(),
469 },
470 _ => ReportFilter::TagIn {
471 entity,
472 name: name.to_string(),
473 values: values.to_vec(),
474 },
475 }
476}
477
478fn build_filter_item(item: &FilterItem) -> Result<Option<ReportFilter>, ()> {
485 match item {
486 FilterItem::Tag {
487 entities,
488 name,
489 values,
490 } => {
491 if name.trim().is_empty() || values.is_empty() {
492 return Ok(None);
493 }
494 let mut seen = Vec::new();
495 let filters = entities
496 .iter()
497 .filter(|e| {
498 let fresh = !seen.contains(e);
499 if fresh {
500 seen.push(*e);
501 }
502 fresh
503 })
504 .map(|e| match e.as_str() {
505 "account" => Ok(build_tag_entity_filter(FilterEntity::Account, name, values)),
506 "transaction" => Ok(build_tag_entity_filter(
507 FilterEntity::Transaction,
508 name,
509 values,
510 )),
511 "split" => Ok(build_tag_entity_filter(FilterEntity::Split, name, values)),
512 _ => Err(()),
513 })
514 .collect::<Result<Vec<ReportFilter>, ()>>()?;
515 match filters.len() {
516 0 => Err(()),
519 1 => Ok(filters.into_iter().next()),
520 _ => Ok(Some(ReportFilter::Or(filters))),
521 }
522 }
523 FilterItem::Account {
524 account_id,
525 include_subtree,
526 ..
527 } => {
528 if account_id.trim().is_empty() {
529 return Ok(None);
530 }
531 let id = account_id.parse::<Uuid>().map_err(|_| ())?;
532 Ok(Some(if *include_subtree {
533 ReportFilter::AccountSubtree(id)
534 } else {
535 ReportFilter::AccountEq(id)
536 }))
537 }
538 FilterItem::Group { logic, items } => group_filter(logic, items),
539 }
540}
541
542fn build_filter_items(items: &[FilterItem]) -> Result<Vec<ReportFilter>, ()> {
544 let mut built = Vec::with_capacity(items.len());
545 for item in items {
546 if let Some(filter) = build_filter_item(item)? {
547 built.push(filter);
548 }
549 }
550 Ok(built)
551}
552
553fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
558 match logic {
559 "not" => match filters.len() {
560 1 => filters
561 .into_iter()
562 .next()
563 .map(|f| ReportFilter::Not(Box::new(f))),
564 _ => None,
565 },
566 "and" | "or" => match filters.len() {
567 0 => None,
568 1 => filters.into_iter().next(),
569 _ if logic == "or" => Some(ReportFilter::Or(filters)),
570 _ => Some(ReportFilter::And(filters)),
571 },
572 _ => None,
573 }
574}
575
576fn build_filter_from_group(group: &FilterGroup) -> Result<Option<ReportFilter>, ()> {
577 group_filter(&group.logic, &group.items)
578}
579
580fn group_filter(logic: &str, items: &[FilterItem]) -> Result<Option<ReportFilter>, ()> {
583 if !matches!(logic, "and" | "or" | "not") {
584 return Err(());
585 }
586 let filters = build_filter_items(items)?;
587 if filters.is_empty() {
588 return Ok(None);
589 }
590 combine_group(logic, filters).map(Some).ok_or(())
591}
592
593pub fn build_report_filter(
601 tag_filters: Option<&str>,
602 tag_filter_mode: Option<&str>,
603) -> Result<Option<ReportFilter>, StatusCode> {
604 let Some(raw) = tag_filters.filter(|s| !s.is_empty()) else {
605 return Ok(None);
606 };
607
608 if tag_filter_mode == Some("script") {
609 return build_filter_from_sexpr(raw)
610 .map(Some)
611 .ok_or(StatusCode::BAD_REQUEST);
612 }
613
614 let group: FilterGroup = serde_json::from_str(raw).map_err(|_| StatusCode::BAD_REQUEST)?;
615 build_filter_from_group(&group).map_err(|()| StatusCode::BAD_REQUEST)
619}
620
621fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
622 ReportFilter::from_sexpr(raw).ok()
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
634 fn a_malformed_child_does_not_silently_widen_the_filter() {
635 let bad_uuid = FilterItem::Account {
636 account_id: "not-a-uuid".to_owned(),
637 include_subtree: false,
638 _display_name: String::new(),
639 };
640 let group = FilterGroup {
641 logic: "and".to_owned(),
642 items: vec![tag_item("category", "food"), bad_uuid],
643 };
644 assert!(
645 build_filter_from_group(&group).is_err(),
646 "a bad child must fail the request, not vanish from the AND"
647 );
648
649 let nested_bad = FilterGroup {
650 logic: "and".to_owned(),
651 items: vec![
652 tag_item("category", "food"),
653 FilterItem::Group {
654 logic: "not".to_owned(),
655 items: vec![tag_item("a", "1"), tag_item("b", "2")],
656 },
657 ],
658 };
659 assert!(
660 build_filter_from_group(&nested_bad).is_err(),
661 "a malformed NESTED group must fail too — same input, same answer at any depth"
662 );
663 }
664
665 #[test]
667 fn an_unfilled_row_is_skipped_not_refused() {
668 let group = FilterGroup {
669 logic: "and".to_owned(),
670 items: vec![tag_item("category", "food"), tag_item("", "")],
671 };
672 assert!(
673 matches!(build_filter_from_group(&group), Ok(Some(_))),
674 "a blank row is normal UI state"
675 );
676 }
677
678 #[test]
682 fn an_empty_filter_group_is_no_filter_not_an_error() {
683 for raw in [
684 r#"{"logic":"and","items":[]}"#,
685 r#"{"logic":"or","items":[]}"#,
686 ] {
687 assert!(
688 matches!(build_report_filter(Some(raw), None), Ok(None)),
689 "an empty group must mean no filter: {raw}"
690 );
691 }
692 }
693
694 #[test]
698 fn an_unparsable_filter_is_refused() {
699 assert!(build_report_filter(Some("{not json"), None).is_err());
700 assert!(build_report_filter(Some("(and"), Some("script")).is_err());
701 }
702
703 #[test]
705 fn no_filter_requested_is_ok_none() {
706 assert!(matches!(build_report_filter(None, None), Ok(None)));
707 assert!(matches!(build_report_filter(Some(""), None), Ok(None)));
708 }
709
710 fn tag_item(name: &str, value: &str) -> FilterItem {
711 FilterItem::Tag {
712 entities: vec!["transaction".to_owned()],
713 name: name.to_owned(),
714 values: vec![value.to_owned()],
715 }
716 }
717
718 #[test]
719 fn not_group_with_single_child_becomes_not() {
720 let group = FilterGroup {
721 logic: "not".to_owned(),
722 items: vec![tag_item("category", "food")],
723 };
724 let result = build_filter_from_group(&group)
725 .expect("filter built")
726 .expect("a condition");
727 match result {
728 ReportFilter::Not(inner) => match *inner {
729 ReportFilter::Tag { name, value, .. } => {
730 assert_eq!(name, "category");
731 assert_eq!(value, "food");
732 }
733 other => panic!("expected inner Tag, got {other:?}"),
734 },
735 other => panic!("expected Not(Tag), got {other:?}"),
736 }
737 }
738
739 #[test]
740 fn not_group_with_zero_children_contributes_nothing() {
741 let group = FilterGroup {
742 logic: "not".to_owned(),
743 items: vec![],
744 };
745 assert!(
746 matches!(build_filter_from_group(&group), Ok(None)),
747 "an unfilled group is no condition, not a malformed request"
748 );
749 }
750
751 #[test]
752 fn not_group_with_multiple_children_is_refused() {
753 let group = FilterGroup {
754 logic: "not".to_owned(),
755 items: vec![tag_item("category", "food"), tag_item("category", "rent")],
756 };
757 assert!(
758 build_filter_from_group(&group).is_err(),
759 "multi-child NOT is malformed; dropping it would widen the filter"
760 );
761 }
762
763 #[test]
764 fn explicit_not_of_or_via_nesting() {
765 let group = FilterGroup {
767 logic: "not".to_owned(),
768 items: vec![FilterItem::Group {
769 logic: "or".to_owned(),
770 items: vec![tag_item("category", "food"), tag_item("category", "rent")],
771 }],
772 };
773 let result = build_filter_from_group(&group)
774 .expect("filter built")
775 .expect("a condition");
776 let ReportFilter::Not(inner) = result else {
777 panic!("expected Not");
778 };
779 let ReportFilter::Or(children) = *inner else {
780 panic!("expected Not(Or(..))");
781 };
782 assert_eq!(children.len(), 2);
783 }
784
785 #[test]
786 fn and_group_unchanged() {
787 let group = FilterGroup {
788 logic: "and".to_owned(),
789 items: vec![tag_item("category", "food"), tag_item("project", "roof")],
790 };
791 let result = build_filter_from_group(&group)
792 .expect("filter built")
793 .expect("a condition");
794 assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));
795 }
796
797 #[test]
800 fn a_tag_row_with_no_entity_ticked_is_refused() {
801 let item = FilterItem::Tag {
802 entities: vec![],
803 name: "category".to_owned(),
804 values: vec!["food".to_owned()],
805 };
806 assert!(build_filter_item(&item).is_err());
807 }
808
809 #[test]
811 fn a_blank_account_row_is_skipped_not_refused() {
812 let blank = FilterItem::Account {
813 account_id: " ".to_owned(),
814 include_subtree: false,
815 _display_name: String::new(),
816 };
817 assert!(matches!(build_filter_item(&blank), Ok(None)));
818 let group = FilterGroup {
819 logic: "and".to_owned(),
820 items: vec![tag_item("category", "food"), blank],
821 };
822 assert!(matches!(
823 build_filter_from_group(&group),
824 Ok(Some(ReportFilter::Tag { .. }))
825 ));
826 }
827
828 #[test]
830 fn an_unknown_group_operator_is_refused() {
831 for logic in ["xor", "", "AND"] {
832 let group = FilterGroup {
833 logic: logic.to_owned(),
834 items: vec![tag_item("category", "food"), tag_item("payee", "shop")],
835 };
836 assert!(
837 build_filter_from_group(&group).is_err(),
838 "{logic} must not be accepted as AND"
839 );
840 }
841 }
842
843 #[test]
846 fn an_unknown_group_operator_is_refused_even_when_empty() {
847 let group = FilterGroup {
848 logic: "xor".to_owned(),
849 items: vec![],
850 };
851 assert!(build_filter_from_group(&group).is_err());
852 }
853
854 #[test]
857 fn repeated_entities_build_one_condition_each() {
858 let item = FilterItem::Tag {
859 entities: vec!["account".to_owned(); 500],
860 name: "category".to_owned(),
861 values: vec!["food".to_owned()],
862 };
863 assert!(matches!(
864 build_filter_item(&item),
865 Ok(Some(ReportFilter::Tag { .. }))
866 ));
867 }
868
869 #[test]
872 fn an_unknown_operator_is_refused_through_the_entry_point() {
873 assert_eq!(
874 build_report_filter(Some(r#"{"logic":"xor","items":[]}"#), None).err(),
875 Some(StatusCode::BAD_REQUEST)
876 );
877 assert!(matches!(
879 build_report_filter(Some(r#"{"logic":"and","items":[]}"#), None),
880 Ok(None)
881 ));
882 }
883}