steid

@jamesgill /

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