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