ocpi-tariffs 0.52.0

OCPI tariff calculations
Documentation
//! Tests for the `v2.2.1` CDR intermediate-representation (IR) builder.

use super::{build_cdr, Integrity, Warning};
use crate::{json, schema::HasElement as _, warning};
use std::assert_matches;

/// Return true if any warning in the set satisfies `pred`.
fn any_warning(warnings: &warning::Set<Warning>, pred: impl Fn(&Warning) -> bool) -> bool {
    warnings
        .iter()
        .any(|group| group.to_parts().1.into_iter().any(&pred))
}

/// A CDR exercising the modeled IR fields. It omits some schema-required but unmodeled
/// fields (`cdr_token`, `cdr_location`, `id`, ...), so the build reports missing-field
/// warnings; the tests assert on the IR fields, not on a clean warning set.
const CDR: &str = r#"{
    "currency": "EUR",
    "start_date_time": "2024-01-01T00:00:00Z",
    "end_date_time": "2024-01-01T01:00:00Z",
    "charging_periods": [
        {
            "start_date_time": "2024-01-01T00:00:00Z",
            "dimensions": [{"type": "ENERGY", "volume": 10.0}]
        }
    ],
    "total_cost": {"excl_vat": 5.0, "incl_vat": 6.05},
    "total_energy": 10.0,
    "total_time": 1.0,
    "tariffs": [
        {
            "country_code": "NL",
            "party_id": "CPO",
            "currency": "EUR",
            "id": "T1",
            "last_updated": "2024-01-01T00:00:00Z",
            "elements": [
                {"price_components": [{"price": 0.5, "step_size": 1, "type": "ENERGY"}]}
            ]
        }
    ]
}"#;

#[test]
fn cdr_builds_its_modeled_fields() {
    let doc = json::parse(CDR.into()).unwrap();
    let (cdr, warnings) = build_cdr(&doc).into_parts();

    // The fixture sets only the fields the schema models, so every other required field
    // is reported missing.
    crate::test::assert_schema_warnings(
        &warnings,
        &[(
            "$",
            &[
                "missing_field(auth_method)",
                "missing_field(cdr_location)",
                "missing_field(cdr_token)",
                "missing_field(country_code)",
                "missing_field(id)",
                "missing_field(last_updated)",
                "missing_field(party_id)",
            ],
        )],
    );

    let Integrity::Ok(currency) = &cdr.currency else {
        panic!("currency should be built: {:?}", cdr.currency);
    };
    assert_eq!(
        currency.element().to_raw_str().unwrap().as_unescaped_str(),
        "EUR"
    );

    // A `Price` total with both legs present.
    let Integrity::Ok(total_cost) = &cdr.total_cost else {
        panic!("total_cost should be built: {:?}", cdr.total_cost);
    };
    let super::Price::Object {
        excl_vat, incl_vat, ..
    } = total_cost
    else {
        panic!("total_cost should be an object: {total_cost:?}");
    };
    assert_matches!(excl_vat, Integrity::Ok(_));
    assert_matches!(incl_vat, Integrity::Ok(_));
    assert_matches!(cdr.total_energy, Integrity::Ok(_));

    // Charging periods -> dimensions.
    let Integrity::Ok(periods) = &cdr.charging_periods else {
        panic!(
            "charging_periods should be built: {:?}",
            cdr.charging_periods
        );
    };
    assert_eq!(periods.len(), 1);
    let Integrity::Ok(period) = &periods[0] else {
        panic!("the charging period should be built");
    };
    let Integrity::Ok(dimensions) = &period.dimensions else {
        panic!("dimensions should be built");
    };
    assert_eq!(dimensions.len(), 1);
    let Integrity::Ok(dimension) = &dimensions[0] else {
        panic!("the dimension should be built");
    };
    let Integrity::Ok(dimension_type) = &dimension.dimension_type else {
        panic!("the dimension type should be built");
    };
    assert_eq!(dimension_type.canonical(), "ENERGY");
    assert_matches!(dimension.volume, Integrity::Ok(_));

    // The embedded tariff is built via the same machinery as a top-level tariff.
    // `tariffs` is optional, so a present array is `Ok(Some(..))`.
    let Integrity::Ok(Some(tariffs)) = &cdr.tariffs else {
        panic!("tariffs should be built: {:?}", cdr.tariffs);
    };
    assert_eq!(tariffs.len(), 1);
    let Integrity::Ok(tariff) = &tariffs[0] else {
        panic!("the embedded tariff should be built");
    };
    assert_matches!(tariff.currency, Integrity::Ok(_));
    assert_matches!(tariff.elements, Integrity::Ok(_));
}

