Skip to main content

ocpi_kit/testkit/
stores.rs

1//! In-memory object stores, with pagination that behaves the way the specification describes.
2//!
3//! These are what the `testkit` exists for: a working Locations Sender in three lines, so a test
4//! can be about the thing it is testing rather than about a fake database.
5//!
6//! The pagination is not a stub. It honours `date_from`/`date_to` as the half-open interval the
7//! spec defines, applies `offset` and `limit`, orders oldest-first as the spec advises —
8//! *"It is best practice to return the oldest objects first"* — and reports `X-Total-Count`
9//! excluding `limit` and `offset`, which is the part implementations most often get wrong.
10
11use std::sync::RwLock;
12
13use crate::transport::{Page, PageMeta, PageQuery};
14use crate::types::{DateTime, Url};
15
16/// An object that a store can hold: it has an id and a `last_updated`.
17pub trait Stored: Clone + Send + Sync + 'static {
18    /// The id this object is keyed by, compared case-insensitively.
19    fn key(&self) -> String;
20    /// The `last_updated` the pagination filters on.
21    fn last_updated(&self) -> DateTime;
22}
23
24/// A store of one object type.
25#[derive(Debug, Default)]
26pub struct InMemoryStore<T> {
27    items: RwLock<Vec<T>>,
28    max_page: usize,
29}
30
31impl<T: Stored> InMemoryStore<T> {
32    /// An empty store with a page size of 100.
33    #[must_use]
34    pub fn new() -> Self {
35        Self { items: RwLock::new(Vec::new()), max_page: 100 }
36    }
37
38    /// An empty store with a specific page size, for testing a crawl over several pages.
39    #[must_use]
40    pub fn with_page_size(max_page: usize) -> Self {
41        Self { items: RwLock::new(Vec::new()), max_page: max_page.max(1) }
42    }
43
44    /// Inserts or replaces an object, keyed case-insensitively by its id.
45    ///
46    /// Returns whether the object was newly created, which is what decides between HTTP 201 and
47    /// HTTP 200 on a `PUT`.
48    pub fn put(&self, item: T) -> bool {
49        let mut items = self.items.write().expect("store lock poisoned");
50        let key = item.key().to_ascii_lowercase();
51        if let Some(index) = items.iter().position(|existing| existing.key().to_ascii_lowercase() == key) {
52            items[index] = item;
53            false
54        } else {
55            items.push(item);
56            true
57        }
58    }
59
60    /// Fetches an object by id, comparing case-insensitively.
61    #[must_use]
62    pub fn get(&self, key: &str) -> Option<T> {
63        let items = self.items.read().expect("store lock poisoned");
64        items.iter().find(|item| item.key().eq_ignore_ascii_case(key)).cloned()
65    }
66
67    /// Removes an object.
68    pub fn remove(&self, key: &str) -> bool {
69        let mut items = self.items.write().expect("store lock poisoned");
70        let before = items.len();
71        items.retain(|item| !item.key().eq_ignore_ascii_case(key));
72        items.len() != before
73    }
74
75    /// How many objects are stored.
76    #[must_use]
77    pub fn len(&self) -> usize {
78        self.items.read().expect("store lock poisoned").len()
79    }
80
81    /// Whether the store is empty.
82    #[must_use]
83    pub fn is_empty(&self) -> bool {
84        self.len() == 0
85    }
86
87    /// Every object, oldest first.
88    #[must_use]
89    pub fn all(&self) -> Vec<T> {
90        let mut items = self.items.read().expect("store lock poisoned").clone();
91        items.sort_by_key(Stored::last_updated);
92        items
93    }
94
95    /// One page, applying the query the way the specification defines it.
96    ///
97    /// `base` is this endpoint's own URL, used to build the `Link` header of the next page — which
98    /// *"should also contain any filters present in the original request"*.
99    #[must_use]
100    pub fn page(&self, query: &PageQuery, base: &Url) -> Page<T> {
101        let matching: Vec<T> = self
102            .all()
103            .into_iter()
104            .filter(|item| {
105                let updated = item.last_updated();
106                // "date_from is inclusive and date_to exclusive"
107                query.date_from.is_none_or(|from| updated >= from)
108                    && query.date_to.is_none_or(|to| updated < to)
109            })
110            .collect();
111
112        // "X-Total-Count: The total number of objects available … (including the given query
113        //  parameters, for example: date_to and date_from but excluding limit and offset)"
114        let total = matching.len() as u64;
115        let offset = query.offset_or_default();
116        let limit = query.limit.map_or(self.max_page as u64, |l| l.min(self.max_page as u64)).max(1);
117
118        let start = usize::try_from(offset).unwrap_or(usize::MAX).min(matching.len());
119        let end = start.saturating_add(usize::try_from(limit).unwrap_or(usize::MAX)).min(matching.len());
120        let items = matching[start..end].to_vec();
121
122        let next = if (end as u64) < total {
123            let next_query = query.clone().with_offset(end as u64).with_limit(limit);
124            Some(next_query.apply_to(base))
125        } else {
126            None
127        };
128
129        Page { items, meta: PageMeta { next, total_count: Some(total), limit: Some(self.max_page as u64) } }
130    }
131}
132
133macro_rules! stored_for {
134    ($($ty:ty),* $(,)?) => {$(
135        impl Stored for $ty {
136            fn key(&self) -> String {
137                self.id.as_str().to_owned()
138            }
139            fn last_updated(&self) -> DateTime {
140                self.last_updated
141            }
142        }
143    )*};
144}
145
146stored_for!(
147    crate::v2_3_0::locations::Location,
148    crate::v2_3_0::sessions::Session,
149    crate::v2_3_0::cdrs::Cdr,
150    crate::v2_3_0::tariffs::Tariff,
151);
152
153impl Stored for crate::v2_3_0::tokens::Token {
154    fn key(&self) -> String {
155        self.uid.as_str().to_owned()
156    }
157    fn last_updated(&self) -> DateTime {
158        self.last_updated
159    }
160}
161
162/// An in-memory store of Locations.
163pub type InMemoryLocations = InMemoryStore<crate::v2_3_0::locations::Location>;
164/// An in-memory store of Sessions.
165pub type InMemorySessions = InMemoryStore<crate::v2_3_0::sessions::Session>;
166/// An in-memory store of CDRs.
167pub type InMemoryCdrs = InMemoryStore<crate::v2_3_0::cdrs::Cdr>;
168/// An in-memory store of Tariffs.
169pub type InMemoryTariffs = InMemoryStore<crate::v2_3_0::tariffs::Tariff>;
170/// An in-memory store of Tokens, keyed by `uid`.
171pub type InMemoryTokens = InMemoryStore<crate::v2_3_0::tokens::Token>;
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::testkit::sample;
177
178    fn store_with(count: usize, page_size: usize) -> InMemoryLocations {
179        let store = InMemoryLocations::with_page_size(page_size);
180        for i in 0..count {
181            let mut location = sample::location(&format!("LOC{i}")).unwrap();
182            // Space the timestamps a minute apart so ordering is well defined.
183            location.last_updated =
184                DateTime::from_unix_timestamp(1_705_312_800 + i64::try_from(i).unwrap() * 60).unwrap();
185            store.put(location);
186        }
187        store
188    }
189
190    fn base() -> Url {
191        Url::new("https://cpo.example.com/ocpi/cpo/2.3.0/locations").unwrap()
192    }
193
194    #[test]
195    fn put_reports_whether_the_object_was_created() {
196        let store = InMemoryLocations::new();
197        let location = sample::location("LOC1").unwrap();
198        assert!(store.put(location.clone()), "the first PUT creates");
199        assert!(!store.put(location), "the second replaces");
200        assert_eq!(store.len(), 1);
201    }
202
203    #[test]
204    fn ids_are_matched_case_insensitively_as_cistring_requires() {
205        let store = InMemoryLocations::new();
206        store.put(sample::location("LOC1").unwrap());
207        assert!(store.get("loc1").is_some());
208        assert!(store.remove("Loc1"));
209        assert!(store.is_empty());
210    }
211
212    #[test]
213    fn a_page_carries_a_next_link_until_the_last_one() {
214        let store = store_with(25, 10);
215        let first = store.page(&PageQuery::new(), &base());
216        assert_eq!(first.items.len(), 10);
217        assert_eq!(first.meta.total_count, Some(25));
218        assert_eq!(first.meta.limit, Some(10), "X-Limit is the server maximum");
219        let next = first.meta.next.expect("not the last page");
220        assert!(next.as_str().contains("offset=10"), "{next}");
221
222        let last = store.page(&PageQuery::new().with_offset(20), &base());
223        assert_eq!(last.items.len(), 5);
224        assert!(last.meta.next.is_none(), "the last page has no Link");
225    }
226
227    #[test]
228    fn the_date_window_is_half_open() {
229        let store = store_with(5, 100);
230        let all = store.all();
231        let second = all[1].last_updated;
232        let fourth = all[3].last_updated;
233
234        let page = store.page(&PageQuery::between(second, fourth), &base());
235        // date_from inclusive, date_to exclusive: objects 1 and 2, not 3.
236        assert_eq!(page.items.len(), 2);
237        assert_eq!(page.items[0].last_updated, second);
238        assert_eq!(page.meta.total_count, Some(2), "the total reflects the filter");
239    }
240
241    #[test]
242    fn sequential_intervals_do_not_overlap() {
243        let store = store_with(6, 100);
244        let all = store.all();
245        let boundary = all[3].last_updated;
246        let first = store.page(&PageQuery::between(all[0].last_updated, boundary), &base());
247        let second = store.page(&PageQuery::since(boundary), &base());
248        assert_eq!(first.items.len() + second.items.len(), 6, "every object appears exactly once");
249    }
250
251    #[test]
252    fn the_total_count_excludes_limit_and_offset() {
253        let store = store_with(25, 10);
254        let page = store.page(&PageQuery::new().with_offset(10).with_limit(5), &base());
255        assert_eq!(page.items.len(), 5);
256        assert_eq!(page.meta.total_count, Some(25));
257    }
258
259    #[test]
260    fn objects_come_back_oldest_first() {
261        let store = store_with(3, 100);
262        let page = store.page(&PageQuery::new(), &base());
263        assert!(page.items[0].last_updated < page.items[1].last_updated);
264        assert!(page.items[1].last_updated < page.items[2].last_updated);
265    }
266}