ocpi-tariffs 0.43.0

OCPI tariff calculations
Documentation
#![allow(clippy::indexing_slicing, reason = "tests are allowed to panic")]

use assert_matches::assert_matches;

use crate::{
    cdr, json, test, timezone::Warning, warning::test::VerdictTestExt, ObjectType, Verdict, Version,
};

use super::{find_or_infer, Source};

#[test]
fn should_find_timezone() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "time_zone": "Europe/Amsterdam"
    }
}"#;

    test::setup();
    let timezone = parse_expect_v221_and_time_zone_field(JSON)
        .unwrap()
        .unwrap();

    assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
}

#[test]
fn should_find_timezone_but_warn_about_use_of_location_for_v221_cdr() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "location": {
        "time_zone": "Europe/Amsterdam"
    }
}"#;

    test::setup();
    // If you parse a CDR that has a `location` field as v221 you will get multiple warnings...
    let cdr::ParseReport {
        cdr,
        unexpected_fields,
    } = cdr::parse_with_version(JSON, Version::V221).unwrap();

    // The parse function will complain about unexpected fields
    assert_unexpected_fields(&unexpected_fields, &["$.location", "$.location.time_zone"]);

    let (timezone_source, warnings) = find_or_infer(&cdr).unwrap().into_parts();
    let warnings = warnings.into_path_as_str_map();
    let warnings = &warnings["$"];

    assert_matches!(
        timezone_source,
        Source::Found(chrono_tz::Tz::Europe__Amsterdam)
    );
    // And the `find_or_infer` fn will warn about a v221 CDR having a `location` field.
    assert_matches!(warnings.as_slice(), [Warning::V221CdrHasLocationField]);
}

#[test]
fn should_find_timezone_without_cdr_country() {
    const JSON: &str = r#"{
    "cdr_location": {
        "time_zone": "Europe/Amsterdam"
    }
}"#;

    test::setup();
    let timezone = parse_expect_v221_and_time_zone_field(JSON)
        .unwrap()
        .unwrap();

    assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
}

#[test]
fn should_infer_timezone_and_warn_about_invalid_type() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "time_zone": null,
        "country": "BEL"
    }
}"#;

    test::setup();
    let (timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON)
        .unwrap()
        .into_parts();
    let warnings = warnings.into_path_as_str_map();
    let warnings = &warnings["$.cdr_location.time_zone"];

    assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
    assert_matches!(warnings.as_slice(), [Warning::InvalidTimezoneType]);
}

#[test]
fn should_find_timezone_and_warn_about_invalid_type() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "time_zone": "Europe/Hamsterdam",
        "country": "BEL"
    }
}"#;

    test::setup();
    let (timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON)
        .unwrap()
        .into_parts();

    assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
    let warnings = warnings.into_path_as_str_map();
    let warnings = &warnings["$.cdr_location.time_zone"];
    assert_matches!(warnings.as_slice(), [Warning::InvalidTimezone]);
}

#[test]
fn should_find_timezone_and_warn_about_escape_codes_and_invalid_type() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "time_zone": "Europe\/Hamsterdam",
        "country": "BEL"
    }
}"#;

    test::setup();
    let (timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON)
        .unwrap()
        .into_parts();

    assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));

    let groups = warnings.into_path_as_str_map();
    let warnings = &groups["$.cdr_location.time_zone"];
    assert_matches!(
        warnings.as_slice(),
        [Warning::ContainsEscapeCodes, Warning::InvalidTimezone]
    );
}

#[test]
fn should_find_timezone_and_warn_about_escape_codes() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "time_zone": "Europe\/Amsterdam",
        "country": "BEL"
    }
}"#;

    test::setup();
    let (timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON)
        .unwrap()
        .into_parts();

    assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));

    let groups = warnings.into_path_as_str_map();
    let warnings = &groups["$.cdr_location.time_zone"];
    assert_matches!(warnings.as_slice(), [Warning::ContainsEscapeCodes]);
}

#[test]
fn should_infer_timezone_from_location_country() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "country": "BEL"
    }
}"#;

    test::setup();
    let timezone = parse_expect_v221(JSON).unwrap().unwrap();

    assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
}

