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::{
4a0d227feat: store personal access tokens8d
13 Email, Membership, OrgId, OrgName, Organization, PersonalAccessToken, RepoId, RepoName,
14 Repository, Session, SessionTokenHash, TokenHash, TokenId, User, UserId,
54a0eaffeat: session storage and actor resolution1mo
15 repository::{
c5b3ff5feat: repository persistence24d
16 MembershipRepository, OrgRepository, RepoRepository, RepositoryResult, SessionRepository,
4a0d227feat: store personal access tokens8d
17 TokenRepository, UserRepository,
54a0eaffeat: session storage and actor resolution1mo
18 },
fc5b566feat: repository ports and in-memory implementations1mo
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
4a0d227feat: store personal access tokens8d
122/// Personal access tokens held in memory.
123#[derive(Debug, Default, Clone)]
124pub struct InMemoryTokenRepo {
125 tokens: Arc<Mutex<HashMap<String, PersonalAccessToken>>>,
126}
127
128impl InMemoryTokenRepo {
129 pub fn new() -> Self {
130 Self::default()
131 }
132}
133
134impl TokenRepository for InMemoryTokenRepo {
135 async fn find_by_hash(
136 &self,
137 hash: &TokenHash,
138 ) -> RepositoryResult<Option<PersonalAccessToken>> {
139 let tokens = self.tokens.lock().expect("lock poisoned");
140
141 Ok(tokens
142 .values()
143 .find(|token| token.token_hash.matches(hash))
144 .cloned())
145 }
146
147 async fn list_by_user(&self, user_id: &UserId) -> RepositoryResult<Vec<PersonalAccessToken>> {
148 let tokens = self.tokens.lock().expect("lock poisoned");
149
150 let mut found: Vec<PersonalAccessToken> = tokens
151 .values()
152 .filter(|token| &token.user_id == user_id)
153 .cloned()
154 .collect();
155
156 // Newest first, with the id breaking ties: two tokens issued in the same second
157 // would otherwise come back in whatever order the map happened to hold them.
158 found.sort_by(|a, b| {
159 b.created_at
160 .cmp(&a.created_at)
161 .then_with(|| a.id.as_str().cmp(b.id.as_str()))
162 });
163
164 Ok(found)
165 }
166
167 async fn save(&self, token: &PersonalAccessToken) -> RepositoryResult<()> {
168 let mut tokens = self.tokens.lock().expect("lock poisoned");
169 tokens.insert(token.id.as_str().to_owned(), token.clone());
170 Ok(())
171 }
172
173 async fn delete(&self, id: &TokenId) -> RepositoryResult<()> {
174 let mut tokens = self.tokens.lock().expect("lock poisoned");
175 tokens.remove(id.as_str());
176 Ok(())
177 }
178}
179
fc5b566feat: repository ports and in-memory implementations1mo
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::domain::{MembershipId, PasswordHash, Role};
184
185 fn user(email: &str, org_id: &OrgId) -> User {
186 User::new(
187 UserId::generate(),
188 Email::new(email).expect("valid email"),
189 PasswordHash::from_trusted("$argon2id$test"),
190 org_id.clone(),
191 )
192 }
193
194 #[tokio::test]
195 async fn users_are_found_by_id_and_email() {
196 let repo = InMemoryUserRepo::new();
197 let org_id = OrgId::generate();
198 let user = user("dev@example.com", &org_id);
199 repo.save(&user).await.expect("save");
200
201 let by_id = repo.find_by_id(&user.id).await.expect("lookup");
202 let by_email = repo.find_by_email(&user.email).await.expect("lookup");
203
204 assert_eq!(by_id.as_ref(), Some(&user));
205 assert_eq!(by_email.as_ref(), Some(&user));
206 }
207
208 #[tokio::test]
209 async fn missing_users_are_none_not_an_error() {
210 let repo = InMemoryUserRepo::new();
211
212 let found = repo.find_by_id(&UserId::generate()).await.expect("lookup");
213
214 assert_eq!(found, None);
215 }
216
217 #[tokio::test]
218 async fn saving_the_same_id_replaces_rather_than_duplicates() {
219 let repo = InMemoryUserRepo::new();
220 let org_id = OrgId::generate();
221 let mut user = user("dev@example.com", &org_id);
222 repo.save(&user).await.expect("save");
223
224 user.email = Email::new("changed@example.com").expect("valid email");
225 repo.save(&user).await.expect("save");
226
227 assert_eq!(
228 repo.find_by_email(&Email::new("dev@example.com").unwrap())
229 .await
230 .expect("lookup"),
231 None
232 );
233 assert!(repo.any_exist().await.expect("any_exist"));
234 }
235
236 #[tokio::test]
237 async fn any_exist_reports_emptiness() {
238 let repo = InMemoryUserRepo::new();
239 assert!(!repo.any_exist().await.expect("any_exist"));
240
241 repo.save(&user("dev@example.com", &OrgId::generate()))
242 .await
243 .expect("save");
244
245 assert!(repo.any_exist().await.expect("any_exist"));
246 }
247
248 #[tokio::test]
249 async fn clones_share_storage() {
250 let repo = InMemoryUserRepo::new();
251 let clone = repo.clone();
252
253 clone
254 .save(&user("dev@example.com", &OrgId::generate()))
255 .await
256 .expect("save");
257
258 assert!(repo.any_exist().await.expect("any_exist"));
259 }
260
261 #[tokio::test]
262 async fn orgs_are_found_by_id_and_name() {
263 let repo = InMemoryOrgRepo::new();
7148ad4feat: expand the reserved-handle list to twenty1mo
264 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
fc5b566feat: repository ports and in-memory implementations1mo
265 repo.save(&org).await.expect("save");
266
267 assert_eq!(
268 repo.find_by_id(&org.id).await.expect("lookup").as_ref(),
269 Some(&org)
270 );
271 assert_eq!(
272 repo.find_by_name(&org.name).await.expect("lookup").as_ref(),
273 Some(&org)
274 );
275 }
276
277 #[tokio::test]
278 async fn org_lookup_by_name_respects_normalisation() {
279 let repo = InMemoryOrgRepo::new();
7148ad4feat: expand the reserved-handle list to twenty1mo
280 let org = Organization::new(OrgId::generate(), "Acme", None).expect("valid org");
fc5b566feat: repository ports and in-memory implementations1mo
281 repo.save(&org).await.expect("save");
282
283 let found = repo
7148ad4feat: expand the reserved-handle list to twenty1mo
284 .find_by_name(&OrgName::new("ACME").expect("valid"))
fc5b566feat: repository ports and in-memory implementations1mo
285 .await
286 .expect("lookup");
287
288 assert_eq!(found.as_ref(), Some(&org));
289 }
290
291 #[tokio::test]
292 async fn memberships_are_found_by_org_and_user() {
293 let repo = InMemoryMembershipRepo::new();
294 let org_id = OrgId::generate();
295 let user_id = UserId::generate();
296 let membership = Membership::new(
297 MembershipId::generate(),
298 org_id.clone(),
299 user_id.clone(),
300 Role::Owner,
301 );
302 repo.save(&membership).await.expect("save");
303
304 let found = repo.find(&org_id, &user_id).await.expect("lookup");
305
306 assert_eq!(found.as_ref(), Some(&membership));
307 }
308
309 #[tokio::test]
310 async fn membership_lookup_does_not_match_a_different_user() {
311 let repo = InMemoryMembershipRepo::new();
312 let org_id = OrgId::generate();
313 repo.save(&Membership::new(
314 MembershipId::generate(),
315 org_id.clone(),
316 UserId::generate(),
317 Role::Owner,
318 ))
319 .await
320 .expect("save");
321
322 let found = repo
323 .find(&org_id, &UserId::generate())
324 .await
325 .expect("lookup");
326
327 assert_eq!(found, None);
328 }
329
330 #[tokio::test]
331 async fn list_for_user_returns_only_that_users_memberships() {
332 let repo = InMemoryMembershipRepo::new();
333 let user_id = UserId::generate();
334 for _ in 0..2 {
335 repo.save(&Membership::new(
336 MembershipId::generate(),
337 OrgId::generate(),
338 user_id.clone(),
339 Role::Member,
340 ))
341 .await
342 .expect("save");
343 }
344 repo.save(&Membership::new(
345 MembershipId::generate(),
346 OrgId::generate(),
347 UserId::generate(),
348 Role::Owner,
349 ))
350 .await
351 .expect("save");
352
353 let found = repo.list_for_user(&user_id).await.expect("lookup");
354
355 assert_eq!(found.len(), 2);
356 assert!(found.iter().all(|m| m.user_id == user_id));
357 }
4a0d227feat: store personal access tokens8d
358 // --- InMemoryTokenRepo -------------------------------------------------------
359
360 use crate::domain::TokenSecret;
361
362 fn token_for(user: &UserId, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
363 use std::time::Duration;
364
365 let secret = TokenSecret::generate();
366 let token = PersonalAccessToken::new(
367 TokenId::generate(),
368 user.clone(),
369 name,
370 &secret,
371 SystemTime::UNIX_EPOCH + Duration::from_secs(at),
372 )
373 .expect("valid token");
374
375 (token, secret)
376 }
377
378 #[tokio::test]
379 async fn the_fake_finds_a_token_by_its_hash() {
380 let tokens = InMemoryTokenRepo::new();
381 let user = UserId::generate();
382 let (token, secret) = token_for(&user, "laptop", 1_000);
383 tokens.save(&token).await.expect("save");
384
385 assert_eq!(
386 tokens.find_by_hash(&secret.hash()).await.expect("lookup"),
387 Some(token)
388 );
389 assert_eq!(
390 tokens
391 .find_by_hash(&TokenSecret::generate().hash())
392 .await
393 .expect("lookup"),
394 None
395 );
396 }
397
398 #[tokio::test]
399 async fn the_fake_lists_newest_first_for_one_user_only() {
400 // Mirrors the SQLite ordering. A fake that lists in a different order lets a use
401 // case pass here and surprise someone in production.
402 let tokens = InMemoryTokenRepo::new();
403 let user = UserId::generate();
404 let other = UserId::generate();
405
406 for (owner, name, at) in [
407 (&user, "old", 1_000),
408 (&user, "new", 3_000),
409 (&user, "middle", 2_000),
410 (&other, "theirs", 4_000),
411 ] {
412 let (token, _) = token_for(owner, name, at);
413 tokens.save(&token).await.expect("save");
414 }
415
416 assert_eq!(
417 tokens
418 .list_by_user(&user)
419 .await
420 .expect("list")
421 .iter()
422 .map(|token| token.name.as_str())
423 .collect::<Vec<_>>(),
424 vec!["new", "middle", "old"]
425 );
426 }
427
428 #[tokio::test]
429 async fn the_fake_forgets_a_deleted_token() {
430 let tokens = InMemoryTokenRepo::new();
431 let user = UserId::generate();
432 let (token, secret) = token_for(&user, "laptop", 1_000);
433 tokens.save(&token).await.expect("save");
434
435 tokens.delete(&token.id).await.expect("delete");
436
437 assert!(
438 tokens
439 .find_by_hash(&secret.hash())
440 .await
441 .expect("lookup")
442 .is_none()
443 );
444 }
fc5b566feat: repository ports and in-memory implementations1mo
445}
54a0eaffeat: session storage and actor resolution1mo
446
447#[derive(Debug, Default, Clone)]
448pub struct InMemorySessionRepo {
449 sessions: Arc<Mutex<HashMap<String, Session>>>,
450}
451
452impl InMemorySessionRepo {
453 pub fn new() -> Self {
454 Self::default()
455 }
456}
457
458impl SessionRepository for InMemorySessionRepo {
459 async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
460 let sessions = self.sessions.lock().expect("lock poisoned");
461 Ok(sessions.get(token_hash.as_str()).cloned())
462 }
463
464 async fn save(&self, session: &Session) -> RepositoryResult<()> {
465 let mut sessions = self.sessions.lock().expect("lock poisoned");
466 sessions.insert(session.token_hash.as_str().to_owned(), session.clone());
467 Ok(())
468 }
469
470 async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
471 let mut sessions = self.sessions.lock().expect("lock poisoned");
472 sessions.remove(token_hash.as_str());
473 Ok(())
474 }
475
476 async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
477 let mut sessions = self.sessions.lock().expect("lock poisoned");
478 let before = sessions.len();
479 sessions.retain(|_, session| !session.is_expired_at(now));
480 Ok((before - sessions.len()) as u64)
481 }
482}
c5b3ff5feat: repository persistence24d
483
484#[derive(Debug, Default, Clone)]
485pub struct InMemoryRepoRepo {
486 repos: Arc<Mutex<HashMap<String, Repository>>>,
487}
488
489impl InMemoryRepoRepo {
490 pub fn new() -> Self {
491 Self::default()
492 }
493}
494
495impl RepoRepository for InMemoryRepoRepo {
496 async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
497 let repos = self.repos.lock().expect("lock poisoned");
498 Ok(repos.get(id.as_str()).cloned())
499 }
500
501 async fn find_by_org_and_name(
502 &self,
503 org_id: &OrgId,
504 name: &RepoName,
505 ) -> RepositoryResult<Option<Repository>> {
506 let repos = self.repos.lock().expect("lock poisoned");
507 Ok(repos
508 .values()
509 .find(|repo| &repo.org_id == org_id && &repo.name == name)
510 .cloned())
511 }
512
513 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
514 let repos = self.repos.lock().expect("lock poisoned");
515 let mut found: Vec<Repository> = repos
516 .values()
517 .filter(|repo| &repo.org_id == org_id)
518 .cloned()
519 .collect();
520 // Sorted here as well as in SQL, so the two implementations agree and a test
521 // written against one holds for the other.
522 found.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
523 Ok(found)
524 }
525
526 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
527 let mut repos = self.repos.lock().expect("lock poisoned");
528 repos.insert(repo.id.as_str().to_owned(), repo.clone());
529 Ok(())
530 }
531}