#[cfg(all(feature = "validate", feature = "versioned"))]
mod identifier_tests {
use garde::Validate as _;
use rubo4e::identifiers::MaloId;
#[test]
fn malo_id_valid_passes_garde() {
let id = MaloId::new("51238696781").unwrap();
assert!(id.validate().is_ok());
}
#[test]
fn malo_id_invalid_rejected_at_construction() {
assert!(MaloId::new("12345").is_err());
}
}
#[cfg(all(feature = "validate", feature = "versioned"))]
mod marktlokation_tests {
use garde::Validate as _;
use rubo4e::v202607::{Adresse, Geokoordinaten, Katasteradresse, Marktlokation};
fn base() -> Marktlokation {
Marktlokation::default()
}
#[test]
fn one_ortsangabe_is_fine() {
let mut m = base();
m.lokationsadresse = Some(Adresse::default());
assert!(m.validate().is_ok());
}
#[test]
fn no_ortsangabe_at_all_is_fine() {
assert!(
base().validate().is_ok(),
"a Marktlokation with no Ortsangabe conforms — BO4E marks none of \
the three required"
);
let referenced = Marktlokation {
marktlokations_id: Some(
rubo4e::identifiers::MaloId::new("51238696781").expect("valid"),
),
..Default::default()
};
assert!(
referenced.validate().is_ok(),
"an ID-only reference conforms"
);
}
#[test]
fn two_ortsangaben_conflict() {
let mut m = base();
m.lokationsadresse = Some(Adresse::default());
m.geoadresse = Some(Geokoordinaten::default());
let report = m.validate().expect_err("two Ortsangaben must conflict");
let text = report.to_string();
assert!(
text.contains("lokationsadresse") && text.contains("geoadresse"),
"the message must name which two are set: {text}"
);
assert!(
!text.contains("katasterinformation"),
"…and must not name the one that is not: {text}"
);
}
#[test]
fn three_ortsangaben_conflict() {
let mut m = base();
m.lokationsadresse = Some(Adresse::default());
m.geoadresse = Some(Geokoordinaten::default());
m.katasterinformation = Some(Katasteradresse::default());
let text = m.validate().expect_err("three must conflict").to_string();
for field in ["lokationsadresse", "geoadresse", "katasterinformation"] {
assert!(text.contains(field), "{field} missing from: {text}");
}
}
#[test]
fn kataster_alone_is_fine() {
let mut m = base();
m.katasterinformation = Some(Katasteradresse::default());
assert!(m.validate().is_ok());
}
#[cfg(feature = "json")]
#[test]
fn golden_payload_with_lokationsadresse_validates_ok() {
let json = r#"{
"lokationsadresse": {
"strasse": "Musterstraße",
"hausnummer": "1",
"postleitzahl": "12345",
"ort": "Berlin",
"landescode": "DE"
}
}"#;
let m: Marktlokation = serde_json::from_str(json).expect("deserialize failed");
assert!(
m.lokationsadresse.is_some(),
"lokationsadresse should be set"
);
assert!(m.geoadresse.is_none());
assert!(m.katasterinformation.is_none());
assert!(
m.validate().is_ok(),
"validate() should be Ok with lokationsadresse set"
);
}
}
#[cfg(all(feature = "validate", feature = "versioned"))]
mod messlokation_tests {
use garde::Validate as _;
use rubo4e::v202607::{Adresse, Geokoordinaten, Messlokation};
#[test]
fn one_ortsangabe_is_fine() {
let m = Messlokation {
messadresse: Some(Adresse::default()),
..Default::default()
};
assert!(m.validate().is_ok());
}
#[test]
fn no_ortsangabe_at_all_is_fine() {
assert!(
Messlokation::default().validate().is_ok(),
"the schema tells senders to omit messadresse when it matches the \
Marktlokation's — that cannot then be a violation"
);
}
#[test]
fn two_ortsangaben_conflict() {
let m = Messlokation {
messadresse: Some(Adresse::default()),
geoadresse: Some(Geokoordinaten::default()),
..Default::default()
};
let text = m.validate().expect_err("two must conflict").to_string();
assert!(
text.contains("messadresse") && text.contains("geoadresse"),
"the message must name which two are set: {text}"
);
}
}
#[cfg(all(feature = "validate", feature = "versioned", feature = "time"))]
mod vertrag_tests {
use garde::Validate as _;
use rubo4e::v202607::Vertrag;
use time::OffsetDateTime;
#[test]
fn valid_date_range_ok() {
let v = Vertrag {
vertragsbeginn: Some(OffsetDateTime::from_unix_timestamp(0).unwrap()),
vertragsende: Some(OffsetDateTime::from_unix_timestamp(3600).unwrap()),
..Default::default()
};
assert!(v.validate().is_ok());
}
#[test]
fn inverted_date_range_fails() {
let v = Vertrag {
vertragsbeginn: Some(OffsetDateTime::from_unix_timestamp(3600).unwrap()),
vertragsende: Some(OffsetDateTime::from_unix_timestamp(0).unwrap()),
..Default::default()
};
assert!(v.validate().is_err());
}
#[test]
fn one_date_missing_ok() {
let v = Vertrag {
vertragsbeginn: Some(OffsetDateTime::from_unix_timestamp(0).unwrap()),
..Default::default()
};
assert!(v.validate().is_ok());
}
}
#[cfg(all(feature = "validate", feature = "versioned", feature = "decimal"))]
mod rechnung_tests {
use garde::Validate as _;
use rubo4e::v202607::{Betrag, Rechnung};
use rust_decimal::prelude::FromStr as _;
use rust_decimal::Decimal;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn betrag(wert: Decimal) -> Option<Betrag> {
Some(Betrag {
wert: Some(wert),
..Default::default()
})
}
#[test]
fn balanced_invoice_ok() {
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
..Default::default()
};
assert!(r.validate().is_ok());
}
#[test]
fn netto_steuer_mismatch_fails() {
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("120.00")), ..Default::default()
};
assert!(r.validate().is_err());
}
#[test]
fn two_of_three_totals_conforms_but_fails_the_quality_check() {
use rubo4e::validation::current::quality;
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
..Default::default()
};
assert!(
r.validate().is_ok(),
"BO4E requires none of the three totals — this conforms"
);
let text = quality::rechnung_totals_are_complete(&r)
.expect_err("the opt-in house rule does flag it")
.to_string();
assert!(
text.contains("gesamtbrutto"),
"the message must name the derivable total: {text}"
);
}
#[test]
fn the_completeness_check_only_fires_on_exactly_two() {
use rubo4e::validation::current::quality;
let all_three = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
..Default::default()
};
assert!(quality::rechnung_totals_are_complete(&all_three).is_ok());
let gross_only = Rechnung {
gesamtbrutto: betrag(dec("119.00")),
..Default::default()
};
assert!(
quality::rechnung_totals_are_complete(&gross_only).is_ok(),
"an invoice may legitimately state only a gross total"
);
assert!(quality::rechnung_totals_are_complete(&Rechnung::default()).is_ok());
}
#[test]
fn net_discount_does_not_constrain_zu_zahlen() {
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
rabatt_netto: betrag(dec("10.00")),
zu_zahlen: betrag(dec("107.10")),
..Default::default()
};
assert!(
r.validate().is_ok(),
"a correctly discounted invoice must not be rejected: {:?}",
r.validate().unwrap_err()
);
}
#[test]
fn zu_zahlen_after_advance_payment_ok() {
use rubo4e::v202607::Vorauszahlung;
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
vorauszahlungen: Some(vec![Vorauszahlung {
betrag: betrag(dec("20.00")),
..Default::default()
}]),
zu_zahlen: betrag(dec("99.00")),
..Default::default()
};
assert!(r.validate().is_ok());
}
#[test]
fn tax_lines_summing_to_total_ok() {
use rubo4e::v202607::Steuerbetrag;
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
steuerbetraege: Some(vec![
Steuerbetrag {
steuerwert: Some(dec("11.40")),
..Default::default()
},
Steuerbetrag {
steuerwert: Some(dec("7.60")),
..Default::default()
},
]),
..Default::default()
};
assert!(r.validate().is_ok());
}
#[test]
fn tax_lines_not_summing_to_total_fails() {
use rubo4e::v202607::Steuerbetrag;
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
steuerbetraege: Some(vec![Steuerbetrag {
steuerwert: Some(dec("18.00")),
..Default::default()
}]),
..Default::default()
};
let err = r.validate().unwrap_err().to_string();
assert!(err.contains("steuerbetraege"), "unexpected error: {err}");
}
#[test]
fn tax_lines_with_missing_amounts_are_skipped() {
use rubo4e::v202607::Steuerbetrag;
let r = Rechnung {
gesamtnetto: betrag(dec("100.00")),
gesamtsteuer: betrag(dec("19.00")),
gesamtbrutto: betrag(dec("119.00")),
steuerbetraege: Some(vec![
Steuerbetrag {
steuerwert: Some(dec("11.40")),
..Default::default()
},
Steuerbetrag::default(),
]),
..Default::default()
};
assert!(r.validate().is_ok());
}
}
#[cfg(all(
feature = "validate",
feature = "versioned",
feature = "decimal",
feature = "time"
))]
mod kostenposition_tests {
use garde::Validate as _;
use rubo4e::v202607::{Kostenposition, Menge, Preis};
use rust_decimal::Decimal;
use std::str::FromStr as _;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn position(einzelpreis: &str, menge: &str, betrag: &str) -> Kostenposition {
Kostenposition {
einzelpreis: Some(Preis {
wert: Some(dec(einzelpreis)),
..Default::default()
}),
menge: Some(Menge {
wert: Some(dec(menge)),
..Default::default()
}),
betrag_kostenposition: Some(rubo4e::v202607::Betrag {
wert: Some(dec(betrag)),
..Default::default()
}),
..Default::default()
}
}
#[test]
fn exact_product_ok() {
assert!(position("2.00", "3", "6.00").validate().is_ok());
}
#[test]
fn product_rounded_to_the_stated_scale_ok() {
let p = position("0.2843", "3333", "947.57");
assert!(
p.validate().is_ok(),
"rounding to the stated scale must be accepted: {:?}",
p.validate().unwrap_err()
);
}
#[test]
fn genuinely_wrong_total_fails() {
let err = position("0.2843", "3333", "900.00")
.validate()
.unwrap_err()
.to_string();
assert!(err.contains("betrag_kostenposition"), "unexpected: {err}");
}
#[test]
fn an_amount_at_the_maximum_decimal_scale_does_not_panic() {
let tiny = "0.0000000000000000000000000001"; assert_eq!(dec(tiny).scale(), 28, "the fixture must sit at the ceiling");
assert!(position("1", tiny, tiny).validate().is_ok());
assert!(position("2", tiny, tiny).validate().is_err());
}
#[test]
fn time_proportional_position_is_not_checked() {
let mut p = position("55.00", "1", "13.75"); p.zeitmenge = Some(Menge {
wert: Some(dec("3")),
..Default::default()
});
assert!(p.validate().is_ok());
}
}
#[cfg(all(feature = "validate", feature = "versioned"))]
mod recursion_tests {
use garde::Validate as _;
use rubo4e::v202607::*;
#[test]
fn a_nested_zeitraum_is_checked() {
assert!(Zeitraum::default().validate().is_err(), "standalone");
let r = Rechnung {
rechnungsperiode: Some(Zeitraum::default()),
..Default::default()
};
let report = r
.validate()
.expect_err("an empty Zeitraum must invalidate the Rechnung carrying it");
let text = report.to_string();
assert!(
text.contains("rechnungsperiode"),
"the path must name the field it came from: {text}"
);
}
#[test]
#[cfg(feature = "decimal")]
fn a_position_two_levels_down_is_checked() {
use rust_decimal::Decimal;
let wrong = Kostenposition {
einzelpreis: Some(Preis {
wert: Some(Decimal::from(2)),
..Default::default()
}),
menge: Some(Menge {
wert: Some(Decimal::from(3)),
..Default::default()
}),
betrag_kostenposition: Some(Betrag {
wert: Some(Decimal::from(999)), ..Default::default()
}),
..Default::default()
};
assert!(wrong.validate().is_err(), "standalone");
let kosten = Kosten {
kostenbloecke: Some(vec![Kostenblock {
kostenpositionen: Some(vec![wrong]),
..Default::default()
}]),
..Default::default()
};
let text = kosten
.validate()
.expect_err("a wrong line total must invalidate the Kosten carrying it")
.to_string();
assert!(
text.contains("kostenbloecke[0].kostenpositionen[0]"),
"the report must locate the offending position: {text}"
);
}
#[test]
fn a_nested_bos_rules_are_checked() {
use rubo4e::v202607::Geokoordinaten;
let malo = Marktlokation {
lokationsadresse: Some(Adresse::default()),
..Default::default()
};
assert!(malo.validate().is_ok(), "the outer value alone is valid");
let conflicting = Marktlokation {
lokationsadresse: Some(Adresse::default()),
geoadresse: Some(Geokoordinaten::default()),
..Default::default()
};
let outer = Marktlokation {
lokationsadresse: Some(Adresse::default()),
lokationszuordnungen: Some(vec![Box::new(Lokationszuordnung {
marktlokationen: Some(vec![Box::new(conflicting)]),
..Default::default()
})]),
..Default::default()
};
let text = outer
.validate()
.expect_err("the nested Marktlokation carries two Ortsangaben")
.to_string();
assert!(
text.contains("lokationszuordnungen[0].marktlokationen[0]"),
"the report must locate the nested value: {text}"
);
}
#[test]
#[cfg(feature = "json")]
fn a_nested_identifier_is_checked() {
use rubo4e::json::Bo4eJsonExt;
let ok = r#"{"lokationszuordnungen":[{"marktlokationen":[
{"marktlokationsId":"51238696781"}]}]}"#;
let m = Marktlokation::from_json_german(ok).expect("valid check digit");
assert!(m.validate().is_ok());
let bad = r#"{"lokationszuordnungen":[{"marktlokationen":[
{"marktlokationsId":"51238696782"}]}]}"#;
assert!(
Marktlokation::from_json_german(bad).is_err(),
"a nested identifier is validated on the way in"
);
}
#[test]
#[cfg(feature = "json")]
fn the_deepest_accepted_payload_does_not_overflow_the_stack() {
use rubo4e::json::{Bo4eJsonExt, JsonParseLimits};
let mut body = String::from(r#"{"lokationsadresse":{"ort":"Bremen"}}"#);
for _ in 0..25 {
body = format!(
r#"{{"lokationsadresse":{{"ort":"Bremen"}},"lokationszuordnungen":[{{"marktlokationen":[{body}]}}]}}"#
);
}
let m = Marktlokation::from_json_german_hardened(&body, JsonParseLimits::unlimited())
.expect("within the depth cap");
assert!(
m.validate().is_ok(),
"{:?}",
m.validate().err().map(|e| e.to_string())
);
}
#[test]
#[cfg(feature = "json")]
fn the_golden_marktlokation_still_validates() {
use rubo4e::json::Bo4eJsonExt;
let body = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden/marktlokation_typical.json"),
)
.expect("golden payload");
let malo = Marktlokation::from_json_german(&body).expect("valid BO4E payload");
assert!(
malo.validate().is_ok(),
"a real payload must survive recursive validation: {:?}",
malo.validate().unwrap_err().to_string()
);
}
}
#[cfg(all(feature = "validate", feature = "versioned"))]
mod report_errors_tests {
use garde::Validate as _;
use rubo4e::v202607::Marktlokation;
use rubo4e::validation::report_errors;
#[test]
fn report_errors_returns_structured_failures() {
use rubo4e::v202607::{Adresse, Geokoordinaten};
let malo = Marktlokation {
lokationsadresse: Some(Adresse::default()),
geoadresse: Some(Geokoordinaten::default()),
..Default::default()
};
let report = malo
.validate()
.expect_err("two Ortsangaben should be invalid");
let failures = report_errors(&report);
assert!(
!failures.is_empty(),
"report_errors must return at least one failure for invalid Marktlokation"
);
for f in &failures {
assert!(!f.message.is_empty(), "failure message must not be empty");
}
let combined: String = failures
.iter()
.map(|f| f.message.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(
combined.contains("lokationsadresse") && combined.contains("geoadresse"),
"the message should name which Ortsangaben conflict; got: {combined}"
);
}
#[test]
fn report_errors_empty_for_valid_type() {
use rubo4e::v202607::Adresse;
let malo = Marktlokation {
lokationsadresse: Some(Adresse::default()),
..Default::default()
};
let failures = match malo.validate() {
Ok(()) => vec![],
Err(r) => report_errors(&r),
};
assert!(
failures.is_empty(),
"expected no failures for Marktlokation with address; got: {failures:?}"
);
}
}
#[cfg(all(feature = "validate", feature = "versioned", feature = "time"))]
mod zeitraum_tests {
use garde::Validate as _;
use rubo4e::v202607::Zeitraum;
use time::macros::date;
fn zeitraum(start: Option<time::Date>, end: Option<time::Date>) -> Zeitraum {
Zeitraum {
startdatum: start,
enddatum: end,
..Default::default()
}
}
#[test]
fn valid_closed_range_ok() {
let z = zeitraum(Some(date!(2025 - 01 - 01)), Some(date!(2025 - 12 - 31)));
assert!(z.validate().is_ok(), "closed range start < end must pass");
}
#[test]
fn same_day_is_a_valid_one_day_period() {
let z = zeitraum(Some(date!(2025 - 06 - 15)), Some(date!(2025 - 06 - 15)));
assert!(
z.validate().is_ok(),
"start == end is a one-day period, not an empty one"
);
}
#[test]
fn inverted_range_fails() {
let z = zeitraum(Some(date!(2025 - 12 - 31)), Some(date!(2025 - 01 - 01)));
assert!(z.validate().is_err(), "end before start must be rejected");
}
#[test]
fn only_start_ok() {
let z = zeitraum(Some(date!(2025 - 01 - 01)), None);
assert!(z.validate().is_ok(), "open-ended period (no end) must pass");
}
#[test]
fn only_end_ok() {
let z = zeitraum(None, Some(date!(2025 - 12 - 31)));
assert!(z.validate().is_ok(), "period with only end must pass");
}
#[test]
fn all_absent_fails() {
let z = zeitraum(None, None);
assert!(
z.validate().is_err(),
"empty Zeitraum (no temporal attributes at all) must fail validation"
);
}
#[test]
fn no_dates_but_dauer_ok() {
let z = rubo4e::v202607::Zeitraum {
dauer: Some("15".to_owned()),
..Default::default()
};
assert!(
z.validate().is_ok(),
"Zeitraum with only dauer must pass date-ordering check"
);
}
#[test]
fn error_message_contains_dates() {
let z = zeitraum(Some(date!(2025 - 12 - 31)), Some(date!(2025 - 01 - 01)));
let err = z.validate().expect_err("inverted range must fail");
let msg = err.to_string();
assert!(
msg.contains("2025"),
"error message should include the year; got: {msg}"
);
}
}
#[cfg(all(feature = "versioned", feature = "time"))]
mod convenience_tests {
use rubo4e::v202607::{PreisblattNetznutzung, Rechnung, Zeitraum};
use time::macros::date;
fn closed_zeitraum() -> Zeitraum {
Zeitraum {
startdatum: Some(date!(2025 - 01 - 01)),
enddatum: Some(date!(2025 - 12 - 31)),
..Default::default()
}
}
fn open_zeitraum() -> Zeitraum {
Zeitraum {
startdatum: Some(date!(2025 - 01 - 01)),
enddatum: None,
..Default::default()
}
}
fn no_start_zeitraum() -> Zeitraum {
Zeitraum {
startdatum: None,
enddatum: Some(date!(2025 - 12 - 31)),
..Default::default()
}
}
#[test]
fn inclusive_range_both_present() {
let r = closed_zeitraum()
.as_inclusive_range()
.expect("both dates present");
assert_eq!(*r.start(), date!(2025 - 01 - 01));
assert_eq!(*r.end(), date!(2025 - 12 - 31));
assert!(r.contains(&date!(2025 - 12 - 31)), "the end date is inside");
}
#[test]
fn inclusive_range_needs_both_bounds() {
assert!(open_zeitraum().as_inclusive_range().is_none());
assert!(no_start_zeitraum().as_inclusive_range().is_none());
}
#[test]
fn bounds_reports_each_side_independently() {
assert_eq!(
closed_zeitraum().bounds(),
(Some(date!(2025 - 01 - 01)), Some(date!(2025 - 12 - 31)))
);
assert_eq!(
open_zeitraum().bounds(),
(Some(date!(2025 - 01 - 01)), None)
);
assert_eq!(
no_start_zeitraum().bounds(),
(None, Some(date!(2025 - 12 - 31)))
);
assert_eq!(Zeitraum::default().bounds(), (None, None));
}
#[test]
fn contains_includes_both_boundaries() {
let z = closed_zeitraum();
assert!(z.contains(date!(2025 - 01 - 01)), "startdatum is inside");
assert!(z.contains(date!(2025 - 07 - 01)));
assert!(z.contains(date!(2025 - 12 - 31)), "enddatum is inside");
assert!(!z.contains(date!(2024 - 12 - 31)));
assert!(!z.contains(date!(2026 - 01 - 01)));
}
#[test]
fn a_single_day_period_contains_its_one_day() {
let one_day = Zeitraum {
startdatum: Some(date!(2025 - 03 - 15)),
enddatum: Some(date!(2025 - 03 - 15)),
..Default::default()
};
assert!(one_day.contains(date!(2025 - 03 - 15)));
assert!(!one_day.contains(date!(2025 - 03 - 14)));
assert!(!one_day.contains(date!(2025 - 03 - 16)));
assert_eq!(one_day.whole_days(), Some(1));
#[cfg(feature = "validate")]
{
use garde::Validate as _;
assert!(one_day.validate().is_ok(), "a one-day period is valid BO4E");
}
}
#[test]
fn an_absent_boundary_is_unbounded_on_that_side() {
assert!(open_zeitraum().contains(date!(2099 - 12 - 31)));
assert!(!open_zeitraum().contains(date!(2024 - 12 - 31)));
assert!(no_start_zeitraum().contains(date!(1900 - 01 - 01)));
assert!(!no_start_zeitraum().contains(date!(2026 - 01 - 01)));
}
#[test]
fn whole_days_counts_both_boundaries() {
let january = Zeitraum {
startdatum: Some(date!(2026 - 01 - 01)),
enddatum: Some(date!(2026 - 01 - 31)),
..Default::default()
};
assert_eq!(january.whole_days(), Some(31));
assert_eq!(closed_zeitraum().whole_days(), Some(365));
let leap = Zeitraum {
startdatum: Some(date!(2024 - 01 - 01)),
enddatum: Some(date!(2024 - 12 - 31)),
..Default::default()
};
assert_eq!(leap.whole_days(), Some(366));
}
#[test]
fn whole_days_needs_both_bounds() {
assert_eq!(open_zeitraum().whole_days(), None);
assert_eq!(no_start_zeitraum().whole_days(), None);
}
#[test]
fn billing_period_with_full_period() {
let r = Rechnung {
rechnungsperiode: Some(closed_zeitraum()),
..Default::default()
};
let period = r.billing_period().expect("both dates present");
assert_eq!(*period.start(), date!(2025 - 01 - 01));
assert_eq!(*period.end(), date!(2025 - 12 - 31));
assert!(
period.contains(&date!(2025 - 12 - 31)),
"the last day of the period is billed"
);
}
#[test]
fn billing_period_no_periode_returns_none() {
let r = Rechnung {
rechnungsperiode: None,
..Default::default()
};
assert!(r.billing_period().is_none());
}
#[test]
fn billing_period_open_ended_returns_none() {
let r = Rechnung {
rechnungsperiode: Some(open_zeitraum()),
..Default::default()
};
assert!(r.billing_period().is_none());
}
#[test]
fn validity_reports_both_bounds() {
let p = PreisblattNetznutzung {
gueltigkeit: Some(closed_zeitraum()),
..Default::default()
};
assert_eq!(
p.validity(),
(Some(date!(2025 - 01 - 01)), Some(date!(2025 - 12 - 31)))
);
assert!(
p.is_valid_at(date!(2025 - 12 - 31)),
"a price sheet is still valid on its enddatum"
);
}
#[test]
fn validity_open_ended() {
let p = PreisblattNetznutzung {
gueltigkeit: Some(open_zeitraum()),
..Default::default()
};
assert_eq!(p.validity(), (Some(date!(2025 - 01 - 01)), None));
}
#[test]
fn validity_without_gueltigkeit_is_unstated_and_never_valid() {
let p = PreisblattNetznutzung {
gueltigkeit: None,
..Default::default()
};
assert_eq!(p.validity(), (None, None));
assert!(
!p.is_valid_at(date!(2025 - 06 - 01)),
"an unstated validity must not read as valid"
);
}
}
#[cfg(all(feature = "validate", feature = "versioned", feature = "json"))]
mod validated_deserialize_tests {
use rubo4e::current::{Adresse, Marktlokation};
use rubo4e::validation::Validated;
#[test]
fn a_valid_payload_decodes() {
let body = r#"{"marktlokationsId":"51238696781","lokationsadresse":{"ort":"Bremen"}}"#;
let malo: Validated<Marktlokation> = serde_json::from_str(body).expect("valid");
assert_eq!(
malo.lokationsadresse
.as_ref()
.and_then(|a| a.ort.as_deref()),
Some("Bremen")
);
}
#[test]
fn an_invalid_payload_does_not_decode() {
let body = r#"{"lokationsadresse":{"ort":"Bremen"},"geoadresse":{"breitengrad":"53.1"}}"#;
assert!(
serde_json::from_str::<Marktlokation>(body).is_ok(),
"plain T still decodes — the serde path stays lenient"
);
let err = serde_json::from_str::<Validated<Marktlokation>>(body)
.expect_err("the wrapper must refuse it");
assert!(
err.to_string().contains("Ortsangabe"),
"the garde report must reach the deserializer error: {err}"
);
}
#[test]
fn an_id_only_reference_decodes() {
let body = r#"{"marktlokationsId":"51238696781"}"#;
let malo: Validated<Marktlokation> =
serde_json::from_str(body).expect("a referenced location carries only its ID");
assert!(malo.lokationsadresse.is_none());
}
#[test]
fn a_nested_violation_is_refused() {
let body = r#"{
"marktlokationsId":"51238696781",
"lokationsadresse":{"ort":"Bremen"},
"lokationszuordnungen":[{"marktlokationen":[
{"lokationsadresse":{"ort":"Bremen"},"geoadresse":{"breitengrad":"53.1"}}]}]
}"#;
assert!(serde_json::from_str::<Validated<Marktlokation>>(body).is_err());
}
#[test]
fn serialization_is_transparent() {
let malo = Marktlokation {
lokationsadresse: Some(Adresse {
ort: Some("Bremen".into()),
..Default::default()
}),
..Default::default()
};
let wrapped = Validated::new(malo.clone()).expect("valid");
assert_eq!(
serde_json::to_string(&wrapped).unwrap(),
serde_json::to_string(&malo).unwrap()
);
}
}
#[cfg(all(feature = "validate", feature = "versioned"))]
mod current_alias_tests {
use rubo4e::current::Zeitraum;
use rubo4e::validation::{current, v202607};
#[test]
fn current_is_the_versioned_module() {
type Check<T> = fn(&T, &()) -> Result<(), garde::Error>;
let via_current: Check<Zeitraum> = current::validate_zeitraum;
let via_version: Check<Zeitraum> = v202607::validate_zeitraum;
assert!(std::ptr::fn_addr_eq(via_current, via_version));
let quality_current: fn(&rubo4e::current::Rechnung) -> Result<(), garde::Error> =
current::quality::rechnung_totals_are_complete;
let quality_version: fn(&rubo4e::current::Rechnung) -> Result<(), garde::Error> =
v202607::quality::rechnung_totals_are_complete;
assert!(std::ptr::fn_addr_eq(quality_current, quality_version));
}
#[test]
fn every_validator_is_reachable_through_current() {
use rubo4e::current::{
Bilanzierung, Kostenposition, Marktlokation, Messlokation, Rechnung, Vertrag, Zeitraum,
};
fn is_validator<T>(_: fn(&T, &()) -> Result<(), garde::Error>) {}
is_validator::<Marktlokation>(current::validate_marktlokation);
is_validator::<Messlokation>(current::validate_messlokation);
is_validator::<Vertrag>(current::validate_vertrag_dates);
is_validator::<Bilanzierung>(current::validate_bilanzierung_dates);
is_validator::<Rechnung>(current::validate_rechnung_arithmetic);
is_validator::<Zeitraum>(current::validate_zeitraum);
is_validator::<Kostenposition>(current::validate_kostenposition_arithmetic);
}
}
#[cfg(all(feature = "versioned", feature = "validate"))]
mod required_field_constructor_tests {
use garde::Validate as _;
use rubo4e::current::{BoTyp, Lastgang, Menge, Mengeneinheit};
use rubo4e::Bo4eTyped as _;
#[test]
fn lastgang_new_fills_in_everything_else() {
let interval = Menge {
einheit: Some(Mengeneinheit::Kwh),
..Default::default()
};
let lg = Lastgang::new(interval.clone());
assert_eq!(lg.zeit_intervall_laenge, interval);
assert_eq!(lg.typ, Some(BoTyp::Lastgang), "`_typ` is stamped");
assert!(lg.marktlokation.is_none(), "everything else defaults");
assert_eq!(Lastgang::TYP, BoTyp::Lastgang);
assert!(lg.validate().is_ok());
}
#[test]
#[cfg(feature = "decimal")]
fn tarif_new_takes_every_required_field() {
use rubo4e::current::{
Energiemix, Kundentyp, Preisgarantie, Registeranzahl, Sparte, Tarif,
Tarifberechnungsparameter, Tarifmerkmal, Tariftyp, Vertragskonditionen,
};
let t = Tarif::new(
Tarifberechnungsparameter::default(),
vec![Energiemix::default()],
vec![Kundentyp::Gewerbe],
Preisgarantie::default(),
Registeranzahl::Eintarif,
Sparte::Strom,
vec![Tarifmerkmal::Vorkasse],
Tariftyp::Grundversorgung,
Vertragskonditionen::default(),
"https://example.invalid/tarif".to_owned(),
);
assert_eq!(t.sparte, Sparte::Strom);
assert_eq!(t.typ, Some(BoTyp::Tarif), "`_typ` is stamped");
assert_eq!(
t.version.as_deref(),
Some(Tarif::SCHEMA_VERSION),
"`_version` is stamped, and agrees with the constant"
);
assert!(t.bezeichnung.is_none(), "everything else defaults");
}
}