#[test]
fn should_find_timezone_but_report_alpha2_location_country_code() {
    const JSON: &str = r#"{
    "country_code": "NL",
    "cdr_location": {
        "country": "BE"
    }
}"#;

    test::setup();
    let (timezone, warnings) = parse_expect_v221(JSON).unwrap().into_parts();

    assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));

    let groups = warnings.into_path_as_str_map();
    let warnings = &groups["$.cdr_location.country"];
    assert_matches!(
        warnings.as_slice(),
        [Warning::LocationCountryShouldBeAlpha3,]
    );
}

#[test]
fn should_not_find_timezone_due_to_no_location() {
    const JSON: &str = r#"{ "country_code": "BE" }"#;

    test::setup();
    let error = parse_expect_v221(JSON).unwrap_only_error();

    assert_eq!(error.element().path().as_str(), "$");
    assert_matches!(error.warning(), Warning::NoLocation);
}

#[test]
fn should_not_find_timezone_due_to_no_country() {
    // The `$.country_code` field is not used at all when inferring the timezone.
    const JSON: &str = r#"{
    "country_code": "BELGIUM",
    "cdr_location": {}
}"#;

    test::setup();
    let error = parse_expect_v221(JSON).unwrap_only_error();

    assert_eq!(error.element().path().as_str(), "$.cdr_location");
    assert_matches!(error.warning(), Warning::NoLocationCountry);
}

#[test]
fn should_not_find_timezone_due_to_country_having_many_timezones() {
    const JSON: &str = r#"{
    "country_code": "BE",
    "cdr_location": {
        "country": "CHN"
    }
}"#;

    test::setup();
    let error = parse_expect_v221(JSON).unwrap_only_error();

    assert_eq!(error.element().path().as_str(), "$.cdr_location.country");
    assert_matches!(error.warning(), Warning::CantInferTimezoneFromCountry("CN"));
}

#[test]
fn should_fail_due_to_json_not_being_object() {
    const JSON: &str = r#"["not_a_cdr"]"#;

    test::setup();
    let error = parse_expect_v221(JSON).unwrap_only_error();

    assert_eq!(error.element().path().as_str(), "$");
    assert_matches!(error.warning(), Warning::ShouldBeAnObject);
}

/// Parse CDR and infer the timezone and assert that there are no unexpected fields.
#[track_caller]
#[expect(
    clippy::unwrap_in_result,
    reason = "A test can unwrap whereever it wants"
)]
fn parse_expect_v221(json: &str) -> Verdict<Source, Warning> {
    let cdr::ParseReport {
        cdr,
        unexpected_fields,
    } = cdr::parse_with_version(json, Version::V221).unwrap();
    test::assert_no_unexpected_fields(ObjectType::Cdr, &unexpected_fields);

    find_or_infer(&cdr)
}

/// Parse CDR and infer the timezone and assert that the `$.cdr_location.time_zone` fields.
#[track_caller]
#[expect(
    clippy::unwrap_in_result,
    reason = "A test can unwrap whereever it wants"
)]
fn parse_expect_v221_and_time_zone_field(json: &str) -> Verdict<Source, Warning> {
    let cdr::ParseReport {
        cdr,
        unexpected_fields,
    } = cdr::parse_with_version(json, Version::V221).unwrap();
    assert_unexpected_fields(&unexpected_fields, &["$.cdr_location.time_zone"]);

    find_or_infer(&cdr)
}

#[track_caller]
fn assert_unexpected_fields(
    unexpected_fields: &json::UnexpectedFields<'_>,
    expected: &[&'static str],
) {
    if unexpected_fields.len() != expected.len() {
        let unexpected_fields = unexpected_fields
            .into_iter()
            .map(|path| path.to_string())
            .collect::<Vec<_>>();

        panic!(
                "The unexpected fields and expected fields lists have different lengths.\n\nUnexpected fields found:\n{}",
                unexpected_fields.join(",\n")
            );
    }

    let unmatched_paths = unexpected_fields
        .into_iter()
        .zip(expected.iter())
        .filter(|(a, b)| a != *b)
        .collect::<Vec<_>>();

    if !unmatched_paths.is_empty() {
        let unmatched_paths = unmatched_paths
            .into_iter()
            .map(|(a, b)| format!("{a} != {b}"))
            .collect::<Vec<_>>();

        panic!(
                "The unexpected fields don't match the expected fields.\n\nUnexpected fields found:\n{}",
                unmatched_paths.join(",\n")
            );
    }
}