steid

@jamesgill /

feat: repository persistence

RepoRepository port with in-memory and SQLite implementations, plus the
migration. 9 new tests, 143 total.

unique (org_id, name) is deliberately a pair: two owners may each have a repo
called the same thing, only one owner may not have two. On `name` alone,
repository names would be globally unique across the installation -- there is
a test for that specifically, because it is the kind of constraint that looks
right and quietly imposes the wrong rule. `collate nocase` makes it agree with
RepoName, which lowercases on the way in.

An unparseable visibility surfaces as an error rather than defaulting.
Falling back to Public would publish a repository whose row we cannot read;
falling back to Private would hide a public one. Neither is a guess worth
making.

list_by_org returns everything regardless of visibility. Filtering is an
authorization decision and belongs in the use case, so the page and /api
cannot apply different rules -- the same reasoning as viewer_is_owner. Both
implementations sort by name so a test written against one holds for the
other.

Also reorders sqlite.rs so every implementation precedes the tests module.
SqliteSessionRepo had been appended after it in an earlier change, which made
"add code at the end of the file" land inside the wrong block twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 24 days agoparentc6d74a8Browse filesc5b3ff55125e3d33b53c38d6d4ab084613483386

7 files changed+446 −68

migrations/20260804093035_create_repositories.sql+15 −0View file
@@ -0,0 +1,15 @@
1+-- Repositories are owned by an organisation, never directly by a user.
2+--
3+-- `unique (org_id, name)` is a pair, so two owners can each have a repo called the
4+-- same thing; only one owner cannot have two. `collate nocase` on the column makes
5+-- that constraint case-insensitive, matching RepoName, which lowercases on the way in.
6+create table repositories (
7+ id text primary key,
8+ org_id text not null references orgs (id),
9+ name text not null collate nocase,
10+ description text,
11+ visibility text not null,
12+ unique (org_id, name)
13+);
14+
15+create index repositories_org_id on repositories (org_id);
plans/current.md+6 −2View file
@@ -17,9 +17,9 @@ problem twice as interesting, so not in the first pass.
1717 ### Steps
1818
1919 - [x] Domain: `RepoId`, `RepoName`, `Visibility` (Public/Private), `Repository`
20- [ ] Domain: `RepoRepository` port — `find_by_id`, `find_by_org_and_name`,
20+- [x] Domain: `RepoRepository` port — `find_by_id`, `find_by_org_and_name`,
2121 `list_by_org`, `save`
22- [ ] Infrastructure: in-memory + SQLite implementations, migration
22+- [x] Infrastructure: in-memory + SQLite implementations, migration
2323 - [ ] Application: `GitStorage` port — `init_bare`, `repo_path`
2424 - [ ] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare`
2525 - [ ] Application: `create_repo` use case — owner only, validates, creates record and
@@ -47,6 +47,10 @@ milestone 4.
4747 - **Repositories carry an optional description**, capped at 300 characters — a sentence
4848 for the profile listing, not a README. *Added without being asked for; remove if it
4949 is not wanted.*
50+- **`list_by_org` returns every repository regardless of visibility.** Filtering is an
51+ authorization decision and belongs to the use case, so the page and `/api` cannot end
52+ up applying different rules. The cost is that a private repo is briefly in memory
53+ before being filtered, which is fine in-process.
5054
5155 ### Watch for
5256
src/domain/repository/mod.rs+2 −0View file
@@ -6,11 +6,13 @@
66
77 pub mod membership_repo;
88 pub mod org_repo;
9+pub mod repo_repo;
910 pub mod session_repo;
1011 pub mod user_repo;
1112
1213 pub use membership_repo::MembershipRepository;
1314 pub use org_repo::OrgRepository;
15+pub use repo_repo::RepoRepository;
1416 pub use session_repo::SessionRepository;
1517 pub use user_repo::UserRepository;
1618
src/domain/repository/repo_repo.rs+32 −0View file
@@ -0,0 +1,32 @@
1+use super::RepositoryResult;
2+use crate::domain::{OrgId, RepoId, RepoName, Repository};
3+
4+/// Persistence for [`Repository`].
5+pub trait RepoRepository: Send + Sync {
6+ fn find_by_id(
7+ &self,
8+ id: &RepoId,
9+ ) -> impl Future<Output = RepositoryResult<Option<Repository>>> + Send;
10+
11+ /// Looks a repository up by its owner and name — the `/{handle}/repos/{name}` pair.
12+ fn find_by_org_and_name(
13+ &self,
14+ org_id: &OrgId,
15+ name: &RepoName,
16+ ) -> impl Future<Output = RepositoryResult<Option<Repository>>> + Send;
17+
18+ /// Every repository owned by an organisation, ordered by name.
19+ ///
20+ /// Returns them all regardless of visibility. Filtering is an authorization
21+ /// decision and belongs to the use case, so that the page and `/api` cannot end up
22+ /// applying different rules.
23+ fn list_by_org(
24+ &self,
25+ org_id: &OrgId,
26+ ) -> impl Future<Output = RepositoryResult<Vec<Repository>>> + Send;
27+
28+ /// Inserts or replaces a repository.
29+ ///
30+ /// The owning organisation must already exist; the foreign key runs that direction.
31+ fn save(&self, repo: &Repository) -> impl Future<Output = RepositoryResult<()>> + Send;
32+}
src/infrastructure/repository/in_memory.rs+53 −2View file
@@ -10,9 +10,11 @@ use std::{
1010 };
1111
1212 use crate::domain::{
13 Email, Membership, OrgId, OrgName, Organization, Session, SessionTokenHash, User, UserId,
13+ Email, Membership, OrgId, OrgName, Organization, RepoId, RepoName, Repository, Session,
14+ SessionTokenHash, User, UserId,
1415 repository::{
15 MembershipRepository, OrgRepository, RepositoryResult, SessionRepository, UserRepository,
16+ MembershipRepository, OrgRepository, RepoRepository, RepositoryResult, SessionRepository,
17+ UserRepository,
1618 },
1719 };
1820
@@ -333,3 +335,52 @@ impl SessionRepository for InMemorySessionRepo {
333335 Ok((before - sessions.len()) as u64)
334336 }
335337 }
338+
339+#[derive(Debug, Default, Clone)]
340+pub struct InMemoryRepoRepo {
341+ repos: Arc<Mutex<HashMap<String, Repository>>>,
342+}
343+
344+impl InMemoryRepoRepo {
345+ pub fn new() -> Self {
346+ Self::default()
347+ }
348+}
349+
350+impl RepoRepository for InMemoryRepoRepo {
351+ async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
352+ let repos = self.repos.lock().expect("lock poisoned");
353+ Ok(repos.get(id.as_str()).cloned())
354+ }
355+
356+ async fn find_by_org_and_name(
357+ &self,
358+ org_id: &OrgId,
359+ name: &RepoName,
360+ ) -> RepositoryResult<Option<Repository>> {
361+ let repos = self.repos.lock().expect("lock poisoned");
362+ Ok(repos
363+ .values()
364+ .find(|repo| &repo.org_id == org_id && &repo.name == name)
365+ .cloned())
366+ }
367+
368+ async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
369+ let repos = self.repos.lock().expect("lock poisoned");
370+ let mut found: Vec<Repository> = repos
371+ .values()
372+ .filter(|repo| &repo.org_id == org_id)
373+ .cloned()
374+ .collect();
375+ // Sorted here as well as in SQL, so the two implementations agree and a test
376+ // written against one holds for the other.
377+ found.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
378+ Ok(found)
379+ }
380+
381+ async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
382+ let mut repos = self.repos.lock().expect("lock poisoned");
383+ repos.insert(repo.id.as_str().to_owned(), repo.clone());
384+ Ok(())
385+ }
386+}
src/infrastructure/repository/mod.rs+5 −2View file
@@ -2,6 +2,9 @@ pub mod in_memory;
22 pub mod sqlite;
33
44 pub use in_memory::{
5 InMemoryMembershipRepo, InMemoryOrgRepo, InMemorySessionRepo, InMemoryUserRepo,
5+ InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo, InMemorySessionRepo,
6+ InMemoryUserRepo,
7+};
8+pub use sqlite::{
9+ SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteUserRepo,
610 };
7pub use sqlite::{SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo};
src/infrastructure/repository/sqlite.rs+333 −62View file
@@ -8,11 +8,11 @@ use std::time::{Duration, SystemTime};
88 use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
99
1010 use crate::domain::{
11 Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, Role, Session,
12 SessionTokenHash, User, UserId,
11+ Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, RepoId, RepoName,
12+ Repository, Role, Session, SessionTokenHash, User, UserId, Visibility,
1313 repository::{
14 MembershipRepository, OrgRepository, RepositoryError, RepositoryResult, SessionRepository,
15 UserRepository,
14+ MembershipRepository, OrgRepository, RepoRepository, RepositoryError, RepositoryResult,
15+ SessionRepository, UserRepository,
1616 },
1717 };
1818
@@ -220,6 +220,175 @@ impl MembershipRepository for SqliteMembershipRepo {
220220 }
221221 }
222222
223+#[derive(Debug, Clone)]
224+pub struct SqliteSessionRepo {
225+ pool: SqlitePool,
226+}
227+
228+impl SqliteSessionRepo {
229+ pub fn new(pool: SqlitePool) -> Self {
230+ Self { pool }
231+ }
232+}
233+
234+/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in
235+/// the future — so saturating at 0 is safe rather than lossy.
236+fn to_unix(time: SystemTime) -> i64 {
237+ time.duration_since(SystemTime::UNIX_EPOCH)
238+ .map(|d| d.as_secs() as i64)
239+ .unwrap_or(0)
240+}
241+
242+fn from_unix(seconds: i64) -> SystemTime {
243+ SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
244+}
245+
246+impl SessionRepository for SqliteSessionRepo {
247+ async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
248+ let row = sqlx::query("select * from sessions where token_hash = ?")
249+ .bind(token_hash.as_str())
250+ .fetch_optional(&self.pool)
251+ .await
252+ .map_err(backend)?;
253+
254+ Ok(row.map(|row| {
255+ Session::new(
256+ SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")),
257+ UserId::from_trusted(row.get::<String, _>("user_id")),
258+ from_unix(row.get::<i64, _>("expires_at")),
259+ )
260+ }))
261+ }
262+
263+ async fn save(&self, session: &Session) -> RepositoryResult<()> {
264+ sqlx::query(
265+ "insert into sessions (token_hash, user_id, expires_at)
266+ values (?, ?, ?)
267+ on conflict (token_hash) do update set
268+ user_id = excluded.user_id,
269+ expires_at = excluded.expires_at",
270+ )
271+ .bind(session.token_hash.as_str())
272+ .bind(session.user_id.as_str())
273+ .bind(to_unix(session.expires_at))
274+ .execute(&self.pool)
275+ .await
276+ .map_err(backend)?;
277+
278+ Ok(())
279+ }
280+
281+ async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
282+ sqlx::query("delete from sessions where token_hash = ?")
283+ .bind(token_hash.as_str())
284+ .execute(&self.pool)
285+ .await
286+ .map_err(backend)?;
287+
288+ Ok(())
289+ }
290+
291+ async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
292+ let result = sqlx::query("delete from sessions where expires_at <= ?")
293+ .bind(to_unix(now))
294+ .execute(&self.pool)
295+ .await
296+ .map_err(backend)?;
297+
298+ Ok(result.rows_affected())
299+ }
300+}
301+
302+#[derive(Debug, Clone)]
303+pub struct SqliteRepoRepo {
304+ pool: SqlitePool,
305+}
306+
307+impl SqliteRepoRepo {
308+ pub fn new(pool: SqlitePool) -> Self {
309+ Self { pool }
310+ }
311+
312+ /// An unparseable visibility is a storage fault, not a default.
313+ ///
314+ /// Falling back to `Public` would publish a repository whose row we cannot read;
315+ /// falling back to `Private` would hide a public one. Neither is a guess worth
316+ /// making, so the row surfaces as an error.
317+ fn map(row: &SqliteRow) -> RepositoryResult<Repository> {
318+ let raw: String = row.get("visibility");
319+ let visibility: Visibility = raw
320+ .parse()
321+ .map_err(|error| RepositoryError::backend(format!("{error}")))?;
322+
323+ Ok(Repository::from_trusted(
324+ RepoId::from_trusted(row.get::<String, _>("id")),
325+ OrgId::from_trusted(row.get::<String, _>("org_id")),
326+ RepoName::from_trusted(row.get::<String, _>("name")),
327+ row.get::<Option<String>, _>("description"),
328+ visibility,
329+ ))
330+ }
331+}
332+
333+impl RepoRepository for SqliteRepoRepo {
334+ async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
335+ let row = sqlx::query("select * from repositories where id = ?")
336+ .bind(id.as_str())
337+ .fetch_optional(&self.pool)
338+ .await
339+ .map_err(backend)?;
340+
341+ row.as_ref().map(Self::map).transpose()
342+ }
343+
344+ async fn find_by_org_and_name(
345+ &self,
346+ org_id: &OrgId,
347+ name: &RepoName,
348+ ) -> RepositoryResult<Option<Repository>> {
349+ let row = sqlx::query("select * from repositories where org_id = ? and name = ?")
350+ .bind(org_id.as_str())
351+ .bind(name.as_str())
352+ .fetch_optional(&self.pool)
353+ .await
354+ .map_err(backend)?;
355+
356+ row.as_ref().map(Self::map).transpose()
357+ }
358+
359+ async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
360+ let rows = sqlx::query("select * from repositories where org_id = ? order by name")
361+ .bind(org_id.as_str())
362+ .fetch_all(&self.pool)
363+ .await
364+ .map_err(backend)?;
365+
366+ rows.iter().map(Self::map).collect()
367+ }
368+
369+ async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
370+ sqlx::query(
371+ "insert into repositories (id, org_id, name, description, visibility)
372+ values (?, ?, ?, ?, ?)
373+ on conflict (id) do update set
374+ org_id = excluded.org_id,
375+ name = excluded.name,
376+ description = excluded.description,
377+ visibility = excluded.visibility",
378+ )
379+ .bind(repo.id.as_str())
380+ .bind(repo.org_id.as_str())
381+ .bind(repo.name.as_str())
382+ .bind(repo.description.as_deref())
383+ .bind(repo.visibility.as_str())
384+ .execute(&self.pool)
385+ .await
386+ .map_err(backend)?;
387+
388+ Ok(())
389+ }
390+}
391+
223392 #[cfg(test)]
224393 mod tests {
225394 use super::*;
@@ -480,83 +649,185 @@ mod tests {
480649 "unique(users.email) closes the other half"
481650 );
482651 }
483}
484
485#[derive(Debug, Clone)]
486pub struct SqliteSessionRepo {
487 pool: SqlitePool,
488}
652+ async fn org_with(orgs: &SqliteOrgRepo, name: &str) -> Organization {
653+ let org = Organization::new(OrgId::generate(), name, None).expect("valid org");
654+ orgs.save(&org).await.expect("save org");
655+ org
656+ }
489657
490impl SqliteSessionRepo {
491 pub fn new(pool: SqlitePool) -> Self {
492 Self { pool }
658+ async fn saved_repo(
659+ repos: &SqliteRepoRepo,
660+ org: &Organization,
661+ name: &str,
662+ visibility: Visibility,
663+ ) -> Repository {
664+ let repo = Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
665+ .expect("valid repo");
666+ repos.save(&repo).await.expect("save repo");
667+ repo
493668 }
494}
495669
496/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in
497/// the future — so saturating at 0 is safe rather than lossy.
498fn to_unix(time: SystemTime) -> i64 {
499 time.duration_since(SystemTime::UNIX_EPOCH)
500 .map(|d| d.as_secs() as i64)
501 .unwrap_or(0)
502}
670+ #[tokio::test]
671+ async fn a_repository_round_trips() {
672+ let pool = test_pool().await;
673+ let orgs = SqliteOrgRepo::new(pool.clone());
674+ let repos = SqliteRepoRepo::new(pool);
675+ let org = org_with(&orgs, "acme").await;
503676
504fn from_unix(seconds: i64) -> SystemTime {
505 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
506}
677+ let repo = Repository::new(
678+ RepoId::generate(),
679+ org.id.clone(),
680+ "steid",
681+ Some("A gitforge.".to_owned()),
682+ Visibility::Private,
683+ )
684+ .expect("valid repo");
685+ repos.save(&repo).await.expect("save");
507686
508impl SessionRepository for SqliteSessionRepo {
509 async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
510 let row = sqlx::query("select * from sessions where token_hash = ?")
511 .bind(token_hash.as_str())
512 .fetch_optional(&self.pool)
687+ let found = repos
688+ .find_by_id(&repo.id)
513689 .await
514 .map_err(backend)?;
690+ .expect("lookup")
691+ .expect("should exist");
515692
516 Ok(row.map(|row| {
517 Session::new(
518 SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")),
519 UserId::from_trusted(row.get::<String, _>("user_id")),
520 from_unix(row.get::<i64, _>("expires_at")),
521 )
522 }))
693+ assert_eq!(found, repo);
694+ assert_eq!(found.visibility, Visibility::Private);
695+ assert_eq!(found.description.as_deref(), Some("A gitforge."));
523696 }
524697
525 async fn save(&self, session: &Session) -> RepositoryResult<()> {
698+ #[tokio::test]
699+ async fn lookup_by_name_is_case_insensitive() {
700+ let pool = test_pool().await;
701+ let orgs = SqliteOrgRepo::new(pool.clone());
702+ let repos = SqliteRepoRepo::new(pool);
703+ let org = org_with(&orgs, "acme").await;
704+ saved_repo(&repos, &org, "steid", Visibility::Public).await;
705+
706+ let found = repos
707+ .find_by_org_and_name(&org.id, &RepoName::from_trusted("STEID"))
708+ .await
709+ .expect("lookup");
710+
711+ assert!(found.is_some(), "collate nocase should make this match");
712+ }
713+
714+ #[tokio::test]
715+ async fn one_owner_cannot_have_two_repositories_with_the_same_name() {
716+ let pool = test_pool().await;
717+ let orgs = SqliteOrgRepo::new(pool.clone());
718+ let repos = SqliteRepoRepo::new(pool);
719+ let org = org_with(&orgs, "acme").await;
720+ saved_repo(&repos, &org, "steid", Visibility::Public).await;
721+
722+ let clash = Repository::new(
723+ RepoId::generate(),
724+ org.id.clone(),
725+ "steid",
726+ None,
727+ Visibility::Public,
728+ )
729+ .expect("valid repo");
730+
731+ assert!(repos.save(&clash).await.is_err(), "unique (org_id, name)");
732+ }
733+
734+ #[tokio::test]
735+ async fn two_owners_may_each_have_a_repository_of_the_same_name() {
736+ let pool = test_pool().await;
737+ let orgs = SqliteOrgRepo::new(pool.clone());
738+ let repos = SqliteRepoRepo::new(pool);
739+ let first = org_with(&orgs, "acme").await;
740+ let second = org_with(&orgs, "globex").await;
741+
742+ saved_repo(&repos, &first, "steid", Visibility::Public).await;
743+ saved_repo(&repos, &second, "steid", Visibility::Public).await;
744+
745+ // The constraint is a pair. On `name` alone, repository names would be globally
746+ // unique across the whole installation.
747+ assert_eq!(repos.list_by_org(&first.id).await.expect("list").len(), 1);
748+ assert_eq!(repos.list_by_org(&second.id).await.expect("list").len(), 1);
749+ }
750+
751+ #[tokio::test]
752+ async fn a_repository_owned_by_a_missing_org_is_refused() {
753+ let pool = test_pool().await;
754+ let repos = SqliteRepoRepo::new(pool);
755+ let orphan = Repository::new(
756+ RepoId::generate(),
757+ OrgId::generate(),
758+ "steid",
759+ None,
760+ Visibility::Public,
761+ )
762+ .expect("valid repo");
763+
764+ assert!(repos.save(&orphan).await.is_err(), "foreign key");
765+ }
766+
767+ #[tokio::test]
768+ async fn an_unreadable_visibility_surfaces_rather_than_defaulting() {
769+ let pool = test_pool().await;
770+ let orgs = SqliteOrgRepo::new(pool.clone());
771+ let repos = SqliteRepoRepo::new(pool.clone());
772+ let org = org_with(&orgs, "acme").await;
773+
526774 sqlx::query(
527 "insert into sessions (token_hash, user_id, expires_at)
528 values (?, ?, ?)
529 on conflict (token_hash) do update set
530 user_id = excluded.user_id,
531 expires_at = excluded.expires_at",
775+ "insert into repositories (id, org_id, name, visibility)
776+ values ('r1', ?, 'secret', 'internal')",
532777 )
533 .bind(session.token_hash.as_str())
534 .bind(session.user_id.as_str())
535 .bind(to_unix(session.expires_at))
536 .execute(&self.pool)
778+ .bind(org.id.as_str())
779+ .execute(&pool)
537780 .await
538 .map_err(backend)?;
781+ .expect("insert");
539782
540 Ok(())
783+ assert!(
784+ repos.find_by_id(&RepoId::from_trusted("r1")).await.is_err(),
785+ "a visibility we cannot parse must not become a guess"
786+ );
541787 }
542788
543 async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
544 sqlx::query("delete from sessions where token_hash = ?")
545 .bind(token_hash.as_str())
546 .execute(&self.pool)
547 .await
548 .map_err(backend)?;
789+ #[tokio::test]
790+ async fn listing_returns_only_that_org_ordered_by_name() {
791+ let pool = test_pool().await;
792+ let orgs = SqliteOrgRepo::new(pool.clone());
793+ let repos = SqliteRepoRepo::new(pool);
794+ let mine = org_with(&orgs, "acme").await;
795+ let theirs = org_with(&orgs, "globex").await;
549796
550 Ok(())
797+ for name in ["zebra", "alpha", "middle"] {
798+ saved_repo(&repos, &mine, name, Visibility::Public).await;
799+ }
800+ saved_repo(&repos, &theirs, "not-mine", Visibility::Public).await;
801+
802+ let listed = repos.list_by_org(&mine.id).await.expect("list");
803+
804+ let names: Vec<&str> = listed.iter().map(|repo| repo.name.as_str()).collect();
805+ assert_eq!(names, vec!["alpha", "middle", "zebra"]);
551806 }
552807
553 async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
554 let result = sqlx::query("delete from sessions where expires_at <= ?")
555 .bind(to_unix(now))
556 .execute(&self.pool)
808+ #[tokio::test]
809+ async fn listing_includes_private_repositories() {
810+ let pool = test_pool().await;
811+ let orgs = SqliteOrgRepo::new(pool.clone());
812+ let repos = SqliteRepoRepo::new(pool);
813+ let org = org_with(&orgs, "acme").await;
814+ saved_repo(&repos, &org, "secret", Visibility::Private).await;
815+
816+ // The port returns everything; filtering by visibility is the use case's job, so
817+ // the page and /api cannot end up applying different rules.
818+ assert_eq!(repos.list_by_org(&org.id).await.expect("list").len(), 1);
819+ }
820+
821+ #[tokio::test]
822+ async fn a_missing_repository_is_none_not_an_error() {
823+ let pool = test_pool().await;
824+ let repos = SqliteRepoRepo::new(pool);
825+
826+ let found = repos
827+ .find_by_id(&RepoId::generate())
557828 .await
558 .map_err(backend)?;
829+ .expect("lookup should not error");
559830
560 Ok(result.rows_affected())
831+ assert_eq!(found, None);
561832 }
562833 }