Skip to main content

ocpi_kit/testkit/
peer.rs

1//! A complete, runnable OCPI party backed by the in-memory stores.
2//!
3//! Every OCPI integration needs something at the other end of the socket before the partner is
4//! ready, and that something is the same dozen handler traits over a `HashMap` for everybody.
5//! [`MockPeer`] is that implementation, tested here rather than in each user's repository. It is
6//! what `ocpi serve-mock` runs.
7//!
8//! ```no_run
9//! use ocpi_kit::server::OcpiRouter;
10//! use ocpi_kit::testkit::{MockPeer, sample};
11//! use ocpi_kit::types::Url;
12//! use ocpi_kit::VersionNumber;
13//!
14//! # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
15//! let base = Url::new("http://127.0.0.1:8080")?;
16//! let peer = MockPeer::cpo(base.clone());
17//! peer.locations.put(sample::location("LOC1")?);
18//!
19//! let app = peer.mount(OcpiRouter::new(VersionNumber::V2_3_0, base, peer.token_store())).build();
20//! axum::serve(tokio::net::TcpListener::bind("127.0.0.1:8080").await?, app).await?;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! It is **conformant**: pagination, `date_from`/`date_to`, ownership, `Created` vs updated, the
26//! `2004 Unknown Token` code, the PATCH rule. The test suite points
27//! [`Conformance`](crate::client::Conformance) at it and requires a clean report.
28//!
29//! It is **not** a charge point. Commands, Charging Profiles and Payments are deliberately not
30//! mounted: a mock that answered `ACCEPTED` and never called the `response_url` back would teach a
31//! client the wrong lesson, and one that did call back would need a Charge Point to have an
32//! opinion. Version discovery advertises exactly what is mounted.
33
34use std::sync::Arc;
35
36use crate::server::{
37    CdrsReceiver, CdrsSender, Created, CredentialsHandler, Handled, InMemoryTokenStore, LocationsReceiver,
38    LocationsSender, OcpiRouter, RequestContext, SessionsReceiver, SessionsSender, TariffsReceiver,
39    TariffsSender, TokensReceiver, TokensSender,
40};
41use crate::testkit;
42use crate::transport::{OcpiError, Page, PageQuery, Patch, StatusCode};
43use crate::types::{PartyRef, Url};
44use crate::v2_3_0::cdrs::Cdr;
45use crate::v2_3_0::credentials::Credentials;
46use crate::v2_3_0::locations::{Connector, Evse, Location};
47use crate::v2_3_0::sessions::{ChargingPreferences, ChargingPreferencesResponse, Session};
48use crate::v2_3_0::tariffs::Tariff;
49use crate::v2_3_0::tokens::{AllowedType, AuthorizationInfo, LocationReferences, Token, TokenType};
50use crate::v2_3_0::types::Role;
51
52use super::stores::{InMemoryCdrs, InMemoryLocations, InMemorySessions, InMemoryTariffs, InMemoryTokens};
53
54/// A conformant OCPI party, held in memory.
55///
56/// Cheap to clone: the stores are shared, so a handle handed to the router and a handle kept for
57/// the test see the same objects.
58#[derive(Clone, Debug)]
59pub struct MockPeer(Arc<MockPeerStores>);
60
61/// The objects a [`MockPeer`] serves, reached through its `Deref`.
62///
63/// Seed and inspect them directly — `peer.locations.put(location)`, `peer.cdrs.len()` — which is
64/// what a test needs and what a handler trait cannot give you.
65#[derive(Debug)]
66pub struct MockPeerStores {
67    /// The Locations this peer serves.
68    pub locations: InMemoryLocations,
69    /// The Sessions this peer serves.
70    pub sessions: InMemorySessions,
71    /// The CDRs this peer serves, and the ones a partner has POSTed to it.
72    pub cdrs: InMemoryCdrs,
73    /// The Tariffs this peer serves.
74    pub tariffs: InMemoryTariffs,
75    /// The Tokens this peer serves, and authorizes against.
76    pub tokens: InMemoryTokens,
77    base: Url,
78    party: PartyRef,
79    role: Role,
80}
81
82impl core::ops::Deref for MockPeer {
83    type Target = MockPeerStores;
84    fn deref(&self) -> &MockPeerStores {
85        &self.0
86    }
87}
88
89impl MockPeer {
90    /// A peer filling `role`, publishing its endpoints under `base`.
91    #[must_use]
92    pub fn new(base: Url, party: PartyRef, role: Role) -> Self {
93        Self(Arc::new(MockPeerStores {
94            base,
95            party,
96            role,
97            locations: InMemoryLocations::new(),
98            sessions: InMemorySessions::new(),
99            cdrs: InMemoryCdrs::new(),
100            tariffs: InMemoryTariffs::new(),
101            tokens: InMemoryTokens::new(),
102        }))
103    }
104
105    /// A CPO at [`test_cpo`](super::test_cpo).
106    #[must_use]
107    pub fn cpo(base: Url) -> Self {
108        Self::new(base, super::test_cpo(), Role::Cpo)
109    }
110
111    /// An eMSP at [`test_msp`](super::test_msp).
112    #[must_use]
113    pub fn msp(base: Url) -> Self {
114        Self::new(base, super::test_msp(), Role::Emsp)
115    }
116
117    /// Fills every store with one conformant object, so a partner's first pull is not empty.
118    ///
119    /// # Panics
120    ///
121    /// Panics if the crate's own sample objects are not constructible, which would be a bug here.
122    #[must_use]
123    pub fn seeded(self) -> Self {
124        self.locations.put(testkit::sample::location("LOC1").expect("a valid sample Location"));
125        self.sessions.put(testkit::sample::session("101").expect("a valid sample Session"));
126        self.cdrs.put(testkit::sample::cdr("CDR1").expect("a valid sample CDR"));
127        self.tariffs.put(testkit::sample::tariff("T1", "0.25").expect("a valid sample Tariff"));
128        self.tokens.put(testkit::sample::token("012345678").expect("a valid sample Token"));
129        self
130    }
131
132    /// The party this peer speaks as.
133    #[must_use]
134    pub fn party(&self) -> &PartyRef {
135        &self.0.party
136    }
137
138    /// A token store that accepts [`test_token("c")`](super::test_token) as this peer's partner.
139    ///
140    /// The partner is given the *opposite* role's party, so the router's ownership check behaves
141    /// the way it would in a real deployment: a partner may write under its own party and no
142    /// other.
143    #[must_use]
144    pub fn token_store(&self) -> Arc<InMemoryTokenStore> {
145        let partner = if self.role == Role::Cpo { super::test_msp() } else { super::test_cpo() };
146        let store = Arc::new(InMemoryTokenStore::new());
147        store.insert(super::test_token("c"), super::registered_peer("partner", vec![partner.clone()]));
148        store.insert(super::test_token("a"), super::bootstrap_peer("bootstrap", vec![partner]));
149        store
150    }
151
152    /// Mounts every module this peer serves onto `router`.
153    ///
154    /// Both interfaces of every object module, so one process can stand in for either side of a
155    /// roaming relationship. That is only mountable because the router publishes the Receiver
156    /// interfaces one segment deeper by default; see
157    /// [`ServerConfig::receiver_path_prefix`](crate::server::ServerConfig::receiver_path_prefix).
158    #[must_use]
159    pub fn mount(&self, router: OcpiRouter) -> OcpiRouter {
160        router
161            .credentials(self.clone())
162            .locations_sender(self.clone())
163            .locations_receiver(self.clone())
164            .sessions_sender(self.clone())
165            .sessions_receiver(self.clone())
166            .cdrs_sender(self.clone())
167            .cdrs_receiver(self.clone())
168            .tariffs_sender(self.clone())
169            .tariffs_receiver(self.clone())
170            .tokens_sender(self.clone())
171            .tokens_receiver(self.clone())
172    }
173
174    fn endpoint(&self, module: &str) -> Url {
175        self.base.join(module)
176    }
177}
178
179/// `404` for an object this peer does not hold.
180fn missing(what: &str, id: &str) -> OcpiError {
181    OcpiError::NotFound(format!("no {what} {id}"))
182}
183
184// ---------------------------------------------------------------------------------------------
185// Locations
186// ---------------------------------------------------------------------------------------------
187
188impl LocationsSender for MockPeer {
189    async fn list(&self, query: PageQuery, _c: RequestContext) -> Handled<Page<Location>> {
190        Ok(self.locations.page(&query, &self.endpoint("locations")))
191    }
192
193    async fn location(&self, location_id: String, _c: RequestContext) -> Handled<Location> {
194        self.locations.get(&location_id).ok_or_else(|| missing("Location", &location_id))
195    }
196
197    async fn evse(&self, location_id: String, evse_uid: String, _c: RequestContext) -> Handled<Evse> {
198        self.locations
199            .get(&location_id)
200            .and_then(|l| l.evse(&evse_uid).cloned())
201            .ok_or_else(|| missing("EVSE", &evse_uid))
202    }
203
204    async fn connector(
205        &self,
206        location_id: String,
207        evse_uid: String,
208        connector_id: String,
209        _c: RequestContext,
210    ) -> Handled<Connector> {
211        self.locations
212            .get(&location_id)
213            .and_then(|l| l.evse(&evse_uid).and_then(|e| e.connector(&connector_id).cloned()))
214            .ok_or_else(|| missing("Connector", &connector_id))
215    }
216}
217
218impl LocationsReceiver for MockPeer {
219    async fn location(&self, _o: PartyRef, location_id: String, _c: RequestContext) -> Handled<Location> {
220        self.locations.get(&location_id).ok_or_else(|| missing("Location", &location_id))
221    }
222
223    async fn put_location(&self, _o: PartyRef, location: Location, _c: RequestContext) -> Handled<Created> {
224        Ok(Created::from(self.locations.put(location)))
225    }
226
227    async fn put_evse(
228        &self,
229        _o: PartyRef,
230        location_id: String,
231        evse: Evse,
232        _c: RequestContext,
233    ) -> Handled<Created> {
234        let mut location =
235            self.locations.get(&location_id).ok_or_else(|| missing("Location", &location_id))?;
236        let created = location.evse(evse.uid.as_str()).is_none();
237        location.evses.retain(|e| !e.uid.eq_ignore_case(evse.uid.as_str()));
238        location.evses.push(evse);
239        self.locations.put(location);
240        Ok(Created::from(created))
241    }
242
243    async fn put_connector(
244        &self,
245        _o: PartyRef,
246        location_id: String,
247        evse_uid: String,
248        connector: Connector,
249        _c: RequestContext,
250    ) -> Handled<Created> {
251        let mut location =
252            self.locations.get(&location_id).ok_or_else(|| missing("Location", &location_id))?;
253        let evse = location
254            .evses
255            .iter_mut()
256            .find(|e| e.uid.eq_ignore_case(&evse_uid))
257            .ok_or_else(|| missing("EVSE", &evse_uid))?;
258        let created = evse.connector(connector.id.as_str()).is_none();
259        evse.connectors.retain(|c| !c.id.eq_ignore_case(connector.id.as_str()));
260        evse.connectors.push(connector);
261        self.locations.put(location);
262        Ok(Created::from(created))
263    }
264
265    async fn patch(
266        &self,
267        _o: PartyRef,
268        location_id: String,
269        evse_uid: Option<String>,
270        connector_id: Option<String>,
271        patch: Patch<serde_json::Value>,
272        _c: RequestContext,
273    ) -> Handled<()> {
274        let mut location =
275            self.locations.get(&location_id).ok_or_else(|| missing("Location", &location_id))?;
276        match (evse_uid, connector_id) {
277            (None, _) => location = patch.retype::<Location>().apply(&location)?,
278            (Some(uid), None) => {
279                let evse = location
280                    .evses
281                    .iter_mut()
282                    .find(|e| e.uid.eq_ignore_case(&uid))
283                    .ok_or_else(|| missing("EVSE", &uid))?;
284                *evse = patch.retype::<Evse>().apply(evse)?;
285            }
286            (Some(uid), Some(id)) => {
287                let evse = location
288                    .evses
289                    .iter_mut()
290                    .find(|e| e.uid.eq_ignore_case(&uid))
291                    .ok_or_else(|| missing("EVSE", &uid))?;
292                let connector = evse
293                    .connectors
294                    .iter_mut()
295                    .find(|c| c.id.eq_ignore_case(&id))
296                    .ok_or_else(|| missing("Connector", &id))?;
297                *connector = patch.retype::<Connector>().apply(connector)?;
298            }
299        }
300        self.locations.put(location);
301        Ok(())
302    }
303}
304
305// ---------------------------------------------------------------------------------------------
306// Sessions
307// ---------------------------------------------------------------------------------------------
308
309impl SessionsSender for MockPeer {
310    async fn list(&self, query: PageQuery, _c: RequestContext) -> Handled<Page<Session>> {
311        Ok(self.sessions.page(&query, &self.endpoint("sessions")))
312    }
313
314    async fn set_charging_preferences(
315        &self,
316        session_id: String,
317        _preferences: ChargingPreferences,
318        _c: RequestContext,
319    ) -> Handled<ChargingPreferencesResponse> {
320        // "If a PUT with ChargingPreferences is received for an EVSE that does not have the
321        //  capability CHARGING_PREFERENCES_CAPABLE, the receiver should respond with an HTTP
322        //  status of 404 and an OCPI status code of 2001." The sample EVSE does not have it, so
323        //  this mock answers the honest thing rather than pretending to accept a preference it
324        //  has nowhere to apply.
325        self.sessions.get(&session_id).ok_or_else(|| missing("Session", &session_id))?;
326        Ok(ChargingPreferencesResponse::NotPossible)
327    }
328}
329
330impl SessionsReceiver for MockPeer {
331    async fn session(&self, _o: PartyRef, session_id: String, _c: RequestContext) -> Handled<Session> {
332        self.sessions.get(&session_id).ok_or_else(|| missing("Session", &session_id))
333    }
334
335    async fn put_session(&self, _o: PartyRef, session: Session, _c: RequestContext) -> Handled<Created> {
336        Ok(Created::from(self.sessions.put(session)))
337    }
338
339    async fn patch_session(
340        &self,
341        _o: PartyRef,
342        session_id: String,
343        patch: Patch<Session>,
344        _c: RequestContext,
345    ) -> Handled<()> {
346        let current = self.sessions.get(&session_id).ok_or_else(|| missing("Session", &session_id))?;
347        self.sessions.put(patch.apply(&current)?);
348        Ok(())
349    }
350}
351
352// ---------------------------------------------------------------------------------------------
353// CDRs
354// ---------------------------------------------------------------------------------------------
355
356impl CdrsSender for MockPeer {
357    async fn list(&self, query: PageQuery, _c: RequestContext) -> Handled<Page<Cdr>> {
358        Ok(self.cdrs.page(&query, &self.endpoint("cdrs")))
359    }
360}
361
362impl CdrsReceiver for MockPeer {
363    async fn cdr(&self, cdr_id: String, _c: RequestContext) -> Handled<Cdr> {
364        self.cdrs.get(&cdr_id).ok_or_else(|| missing("CDR", &cdr_id))
365    }
366
367    async fn post_cdr(&self, cdr: Cdr, _c: RequestContext) -> Handled<Url> {
368        // "The eMSP returns the URL to the just created CDR object in the Location header field."
369        // A CDR is immutable, so re-POSTing one is the peer's mistake, not ours to overwrite.
370        if self.cdrs.get(cdr.id.as_str()).is_some() {
371            return Err(OcpiError::Remote {
372                status_code: StatusCode::INVALID_PARAMETERS,
373                status_message: Some(format!("CDR {} has already been received", cdr.id)),
374            });
375        }
376        let url = self.endpoint("cdrs").join(cdr.id.as_str());
377        self.cdrs.put(cdr);
378        Ok(url)
379    }
380}
381
382// ---------------------------------------------------------------------------------------------
383// Tariffs
384// ---------------------------------------------------------------------------------------------
385
386impl TariffsSender for MockPeer {
387    async fn list(&self, query: PageQuery, _c: RequestContext) -> Handled<Page<Tariff>> {
388        Ok(self.tariffs.page(&query, &self.endpoint("tariffs")))
389    }
390}
391
392impl TariffsReceiver for MockPeer {
393    async fn tariff(&self, _o: PartyRef, tariff_id: String, _c: RequestContext) -> Handled<Tariff> {
394        self.tariffs.get(&tariff_id).ok_or_else(|| missing("Tariff", &tariff_id))
395    }
396
397    async fn put_tariff(&self, _o: PartyRef, tariff: Tariff, _c: RequestContext) -> Handled<Created> {
398        Ok(Created::from(self.tariffs.put(tariff)))
399    }
400
401    async fn delete_tariff(&self, _o: PartyRef, tariff_id: String, _c: RequestContext) -> Handled<()> {
402        if self.tariffs.remove(&tariff_id) { Ok(()) } else { Err(missing("Tariff", &tariff_id)) }
403    }
404}
405
406// ---------------------------------------------------------------------------------------------
407// Tokens
408// ---------------------------------------------------------------------------------------------
409
410impl TokensSender for MockPeer {
411    async fn list(&self, query: PageQuery, _c: RequestContext) -> Handled<Page<Token>> {
412        Ok(self.tokens.page(&query, &self.endpoint("tokens")))
413    }
414
415    async fn authorize(
416        &self,
417        token_uid: String,
418        _token_type: Option<TokenType>,
419        location: Option<LocationReferences>,
420        _c: RequestContext,
421    ) -> Handled<AuthorizationInfo> {
422        let token = self.tokens.get(&token_uid).ok_or(OcpiError::Remote {
423            status_code: StatusCode::UNKNOWN_TOKEN,
424            status_message: Some(format!("no Token {token_uid}")),
425        })?;
426        let allowed = if token.valid { AllowedType::Allowed } else { AllowedType::Blocked };
427        // "Only the EVSEs the EV driver is allowed to charge at are returned" — and a location is
428        // only returned at all when the answer is ALLOWED, which `AuthorizationInfo::validate`
429        // enforces.
430        let location = location.filter(|_| allowed == AllowedType::Allowed);
431        Ok(AuthorizationInfo::builder().allowed(allowed).token(token).maybe_location(location).build())
432    }
433}
434
435impl TokensReceiver for MockPeer {
436    async fn token(
437        &self,
438        _o: PartyRef,
439        token_uid: String,
440        _token_type: Option<TokenType>,
441        _c: RequestContext,
442    ) -> Handled<Token> {
443        self.tokens.get(&token_uid).ok_or_else(|| missing("Token", &token_uid))
444    }
445
446    async fn put_token(&self, _o: PartyRef, token: Token, _c: RequestContext) -> Handled<Created> {
447        Ok(Created::from(self.tokens.put(token)))
448    }
449
450    async fn patch_token(
451        &self,
452        _o: PartyRef,
453        token_uid: String,
454        _token_type: Option<TokenType>,
455        patch: Patch<Token>,
456        _c: RequestContext,
457    ) -> Handled<()> {
458        let current = self.tokens.get(&token_uid).ok_or_else(|| missing("Token", &token_uid))?;
459        self.tokens.put(patch.apply(&current)?);
460        Ok(())
461    }
462}
463
464// ---------------------------------------------------------------------------------------------
465// Credentials
466// ---------------------------------------------------------------------------------------------
467
468impl CredentialsHandler for MockPeer {
469    async fn get(&self, _c: RequestContext) -> Handled<Credentials> {
470        Ok(self.credentials())
471    }
472
473    async fn post(&self, _credentials: Credentials, _c: RequestContext) -> Handled<Credentials> {
474        // A real implementation fetches the client's versions and version details with the token
475        // it was just given, and answers `3001` if that fails. This one has nothing to fetch
476        // from and no state to keep, so it answers with its own credentials and says so here
477        // rather than pretending the handshake completed.
478        Ok(self.credentials())
479    }
480
481    async fn put(&self, _credentials: Credentials, _c: RequestContext) -> Handled<Credentials> {
482        Ok(self.credentials())
483    }
484
485    async fn delete(&self, _c: RequestContext) -> Handled<()> {
486        Ok(())
487    }
488}
489
490impl MockPeer {
491    /// This peer's own credentials object, as the credentials endpoint returns it.
492    #[must_use]
493    pub fn credentials(&self) -> Credentials {
494        use crate::v2_3_0::credentials::CredentialsRole;
495        use crate::v2_3_0::locations::BusinessDetails;
496        Credentials::builder()
497            .token(super::test_token("c").to_credentials_field())
498            .url(self.base.join("versions"))
499            .roles(vec![
500                CredentialsRole::builder()
501                    .role(self.role)
502                    .business_details(BusinessDetails::builder().name("ocpi-kit mock peer").build())
503                    .party_id(self.party.party_id.clone())
504                    .country_code(self.party.country_code.clone())
505                    .build(),
506            ])
507            .build()
508    }
509}