Skip to main content

ocpi_kit/testkit/
sample.rs

1//! Valid sample objects, for tests that need *an* object rather than a specific one.
2//!
3//! Every object these produce passes [`Validate`](crate::types::Validate); that is asserted by
4//! this module's own tests, so a test that starts from one of these is starting from something
5//! conformant.
6
7use crate::types::{CiString, DateTime, Extensions, InvalidString, Number, Url};
8use crate::v2_3_0::cdrs::{
9    AuthMethod, Cdr, CdrDimension, CdrDimensionType, CdrLocation, CdrToken, ChargingPeriod,
10};
11use crate::v2_3_0::locations::{
12    Connector, ConnectorFormat, ConnectorType, Evse, GeoLocation, Location, PowerType, Status,
13};
14use crate::v2_3_0::sessions::{Session, SessionStatus};
15use crate::v2_3_0::tariffs::{PriceComponent, Tariff, TariffDimensionType, TariffElement, TaxIncluded};
16use crate::v2_3_0::tokens::{Token, TokenType, WhitelistType};
17use crate::v2_3_0::types::Price;
18
19/// A fixed timestamp, so sample objects compare equal across runs.
20#[must_use]
21pub fn timestamp() -> DateTime {
22    "2024-01-15T10:00:00Z".parse().expect("a valid RFC 3339 timestamp")
23}
24
25/// The coordinates the specification's Location example uses.
26#[must_use]
27pub fn coordinates() -> GeoLocation {
28    GeoLocation::new("51.047599", "3.729944").expect("valid WGS 84 coordinates")
29}
30
31/// A Connector with one AC socket.
32///
33/// # Errors
34///
35/// Returns [`InvalidString`] if `id` is not a usable `CiString(36)`.
36pub fn connector(id: &str) -> Result<Connector, InvalidString> {
37    Ok(Connector {
38        id: CiString::new(id)?,
39        standard: ConnectorType::Iec62196T2,
40        format: ConnectorFormat::Socket,
41        power_type: PowerType::Ac3Phase,
42        max_voltage: 400,
43        max_amperage: 32,
44        max_electric_power: Some(22_000),
45        tariff_ids: Vec::new(),
46        terms_and_conditions: None,
47        capabilities: Vec::new(),
48        last_updated: timestamp(),
49        extensions: Extensions::new(),
50    })
51}
52
53/// An EVSE with a single connector.
54///
55/// # Errors
56///
57/// Returns [`InvalidString`] if `uid` is not a usable `CiString(36)`.
58pub fn evse(uid: &str) -> Result<Evse, InvalidString> {
59    Ok(Evse {
60        uid: CiString::new(uid)?,
61        evse_id: Some(CiString::new("BE*BEC*E041503001")?),
62        status: Status::Available,
63        status_schedule: Vec::new(),
64        capabilities: Vec::new(),
65        connectors: vec![connector("1")?],
66        floor_level: None,
67        coordinates: None,
68        physical_reference: None,
69        directions: Vec::new(),
70        parking_restrictions: Vec::new(),
71        parking: Vec::new(),
72        images: Vec::new(),
73        accepted_service_providers: Vec::new(),
74        last_updated: timestamp(),
75        extensions: Extensions::new(),
76    })
77}
78
79/// A published Location with one EVSE.
80///
81/// # Errors
82///
83/// Returns [`InvalidString`] if `id` is not a usable `CiString(36)`.
84pub fn location(id: &str) -> Result<Location, InvalidString> {
85    Ok(Location::builder()
86        .country_code(CiString::new("NL")?)
87        .party_id(CiString::new("TNM")?)
88        .id(CiString::new(id)?)
89        .publish(true)
90        .name("Gent Zuid")
91        .address("F.Rooseveltlaan 3A")
92        .city("Gent")
93        .postal_code("9000")
94        .country("BEL")
95        .coordinates(coordinates())
96        .evses(vec![evse("3256")?])
97        .time_zone("Europe/Brussels")
98        .last_updated(timestamp())
99        .build())
100}
101
102/// A Token an app user would present.
103///
104/// # Errors
105///
106/// Returns [`InvalidString`] if `uid` is not a usable `CiString(36)`.
107pub fn token(uid: &str) -> Result<Token, InvalidString> {
108    Ok(Token::builder()
109        .country_code(CiString::new("DE")?)
110        .party_id(CiString::new("TNM")?)
111        .uid(CiString::new(uid)?)
112        .token_type(TokenType::AppUser)
113        .contract_id(CiString::new("DE8ACC12E46L89")?)
114        .issuer("TheNewMotion")
115        .valid(true)
116        .whitelist(WhitelistType::Never)
117        .last_updated(timestamp())
118        .build())
119}
120
121/// The `CdrToken` that matches [`token`].
122///
123/// # Errors
124///
125/// Returns [`InvalidString`] if `uid` is not a usable `CiString(36)`.
126pub fn cdr_token(uid: &str) -> Result<CdrToken, InvalidString> {
127    Ok(CdrToken::builder()
128        .country_code(CiString::new("DE")?)
129        .party_id(CiString::new("TNM")?)
130        .uid(CiString::new(uid)?)
131        .token_type(TokenType::AppUser)
132        .contract_id(CiString::new("DE8ACC12E46L89")?)
133        .build())
134}
135
136/// A simple Tariff: a per-kWh price with 10% VAT.
137///
138/// # Errors
139///
140/// Returns [`InvalidString`] if `id` is not a usable `CiString(36)`.
141pub fn tariff(id: &str, price_per_kwh: &str) -> Result<Tariff, InvalidString> {
142    let price: Number = price_per_kwh.parse().unwrap_or(Number::ZERO);
143    Ok(Tariff::builder()
144        .country_code(CiString::new("NL")?)
145        .party_id(CiString::new("TNM")?)
146        .id(CiString::new(id)?)
147        .currency("EUR")
148        .elements(vec![
149            TariffElement::builder()
150                .price_components(vec![PriceComponent {
151                    component_type: TariffDimensionType::Energy,
152                    price,
153                    vat: Some(Number::from(10u32)),
154                    step_size: 1,
155                    extensions: Extensions::new(),
156                }])
157                .build(),
158        ])
159        .tax_included(TaxIncluded::No)
160        .last_updated(timestamp())
161        .build())
162}
163
164/// An active Session that has charged some energy.
165///
166/// # Errors
167///
168/// Returns [`InvalidString`] if `id` is not a usable `CiString(36)`.
169pub fn session(id: &str) -> Result<Session, InvalidString> {
170    Ok(Session::builder()
171        .country_code(CiString::new("NL")?)
172        .party_id(CiString::new("TNM")?)
173        .id(CiString::new(id)?)
174        .start_date_time(timestamp())
175        .kwh(Number::from(12u32))
176        .cdr_token(cdr_token("012345678")?)
177        .auth_method(AuthMethod::Whitelist)
178        .location_id(CiString::new("LOC1")?)
179        .evse_uid(CiString::new("3256")?)
180        .connector_id(CiString::new("1")?)
181        .currency("EUR")
182        .charging_periods(vec![charging_period()])
183        .status(SessionStatus::Active)
184        .last_updated(timestamp())
185        .build())
186}
187
188/// A charging period that consumed 12 kWh over one hour.
189#[must_use]
190pub fn charging_period() -> ChargingPeriod {
191    ChargingPeriod::builder()
192        .start_date_time(timestamp())
193        .dimensions(vec![
194            CdrDimension::new(CdrDimensionType::Energy, Number::from(12u32)),
195            CdrDimension::new(CdrDimensionType::Time, Number::ONE),
196        ])
197        .build()
198}
199
200/// A completed CDR for a one-hour, 12 kWh session at €0.25/kWh.
201///
202/// # Errors
203///
204/// Returns [`InvalidString`] if `id` is not a usable `CiString(39)`.
205pub fn cdr(id: &str) -> Result<Cdr, InvalidString> {
206    let energy_cost: Number = "3.00".parse().unwrap_or(Number::ZERO);
207    Ok(Cdr::builder()
208        .country_code(CiString::new("NL")?)
209        .party_id(CiString::new("TNM")?)
210        .id(CiString::new(id)?)
211        .start_date_time(timestamp())
212        .end_date_time("2024-01-15T11:00:00Z".parse::<DateTime>().unwrap_or_else(|_| timestamp()))
213        .session_id(CiString::new("101")?)
214        .cdr_token(cdr_token("012345678")?)
215        .auth_method(AuthMethod::Whitelist)
216        .cdr_location(cdr_location()?)
217        .currency("EUR")
218        .charging_periods(vec![charging_period()])
219        .total_cost(Price::new(energy_cost))
220        .total_energy(Number::from(12u32))
221        .total_time(Number::ONE)
222        .last_updated(timestamp())
223        .build())
224}
225
226/// The `CdrLocation` that matches [`location`].
227///
228/// # Errors
229///
230/// Returns [`InvalidString`] if any of the fixed values is not usable, which cannot happen.
231pub fn cdr_location() -> Result<CdrLocation, InvalidString> {
232    Ok(CdrLocation::builder()
233        .id(CiString::new("LOC1")?)
234        .address("F.Rooseveltlaan 3A")
235        .city("Gent")
236        .postal_code("9000")
237        .country("BEL")
238        .coordinates(coordinates())
239        .evse_uid(CiString::new("3256")?)
240        .evse_id(CiString::new("BE*BEC*E041503001")?)
241        .connector_id(CiString::new("1")?)
242        .connector_standard(ConnectorType::Iec62196T2)
243        .connector_format(ConnectorFormat::Socket)
244        .connector_power_type(PowerType::Ac3Phase)
245        .build())
246}
247
248/// A credentials object for a CPO platform.
249///
250/// # Errors
251///
252/// Returns [`InvalidString`] if the values are not usable, which cannot happen.
253pub fn credentials(
254    token: &str,
255    versions_url: &str,
256) -> Result<crate::v2_3_0::credentials::Credentials, InvalidString> {
257    use crate::v2_3_0::credentials::{Credentials, CredentialsRole};
258    use crate::v2_3_0::locations::BusinessDetails;
259    use crate::v2_3_0::types::Role;
260    Ok(Credentials::builder()
261        .token(crate::types::OcpiString::<64>::new(token)?)
262        .url(Url::new_lenient(versions_url))
263        .roles(vec![
264            CredentialsRole::builder()
265                .role(Role::Cpo)
266                .business_details(BusinessDetails::builder().name("Example Operations").build())
267                .party_id(CiString::new("TNM")?)
268                .country_code(CiString::new("NL")?)
269                .build(),
270        ])
271        .build())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::types::Validate;
278
279    #[test]
280    fn every_sample_object_is_conformant() {
281        location("LOC1").unwrap().validate().unwrap();
282        evse("3256").unwrap().validate().unwrap();
283        connector("1").unwrap().validate().unwrap();
284        token("012345678").unwrap().validate().unwrap();
285        tariff("T1", "0.25").unwrap().validate().unwrap();
286        session("101").unwrap().validate().unwrap();
287        cdr("CDR1").unwrap().validate().unwrap();
288        credentials("test-token", "https://example.com/ocpi/versions").unwrap().validate().unwrap();
289    }
290
291    #[test]
292    fn every_sample_object_round_trips_through_json() {
293        let original = location("LOC1").unwrap();
294        let json = serde_json::to_string(&original).unwrap();
295        let decoded: Location = serde_json::from_str(&json).unwrap();
296        assert_eq!(decoded, original);
297    }
298}