ocpi-tariffs 0.52.0

OCPI tariff calculations
Documentation
//! Tests for lowering `schema::v221` CDR IR objects into domain types via `FromSchema`.
//!
//! A `schema::v221` IR object is only produced by the builder, so each test drives a real
//! `v2.2.1` CDR through `build_cdr` and lowers the single charging period, or that period's
//! single dimension, out of it.

#![allow(
    clippy::indexing_slicing,
    clippy::unwrap_in_result,
    reason = "unwraps and indexing are allowed anywhere in tests"
)]

use std::assert_matches;

use chrono::TimeDelta;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use super::{Cdr, ChargingPeriod, Dimension, DimensionType};
use crate::{
    json,
    price::Warning,
    schema::{v221, Integrity, Warning as SchemaWarning},
    test::datetime_from_str,
    warning, FromSchema as _,
};

/// A minimal, valid `v2.2.1` CDR with a single charging period and a single dimension.
const VALID: &str = r#"{
    "country_code": "NL",
    "party_id": "ENE",
    "start_date_time": "2022-01-13T16:00:00Z",
    "end_date_time": "2022-01-13T19:12:00Z",
    "currency": "EUR",
    "cdr_location": {"country": "NLD"},
    "charging_periods": [
        {
            "start_date_time": "2022-01-13T16:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 2.5}]
        }
    ],
    "total_cost": {"excl_vat": 11.25, "incl_vat": 12.75},
    "total_time": 3.2,
    "total_energy": 0,
    "last_updated": "2022-01-13T00:00:00Z"
}"#;

/// Build a `v2.2.1` CDR, dropping the schema-level warnings.
fn build_cdr<'buf>(doc: &json::Document<'buf>) -> v221::Cdr<'buf> {
    v221::build_cdr(doc).ignore_warnings()
}

/// Borrow the single charging-period IR object out of a built CDR.
fn period<'a, 'buf>(cdr: &'a v221::Cdr<'buf>) -> &'a v221::ChargingPeriod<'buf> {
    let Integrity::Ok(periods) = &cdr.charging_periods else {
        panic!(
            "charging_periods should be built: {:?}",
            cdr.charging_periods
        );
    };
    let Integrity::Ok(period) = &periods[0] else {
        panic!("the charging period should be built");
    };
    period
}

/// Borrow the single dimension IR object out of a built CDR.
fn dimension<'a, 'buf>(cdr: &'a v221::Cdr<'buf>) -> &'a v221::Dimension<'buf> {
    let Integrity::Ok(dimensions) = &period(cdr).dimensions else {
        panic!("dimensions should be built");
    };
    let Integrity::Ok(dimension) = &dimensions[0] else {
        panic!("the dimension should be built");
    };
    dimension
}

fn all_warnings(warnings: &warning::Set<Warning>) -> Vec<&Warning> {
    warnings.path_map().into_values().flatten().collect()
}

