use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::types::validate_fields;
use crate::types::{
CiString, ContractId, CountryCode, DateTime, Extensions, Number, OcpiText, PartyId, PartyRef, Url,
Validate, Validator, ViolationCode,
};
use super::locations::{ConnectorFormat, ConnectorType, EvsePosition, PowerType, VehicleType};
use super::tokens::TokenType;
use super::types::Role;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Booking {
pub id: CiString<36>,
pub country_code: CountryCode,
pub party_id: PartyId,
pub request_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking_option: Option<BookingOption>,
pub location_id: CiString<36>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub booking_tokens: Vec<BookingToken>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub tariff_ids: Vec<CiString<36>>,
pub period: Timeslot,
pub reservation_status: ReservationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canceled: Option<Cancellation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub access_information: Vec<AccessInformation>,
pub authorization_reference: CiString<36>,
pub booking_terms: BookingTerms,
pub booking_requests: Vec<BookingRequestStatus>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Booking {
#[must_use]
pub fn owner_party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
#[must_use]
pub fn is_final(&self) -> bool {
self.reservation_status.is_terminal()
}
}
impl Validate for Booking {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
id,
country_code,
party_id,
request_id,
booking_option,
location_id,
booking_tokens,
tariff_ids,
period,
reservation_status,
canceled,
access_information,
authorization_reference,
booking_terms,
booking_requests,
last_updated,
);
if self.booking_requests.is_empty() {
v.report_at(
"booking_requests",
ViolationCode::EmptyRequiredList,
"a Booking has cardinality `+` booking_requests: the request that created it is \
always one of them",
);
}
match (self.reservation_status, self.canceled.is_some()) {
(ReservationStatus::Canceled, false) => v.report_at(
"canceled",
ViolationCode::MissingConditional,
"a CANCELED booking should say why and by whom",
),
(status, true) if status != ReservationStatus::Canceled => v.report_at(
"reservation_status",
ViolationCode::Inconsistent,
format!("a cancellation is recorded, but the status is {status}"),
),
_ => {}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BookingLocation {
pub country_code: CountryCode,
pub party_id: PartyId,
pub id: CiString<36>,
pub location_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking_option: Option<BookingOption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<Policy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub tariff_ids: Vec<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking_terms: Option<BookingTerms>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub calendars: Vec<Calendar>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl BookingLocation {
#[must_use]
pub fn owner_party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
}
impl Validate for BookingLocation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
country_code,
party_id,
id,
location_id,
booking_option,
policy,
tariff_ids,
booking_terms,
calendars,
last_updated,
);
let names_an_evse = self.booking_option.as_ref().is_some_and(|o| o.evse_uid.is_some());
if self.booking_option.is_none() && !names_an_evse {
v.report_at(
"booking_option",
ViolationCode::MissingConditional,
"either `booking_option` or an EVSE must be given; one of them is mandatory",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Calendar {
pub id: CiString<36>,
pub begin_from: DateTime,
pub end_before: DateTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeslot_increment: Option<u32>,
pub available_timeslots: Vec<Timeslot>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Calendar {
#[must_use]
pub fn can_accommodate(&self, slot: &Timeslot) -> bool {
self.available_timeslots.iter().any(|available| {
slot.start_date_time >= available.start_date_time && slot.end_date_time <= available.end_date_time
})
}
}
impl Validate for Calendar {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, id, begin_from, end_before, available_timeslots, last_updated);
if self.end_before <= self.begin_from {
v.report_at(
"end_before",
ViolationCode::Inconsistent,
"a calendar must cover a non-empty period",
);
}
if self.available_timeslots.is_empty() {
v.report_at(
"available_timeslots",
ViolationCode::EmptyRequiredList,
"a Calendar has cardinality `+` available_timeslots",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Timeslot {
pub start_date_time: DateTime,
pub end_date_time: DateTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_power: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_power: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub green_energy_support: Option<bool>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Timeslot {
#[must_use]
pub fn duration_minutes(&self) -> Option<i64> {
let seconds = self.end_date_time.unix_timestamp() - self.start_date_time.unix_timestamp();
(seconds > 0).then_some(seconds / 60)
}
}
impl Validate for Timeslot {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, start_date_time, end_date_time, min_power, max_power);
if self.end_date_time <= self.start_date_time {
v.report_at(
"end_date_time",
ViolationCode::Inconsistent,
"a timeslot must cover a non-empty period",
);
}
if let (Some(min), Some(max)) = (self.min_power, self.max_power)
&& max < min
{
v.report_at(
"max_power",
ViolationCode::Inconsistent,
"the maximum power cannot be below the guaranteed minimum",
);
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BookingOption {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evse_uid: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connector_id: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parking_id: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub evse_position: Vec<EvsePosition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub vehicle_types: Vec<VehicleType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub connector_format: Vec<ConnectorFormat>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub connector_types: Vec<ConnectorType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub power_types: Vec<PowerType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_weight: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_height: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_length: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_width: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_parking_space_length: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_parking_space_width: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dangerous_goods_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drive_through: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refrigeration_outlet: Option<bool>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for BookingOption {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
evse_uid,
connector_id,
parking_id,
evse_position,
vehicle_types,
connector_format,
connector_types,
power_types,
max_vehicle_weight,
max_vehicle_height,
max_vehicle_length,
max_vehicle_width,
min_parking_space_length,
min_parking_space_width,
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BookingRequestStatus {
pub request_status: ReservationRequestStatus,
pub booking_request: BookingRequest,
pub request_received: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for BookingRequestStatus {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, request_status, booking_request, request_received);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BookingRequest {
pub country_code: CountryCode,
pub party_id: PartyId,
pub request_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking_option: Option<BookingOption>,
pub location_id: CiString<36>,
pub booking_location_id: CiString<36>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub tokens: Vec<BookingToken>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub access_information: Vec<AccessInformation>,
pub period: Period,
pub authorization_reference: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub power_required: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canceled: Option<Cancellation>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl BookingRequest {
#[must_use]
pub fn requester(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
}
impl Validate for BookingRequest {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
country_code,
party_id,
request_id,
booking_option,
location_id,
booking_location_id,
tokens,
access_information,
period,
authorization_reference,
canceled,
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Period {
pub start_date_time: DateTime,
pub end_date_time: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for Period {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, start_date_time, end_date_time);
if self.end_date_time <= self.start_date_time {
v.report_at(
"end_date_time",
ViolationCode::Inconsistent,
"a period must cover a non-empty span of time",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BookingToken {
pub country_code: CountryCode,
pub party_id: PartyId,
pub uid: CiString<36>,
#[serde(rename = "type")]
pub token_type: TokenType,
pub contract_id: ContractId,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for BookingToken {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BookingTerms {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rfid_auth_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_groups_supported: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_auth_supported: Option<bool>,
pub supported_access_methods: Vec<AccessMethod>,
pub change_until_minutes: Number,
pub cancel_until_minutes: Number,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub change_not_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub early_start_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub early_start_time: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub noshow_timeout: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub noshow_fee: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub late_stop_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub late_stop_time: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlapping_bookings_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_booking_duration: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_booking_duration: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub booking_terms: Option<Url>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl BookingTerms {
#[must_use]
pub fn may_cancel_at(&self, now: DateTime, start: DateTime) -> bool {
minutes_before(now, start) >= self.cancel_until_minutes
}
#[must_use]
pub fn may_change_at(&self, now: DateTime, start: DateTime) -> bool {
if self.change_not_allowed.unwrap_or(false) {
return false;
}
minutes_before(now, start) >= self.change_until_minutes
}
}
fn minutes_before(now: DateTime, start: DateTime) -> Number {
let seconds = start.unix_timestamp() - now.unix_timestamp();
Number::from(seconds) / Number::from(60u32)
}
impl Validate for BookingTerms {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
supported_access_methods,
change_until_minutes,
cancel_until_minutes,
early_start_time,
noshow_timeout,
late_stop_time,
min_booking_duration,
max_booking_duration,
booking_terms,
);
if self.supported_access_methods.is_empty() {
v.report_at(
"supported_access_methods",
ViolationCode::EmptyRequiredList,
"BookingTerms has cardinality `+` supported_access_methods: a driver needs to \
know how to get in",
);
}
if let (Some(min), Some(max)) = (self.min_booking_duration, self.max_booking_duration)
&& max < min
{
v.report_at(
"max_booking_duration",
ViolationCode::Inconsistent,
"the maximum booking duration cannot be below the minimum",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AccessInformation {
pub method: AccessMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<OcpiText>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for AccessInformation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, method, value);
if self.value.is_none()
&& matches!(
self.method,
AccessMethod::Token | AccessMethod::LicensePlate | AccessMethod::AccessCode
)
{
v.report_at(
"value",
ViolationCode::MissingConditional,
format!("{} needs the value the driver is to present", self.method),
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Cancellation {
pub cancellation_reason: CanceledReason,
pub who_canceled: Role,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for Cancellation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, cancellation_reason, who_canceled);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Policy {
pub reservation_required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ad_hoc: Option<Number>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for Policy {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, ad_hoc);
if self.reservation_required && self.ad_hoc.is_some_and(|n| !n.is_zero()) {
v.report_at(
"ad_hoc",
ViolationCode::Inconsistent,
"a Location that requires a reservation cannot offer ad-hoc charging",
);
}
}
}
ocpi_enum! {
pub enum AccessMethod {
Open = "OPEN",
Token = "TOKEN",
LicensePlate = "LICENSE_PLATE",
AccessCode = "ACCESS_CODE",
Intercom = "INTERCOM",
ParkingTicket = "PARKING_TICKET",
}
}
ocpi_enum! {
pub enum CanceledReason {
PowerOutage = "POWER_OUTAGE",
BrokenCharger = "BROKEN_CHARGER",
Full = "FULL",
Blocked = "BLOCKED",
Traffic = "TRAFFIC",
BrokenVehicle = "BROKEN_VEHICLE",
NoCanceled = "NO_CANCELED",
Unknown = "UNKNOWN",
}
}
ocpi_enum! {
pub enum ReservationRequestStatus {
Pending = "PENDING",
Accepted = "ACCEPTED",
Declined = "DECLINED",
Failed = "FAILED",
}
}
ocpi_enum! {
pub enum ReservationStatus {
Pending = "PENDING",
Reserved = "RESERVED",
Canceled = "CANCELED",
Failed = "FAILED",
NoShow = "NO_SHOW",
Fulfilled = "FULFILLED",
Rejected = "REJECTED",
Unknown = "UNKNOWN",
}
}
impl ReservationStatus {
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Canceled | Self::Failed | Self::NoShow | Self::Fulfilled | Self::Rejected)
}
#[must_use]
pub const fn can_transition_to(self, next: Self) -> bool {
match self {
Self::Pending => matches!(
next,
Self::Reserved | Self::Rejected | Self::Failed | Self::Canceled | Self::Unknown
),
Self::Reserved => {
matches!(next, Self::Fulfilled | Self::Canceled | Self::NoShow | Self::Unknown)
}
Self::Unknown => true,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dt(s: &str) -> DateTime {
s.parse().unwrap()
}
#[test]
fn the_lifecycle_is_the_one_the_spec_describes() {
use ReservationStatus::{Canceled, Fulfilled, NoShow, Pending, Rejected, Reserved};
assert!(Pending.can_transition_to(Reserved));
assert!(Pending.can_transition_to(Rejected));
assert!(Reserved.can_transition_to(Fulfilled));
assert!(Reserved.can_transition_to(Canceled));
assert!(Reserved.can_transition_to(NoShow));
assert!(!Pending.can_transition_to(Fulfilled));
assert!(!Fulfilled.can_transition_to(Canceled));
assert!(Fulfilled.is_terminal() && NoShow.is_terminal());
assert!(!Pending.is_terminal() && !Reserved.is_terminal());
}
#[test]
fn a_timeslot_must_be_a_forward_interval_with_coherent_power() {
let slot = Timeslot::builder()
.start_date_time(dt("2024-06-01T10:00:00Z"))
.end_date_time(dt("2024-06-01T12:00:00Z"))
.min_power(Number::from(11_000u32))
.max_power(Number::from(22_000u32))
.build();
assert!(slot.validate().is_ok());
assert_eq!(slot.duration_minutes(), Some(120));
let backwards = Timeslot { end_date_time: dt("2024-06-01T09:00:00Z"), ..slot.clone() };
assert!(backwards.validate().is_err());
assert_eq!(backwards.duration_minutes(), None);
let impossible = Timeslot { max_power: Some(Number::from(1000u32)), ..slot };
assert!(impossible.validate().is_err());
}
#[test]
fn a_calendar_accommodates_a_slot_that_fits_inside_an_available_one() {
let calendar = Calendar::builder()
.id("CAL1")
.begin_from(dt("2024-06-01T00:00:00Z"))
.end_before(dt("2024-06-02T00:00:00Z"))
.available_timeslots(vec![
Timeslot::builder()
.start_date_time(dt("2024-06-01T08:00:00Z"))
.end_date_time(dt("2024-06-01T18:00:00Z"))
.build(),
])
.last_updated(dt("2024-05-01T00:00:00Z"))
.build();
assert!(calendar.validate().is_ok());
let fits = Timeslot::builder()
.start_date_time(dt("2024-06-01T10:00:00Z"))
.end_date_time(dt("2024-06-01T12:00:00Z"))
.build();
assert!(calendar.can_accommodate(&fits));
let overruns = Timeslot::builder()
.start_date_time(dt("2024-06-01T17:00:00Z"))
.end_date_time(dt("2024-06-01T19:00:00Z"))
.build();
assert!(!calendar.can_accommodate(&overruns));
}
#[test]
fn the_change_and_cancel_windows_are_computed_from_the_terms() {
let terms = BookingTerms::builder()
.supported_access_methods(vec![AccessMethod::Open])
.change_until_minutes(Number::from(60u32))
.cancel_until_minutes(Number::from(30u32))
.build();
let start = dt("2024-06-01T12:00:00Z");
assert!(terms.may_change_at(dt("2024-06-01T10:00:00Z"), start));
assert!(!terms.may_change_at(dt("2024-06-01T11:30:00Z"), start), "inside the 60 minutes");
assert!(terms.may_cancel_at(dt("2024-06-01T11:30:00Z"), start));
assert!(!terms.may_cancel_at(dt("2024-06-01T11:45:00Z"), start));
let frozen = BookingTerms { change_not_allowed: Some(true), ..terms };
assert!(!frozen.may_change_at(dt("2024-06-01T00:00:00Z"), start));
}
#[test]
fn an_access_method_that_needs_a_value_must_have_one() {
let bare = AccessInformation {
method: AccessMethod::AccessCode,
value: None,
extensions: Extensions::new(),
};
assert_eq!(bare.validate().unwrap_err().as_slice()[0].pointer, "/value");
let open =
AccessInformation { method: AccessMethod::Open, value: None, extensions: Extensions::new() };
assert!(open.validate().is_ok(), "OPEN needs nothing");
}
#[test]
fn a_location_that_requires_a_reservation_offers_no_ad_hoc_charging() {
let contradiction = Policy {
reservation_required: true,
ad_hoc: Some(Number::from(2u32)),
extensions: Extensions::new(),
};
assert!(contradiction.validate().is_err());
let coherent = Policy { ad_hoc: Some(Number::ZERO), ..contradiction };
assert!(coherent.validate().is_ok());
}
}