steid

@jamesgill /

1//! In-memory repository implementations.
2//!
3//! These exist so use cases can be tested without a database. Attempt #2 reached 60
4//! tests this way and they stayed fast enough to run on every change.
5
6use std::{
7 collections::HashMap,
8 sync::{Arc, Mutex},
9 time::SystemTime,
10};
11
12use crate::domain::{
13 Email, Membership, OrgId, OrgName, Organization, RepoId, RepoName, Repository, Session,
14 SessionTokenHash, User, UserId,
15 repository::{
16 MembershipRepository, OrgRepository, RepoRepository, RepositoryResult, SessionRepository,
17 UserRepository,
18 },
19};
20
21/// Shared, cloneable storage. Cloning shares the same underlying map, so a repository
22/// handed to two use cases sees one set of data.
23#[derive(Debug, Default, Clone)]
24pub struct InMemoryUserRepo {
25 users: Arc<Mutex<HashMap<String, User>>>,
26}
27
28impl InMemoryUserRepo {
29 pub fn new() -> Self {
30 Self::default()
31 }
32}
33
34impl UserRepository for InMemoryUserRepo {
35 async fn find_by_id(&self, id: &UserId) -> RepositoryResult<Option<User>> {
36 let users = self.users.lock().expect("lock poisoned");
37 Ok(users.get(id.as_str()).cloned())
38 }
39
40 async fn find_by_email(&self, email: &Email) -> RepositoryResult<Option<User>> {
41 let users = self.users.lock().expect("lock poisoned");
42 Ok(users.values().find(|user| &user.email == email).cloned())
43 }
44
45 async fn save(&self, user: &User) -> RepositoryResult<()> {
46 let mut users = self.users.lock().expect("lock poisoned");
47 users.insert(user.id.as_str().to_owned(), user.clone());
48 Ok(())
49 }
50
51 async fn any_exist(&self) -> RepositoryResult<bool> {
52 let users = self.users.lock().expect("lock poisoned");
53 Ok(!users.is_empty())
54 }
55}
56
57#[derive(Debug, Default, Clone)]
58pub struct InMemoryOrgRepo {
59 orgs: Arc<Mutex<HashMap<String, Organization>>>,
60}
61
62impl InMemoryOrgRepo {
63 pub fn new() -> Self {
64 Self::default()
65 }
66}
67
68impl OrgRepository for InMemoryOrgRepo {
69 async fn find_by_id(&self, id: &OrgId) -> RepositoryResult<Option<Organization>> {
70 let orgs = self.orgs.lock().expect("lock poisoned");
71 Ok(orgs.get(id.as_str()).cloned())
72 }
73
74 async fn find_by_name(&self, name: &OrgName) -> RepositoryResult<Option<Organization>> {
75 let orgs = self.orgs.lock().expect("lock poisoned");
76 Ok(orgs.values().find(|org| &org.name == name).cloned())
77 }
78
79 async fn save(&self, org: &Organization) -> RepositoryResult<()> {
80 let mut orgs = self.orgs.lock().expect("lock poisoned");
81 orgs.insert(org.id.as_str().to_owned(), org.clone());
82 Ok(())
83 }
84}
85
86#[derive(Debug, Default, Clone)]
87pub struct InMemoryMembershipRepo {
88 memberships: Arc<Mutex<HashMap<String, Membership>>>,
89}
90
91impl InMemoryMembershipRepo {
92 pub fn new() -> Self {
93 Self::default()
94 }
95}
96
97impl MembershipRepository for InMemoryMembershipRepo {
98 async fn find(&self, org_id: &OrgId, user_id: &UserId) -> RepositoryResult<Option<Membership>> {
99 let memberships = self.memberships.lock().expect("lock poisoned");
100 Ok(memberships
101 .values()
102 .find(|m| &m.org_id == org_id && &m.user_id == user_id)
103 .cloned())
104 }
105
106 async fn list_for_user(&self, user_id: &UserId) -> RepositoryResult<Vec<Membership>> {
107 let memberships = self.memberships.lock().expect("lock poisoned");
108 Ok(memberships
109 .values()
110 .filter(|m| &m.user_id == user_id)
111 .cloned()
112 .collect())
113 }
114
115 async fn save(&self, membership: &Membership) -> RepositoryResult<()> {
116 let mut memberships = self.memberships.lock().expect("lock poisoned");
117 memberships.insert(membership.id.as_str().to_owned(), membership.clone());
118 Ok(())
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::domain::{MembershipId, PasswordHash, Role};
126
127 fn user(email: &str, org_id: &OrgId) -> User {
128 User::new(
129 UserId::generate(),
130 Email::new(email).expect("valid email"),
131 PasswordHash::from_trusted("$argon2id$test"),
132 org_id.clone(),
133 )
134 }
135
136 #[tokio::test]
137 async fn users_are_found_by_id_and_email() {
138 let repo = InMemoryUserRepo::new();
139 let org_id = OrgId::generate();
140 let user = user("dev@example.com", &org_id);
141 repo.save(&user).await.expect("save");
142
143 let by_id = repo.find_by_id(&user.id).await.expect("lookup");
144 let by_email = repo.find_by_email(&user.email).await.expect("lookup");
145
146 assert_eq!(by_id.as_ref(), Some(&user));
147 assert_eq!(by_email.as_ref(), Some(&user));
148 }
149
150 #[tokio::test]
151 async fn missing_users_are_none_not_an_error() {
152 let repo = InMemoryUserRepo::new();
153
154 let found = repo.find_by_id(&UserId::generate()).await.expect("lookup");
155
156 assert_eq!(found, None);
157 }
158
159 #[tokio::test]
160 async fn saving_the_same_id_replaces_rather_than_duplicates() {
161 let repo = InMemoryUserRepo::new();
162 let org_id = OrgId::generate();
163 let mut user = user("dev@example.com", &org_id);
164 repo.save(&user).await.expect("save");
165
166 user.email = Email::new("changed@example.com").expect("valid email");
167 repo.save(&user).await.expect("save");
168
169 assert_eq!(
170 repo.find_by_email(&Email::new("dev@example.com").unwrap())
171 .await
172 .expect("lookup"),
173 None
174 );
175 assert!(repo.any_exist().await.expect("any_exist"));
176 }
177
178 #[tokio::test]
179 async fn any_exist_reports_emptiness() {
180 let repo = InMemoryUserRepo::new();
181 assert!(!repo.any_exist().await.expect("any_exist"));
182
183 repo.save(&user("dev@example.com", &OrgId::generate()))
184 .await
185 .expect("save");
186
187 assert!(repo.any_exist().await.expect("any_exist"));
188 }
189
190 #[tokio::test]
191 async fn clones_share_storage() {
192 let repo = InMemoryUserRepo::new();
193 let clone = repo.clone();
194
195 clone
196 .save(&user("dev@example.com", &OrgId::generate()))
197 .await
198 .expect("save");
199
200 assert!(repo.any_exist().await.expect("any_exist"));
201 }
202
203 #[tokio::test]
204 async fn orgs_are_found_by_id_and_name() {
205 let repo = InMemoryOrgRepo::new();
206 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
207 repo.save(&org).await.expect("save");
208
209 assert_eq!(
210 repo.find_by_id(&org.id).await.expect("lookup").as_ref(),
211 Some(&org)
212 );
213 assert_eq!(
214 repo.find_by_name(&org.name).await.expect("lookup").as_ref(),
215 Some(&org)
216 );
217 }
218
219 #[tokio::test]
220 async fn org_lookup_by_name_respects_normalisation() {
221 let repo = InMemoryOrgRepo::new();
222 let org = Organization::new(OrgId::generate(), "Acme", None).expect("valid org");
223 repo.save(&org).await.expect("save");
224
225 let found = repo
226 .find_by_name(&OrgName::new("ACME").expect("valid"))
227 .await
228 .expect("lookup");
229
230 assert_eq!(found.as_ref(), Some(&org));
231 }
232
233 #[tokio::test]
234 async fn memberships_are_found_by_org_and_user() {
235 let repo = InMemoryMembershipRepo::new();
236 let org_id = OrgId::generate();
237 let user_id = UserId::generate();
238 let membership = Membership::new(
239 MembershipId::generate(),
240 org_id.clone(),
241 user_id.clone(),
242 Role::Owner,
243 );
244 repo.save(&membership).await.expect("save");
245
246 let found = repo.find(&org_id, &user_id).await.expect("lookup");
247
248 assert_eq!(found.as_ref(), Some(&membership));
249 }
250
251 #[tokio::test]
252 async fn membership_lookup_does_not_match_a_different_user() {
253 let repo = InMemoryMembershipRepo::new();
254 let org_id = OrgId::generate();
255 repo.save(&Membership::new(
256 MembershipId::generate(),
257 org_id.clone(),
258 UserId::generate(),
259 Role::Owner,
260 ))
261 .await
262 .expect("save");
263
264 let found = repo
265 .find(&org_id, &UserId::generate())
266 .await
267 .expect("lookup");
268
269 assert_eq!(found, None);
270 }
271
272 #[tokio::test]
273 async fn list_for_user_returns_only_that_users_memberships() {
274 let repo = InMemoryMembershipRepo::new();
275 let user_id = UserId::generate();
276 for _ in 0..2 {
277 repo.save(&Membership::new(
278 MembershipId::generate(),
279 OrgId::generate(),
280 user_id.clone(),
281 Role::Member,
282 ))
283 .await
284 .expect("save");
285 }
286 repo.save(&Membership::new(
287 MembershipId::generate(),
288 OrgId::generate(),
289 UserId::generate(),
290 Role::Owner,
291 ))
292 .await
293 .expect("save");
294
295 let found = repo.list_for_user(&user_id).await.expect("lookup");
296
297 assert_eq!(found.len(), 2);
298 assert!(found.iter().all(|m| m.user_id == user_id));
299 }
300}
301
302#[derive(Debug, Default, Clone)]
303pub struct InMemorySessionRepo {
304 sessions: Arc<Mutex<HashMap<String, Session>>>,
305}
306
307impl InMemorySessionRepo {
308 pub fn new() -> Self {
309 Self::default()
310 }
311}
312
313impl SessionRepository for InMemorySessionRepo {
314 async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
315 let sessions = self.sessions.lock().expect("lock poisoned");
316 Ok(sessions.get(token_hash.as_str()).cloned())
317 }
318
319 async fn save(&self, session: &Session) -> RepositoryResult<()> {
320 let mut sessions = self.sessions.lock().expect("lock poisoned");
321 sessions.insert(session.token_hash.as_str().to_owned(), session.clone());
322 Ok(())
323 }
324
325 async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
326 let mut sessions = self.sessions.lock().expect("lock poisoned");
327 sessions.remove(token_hash.as_str());
328 Ok(())
329 }
330
331 async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
332 let mut sessions = self.sessions.lock().expect("lock poisoned");
333 let before = sessions.len();
334 sessions.retain(|_, session| !session.is_expired_at(now));
335 Ok((before - sessions.len()) as u64)
336 }
337}
338
339#[derive(Debug, Default, Clone)]
340pub struct InMemoryRepoRepo {
341 repos: Arc<Mutex<HashMap<String, Repository>>>,
342}
343
344impl InMemoryRepoRepo {
345 pub fn new() -> Self {
346 Self::default()
347 }
348}
349
350impl RepoRepository for InMemoryRepoRepo {
351 async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
352 let repos = self.repos.lock().expect("lock poisoned");
353 Ok(repos.get(id.as_str()).cloned())
354 }
355
356 async fn find_by_org_and_name(
357 &self,
358 org_id: &OrgId,
359 name: &RepoName,
360 ) -> RepositoryResult<Option<Repository>> {
361 let repos = self.repos.lock().expect("lock poisoned");
362 Ok(repos
363 .values()
364 .find(|repo| &repo.org_id == org_id && &repo.name == name)
365 .cloned())
366 }
367
368 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
369 let repos = self.repos.lock().expect("lock poisoned");
370 let mut found: Vec<Repository> = repos
371 .values()
372 .filter(|repo| &repo.org_id == org_id)
373 .cloned()
374 .collect();
375 // Sorted here as well as in SQL, so the two implementations agree and a test
376 // written against one holds for the other.
377 found.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
378 Ok(found)
379 }
380
381 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
382 let mut repos = self.repos.lock().expect("lock poisoned");
383 repos.insert(repo.id.as_str().to_owned(), repo.clone());
384 Ok(())
385 }
386}