#![cfg(feature = "transport")]
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use proptest::prelude::*;
use proptest::test_runner::{Config, FileFailurePersistence};
fn config() -> Config {
Config {
failure_persistence: Some(Box::new(FileFailurePersistence::Direct(
"tests/properties.proptest-regressions",
))),
..Config::default()
}
}
use ocpi_kit::transport::{PageQuery, Patch, merge};
use ocpi_kit::types::{CiString, DateTime, Number, OcpiString, Validate};
fn any_text() -> impl Strategy<Value = String> {
prop_oneof![
"[a-zA-Z0-9 _.:/-]{0,60}",
"[a-zA-Zà-öø-ÿ]{0,40}",
Just(String::new()),
Just("#NA".to_owned()),
Just("ß".repeat(30)),
Just("é".repeat(45)),
]
}
fn any_ascii_text() -> impl Strategy<Value = String> {
prop_oneof![
"[a-zA-Z0-9 _.:/*-]{0,60}",
Just(String::new()),
Just("#NA".to_owned()),
Just("NL*TNM*001".to_owned()),
]
}
fn any_number() -> impl Strategy<Value = Number> {
(-99_999_999i64..99_999_999i64, 0u32..=4).prop_map(|(mantissa, scale)| {
let decimal = rust_decimal::Decimal::new(mantissa, scale);
Number::new(decimal)
})
}
fn any_datetime() -> impl Strategy<Value = DateTime> {
(946_684_800i64..4_102_444_800i64).prop_map(|s| DateTime::from_unix_timestamp(s).expect("in range"))
}
fn any_json() -> impl Strategy<Value = serde_json::Value> {
let leaf = prop_oneof![
Just(serde_json::Value::Null),
any::<bool>().prop_map(serde_json::Value::from),
(-1000i64..1000).prop_map(serde_json::Value::from),
"[a-z]{0,8}".prop_map(serde_json::Value::from),
];
leaf.prop_recursive(3, 24, 4, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..4).prop_map(serde_json::Value::Array),
prop::collection::hash_map("[a-z]{1,6}", inner, 0..4)
.prop_map(|m| serde_json::Value::Object(m.into_iter().collect())),
]
})
}
fn hash_of<T: Hash>(value: &T) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
proptest! {
#![proptest_config(config())]
#[test]
fn cistring_hash_agrees_with_eq(text in any_ascii_text()) {
let lower: CiString<255> = CiString::new_lenient(text.to_ascii_lowercase());
let upper: CiString<255> = CiString::new_lenient(text.to_ascii_uppercase());
prop_assert_eq!(&lower, &upper, "case must not affect equality");
prop_assert_eq!(hash_of(&lower), hash_of(&upper), "Eq and Hash must agree");
prop_assert_eq!(lower.cmp(&upper), core::cmp::Ordering::Equal, "Ord must agree too");
}
#[test]
fn cistring_equality_is_consistent(text in any_ascii_text()) {
let value: CiString<255> = CiString::new_lenient(text.clone());
prop_assert_eq!(&value, &value);
prop_assert!(value.eq_ignore_case(&text));
prop_assert!(value.eq_ignore_case(&text.to_ascii_uppercase()));
prop_assert_eq!(value.as_str(), text.as_str(), "the original case survives");
}
#[test]
fn a_cistring_reports_characters_outside_printable_ascii(text in "[à-öø-ÿ]{1,10}") {
let value: CiString<255> = CiString::new_lenient(text.clone());
prop_assert_eq!(value.as_str(), text.as_str(), "nothing is dropped on ingest");
prop_assert!(value.validate().is_err(), "but it is not conformant");
prop_assert!(CiString::<255>::new(text).is_err(), "and strict construction refuses it");
}
#[test]
fn a_length_limit_is_reported_not_enforced_on_ingest(text in any_text()) {
let value: OcpiString<16> = OcpiString::new_lenient(text.clone());
prop_assert_eq!(value.as_str(), text.as_str(), "nothing is truncated on ingest");
let within = text.chars().count() <= 16;
prop_assert_eq!(value.validate().is_ok(), within,
"a {}-character value in a string(16)", text.chars().count());
prop_assert_eq!(OcpiString::<16>::new(text.clone()).is_ok(), within,
"strict construction must agree with validation");
}
#[test]
fn a_length_limit_counts_characters_not_bytes(n in 0usize..=45) {
let text = "é".repeat(n);
prop_assert!(text.len() >= n, "these are multi-byte characters");
let value: OcpiString<45> = OcpiString::new_lenient(text);
prop_assert!(value.validate().is_ok(), "{n} characters fit in a string(45)");
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn a_realistic_number_round_trips_through_json(n in any_number()) {
prop_assert!(n.json_round_trips(), "{n} should survive a JSON round-trip");
prop_assert!(n.validate().is_ok());
let json = serde_json::to_string(&n).expect("serialises");
let back: Number = serde_json::from_str(&json).expect("deserialises");
prop_assert_eq!(back, n, "via {}", json);
}
#[test]
fn an_integral_number_stays_an_integer_on_the_wire(i in -1_000_000i64..1_000_000) {
let json = serde_json::to_string(&Number::from(i)).expect("serialises");
prop_assert!(!json.contains('.'), "{i} was written as {json}");
prop_assert_eq!(json.parse::<i64>().expect("an integer"), i);
}
#[test]
fn a_quoted_number_parses_to_the_same_value(n in any_number()) {
let quoted = format!("\"{n}\"");
let parsed: Number = serde_json::from_str("ed).expect("quoted numbers are tolerated");
prop_assert_eq!(parsed, n);
}
#[test]
fn decimal_addition_is_associative(values in prop::collection::vec(any_number(), 0..12)) {
let forward: Number = values.iter().copied().sum();
let backward: Number = values.iter().rev().copied().sum();
prop_assert_eq!(forward, backward);
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn a_timestamp_round_trips_and_its_text_is_stable(t in any_datetime()) {
let text = t.to_string();
let parsed: DateTime = text.parse().expect("our own output parses");
prop_assert_eq!(parsed, t);
prop_assert_eq!(parsed.to_string(), text, "formatting is idempotent");
}
#[test]
fn a_timestamp_preserves_its_instant(secs in 946_684_800i64..4_102_444_800i64) {
let t = DateTime::from_unix_timestamp(secs).expect("in range");
prop_assert_eq!(t.unix_timestamp(), secs);
let parsed: DateTime = t.to_string().parse().expect("parses");
prop_assert_eq!(parsed.unix_timestamp(), secs);
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn a_merge_patch_is_idempotent(target in any_json(), patch in any_json()) {
let mut once = target.clone();
merge(&mut once, &patch);
let mut twice = once.clone();
merge(&mut twice, &patch);
prop_assert_eq!(once, twice);
}
#[test]
fn an_empty_object_patch_is_the_identity_on_an_object(target in any_json()) {
prop_assume!(target.is_object());
let mut result = target.clone();
merge(&mut result, &serde_json::json!({}));
prop_assert_eq!(result, target);
}
#[test]
fn an_object_patch_replaces_a_non_object_target(target in any_json()) {
prop_assume!(!target.is_object());
let mut result = target;
merge(&mut result, &serde_json::json!({ "a": 1 }));
prop_assert_eq!(result, serde_json::json!({ "a": 1 }));
}
#[test]
fn a_non_object_patch_replaces_the_target(target in any_json(), replacement in any_json()) {
prop_assume!(!replacement.is_object());
let mut result = target;
merge(&mut result, &replacement);
prop_assert_eq!(result, replacement);
}
#[test]
fn null_removes_exactly_one_key(
keep in "[a-z]{1,6}",
drop_key in "[a-z]{1,6}",
value in any_json(),
) {
prop_assume!(keep != drop_key);
let mut target = serde_json::json!({ keep.clone(): value.clone(), drop_key.clone(): 1 });
merge(&mut target, &serde_json::json!({ drop_key.clone(): serde_json::Value::Null }));
prop_assert!(target.get(&drop_key).is_none(), "the null key is gone");
prop_assert_eq!(target.get(&keep), Some(&value), "the other key is untouched");
}
#[test]
fn a_patch_without_last_updated_never_applies(fields in prop::collection::hash_map("[a-z]{1,6}", any_json(), 0..5)) {
let mut object = serde_json::Map::new();
for (k, v) in fields {
if k != "last_updated" {
object.insert(k, v);
}
}
let patch: Patch<serde_json::Value> = Patch::from_value(serde_json::Value::Object(object));
prop_assert!(patch.last_updated().is_none());
prop_assert!(patch.apply(&serde_json::json!({})).is_err(), "a patch without last_updated is a 2001");
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn a_page_query_survives_the_url(
offset in prop::option::of(0u64..1_000_000),
limit in prop::option::of(1u64..1000),
) {
let mut query = PageQuery::new();
if let Some(o) = offset {
query = query.with_offset(o);
}
if let Some(l) = limit {
query = query.with_limit(l);
}
let text = query.to_query_string();
for (name, value) in [("offset", offset), ("limit", limit)] {
match value {
Some(v) => prop_assert!(text.contains(&format!("{name}={v}")), "{text}"),
None => prop_assert!(!text.contains(&format!("{name}=")), "{text}"),
}
}
}
#[test]
fn clamping_a_limit_only_ever_lowers_it(asked in 1u64..10_000, cap in 1u64..10_000) {
let clamped = PageQuery::new().with_limit(asked).clamped_to(cap);
let effective = clamped.limit.expect("a limit was set");
prop_assert!(effective <= cap, "{effective} exceeds the cap {cap}");
prop_assert!(effective <= asked, "{effective} is more than the {asked} asked for");
}
}
#[cfg(all(feature = "convert", feature = "v2_2_1"))]
mod bridging {
use super::*;
use ocpi_kit::convert::{Downgrade, Upgrade};
use ocpi_kit::types::Extensions;
use ocpi_kit::v2_2_1;
proptest! {
#![proptest_config(config())]
#[test]
fn a_price_survives_a_round_trip_through_2_3_0(
excl in any_number(),
incl in prop::option::of(any_number()),
) {
let original = v2_2_1::types::Price {
excl_vat: excl,
incl_vat: incl,
extensions: Extensions::default(),
};
let up = Upgrade::<ocpi_kit::v2_3_0::types::Price>::upgrade(original.clone());
let down = up.value.downgrade();
prop_assert_eq!(down.value.excl_vat, original.excl_vat, "the net amount is exact");
prop_assert_eq!(down.value.incl_vat, original.incl_vat, "the gross amount is exact");
}
#[test]
fn every_reported_loss_names_a_field_and_a_reason(
excl in any_number(),
incl in prop::option::of(any_number()),
) {
let price = v2_2_1::types::Price {
excl_vat: excl,
incl_vat: incl,
extensions: Extensions::default(),
};
let up = Upgrade::<ocpi_kit::v2_3_0::types::Price>::upgrade(price);
for loss in &up.value.clone().downgrade().lossy {
prop_assert!(loss.pointer.starts_with('/') || loss.pointer.is_empty());
prop_assert!(!loss.reason.trim().is_empty(), "a loss must say why");
}
}
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn every_violation_pointer_is_a_valid_json_pointer(text in any_text()) {
let value: OcpiString<8> = OcpiString::new_lenient(text);
if let Err(violations) = value.validate() {
for v in &violations {
prop_assert!(
v.pointer.is_empty() || v.pointer.starts_with('/'),
"{:?} is not an RFC 6901 pointer", v.pointer
);
prop_assert!(!v.message.trim().is_empty(), "a violation must explain itself");
}
}
}
}
#[cfg(all(feature = "tariffs", feature = "v2_3_0"))]
mod pricing {
use super::{any_number, config};
use ocpi_kit::tariffs::{PricedPeriod, PricedSession, PricingEngine, TimeZone};
use ocpi_kit::types::{DateTime, Extensions, Number};
use ocpi_kit::v2_3_0::tariffs::{
PriceComponent, PriceLimit, Tariff, TariffDimensionType, TariffElement, TaxIncluded,
};
use proptest::prelude::*;
fn any_amount() -> impl Strategy<Value = Number> {
(0i64..100_000i64).prop_map(|cents| Number::new(rust_decimal::Decimal::new(cents, 2)))
}
fn any_vat() -> impl Strategy<Value = Option<Number>> {
proptest::option::of(
(-500i64..3000i64).prop_map(|hundredths| Number::new(rust_decimal::Decimal::new(hundredths, 2))),
)
}
fn any_limit() -> impl Strategy<Value = Option<PriceLimit>> {
proptest::option::of((any_amount(), proptest::option::of(any_amount())).prop_map(
|(before_taxes, after_taxes)| PriceLimit {
before_taxes,
after_taxes,
extensions: Extensions::new(),
},
))
}
fn any_tariff() -> impl Strategy<Value = Tariff> {
(proptest::collection::vec((any_amount(), any_vat(), 0u32..3600u32), 1..4), any_limit(), any_limit())
.prop_map(|(components, min_price, max_price)| {
let dimensions = [
TariffDimensionType::Energy,
TariffDimensionType::Time,
TariffDimensionType::ParkingTime,
TariffDimensionType::Flat,
];
let price_components: Vec<_> = components
.into_iter()
.enumerate()
.map(|(i, (price, vat, step_size))| PriceComponent {
component_type: dimensions[i % dimensions.len()],
price,
vat,
step_size,
extensions: Extensions::new(),
})
.collect();
let mut tariff = Tariff::builder()
.country_code("DE")
.party_id("ALL")
.id("prop")
.currency("EUR")
.elements(vec![TariffElement::builder().price_components(price_components).build()])
.tax_included(TaxIncluded::No)
.last_updated("2024-01-15T10:00:00Z".parse::<DateTime>().expect("valid"))
.build();
tariff.min_price = min_price;
tariff.max_price = max_price;
tariff
})
}
fn any_session() -> impl Strategy<Value = PricedSession> {
proptest::collection::vec((any_number(), any_number(), any_number()), 1..4).prop_map(|periods| {
let start: DateTime = "2024-01-15T10:00:00Z".parse().expect("valid");
let mut session = PricedSession::new(start, TimeZone::utc());
for (i, (energy, charging, parking)) in periods.into_iter().enumerate() {
let at = DateTime::from_unix_timestamp(
start.unix_timestamp() + i64::try_from(i).expect("small") * 600,
)
.expect("in range");
session = session.with_period(PricedPeriod {
energy_kwh: energy.get().abs().into(),
charging_hours: charging.get().abs().into(),
parking_hours: parking.get().abs().into(),
..PricedPeriod::new(at)
});
}
session
})
}
proptest! {
#![proptest_config(config())]
#[test]
fn tax_lines_always_account_for_the_difference_between_the_totals(
tariff in any_tariff(),
session in any_session(),
) {
let breakdown = PricingEngine::new().price(&session, &[tariff]).expect("prices");
let summed: Number = breakdown.taxes.iter().map(|t| t.amount).sum();
prop_assert_eq!(
summed,
breakdown.total_incl_vat - breakdown.total_excl_vat,
"tax lines {:?} against totals {} / {}",
breakdown.taxes,
breakdown.total_excl_vat,
breakdown.total_incl_vat
);
}
#[test]
fn the_totals_are_ordered_and_non_negative(
tariff in any_tariff(),
session in any_session(),
) {
let breakdown = PricingEngine::new().price(&session, &[tariff]).expect("prices");
prop_assert!(!breakdown.total_excl_vat.is_negative(), "{}", breakdown.total_excl_vat);
prop_assert!(
breakdown.total_incl_vat >= breakdown.total_excl_vat,
"{} incl < {} excl",
breakdown.total_incl_vat,
breakdown.total_excl_vat
);
}
#[test]
fn a_breakdown_round_trips_through_json(
tariff in any_tariff(),
session in any_session(),
) {
let breakdown = PricingEngine::new().price(&session, &[tariff]).expect("prices");
let json = serde_json::to_string(&breakdown).expect("serialises");
let back: ocpi_kit::tariffs::CostBreakdown = serde_json::from_str(&json).expect("parses");
prop_assert_eq!(back, breakdown);
}
}
}
fn hostile_text() -> impl Strategy<Value = String> {
prop_oneof![
2 => ".*",
1 => proptest::string::string_regex("[<>;=\"', \t\r\n:/?&+-]{0,40}").expect("a valid regex"),
1 => proptest::string::string_regex("(Token |Bearer |rel=|<|>|;|=){1,10}.{0,30}")
.expect("a valid regex"),
1 => proptest::string::string_regex("[0-9T:.Z+-]{0,40}").expect("a valid regex"),
]
}
proptest! {
#![proptest_config(config())]
#[test]
fn parsing_an_authorization_header_never_panics(value in hostile_text()) {
use ocpi_kit::transport::CredentialsToken;
for lenient in [false, true] {
let _ = CredentialsToken::parse_header(&value, lenient);
}
}
#[test]
fn parsing_a_link_header_never_panics(value in hostile_text()) {
let _ = ocpi_kit::transport::headers::parse_link_next(&value);
}
#[test]
fn parsing_a_scalar_never_panics(value in hostile_text()) {
let _ = value.parse::<DateTime>();
let _ = value.parse::<Number>();
let _ = value.parse::<ocpi_kit::types::PartyRef>();
let _ = ocpi_kit::types::Url::new(&value);
let _ = ocpi_kit::types::UrlPolicy::default().check(&ocpi_kit::types::Url::new_lenient(value));
}
#[test]
fn reading_pagination_headers_never_panics(
link in hostile_text(),
total in hostile_text(),
limit in hostile_text(),
) {
use ocpi_kit::transport::PageMeta;
let mut headers = http::HeaderMap::new();
for (name, value) in [("link", link), ("x-total-count", total), ("x-limit", limit)] {
if let Ok(value) = http::HeaderValue::from_str(&value) {
headers.insert(http::HeaderName::from_static(name), value);
}
}
let _ = PageMeta::from_headers(&headers);
}
#[test]
fn decoding_an_envelope_never_panics(body in prop::collection::vec(any::<u8>(), 0..512)) {
use ocpi_kit::transport::OcpiResponse;
let _ = serde_json::from_slice::<OcpiResponse<serde_json::Value>>(&body);
}
#[test]
fn merging_arbitrary_values_never_panics(
target in any_hostile_json(),
patch in any_hostile_json(),
) {
let mut target = target;
merge(&mut target, &patch);
}
}
fn any_hostile_json() -> impl Strategy<Value = serde_json::Value> {
let leaf = prop_oneof![
Just(serde_json::Value::Null),
any::<bool>().prop_map(serde_json::Value::from),
any::<i32>().prop_map(serde_json::Value::from),
".*".prop_map(serde_json::Value::from),
];
leaf.prop_recursive(3, 24, 4, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..4).prop_map(serde_json::Value::from),
prop::collection::hash_map(".{0,6}", inner, 0..4)
.prop_map(|m| serde_json::Value::Object(m.into_iter().collect())),
]
})
}
#[cfg(all(feature = "convert", feature = "v2_2_1"))]
mod bridge_robustness {
use super::{any_hostile_json, config};
use ocpi_kit::VersionNumber;
use ocpi_kit::convert::wire::ObjectKind;
use proptest::prelude::*;
proptest! {
#![proptest_config(config())]
#[test]
fn bridging_an_arbitrary_document_never_panics(value in any_hostile_json()) {
for kind in [
ObjectKind::Location,
ObjectKind::Cdr,
ObjectKind::Tariff,
ObjectKind::Credentials,
] {
let _ = kind.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, value.clone());
let _ = kind.bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1, value.clone());
}
}
#[test]
fn classifying_an_arbitrary_path_never_panics(path in ".*") {
use ocpi_kit::convert::wire::Payload;
use ocpi_kit::{InterfaceRole, ModuleId};
for module in [ModuleId::Locations, ModuleId::Tokens, ModuleId::Commands, ModuleId::Cdrs] {
for interface in [InterfaceRole::Sender, InterfaceRole::Receiver] {
for payload in [Payload::Request, Payload::Response] {
let _ = ObjectKind::for_endpoint(&module, interface, &path, payload);
}
}
}
}
}
}