steid

@jamesgill /

feat: repository ports and in-memory implementations

UserRepository, OrgRepository, MembershipRepository as domain traits, with
in-memory implementations behind them. 10 tests.

Ports use RPITIT (impl Future in trait) rather than #[async_trait], so
there's no boxing per call. This makes the traits not dyn-safe, which suits
architecture.md's rule that use cases take &impl Port rather than a boxed
trait object.

RepositoryError collapses every storage failure into one opaque variant.
Driver errors carry no domain meaning, and letting sqlx types surface in the
domain would invert the dependency direction.

In-memory repos share storage across clones via Arc<Mutex<_>>, so a repo
handed to two use cases sees one set of data. That property is tested --
it's the one that makes them usable as test doubles rather than just
plausible-looking stubs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparent1e1ae56Browse filesfc5b5663f6f1dd751d0d186878289c6204dbbd26

8 files changed+412 −0

src/domain/mod.rs+1 −0View file
@@ -10,6 +10,7 @@ pub mod id;
1010 pub mod membership;
1111 pub mod org;
1212 pub mod password;
13+pub mod repository;
1314 pub mod user;
1415
1516 pub use actor::Actor;
src/domain/repository/membership_repo.rs+22 −0View file
@@ -0,0 +1,22 @@
1+use super::RepositoryResult;
2+use crate::domain::{Membership, OrgId, UserId};
3+
4+/// Persistence for [`Membership`].
5+pub trait MembershipRepository: Send + Sync {
6+ /// Finds the membership linking a user to an organisation, if any.
7+ ///
8+ /// This is the authorization lookup: a `None` means no access to private content.
9+ fn find(
10+ &self,
11+ org_id: &OrgId,
12+ user_id: &UserId,
13+ ) -> impl Future<Output = RepositoryResult<Option<Membership>>> + Send;
14+
15+ /// Every organisation the user belongs to.
16+ fn list_for_user(
17+ &self,
18+ user_id: &UserId,
19+ ) -> impl Future<Output = RepositoryResult<Vec<Membership>>> + Send;
20+
21+ fn save(&self, membership: &Membership) -> impl Future<Output = RepositoryResult<()>> + Send;
22+}
src/domain/repository/mod.rs+46 −0View file
@@ -0,0 +1,46 @@
1+//! Repository ports.
2+//!
3+//! Traits only — the domain declares what persistence it needs, and
4+//! `infrastructure` supplies it. Every port has two implementations: an in-memory one
5+//! that makes use cases testable without a database, and a SQLite one for real use.
6+
7+pub mod membership_repo;
8+pub mod org_repo;
9+pub mod user_repo;
10+
11+pub use membership_repo::MembershipRepository;
12+pub use org_repo::OrgRepository;
13+pub use user_repo::UserRepository;
14+
15+/// What a repository can fail with.
16+///
17+/// Storage failures are infrastructural and carry no domain meaning, so they collapse
18+/// into one opaque variant rather than leaking driver types upward.
19+#[derive(Debug)]
20+pub enum RepositoryError {
21+ Backend(Box<dyn std::error::Error + Send + Sync>),
22+}
23+
24+impl RepositoryError {
25+ pub fn backend(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
26+ Self::Backend(error.into())
27+ }
28+}
29+
30+impl std::fmt::Display for RepositoryError {
31+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32+ match self {
33+ Self::Backend(error) => write!(f, "storage failure: {error}"),
34+ }
35+ }
36+}
37+
38+impl std::error::Error for RepositoryError {
39+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
40+ match self {
41+ Self::Backend(error) => Some(&**error),
42+ }
43+ }
44+}
45+
46+pub type RepositoryResult<T> = Result<T, RepositoryError>;
src/domain/repository/org_repo.rs+18 −0View file
@@ -0,0 +1,18 @@
1+use super::RepositoryResult;
2+use crate::domain::{OrgId, OrgName, Organization};
3+
4+/// Persistence for [`Organization`].
5+pub trait OrgRepository: Send + Sync {
6+ fn find_by_id(
7+ &self,
8+ id: &OrgId,
9+ ) -> impl Future<Output = RepositoryResult<Option<Organization>>> + Send;
10+
11+ /// Looks an organisation up by handle — the `{owner}` segment of a URL.
12+ fn find_by_name(
13+ &self,
14+ name: &OrgName,
15+ ) -> impl Future<Output = RepositoryResult<Option<Organization>>> + Send;
16+
17+ fn save(&self, org: &Organization) -> impl Future<Output = RepositoryResult<()>> + Send;
18+}
src/domain/repository/user_repo.rs+26 −0View file
@@ -0,0 +1,26 @@
1+use super::RepositoryResult;
2+use crate::domain::{Email, User, UserId};
3+
4+/// Persistence for [`User`].
5+pub trait UserRepository: Send + Sync {
6+ /// Looks a user up by id.
7+ fn find_by_id(
8+ &self,
9+ id: &UserId,
10+ ) -> impl Future<Output = RepositoryResult<Option<User>>> + Send;
11+
12+ /// Looks a user up by email — the credential used to sign in.
13+ fn find_by_email(
14+ &self,
15+ email: &Email,
16+ ) -> impl Future<Output = RepositoryResult<Option<User>>> + Send;
17+
18+ /// Inserts or replaces a user.
19+ ///
20+ /// The user's organisation must already exist: the foreign key runs that
21+ /// direction, and saving in the other order fails.
22+ fn save(&self, user: &User) -> impl Future<Output = RepositoryResult<()>> + Send;
23+
24+ /// Whether any user exists. Drives first-boot bootstrap.
25+ fn any_exist(&self) -> impl Future<Output = RepositoryResult<bool>> + Send;
26+}
src/infrastructure/mod.rs+1 −0View file
@@ -1,2 +1,3 @@
11 pub mod database;
2+pub mod repository;
23 pub mod web;
src/infrastructure/repository/in_memory.rs+295 −0View file
@@ -0,0 +1,295 @@
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+
6+use std::{
7+ collections::HashMap,
8+ sync::{Arc, Mutex},
9+};
10+
11+use crate::domain::{
12+ Email, Membership, OrgId, OrgName, Organization, User, UserId,
13+ repository::{MembershipRepository, OrgRepository, RepositoryResult, UserRepository},
14+};
15+
16+/// Shared, cloneable storage. Cloning shares the same underlying map, so a repository
17+/// handed to two use cases sees one set of data.
18+#[derive(Debug, Default, Clone)]
19+pub struct InMemoryUserRepo {
20+ users: Arc<Mutex<HashMap<String, User>>>,
21+}
22+
23+impl InMemoryUserRepo {
24+ pub fn new() -> Self {
25+ Self::default()
26+ }
27+}
28+
29+impl UserRepository for InMemoryUserRepo {
30+ async fn find_by_id(&self, id: &UserId) -> RepositoryResult<Option<User>> {
31+ let users = self.users.lock().expect("lock poisoned");
32+ Ok(users.get(id.as_str()).cloned())
33+ }
34+
35+ async fn find_by_email(&self, email: &Email) -> RepositoryResult<Option<User>> {
36+ let users = self.users.lock().expect("lock poisoned");
37+ Ok(users.values().find(|user| &user.email == email).cloned())
38+ }
39+
40+ async fn save(&self, user: &User) -> RepositoryResult<()> {
41+ let mut users = self.users.lock().expect("lock poisoned");
42+ users.insert(user.id.as_str().to_owned(), user.clone());
43+ Ok(())
44+ }
45+
46+ async fn any_exist(&self) -> RepositoryResult<bool> {
47+ let users = self.users.lock().expect("lock poisoned");
48+ Ok(!users.is_empty())
49+ }
50+}
51+
52+#[derive(Debug, Default, Clone)]
53+pub struct InMemoryOrgRepo {
54+ orgs: Arc<Mutex<HashMap<String, Organization>>>,
55+}
56+
57+impl InMemoryOrgRepo {
58+ pub fn new() -> Self {
59+ Self::default()
60+ }
61+}
62+
63+impl OrgRepository for InMemoryOrgRepo {
64+ async fn find_by_id(&self, id: &OrgId) -> RepositoryResult<Option<Organization>> {
65+ let orgs = self.orgs.lock().expect("lock poisoned");
66+ Ok(orgs.get(id.as_str()).cloned())
67+ }
68+
69+ async fn find_by_name(&self, name: &OrgName) -> RepositoryResult<Option<Organization>> {
70+ let orgs = self.orgs.lock().expect("lock poisoned");
71+ Ok(orgs.values().find(|org| &org.name == name).cloned())
72+ }
73+
74+ async fn save(&self, org: &Organization) -> RepositoryResult<()> {
75+ let mut orgs = self.orgs.lock().expect("lock poisoned");
76+ orgs.insert(org.id.as_str().to_owned(), org.clone());
77+ Ok(())
78+ }
79+}
80+
81+#[derive(Debug, Default, Clone)]
82+pub struct InMemoryMembershipRepo {
83+ memberships: Arc<Mutex<HashMap<String, Membership>>>,
84+}
85+
86+impl InMemoryMembershipRepo {
87+ pub fn new() -> Self {
88+ Self::default()
89+ }
90+}
91+
92+impl MembershipRepository for InMemoryMembershipRepo {
93+ async fn find(&self, org_id: &OrgId, user_id: &UserId) -> RepositoryResult<Option<Membership>> {
94+ let memberships = self.memberships.lock().expect("lock poisoned");
95+ Ok(memberships
96+ .values()
97+ .find(|m| &m.org_id == org_id && &m.user_id == user_id)
98+ .cloned())
99+ }
100+
101+ async fn list_for_user(&self, user_id: &UserId) -> RepositoryResult<Vec<Membership>> {
102+ let memberships = self.memberships.lock().expect("lock poisoned");
103+ Ok(memberships
104+ .values()
105+ .filter(|m| &m.user_id == user_id)
106+ .cloned()
107+ .collect())
108+ }
109+
110+ async fn save(&self, membership: &Membership) -> RepositoryResult<()> {
111+ let mut memberships = self.memberships.lock().expect("lock poisoned");
112+ memberships.insert(membership.id.as_str().to_owned(), membership.clone());
113+ Ok(())
114+ }
115+}
116+
117+#[cfg(test)]
118+mod tests {
119+ use super::*;
120+ use crate::domain::{MembershipId, PasswordHash, Role};
121+
122+ fn user(email: &str, org_id: &OrgId) -> User {
123+ User::new(
124+ UserId::generate(),
125+ Email::new(email).expect("valid email"),
126+ PasswordHash::from_trusted("$argon2id$test"),
127+ org_id.clone(),
128+ )
129+ }
130+
131+ #[tokio::test]
132+ async fn users_are_found_by_id_and_email() {
133+ let repo = InMemoryUserRepo::new();
134+ let org_id = OrgId::generate();
135+ let user = user("dev@example.com", &org_id);
136+ repo.save(&user).await.expect("save");
137+
138+ let by_id = repo.find_by_id(&user.id).await.expect("lookup");
139+ let by_email = repo.find_by_email(&user.email).await.expect("lookup");
140+
141+ assert_eq!(by_id.as_ref(), Some(&user));
142+ assert_eq!(by_email.as_ref(), Some(&user));
143+ }
144+
145+ #[tokio::test]
146+ async fn missing_users_are_none_not_an_error() {
147+ let repo = InMemoryUserRepo::new();
148+
149+ let found = repo.find_by_id(&UserId::generate()).await.expect("lookup");
150+
151+ assert_eq!(found, None);
152+ }
153+
154+ #[tokio::test]
155+ async fn saving_the_same_id_replaces_rather_than_duplicates() {
156+ let repo = InMemoryUserRepo::new();
157+ let org_id = OrgId::generate();
158+ let mut user = user("dev@example.com", &org_id);
159+ repo.save(&user).await.expect("save");
160+
161+ user.email = Email::new("changed@example.com").expect("valid email");
162+ repo.save(&user).await.expect("save");
163+
164+ assert_eq!(
165+ repo.find_by_email(&Email::new("dev@example.com").unwrap())
166+ .await
167+ .expect("lookup"),
168+ None
169+ );
170+ assert!(repo.any_exist().await.expect("any_exist"));
171+ }
172+
173+ #[tokio::test]
174+ async fn any_exist_reports_emptiness() {
175+ let repo = InMemoryUserRepo::new();
176+ assert!(!repo.any_exist().await.expect("any_exist"));
177+
178+ repo.save(&user("dev@example.com", &OrgId::generate()))
179+ .await
180+ .expect("save");
181+
182+ assert!(repo.any_exist().await.expect("any_exist"));
183+ }
184+
185+ #[tokio::test]
186+ async fn clones_share_storage() {
187+ let repo = InMemoryUserRepo::new();
188+ let clone = repo.clone();
189+
190+ clone
191+ .save(&user("dev@example.com", &OrgId::generate()))
192+ .await
193+ .expect("save");
194+
195+ assert!(repo.any_exist().await.expect("any_exist"));
196+ }
197+
198+ #[tokio::test]
199+ async fn orgs_are_found_by_id_and_name() {
200+ let repo = InMemoryOrgRepo::new();
201+ let org = Organization::new(OrgId::generate(), "steid", None).expect("valid org");
202+ repo.save(&org).await.expect("save");
203+
204+ assert_eq!(
205+ repo.find_by_id(&org.id).await.expect("lookup").as_ref(),
206+ Some(&org)
207+ );
208+ assert_eq!(
209+ repo.find_by_name(&org.name).await.expect("lookup").as_ref(),
210+ Some(&org)
211+ );
212+ }
213+
214+ #[tokio::test]
215+ async fn org_lookup_by_name_respects_normalisation() {
216+ let repo = InMemoryOrgRepo::new();
217+ let org = Organization::new(OrgId::generate(), "Steid", None).expect("valid org");
218+ repo.save(&org).await.expect("save");
219+
220+ let found = repo
221+ .find_by_name(&OrgName::new("STEID").expect("valid"))
222+ .await
223+ .expect("lookup");
224+
225+ assert_eq!(found.as_ref(), Some(&org));
226+ }
227+
228+ #[tokio::test]
229+ async fn memberships_are_found_by_org_and_user() {
230+ let repo = InMemoryMembershipRepo::new();
231+ let org_id = OrgId::generate();
232+ let user_id = UserId::generate();
233+ let membership = Membership::new(
234+ MembershipId::generate(),
235+ org_id.clone(),
236+ user_id.clone(),
237+ Role::Owner,
238+ );
239+ repo.save(&membership).await.expect("save");
240+
241+ let found = repo.find(&org_id, &user_id).await.expect("lookup");
242+
243+ assert_eq!(found.as_ref(), Some(&membership));
244+ }
245+
246+ #[tokio::test]
247+ async fn membership_lookup_does_not_match_a_different_user() {
248+ let repo = InMemoryMembershipRepo::new();
249+ let org_id = OrgId::generate();
250+ repo.save(&Membership::new(
251+ MembershipId::generate(),
252+ org_id.clone(),
253+ UserId::generate(),
254+ Role::Owner,
255+ ))
256+ .await
257+ .expect("save");
258+
259+ let found = repo
260+ .find(&org_id, &UserId::generate())
261+ .await
262+ .expect("lookup");
263+
264+ assert_eq!(found, None);
265+ }
266+
267+ #[tokio::test]
268+ async fn list_for_user_returns_only_that_users_memberships() {
269+ let repo = InMemoryMembershipRepo::new();
270+ let user_id = UserId::generate();
271+ for _ in 0..2 {
272+ repo.save(&Membership::new(
273+ MembershipId::generate(),
274+ OrgId::generate(),
275+ user_id.clone(),
276+ Role::Member,
277+ ))
278+ .await
279+ .expect("save");
280+ }
281+ repo.save(&Membership::new(
282+ MembershipId::generate(),
283+ OrgId::generate(),
284+ UserId::generate(),
285+ Role::Owner,
286+ ))
287+ .await
288+ .expect("save");
289+
290+ let found = repo.list_for_user(&user_id).await.expect("lookup");
291+
292+ assert_eq!(found.len(), 2);
293+ assert!(found.iter().all(|m| m.user_id == user_id));
294+ }
295+}
src/infrastructure/repository/mod.rs+3 −0View file
@@ -0,0 +1,3 @@
1+pub mod in_memory;
2+
3+pub use in_memory::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo};