ocpi_kit/testkit/
stores.rs1use std::sync::RwLock;
12
13use crate::transport::{Page, PageMeta, PageQuery};
14use crate::types::{DateTime, Url};
15
16pub trait Stored: Clone + Send + Sync + 'static {
18 fn key(&self) -> String;
20 fn last_updated(&self) -> DateTime;
22}
23
24#[derive(Debug, Default)]
26pub struct InMemoryStore<T> {
27 items: RwLock<Vec<T>>,
28 max_page: usize,
29}
30
31impl<T: Stored> InMemoryStore<T> {
32 #[must_use]
34 pub fn new() -> Self {
35 Self { items: RwLock::new(Vec::new()), max_page: 100 }
36 }
37
38 #[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 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 #[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 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 #[must_use]
77 pub fn len(&self) -> usize {
78 self.items.read().expect("store lock poisoned").len()
79 }
80
81 #[must_use]
83 pub fn is_empty(&self) -> bool {
84 self.len() == 0
85 }
86
87 #[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 #[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 query.date_from.is_none_or(|from| updated >= from)
108 && query.date_to.is_none_or(|to| updated < to)
109 })
110 .collect();
111
112 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
162pub type InMemoryLocations = InMemoryStore<crate::v2_3_0::locations::Location>;
164pub type InMemorySessions = InMemoryStore<crate::v2_3_0::sessions::Session>;
166pub type InMemoryCdrs = InMemoryStore<crate::v2_3_0::cdrs::Cdr>;
168pub type InMemoryTariffs = InMemoryStore<crate::v2_3_0::tariffs::Tariff>;
170pub 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 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 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}