#[test]
fn dimension_lowers_from_schema() {
    let doc = json::parse(VALID.into()).unwrap();
    let cdr = build_cdr(&doc);

    let (dim, warnings) = Option::<Dimension>::from_schema(dimension(&cdr))
        .unwrap()
        .into_parts();

    let dim = dim.expect("a valid dimension should build");
    assert_eq!(dim.dimension_type, DimensionType::Time);
    assert_eq!(dim.volume, dec!(2.5));
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn unknown_dimension_type_drops_the_dimension() {
    let src = VALID.replace(r#""type": "TIME""#, r#""type": "FOO""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let dim = Option::<Dimension>::from_schema(dimension(&cdr))
        .unwrap()
        .unwrap();

    // The volume has no meaning without a known type, so the dimension is dropped rather
    // than failing the period.
    assert!(dim.is_none());
}

#[test]
fn missing_volume_is_rejected() {
    let src = VALID.replace(r#", "volume": 2.5"#, "");
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let err = Option::<Dimension>::from_schema(dimension(&cdr)).unwrap_err();
    let error = err.unwrap();

    assert_matches!(error.into_warning(), Warning::Rejected);
}

#[test]
fn charging_period_lowers_from_schema() {
    let doc = json::parse(VALID.into()).unwrap();
    let cdr = build_cdr(&doc);

    let (charging_period, warnings) = ChargingPeriod::from_schema(period(&cdr))
        .unwrap()
        .into_parts();

    assert_eq!(
        charging_period.start_date_time,
        datetime_from_str("2022-01-13T16:00:00Z")
    );
    assert_eq!(charging_period.dimensions.len(), 1);
    assert_eq!(
        charging_period.dimensions[0].dimension_type,
        DimensionType::Time
    );
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn unknown_dimension_type_drops_the_dimension_from_the_period() {
    let src = VALID.replace(r#""type": "TIME""#, r#""type": "FOO""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let charging_period = ChargingPeriod::from_schema(period(&cdr)).unwrap().unwrap();

    assert!(charging_period.dimensions.is_empty());
}

#[test]
fn unbuildable_dimension_entry_is_rejected() {
    // Unlike an unusable dimension, an entry the schema could not build at all must not be
    // dropped: the period would then be priced without one of its volumes.
    let src = VALID.replace(r#"{"type": "TIME", "volume": 2.5}"#, "5");
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let err = ChargingPeriod::from_schema(period(&cdr)).unwrap_err();
    let error = err.unwrap();

    assert_matches!(error.into_warning(), Warning::Rejected);
}

#[test]
fn missing_dimensions_is_rejected() {
    let src = VALID.replace(
        r#""2022-01-13T16:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 2.5}]"#,
        r#""2022-01-13T16:00:00Z""#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let err = ChargingPeriod::from_schema(period(&cdr)).unwrap_err();
    let error = err.unwrap();

    assert_matches!(error.into_warning(), Warning::Rejected);
}

#[test]
fn missing_start_date_time_is_rejected() {
    let src = VALID.replace(
        r#"{
            "start_date_time": "2022-01-13T16:00:00Z",
            "dimensions""#,
        r#"{
            "dimensions""#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let err = ChargingPeriod::from_schema(period(&cdr)).unwrap_err();
    let error = err.unwrap();

    assert_matches!(error.into_warning(), Warning::Rejected);
}

#[test]
fn unparsable_start_date_time_is_rejected() {
    let src = VALID.replace(
        r#""start_date_time": "2022-01-13T16:00:00Z",
            "dimensions""#,
        r#""start_date_time": "not a date",
            "dimensions""#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let cdr = build_cdr(&doc);

    let err = ChargingPeriod::from_schema(period(&cdr)).unwrap_err();
    let error = err.unwrap();

    assert_matches!(error.into_warning(), Warning::DateTime(_));
}

#[test]
fn empty_dimensions_lowers_to_no_dimensions() {
    // The empty array is reported by the schema walk as a located `Cardinality` warning, so
    // this layer does not reject it. A period that measures nothing prices nothing, which is
    // for the CDR lowering to refuse once it is wired up.
    let src = VALID.replace(r#"[{"type": "TIME", "volume": 2.5}]"#, "[]");
    let doc = json::parse(src.as_str().into()).unwrap();
    let (cdr, schema_warnings) = v221::build_cdr(&doc).into_parts();

    let cardinality_warnings = schema_warnings
        .path_map()
        .into_values()
        .flatten()
        .filter(|warning| matches!(warning, SchemaWarning::Cardinality { .. }))
        .count();
    assert_eq!(cardinality_warnings, 1);

    let charging_period = ChargingPeriod::from_schema(period(&cdr)).unwrap().unwrap();

    assert!(charging_period.dimensions.is_empty());
}

#[test]
fn cdr_lowers_from_schema() {
    let doc = json::parse(VALID.into()).unwrap();
    let built = build_cdr(&doc);

    let (cdr, warnings) = Cdr::from_schema(&built).unwrap().into_parts();

    assert_eq!(
        cdr.start_date_time,
        datetime_from_str("2022-01-13T16:00:00Z")
    );
    assert_eq!(cdr.end_date_time, datetime_from_str("2022-01-13T19:12:00Z"));
    assert_eq!(cdr.charging_periods.len(), 1);
    assert_eq!(Decimal::from(cdr.totals.cost.excl_vat), dec!(11.25));
    assert_eq!(
        cdr.totals.cost.incl_vat.map(Decimal::from),
        Some(dec!(12.75))
    );
    assert_eq!(Decimal::from(cdr.totals.energy), dec!(0));
    assert_eq!(cdr.totals.duration_charging, TimeDelta::minutes(192));
    assert!(cdr.totals.duration_idle.is_none());
    assert!(all_warnings(&warnings).is_empty());
}

#[test]
fn charging_periods_are_sorted() {
    // Pricing presumes the periods are in order, but a CDR may list them any way it likes.
    let src = VALID.replace(
        r#"        {
            "start_date_time": "2022-01-13T16:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 2.5}]
        }"#,
        r#"        {
            "start_date_time": "2022-01-13T18:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 1.0}]
        },
        {
            "start_date_time": "2022-01-13T16:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 2.5}]
        }"#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let built = build_cdr(&doc);

    let cdr = Cdr::from_schema(&built).unwrap().unwrap();

    let starts: Vec<_> = cdr
        .charging_periods
        .iter()
        .map(|period| period.start_date_time)
        .collect();
    assert_eq!(
        starts,
        vec![
            datetime_from_str("2022-01-13T16:00:00Z"),
            datetime_from_str("2022-01-13T18:00:00Z"),
        ]
    );
}

#[test]
fn each_missing_required_field_is_rejected() {
    for field in [
        r#""start_date_time": "2022-01-13T16:00:00Z",
    "#,
        r#""end_date_time": "2022-01-13T19:12:00Z",
    "#,
        r#""total_cost": {"excl_vat": 11.25, "incl_vat": 12.75},
    "#,
        r#""total_time": 3.2,
    "#,
        r#""total_energy": 0,
    "#,
    ] {
        let src = VALID.replace(field, "");
        let doc = json::parse(src.as_str().into()).unwrap();
        let built = build_cdr(&doc);

        let err = Cdr::from_schema(&built)
            .err()
            .unwrap_or_else(|| panic!("a CDR without `{field}` should be rejected"));
        let error = err.unwrap();

        assert_matches!(error.into_warning(), Warning::Rejected);
    }
}

#[test]
fn missing_fields_that_should_not_halt_pricing() {
    // `country_code`, `currency` and `party_id` are only carried for their warnings, so a CDR
    // that is missing them still prices.
    let src = VALID
        .replace(r#""country_code": "NL","#, "")
        .replace(r#""party_id": "ENE","#, "")
        .replace(r#""currency": "EUR","#, "");
    let doc = json::parse(src.as_str().into()).unwrap();
    let built = build_cdr(&doc);

    let cdr = Cdr::from_schema(&built).unwrap().unwrap();

    assert_eq!(cdr.charging_periods.len(), 1);
}

#[test]
fn alpha3_country_code_is_flagged() {
    let src = VALID.replace(r#""country_code": "NL""#, r#""country_code": "NLD""#);
    let doc = json::parse(src.as_str().into()).unwrap();
    let built = build_cdr(&doc);

    let (_cdr, warnings) = Cdr::from_schema(&built).unwrap().into_parts();

    assert_matches!(
        all_warnings(&warnings).as_slice(),
        [Warning::CountryShouldBeAlpha2]
    );
}

#[test]
fn unbuildable_charging_period_entry_is_rejected() {
    // A period the schema could not build must not be skipped: the CDR would then be
    // validated against the wrong consumption.
    let src = VALID.replace(
        r#"{
            "start_date_time": "2022-01-13T16:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 2.5}]
        }"#,
        "5",
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let built = build_cdr(&doc);

    let err = Cdr::from_schema(&built).unwrap_err();
    let error = err.unwrap();

    assert_matches!(error.into_warning(), Warning::Rejected);
}

#[test]
fn empty_charging_periods_lowers_to_no_periods() {
    // As with the dimensions, the empty array is the schema's `Cardinality` warning to
    // report; `cdr::Versioned::to_v221` is what refuses to price it.
    let src = VALID.replace(
        r#"[
        {
            "start_date_time": "2022-01-13T16:00:00Z",
            "dimensions": [{"type": "TIME", "volume": 2.5}]
        }
    ]"#,
        "[]",
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let built = build_cdr(&doc);

    let cdr = Cdr::from_schema(&built).unwrap().unwrap();

    assert!(cdr.charging_periods.is_empty());
}