@jpgilldev / steid

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, PersonalAccessToken, RepoId, RepoName,
14 Repository, Session, SessionTokenHash, TokenHash, TokenId, User, UserId,
15 repository::{
16 MembershipRepository, OrgRepository, RepoRepository, RepositoryResult, SessionRepository,
17 TokenRepository, 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 sole_user(&self) -> RepositoryResult<Option<User>> {
52 let users = self.users.lock().expect("lock poisoned");
53
54 match users.len() {
55 1 => Ok(users.values().next().cloned()),
56 _ => Ok(None),
57 }
58 }
59
60 async fn any_exist(&self) -> RepositoryResult<bool> {
61 let users = self.users.lock().expect("lock poisoned");
62 Ok(!users.is_empty())
63 }
64}
65
66#[derive(Debug, Default, Clone)]
67pub struct InMemoryOrgRepo {
68 orgs: Arc<Mutex<HashMap<String, Organization>>>,
69}
70
71impl InMemoryOrgRepo {
72 pub fn new() -> Self {
73 Self::default()
74 }
75}
76
77impl OrgRepository for InMemoryOrgRepo {
78 async fn find_by_id(&self, id: &OrgId) -> RepositoryResult<Option<Organization>> {
79 let orgs = self.orgs.lock().expect("lock poisoned");
80 Ok(orgs.get(id.as_str()).cloned())
81 }
82
83 async fn find_by_name(&self, name: &OrgName) -> RepositoryResult<Option<Organization>> {
84 let orgs = self.orgs.lock().expect("lock poisoned");
85 Ok(orgs.values().find(|org| &org.name == name).cloned())
86 }
87
88 async fn save(&self, org: &Organization) -> RepositoryResult<()> {
89 let mut orgs = self.orgs.lock().expect("lock poisoned");
90 orgs.insert(org.id.as_str().to_owned(), org.clone());
91 Ok(())
92 }
93}
94
95#[derive(Debug, Default, Clone)]
96pub struct InMemoryMembershipRepo {
97 memberships: Arc<Mutex<HashMap<String, Membership>>>,
98}
99
100impl InMemoryMembershipRepo {
101 pub fn new() -> Self {
102 Self::default()
103 }
104}
105
106impl MembershipRepository for InMemoryMembershipRepo {
107 async fn find(&self, org_id: &OrgId, user_id: &UserId) -> RepositoryResult<Option<Membership>> {
108 let memberships = self.memberships.lock().expect("lock poisoned");
109 Ok(memberships
110 .values()
111 .find(|m| &m.org_id == org_id && &m.user_id == user_id)
112 .cloned())
113 }
114
115 async fn list_for_user(&self, user_id: &UserId) -> RepositoryResult<Vec<Membership>> {
116 let memberships = self.memberships.lock().expect("lock poisoned");
117 Ok(memberships
118 .values()
119 .filter(|m| &m.user_id == user_id)
120 .cloned()
121 .collect())
122 }
123
124 async fn save(&self, membership: &Membership) -> RepositoryResult<()> {
125 let mut memberships = self.memberships.lock().expect("lock poisoned");
126 memberships.insert(membership.id.as_str().to_owned(), membership.clone());
127 Ok(())
128 }
129}
130
131/// Personal access tokens held in memory.
132#[derive(Debug, Default, Clone)]
133pub struct InMemoryTokenRepo {
134 tokens: Arc<Mutex<HashMap<String, PersonalAccessToken>>>,
135}
136
137impl InMemoryTokenRepo {
138 pub fn new() -> Self {
139 Self::default()
140 }
141}
142
143impl TokenRepository for InMemoryTokenRepo {
144 async fn find_by_hash(
145 &self,
146 hash: &TokenHash,
147 ) -> RepositoryResult<Option<PersonalAccessToken>> {
148 let tokens = self.tokens.lock().expect("lock poisoned");
149
150 Ok(tokens
151 .values()
152 .find(|token| token.token_hash.matches(hash))
153 .cloned())
154 }
155
156 async fn list_by_user(&self, user_id: &UserId) -> RepositoryResult<Vec<PersonalAccessToken>> {
157 let tokens = self.tokens.lock().expect("lock poisoned");
158
159 let mut found: Vec<PersonalAccessToken> = tokens
160 .values()
161 .filter(|token| &token.user_id == user_id)
162 .cloned()
163 .collect();
164
165 // Newest first, with the id breaking ties: two tokens issued in the same second
166 // would otherwise come back in whatever order the map happened to hold them.
167 found.sort_by(|a, b| {
168 b.created_at
169 .cmp(&a.created_at)
170 .then_with(|| a.id.as_str().cmp(b.id.as_str()))
171 });
172
173 Ok(found)
174 }
175
176 async fn save(&self, token: &PersonalAccessToken) -> RepositoryResult<()> {
177 let mut tokens = self.tokens.lock().expect("lock poisoned");
178 tokens.insert(token.id.as_str().to_owned(), token.clone());
179 Ok(())
180 }
181
182 async fn delete(&self, id: &TokenId) -> RepositoryResult<()> {
183 let mut tokens = self.tokens.lock().expect("lock poisoned");
184 tokens.remove(id.as_str());
185 Ok(())
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use std::time::Duration;
192
193 use super::*;
194
195 fn at(seconds: u64) -> SystemTime {
196 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
197 }
198 use crate::domain::{MembershipId, PasswordHash, Role};
199
200 fn user(email: &str, org_id: &OrgId) -> User {
201 User::new(
202 UserId::generate(),
203 Email::new(email).expect("valid email"),
204 PasswordHash::from_trusted("$argon2id$test"),
205 org_id.clone(),
206 )
207 }
208
209 #[tokio::test]
210 async fn users_are_found_by_id_and_email() {
211 let repo = InMemoryUserRepo::new();
212 let org_id = OrgId::generate();
213 let user = user("dev@example.com", &org_id);
214 repo.save(&user).await.expect("save");
215
216 let by_id = repo.find_by_id(&user.id).await.expect("lookup");
217 let by_email = repo.find_by_email(&user.email).await.expect("lookup");
218
219 assert_eq!(by_id.as_ref(), Some(&user));
220 assert_eq!(by_email.as_ref(), Some(&user));
221 }
222
223 #[tokio::test]
224 async fn missing_users_are_none_not_an_error() {
225 let repo = InMemoryUserRepo::new();
226
227 let found = repo.find_by_id(&UserId::generate()).await.expect("lookup");
228
229 assert_eq!(found, None);
230 }
231
232 #[tokio::test]
233 async fn saving_the_same_id_replaces_rather_than_duplicates() {
234 let repo = InMemoryUserRepo::new();
235 let org_id = OrgId::generate();
236 let mut user = user("dev@example.com", &org_id);
237 repo.save(&user).await.expect("save");
238
239 user.email = Email::new("changed@example.com").expect("valid email");
240 repo.save(&user).await.expect("save");
241
242 assert_eq!(
243 repo.find_by_email(&Email::new("dev@example.com").unwrap())
244 .await
245 .expect("lookup"),
246 None
247 );
248 assert!(repo.any_exist().await.expect("any_exist"));
249 }
250
251 #[tokio::test]
252 async fn any_exist_reports_emptiness() {
253 let repo = InMemoryUserRepo::new();
254 assert!(!repo.any_exist().await.expect("any_exist"));
255
256 repo.save(&user("dev@example.com", &OrgId::generate()))
257 .await
258 .expect("save");
259
260 assert!(repo.any_exist().await.expect("any_exist"));
261 }
262
263 #[tokio::test]
264 async fn clones_share_storage() {
265 let repo = InMemoryUserRepo::new();
266 let clone = repo.clone();
267
268 clone
269 .save(&user("dev@example.com", &OrgId::generate()))
270 .await
271 .expect("save");
272
273 assert!(repo.any_exist().await.expect("any_exist"));
274 }
275
276 #[tokio::test]
277 async fn orgs_are_found_by_id_and_name() {
278 let repo = InMemoryOrgRepo::new();
279 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
280 repo.save(&org).await.expect("save");
281
282 assert_eq!(
283 repo.find_by_id(&org.id).await.expect("lookup").as_ref(),
284 Some(&org)
285 );
286 assert_eq!(
287 repo.find_by_name(&org.name).await.expect("lookup").as_ref(),
288 Some(&org)
289 );
290 }
291
292 #[tokio::test]
293 async fn org_lookup_by_name_respects_normalisation() {
294 let repo = InMemoryOrgRepo::new();
295 let org = Organization::new(OrgId::generate(), "Acme", None).expect("valid org");
296 repo.save(&org).await.expect("save");
297
298 let found = repo
299 .find_by_name(&OrgName::new("ACME").expect("valid"))
300 .await
301 .expect("lookup");
302
303 assert_eq!(found.as_ref(), Some(&org));
304 }
305
306 #[tokio::test]
307 async fn memberships_are_found_by_org_and_user() {
308 let repo = InMemoryMembershipRepo::new();
309 let org_id = OrgId::generate();
310 let user_id = UserId::generate();
311 let membership = Membership::new(
312 MembershipId::generate(),
313 org_id.clone(),
314 user_id.clone(),
315 Role::Owner,
316 );
317 repo.save(&membership).await.expect("save");
318
319 let found = repo.find(&org_id, &user_id).await.expect("lookup");
320
321 assert_eq!(found.as_ref(), Some(&membership));
322 }
323
324 #[tokio::test]
325 async fn membership_lookup_does_not_match_a_different_user() {
326 let repo = InMemoryMembershipRepo::new();
327 let org_id = OrgId::generate();
328 repo.save(&Membership::new(
329 MembershipId::generate(),
330 org_id.clone(),
331 UserId::generate(),
332 Role::Owner,
333 ))
334 .await
335 .expect("save");
336
337 let found = repo
338 .find(&org_id, &UserId::generate())
339 .await
340 .expect("lookup");
341
342 assert_eq!(found, None);
343 }
344
345 #[tokio::test]
346 async fn list_for_user_returns_only_that_users_memberships() {
347 let repo = InMemoryMembershipRepo::new();
348 let user_id = UserId::generate();
349 for _ in 0..2 {
350 repo.save(&Membership::new(
351 MembershipId::generate(),
352 OrgId::generate(),
353 user_id.clone(),
354 Role::Member,
355 ))
356 .await
357 .expect("save");
358 }
359 repo.save(&Membership::new(
360 MembershipId::generate(),
361 OrgId::generate(),
362 UserId::generate(),
363 Role::Owner,
364 ))
365 .await
366 .expect("save");
367
368 let found = repo.list_for_user(&user_id).await.expect("lookup");
369
370 assert_eq!(found.len(), 2);
371 assert!(found.iter().all(|m| m.user_id == user_id));
372 }
373 // --- InMemoryTokenRepo -------------------------------------------------------
374
375 use crate::domain::TokenSecret;
376
377 fn token_for(user: &UserId, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
378 use std::time::Duration;
379
380 let secret = TokenSecret::generate();
381 let token = PersonalAccessToken::new(
382 TokenId::generate(),
383 user.clone(),
384 name,
385 &secret,
386 SystemTime::UNIX_EPOCH + Duration::from_secs(at),
387 )
388 .expect("valid token");
389
390 (token, secret)
391 }
392
393 #[tokio::test]
394 async fn the_fake_finds_a_token_by_its_hash() {
395 let tokens = InMemoryTokenRepo::new();
396 let user = UserId::generate();
397 let (token, secret) = token_for(&user, "laptop", 1_000);
398 tokens.save(&token).await.expect("save");
399
400 assert_eq!(
401 tokens.find_by_hash(&secret.hash()).await.expect("lookup"),
402 Some(token)
403 );
404 assert_eq!(
405 tokens
406 .find_by_hash(&TokenSecret::generate().hash())
407 .await
408 .expect("lookup"),
409 None
410 );
411 }
412
413 #[tokio::test]
414 async fn the_fake_lists_newest_first_for_one_user_only() {
415 // Mirrors the SQLite ordering. A fake that lists in a different order lets a use
416 // case pass here and surprise someone in production.
417 let tokens = InMemoryTokenRepo::new();
418 let user = UserId::generate();
419 let other = UserId::generate();
420
421 for (owner, name, at) in [
422 (&user, "old", 1_000),
423 (&user, "new", 3_000),
424 (&user, "middle", 2_000),
425 (&other, "theirs", 4_000),
426 ] {
427 let (token, _) = token_for(owner, name, at);
428 tokens.save(&token).await.expect("save");
429 }
430
431 assert_eq!(
432 tokens
433 .list_by_user(&user)
434 .await
435 .expect("list")
436 .iter()
437 .map(|token| token.name.as_str())
438 .collect::<Vec<_>>(),
439 vec!["new", "middle", "old"]
440 );
441 }
442
443 // --- InMemoryRepoRepo --------------------------------------------------------
444
445 #[tokio::test]
446 async fn the_fake_forgets_a_deleted_repository() {
447 let repos = InMemoryRepoRepo::new();
448 let org_id = OrgId::generate();
449 let repo = Repository::new(
450 RepoId::generate(),
451 org_id.clone(),
452 "steid",
453 None,
454 crate::domain::Visibility::Public,
455 at(1_000),
456 )
457 .expect("valid repo");
458 repos.save(&repo).await.expect("save");
459
460 repos.delete(&repo.id).await.expect("delete");
461
462 assert_eq!(repos.find_by_id(&repo.id).await.expect("lookup"), None);
463 assert!(
464 repos
465 .find_by_org_and_name(&org_id, &repo.name)
466 .await
467 .expect("lookup")
468 .is_none(),
469 "the name should be free again"
470 );
471 assert!(repos.list_by_org(&org_id).await.expect("list").is_empty());
472 }
473
474 #[tokio::test]
475 async fn deleting_a_repository_that_is_not_there_succeeds() {
476 // Idempotent: a second click on a delete button must not become an error.
477 let repos = InMemoryRepoRepo::new();
478
479 assert!(repos.delete(&RepoId::generate()).await.is_ok());
480 }
481
482 #[tokio::test]
483 async fn deleting_one_repository_leaves_the_others() {
484 let repos = InMemoryRepoRepo::new();
485 let org_id = OrgId::generate();
486 let mut saved = Vec::new();
487 for name in ["alpha", "zebra"] {
488 let repo = Repository::new(
489 RepoId::generate(),
490 org_id.clone(),
491 name,
492 None,
493 crate::domain::Visibility::Public,
494 at(1_000),
495 )
496 .expect("valid repo");
497 repos.save(&repo).await.expect("save");
498 saved.push(repo);
499 }
500
501 repos.delete(&saved[0].id).await.expect("delete");
502
503 let listed = repos.list_by_org(&org_id).await.expect("list");
504 assert_eq!(
505 listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
506 vec!["zebra"]
507 );
508 }
509
510 #[tokio::test]
511 async fn the_fake_lists_newest_first_breaking_ties_by_name() {
512 // The order the profile renders. It must match `SqliteRepoRepo::list_by_org`
513 // exactly — a fake that sorted differently would let a use case pass here and
514 // surprise someone in production.
515 let repos = InMemoryRepoRepo::new();
516 let org_id = OrgId::generate();
517 for (name, seconds) in [
518 ("alpha", 3_000),
519 ("older", 1_000),
520 ("newer", 2_000),
521 ("also-older", 1_000),
522 ] {
523 repos
524 .save(&repo_at(&org_id, name, seconds))
525 .await
526 .expect("save");
527 }
528
529 let listed = repos.list_by_org(&org_id).await.expect("list");
530
531 assert_eq!(
532 listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
533 vec!["alpha", "newer", "also-older", "older"]
534 );
535 }
536
537 #[tokio::test]
538 async fn touching_moves_a_repository_to_the_supplied_time() {
539 let repos = InMemoryRepoRepo::new();
540 let org_id = OrgId::generate();
541 let repo = repo_at(&org_id, "steid", 1_000);
542 repos.save(&repo).await.expect("save");
543
544 repos.touch(&repo.id, at(5_000)).await.expect("touch");
545
546 let found = repos
547 .find_by_id(&repo.id)
548 .await
549 .expect("lookup")
550 .expect("still there");
551 assert_eq!(found.updated_at, at(5_000));
552 }
553
554 #[tokio::test]
555 async fn touching_a_repository_that_is_gone_succeeds() {
556 // A push can outlive a delete; there is nothing useful the caller could do.
557 let repos = InMemoryRepoRepo::new();
558
559 assert!(repos.touch(&RepoId::generate(), at(5_000)).await.is_ok());
560 }
561
562 #[tokio::test]
563 async fn the_fake_remembers_whether_a_repository_is_pinned() {
564 let repos = InMemoryRepoRepo::new();
565 let org_id = OrgId::generate();
566 let mut repo = repo_at(&org_id, "steid", 1_000);
567 repo.pinned = true;
568
569 repos.save(&repo).await.expect("save");
570
571 assert!(
572 repos
573 .find_by_id(&repo.id)
574 .await
575 .expect("lookup")
576 .expect("still there")
577 .pinned
578 );
579 }
580
581 fn repo_at(org_id: &OrgId, name: &str, seconds: u64) -> Repository {
582 Repository::new(
583 RepoId::generate(),
584 org_id.clone(),
585 name,
586 None,
587 crate::domain::Visibility::Public,
588 at(seconds),
589 )
590 .expect("valid repo")
591 }
592
593 #[tokio::test]
594 async fn the_fake_forgets_a_deleted_token() {
595 let tokens = InMemoryTokenRepo::new();
596 let user = UserId::generate();
597 let (token, secret) = token_for(&user, "laptop", 1_000);
598 tokens.save(&token).await.expect("save");
599
600 tokens.delete(&token.id).await.expect("delete");
601
602 assert!(
603 tokens
604 .find_by_hash(&secret.hash())
605 .await
606 .expect("lookup")
607 .is_none()
608 );
609 }
610}
611
612#[derive(Debug, Default, Clone)]
613pub struct InMemorySessionRepo {
614 sessions: Arc<Mutex<HashMap<String, Session>>>,
615}
616
617impl InMemorySessionRepo {
618 pub fn new() -> Self {
619 Self::default()
620 }
621}
622
623impl SessionRepository for InMemorySessionRepo {
624 async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
625 let sessions = self.sessions.lock().expect("lock poisoned");
626 Ok(sessions.get(token_hash.as_str()).cloned())
627 }
628
629 async fn save(&self, session: &Session) -> RepositoryResult<()> {
630 let mut sessions = self.sessions.lock().expect("lock poisoned");
631 sessions.insert(session.token_hash.as_str().to_owned(), session.clone());
632 Ok(())
633 }
634
635 async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
636 let mut sessions = self.sessions.lock().expect("lock poisoned");
637 sessions.remove(token_hash.as_str());
638 Ok(())
639 }
640
641 async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
642 let mut sessions = self.sessions.lock().expect("lock poisoned");
643 let before = sessions.len();
644 sessions.retain(|_, session| !session.is_expired_at(now));
645 Ok((before - sessions.len()) as u64)
646 }
647}
648
649#[derive(Debug, Default, Clone)]
650pub struct InMemoryRepoRepo {
651 repos: Arc<Mutex<HashMap<String, Repository>>>,
652}
653
654impl InMemoryRepoRepo {
655 pub fn new() -> Self {
656 Self::default()
657 }
658}
659
660impl RepoRepository for InMemoryRepoRepo {
661 async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
662 let repos = self.repos.lock().expect("lock poisoned");
663 Ok(repos.get(id.as_str()).cloned())
664 }
665
666 async fn find_by_org_and_name(
667 &self,
668 org_id: &OrgId,
669 name: &RepoName,
670 ) -> RepositoryResult<Option<Repository>> {
671 let repos = self.repos.lock().expect("lock poisoned");
672 Ok(repos
673 .values()
674 .find(|repo| &repo.org_id == org_id && &repo.name == name)
675 .cloned())
676 }
677
678 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
679 let repos = self.repos.lock().expect("lock poisoned");
680 let mut found: Vec<Repository> = repos
681 .values()
682 .filter(|repo| &repo.org_id == org_id)
683 .cloned()
684 .collect();
685 // Sorted exactly as the SQL is — newest first, name ascending as a tiebreak.
686 // A fake that ordered differently would let a use case pass in tests and
687 // surprise someone in production.
688 found.sort_by(|a, b| {
689 b.updated_at
690 .cmp(&a.updated_at)
691 .then_with(|| a.name.as_str().cmp(b.name.as_str()))
692 });
693 Ok(found)
694 }
695
696 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
697 let mut repos = self.repos.lock().expect("lock poisoned");
698 repos.insert(repo.id.as_str().to_owned(), repo.clone());
699 Ok(())
700 }
701
702 async fn touch(&self, id: &RepoId, now: SystemTime) -> RepositoryResult<()> {
703 let mut repos = self.repos.lock().expect("lock poisoned");
704 if let Some(repo) = repos.get_mut(id.as_str()) {
705 repo.updated_at = now;
706 }
707 Ok(())
708 }
709
710 async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
711 let mut repos = self.repos.lock().expect("lock poisoned");
712 repos.remove(id.as_str());
713 Ok(())
714 }
715}