#[test]
fn unknown_cdr_dimension_type_is_err() {
    let src = CDR.replace(
        r#""type": "ENERGY", "volume""#,
        r#""type": "FOO", "volume""#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let (cdr, warnings) = build_cdr(&doc).into_parts();

    let Integrity::Ok(periods) = &cdr.charging_periods else {
        panic!("charging_periods should be built");
    };
    let Integrity::Ok(period) = &periods[0] else {
        panic!("the charging period should be built");
    };
    let Integrity::Ok(dimensions) = &period.dimensions else {
        panic!("dimensions should be built");
    };
    let Integrity::Ok(dimension) = &dimensions[0] else {
        panic!("the dimension should be built");
    };
    assert_matches!(dimension.dimension_type, Integrity::Err(_));
    assert!(any_warning(&warnings, |w| matches!(
        w,
        Warning::FieldInvalidValue { .. }
    )));
}

#[test]
fn bare_number_total_cost_builds_with_excl_vat_and_type_mismatch() {
    // OCPI 2.1.1 wrote `total_cost` as a bare number. The 2.2.1 schema accepts that
    // shape: it flags the type mismatch but still builds a `Price` with the number as
    // `excl_vat`, leaving `incl_vat` absent.
    let src = CDR.replace(
        r#""total_cost": {"excl_vat": 5.0, "incl_vat": 6.05},"#,
        r#""total_cost": 5.0,"#,
    );
    let doc = json::parse(src.as_str().into()).unwrap();
    let (cdr, warnings) = build_cdr(&doc).into_parts();

    let Integrity::Ok(total_cost) = &cdr.total_cost else {
        panic!("total_cost should be built: {:?}", cdr.total_cost);
    };
    assert_matches!(total_cost, super::Price::Number(_));

    assert!(any_warning(&warnings, |w| matches!(
        w,
        Warning::TypeMismatch {
            expected: json::ValueKind::Object,
            actual: json::ValueKind::Number,
        }
    )));
}

#[test]
fn building_is_total_on_degenerate_input() {
    // A non-object root cannot hold a field at all, so the walk reports the kind it found
    // and then treats every required field as missing.
    let cases: [(&str, crate::test::ExpectedWarnings<'_>); 3] = [
        ("{}", &[]),
        ("[]", &[("$", &["invalid_type(array)"])]),
        ("\"not an object\"", &[("$", &["invalid_type(string)"])]),
    ];

    for (src, expected) in cases {
        let doc = json::parse(src.into()).unwrap();
        let (cdr, mut warnings) = build_cdr(&doc).into_parts();

        // Every required field is missing whatever the input; the interesting part is that
        // the walk reports nothing else and never panics.
        warnings.remove_missing_fields();
        crate::test::assert_schema_warnings(&warnings, expected);
        // Required fields default to `Missing`.
        assert_matches!(cdr.currency, Integrity::Missing(_));
        assert_matches!(cdr.charging_periods, Integrity::Missing(_));
        assert_matches!(cdr.total_cost, Integrity::Missing(_));
        // `tariffs` is optional: an empty object yields `Ok(None)`, while a non-object
        // root falls back to the `Missing` default.
        assert_matches!(cdr.tariffs, Integrity::Ok(None) | Integrity::Missing(_));
    }
}

#[test]
fn non_spec_time_zone_is_built_and_reported() {
    // `time_zone` was a `v2.1.1` `Location` field that `v2.2.1` dropped. It is read anyway,
    // because a CDR that carries it has its timezone nowhere else.
    const CDR: &str = r#"{
        "cdr_location": {"country": "NLD", "time_zone": "Europe/Amsterdam"}
    }"#;

    let doc = json::parse(CDR.into()).unwrap();
    let (cdr, warnings) = build_cdr(&doc).into_parts();

    let Integrity::Ok(location) = &cdr.cdr_location else {
        panic!("cdr_location should be built: {:?}", cdr.cdr_location);
    };
    let Integrity::Ok(Some(time_zone)) = &location.time_zone else {
        panic!("time_zone should be built: {:?}", location.time_zone);
    };
    assert_eq!(
        time_zone.element().to_raw_str().unwrap().as_unescaped_str(),
        "Europe/Amsterdam"
    );

    // Read, but still off-spec, and not reported as unexpected.
    assert_eq!(
        warnings.non_spec_fields().to_strings(),
        ["$.cdr_location.time_zone"]
    );
    assert!(warnings.unexpected_fields().is_empty());
}

#[test]
fn non_spec_location_object_is_built_and_its_contents_left_alone() {
    // A CDR that still uses the `v2.1.1` `location` field name. The object is reported once
    // and read; the rest of the `v2.1.1` `Location` it carries is not reported field by
    // field.
    const CDR: &str = r#"{
        "location": {
            "country": "NLD",
            "time_zone": "Europe/Amsterdam",
            "address": "Street 1",
            "city": "Amsterdam",
            "id": "LOC1"
        }
    }"#;

    let doc = json::parse(CDR.into()).unwrap();
    let (cdr, warnings) = build_cdr(&doc).into_parts();

    let Integrity::Ok(location) = &cdr.location else {
        panic!("location should be built: {:?}", cdr.location);
    };
    assert_matches!(location.country, Integrity::Ok(_));
    assert_matches!(location.time_zone, Integrity::Ok(Some(_)));

    assert_eq!(warnings.non_spec_fields().to_strings(), ["$.location"]);
    assert!(
        warnings.unexpected_fields().is_empty(),
        "the contents of a non-spec object are not reported: {:?}",
        warnings.unexpected_fields().to_strings()
    );
}

#[test]
fn a_spec_compliant_cdr_has_no_location_field() {
    const CDR: &str = r#"{"cdr_location": {"country": "NLD"}}"#;

    let doc = json::parse(CDR.into()).unwrap();
    let (cdr, warnings) = build_cdr(&doc).into_parts();

    assert_matches!(cdr.location, Integrity::Missing(_));
    // An absent non-spec field is not a violation, so it is not reported either way.
    assert!(warnings.non_spec_fields().is_empty());
    assert!(!any_warning(&warnings, |warning| matches!(
        warning,
        Warning::MissingField { name: "location" }
    )));
}