diff --git a/lexicons/repo/getRepoByName.json b/lexicons/repo/getRepoByName.json new file mode 100644 --- /dev/null +++ b/lexicons/repo/getRepoByName.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.getRepoByName", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["owner", "name"], + "properties": { + "owner": { + "type": "string", + "format": "did", + "description": "DID of the account that owns the repo." + }, + "name": { + "type": "string", + "description": "Name of the repo as it appears in its url." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "value"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + }, + "value": { + "type": "unknown", + "description": "Embedded sh.tangled.repo record." + } + } + } + } + } + } +} diff --git a/bobbin/crates/ingest/src/lib.rs b/bobbin/crates/ingest/src/lib.rs --- a/bobbin/crates/ingest/src/lib.rs +++ b/bobbin/crates/ingest/src/lib.rs @@ -1051,6 +1051,7 @@ record.did.clone(), record.rkey.clone(), repo.repo_did.clone(), + repo.name.clone(), ) .await; if let Some(prior) = superseded { @@ -2882,6 +2883,7 @@ Did::new_owned("did:plc:nel").unwrap(), Rkey::new_owned("abcabcabcabcz").unwrap(), Some(Did::new_owned("did:plc:abalone").unwrap()), + None, ) .await; let issue: HydrantFrame = parse_frame(json!({ @@ -2942,6 +2944,7 @@ Did::new_owned("did:plc:nel").unwrap(), Rkey::new_owned("abcabcabcabcz").unwrap(), Some(Did::new_owned("did:plc:abalone").unwrap()), + None, ) .await; let search = RecordingSearchSink::default(); @@ -2986,6 +2989,102 @@ } #[tokio::test] + async fn repo_record_indexes_its_rkey_and_name() { + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); + let repo: HydrantFrame = parse_frame(json!({ + "id": 1, + "type": "record", + "record": { + "live": false, + "did": "did:plc:nel", + "rev": fresh_tid().as_str(), + "collection": "sh.tangled.repo", + "rkey": "abcabcabcabcz", + "action": "create", + "record": { + "$type": "sh.tangled.repo", + "createdAt": "2026-05-01T00:00:00Z", + "knot": "oyster.cafe", + "name": "abalone" + } + } + })); + handle_frame( + repo, + &store, + &issue_states, + &pull_statuses, + &cov, + &NoopSearchSink, + &NoopRecordStore, + &resolver, + &sys_clock(), + now(), + ) + .await; + let owner = Did::new_owned("did:plc:nel").unwrap(); + assert_eq!( + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, + Some(bobbin_types::ids::RepoIdent::new( + owner.clone(), + Rkey::new_owned("abcabcabcabcz").unwrap() + )), + ); + assert_eq!( + resolver.lookup_by_name(&owner, "abalone").await, + Some(bobbin_types::ids::RepoIdent::new( + owner.clone(), + Rkey::new_owned("abcabcabcabcz").unwrap() + )), + "the record's cosmetic name resolves to its rkey ident", + ); + } + + #[tokio::test] + async fn repo_record_without_a_name_indexes_its_rkey() { + let (store, issue_states, pull_statuses, cov, resolver) = fresh(); + let repo: HydrantFrame = parse_frame(json!({ + "id": 1, + "type": "record", + "record": { + "live": false, + "did": "did:plc:nel", + "rev": fresh_tid().as_str(), + "collection": "sh.tangled.repo", + "rkey": "abalone", + "action": "create", + "record": { + "$type": "sh.tangled.repo", + "createdAt": "2026-05-01T00:00:00Z", + "knot": "oyster.cafe" + } + } + })); + handle_frame( + repo, + &store, + &issue_states, + &pull_statuses, + &cov, + &NoopSearchSink, + &NoopRecordStore, + &resolver, + &sys_clock(), + now(), + ) + .await; + let owner = Did::new_owned("did:plc:nel").unwrap(); + assert_eq!( + resolver.lookup_by_name(&owner, "abalone").await, + Some(bobbin_types::ids::RepoIdent::new( + owner, + Rkey::new_owned("abalone").unwrap() + )), + "repos made before the name field are only reachable by rkey", + ); + } + + #[tokio::test] async fn delete_repo_record_evicts_resolver_cache() { let (store, issue_states, pull_statuses, cov, resolver) = fresh(); let owner = Did::new_owned("did:plc:nel").unwrap(); @@ -2995,8 +3094,10 @@ owner.clone(), rkey.clone(), Some(Did::new_owned("did:plc:abalone").unwrap()), + None, ) .await; + resolver.observe_rkey(owner.clone(), rkey.clone()).await; assert!( resolver.cached_resolution(&owner, &rkey).await.is_some(), "observe must seed the cache", @@ -3029,6 +3130,11 @@ assert!( resolver.cached_resolution(&owner, &rkey).await.is_none(), "deleting the repo record must clear the resolver cache so future observes are not blocked by a stale Authoritative entry", + ); + assert_eq!( + resolver.lookup_by_name(&owner, "abcabcabcabcz").await, + None, + "a deleted repo must stop answering on its url", ); } @@ -3181,7 +3287,7 @@ let slow_rkeys: [Rkey; 2] = [rkey("slowrkeyaa01"), rkey("slowrkeyaa02")]; for r in &fast_rkeys { resolver - .observe(owner.clone(), r.clone(), Some(abalone.clone())) + .observe(owner.clone(), r.clone(), Some(abalone.clone()), None) .await; } diff --git a/bobbin/crates/resolver/src/legacy_upgrade.rs b/bobbin/crates/resolver/src/legacy_upgrade.rs --- a/bobbin/crates/resolver/src/legacy_upgrade.rs +++ b/bobbin/crates/resolver/src/legacy_upgrade.rs @@ -750,6 +750,7 @@ did("did:plc:scallop"), rkey("limpet"), Some(did("did:plc:scallop")), + None, ) .await; let canon = match decoded { @@ -781,7 +782,7 @@ let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); resolver - .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop"))) + .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")), None) .await; let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; let legacy = @@ -918,6 +919,7 @@ did("did:plc:nel"), rkey("abcabcabcabcz"), Some(did("did:plc:scallop")), + None, ) .await; let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","repoDid":""}}"#; @@ -1032,7 +1034,7 @@ let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); resolver - .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop"))) + .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")), None) .await; let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#; let legacy = diff --git a/bobbin/crates/resolver/src/lib.rs b/bobbin/crates/resolver/src/lib.rs --- a/bobbin/crates/resolver/src/lib.rs +++ b/bobbin/crates/resolver/src/lib.rs @@ -21,7 +21,7 @@ use jacquard_common::types::did::Did; use jacquard_common::types::nsid::Nsid; use jacquard_common::types::recordkey::Rkey; -use scc::HashMap as SccMap; +use scc::{HashMap as SccMap, HashSet as SccSet}; use tokio::sync::OnceCell; use tracing::warn; @@ -169,6 +169,8 @@ pub struct RepoIdResolver { cache: SccMap, by_repo_did: SccMap, RepoIdent, RuntimeHasher>, + by_rkey: SccSet, + by_name: SccMap<(Did, DefaultStr), RepoIdent, RuntimeHasher>, in_flight: SccMap>, RuntimeHasher>, probe: Option, stats: ResolverStats, @@ -183,6 +185,8 @@ Self { cache: SccMap::with_hasher(hasher.clone()), by_repo_did: SccMap::with_hasher(hasher.clone()), + by_rkey: SccSet::with_hasher(hasher.clone()), + by_name: SccMap::with_hasher(hasher.clone()), in_flight: SccMap::with_hasher(hasher), probe: Some(SlingshotProbe { client, clock }), stats: ResolverStats::default(), @@ -193,6 +197,8 @@ Self { cache: SccMap::with_hasher(hasher.clone()), by_repo_did: SccMap::with_hasher(hasher.clone()), + by_rkey: SccSet::with_hasher(hasher.clone()), + by_name: SccMap::with_hasher(hasher.clone()), in_flight: SccMap::with_hasher(hasher), probe: None, stats: ResolverStats::default(), @@ -226,13 +232,40 @@ .map(|e| e.get().clone()) } + pub async fn lookup_by_name(&self, owner: &Did, name: &str) -> Option { + // rkey addressing wins so a record can't hijack another repo's rkey via its name + if let Ok(rkey) = Rkey::new_owned(name) { + let ident = RepoIdent::new(owner.clone(), rkey); + if self.by_rkey.contains_async(&ident).await { + return Some(ident); + } + } + self.by_name + .get_async(&(owner.clone(), DefaultStr::from(name))) + .await + .map(|e| e.get().clone()) + } + + pub async fn observe_rkey(&self, owner: Did, rkey: Rkey) { + let _ = self.by_rkey.insert_async(RepoIdent::new(owner, rkey)).await; + } + pub async fn observe( &self, owner: Did, rkey: Rkey, repo_did: Option>, + name: Option, ) -> Option { let ident = RepoIdent::new(owner, rkey); + self.observe_rkey(ident.owner.clone(), ident.rkey.clone()) + .await; + if let Some(name) = name { + let _ = self + .by_name + .insert_async((ident.owner.clone(), name), ident.clone()) + .await; + } let entry = CacheEntry::Authoritative(AuthoritativeResolution::from_repo_did(repo_did.clone())); self.cache @@ -268,6 +301,7 @@ .remove_if_async(&repo_did, |existing| *existing == ident) .await; } + self.by_rkey.remove_async(&ident).await; } async fn fill_provisional(&self, key: RepoIdent, resolution: Resolution) { @@ -454,12 +488,13 @@ did("did:plc:nel"), rkey("3liuighjy2h22"), Some(did("did:plc:clam")), + None, ) .await; assert!(prior.is_none(), "first observation has no prior"); let prior = resolver - .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam"))) + .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam")), None) .await; assert_eq!( prior, @@ -468,7 +503,7 @@ ); let prior = resolver - .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam"))) + .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam")), None) .await; assert!(prior.is_none(), "re-observing the same ident is a no-op"); } @@ -477,7 +512,7 @@ async fn observation_without_repo_did_does_not_track_reverse() { let resolver = RepoIdResolver::detached(RuntimeHasher::default()); let prior = resolver - .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None, None) .await; assert!(prior.is_none()); } @@ -490,10 +525,11 @@ did("did:plc:nel"), rkey("3liuighjy2h22"), Some(did("did:plc:clam")), + None, ) .await; resolver - .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam"))) + .observe(did("did:plc:nel"), rkey("core"), Some(did("did:plc:clam")), None) .await; resolver @@ -505,6 +541,7 @@ did("did:plc:nel"), rkey("core-renamed"), Some(did("did:plc:clam")), + None, ) .await; assert_eq!( @@ -515,6 +552,110 @@ } #[tokio::test] + async fn lookup_by_name_finds_observed_rkey() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) + .await; + let got = resolver + .lookup_by_name(&did("did:plc:nel"), "3liuighjy2h22") + .await; + assert_eq!( + got, + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), + ); + } + + #[tokio::test] + async fn lookup_by_name_is_scoped_to_the_owner() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe_rkey(did("did:plc:nel"), rkey("3liuighjy2h22")) + .await; + let got = resolver + .lookup_by_name(&did("did:plc:olaren"), "3liuighjy2h22") + .await; + assert_eq!(got, None, "one owner's rkey must not answer for another's"); + } + + #[tokio::test] + async fn lookup_by_name_rejects_non_rkey() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + let owner = did("did:plc:nel"); + resolver + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) + .await; + + assert_eq!(resolver.lookup_by_name(&owner, "my repo").await, None); + } + + #[tokio::test] + async fn lookup_by_name_finds_observed_record_name() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe( + did("did:plc:nel"), + rkey("3liuighjy2h22"), + Some(did("did:plc:clam")), + Some(DefaultStr::from("ark")), + ) + .await; + let got = resolver.lookup_by_name(&did("did:plc:nel"), "ark").await; + assert_eq!( + got, + Some(RepoIdent::new(did("did:plc:nel"), rkey("3liuighjy2h22"))), + ); + } + + #[tokio::test] + async fn lookup_by_name_record_name_is_scoped_to_the_owner() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe( + did("did:plc:nel"), + rkey("3liuighjy2h22"), + None, + Some(DefaultStr::from("ark")), + ) + .await; + let got = resolver.lookup_by_name(&did("did:plc:olaren"), "ark").await; + assert_eq!(got, None, "one owner's repo name must not answer for another's"); + } + + #[tokio::test] + async fn lookup_by_name_prefers_rkey_over_claimed_name() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + resolver + .observe(did("did:plc:nel"), rkey("core"), None, None) + .await; + resolver + .observe( + did("did:plc:nel"), + rkey("3liuighjy2h22"), + None, + Some(DefaultStr::from("core")), + ) + .await; + let got = resolver.lookup_by_name(&did("did:plc:nel"), "core").await; + assert_eq!( + got, + Some(RepoIdent::new(did("did:plc:nel"), rkey("core"))), + "a record naming itself after a live rkey must not shadow it", + ); + } + + #[tokio::test] + async fn forget_clears_the_rkey() { + let resolver = RepoIdResolver::detached(RuntimeHasher::default()); + let owner = did("did:plc:nel"); + resolver + .observe_rkey(owner.clone(), rkey("3liuighjy2h22")) + .await; + resolver.forget(&owner, &rkey("3liuighjy2h22")).await; + assert_eq!(resolver.lookup_by_name(&owner, "3liuighjy2h22").await, None); + } + + #[tokio::test] async fn observation_with_repo_did_resolves_mapped() { let resolver = RepoIdResolver::detached(RuntimeHasher::default()); resolver @@ -522,6 +663,7 @@ did("did:plc:nel"), rkey("abcabcabcabcz"), Some(did("did:plc:clam")), + None, ) .await; let got = resolver @@ -534,7 +676,7 @@ async fn observation_without_repo_did_resolves_no_repo_did() { let resolver = RepoIdResolver::detached(RuntimeHasher::default()); resolver - .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None, None) .await; let got = resolver .resolve(&did("did:plc:nel"), &rkey("abcabcabcabcz")) @@ -563,6 +705,7 @@ did("did:plc:nel"), rkey("abcabcabcabcz"), Some(did("did:plc:limpet")), + None, ) .await; let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; @@ -583,7 +726,7 @@ async fn lookup_by_repo_did_misses_when_repo_did_was_none() { let resolver = RepoIdResolver::detached(RuntimeHasher::default()); resolver - .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None) + .observe(did("did:plc:nel"), rkey("abcabcabcabcz"), None, None) .await; let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; assert_eq!(got, None); @@ -597,6 +740,7 @@ did("did:plc:nel"), rkey("abcabcabcabcz"), Some(did("did:plc:limpet")), + None, ) .await; resolver @@ -604,6 +748,7 @@ did("did:plc:olaren"), rkey("xyzxyzxyzxyzx"), Some(did("did:plc:limpet")), + None, ) .await; let got = resolver.lookup_by_repo_did(&did("did:plc:limpet")).await; @@ -621,6 +766,7 @@ did("did:plc:nel"), rkey("abcabcabcabcz"), Some(did("did:plc:clam")), + None, ) .await; resolver @@ -628,6 +774,7 @@ did("did:plc:nel"), rkey("abcabcabcabcz"), Some(did("did:plc:uni")), + None, ) .await; let got = resolver @@ -642,7 +789,7 @@ let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); resolver - .observe(owner.clone(), key.clone(), Some(did("did:plc:clam"))) + .observe(owner.clone(), key.clone(), Some(did("did:plc:clam")), None) .await; resolver .fill_provisional( @@ -663,7 +810,7 @@ let resolver = RepoIdResolver::detached(RuntimeHasher::default()); let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); - resolver.observe(owner.clone(), key.clone(), None).await; + resolver.observe(owner.clone(), key.clone(), None, None).await; resolver .fill_provisional( RepoIdent::new(owner.clone(), key.clone()), @@ -1003,7 +1150,7 @@ Resolution::Mapped(did("did:plc:clam")), ) .await; - resolver.observe(owner.clone(), key.clone(), None).await; + resolver.observe(owner.clone(), key.clone(), None, None).await; let got = resolver.resolve(&owner, &key).await; assert_eq!( got, @@ -1018,7 +1165,7 @@ let owner = did("did:plc:nel"); let key = rkey("abcabcabcabcz"); resolver - .observe(owner.clone(), key.clone(), Some(did("did:plc:clam"))) + .observe(owner.clone(), key.clone(), Some(did("did:plc:clam")), None) .await; assert_eq!( resolver.cached_resolution(&owner, &key).await, diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -181,6 +181,7 @@ "/xrpc/sh.tangled.repo.getReposByRepoDids", get(get_repos_by_repo_dids), ) + .route("/xrpc/sh.tangled.repo.getRepoByName", get(get_repo_by_name)) .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) @@ -605,6 +606,12 @@ struct GetRepoByRepoDidQuery { #[serde(rename = "repoDid")] repo_did: Did, +} + +#[derive(Debug, Deserialize)] +struct GetRepoByNameQuery { + owner: Did, + name: String, } #[derive(Debug, Deserialize)] @@ -1275,6 +1282,29 @@ let ident = state .resolver .lookup_by_repo_did(&q.repo_did) + .await + .ok_or(XrpcError::NotFound)?; + let uri = AtUri::::from_parts_owned( + ident.owner.as_str(), + RepoRecord::NSID, + ident.rkey.as_str(), + ) + .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"); + let (body, value) = fetch_from_uri::>(&state, uri).await?; + Ok(Json(Deduped(RepoGetRecordOutput { + cid: Some(body.cid.clone()), + uri: body.uri.clone(), + value, + }))) +} + +async fn get_repo_by_name( + State(state): State, + XrpcQuery(q): XrpcQuery, +) -> Result>>, XrpcError> { + let ident = state + .resolver + .lookup_by_name(&q.owner, &q.name) .await .ok_or(XrpcError::NotFound)?; let uri = AtUri::::from_parts_owned( diff --git a/bobbin/crates/xrpc/tests/cold_start.rs b/bobbin/crates/xrpc/tests/cold_start.rs --- a/bobbin/crates/xrpc/tests/cold_start.rs +++ b/bobbin/crates/xrpc/tests/cold_start.rs @@ -90,6 +90,13 @@ .unwrap() } +fn xrpc_request2(endpoint: &str, a: (&str, &str), b: (&str, &str)) -> Request { + Request::builder() + .uri(format!("/xrpc/{endpoint}?{}={}&{}={}", a.0, a.1, b.0, b.1)) + .body(Body::empty()) + .unwrap() +} + fn xrpc_request_escaped(endpoint: &str, param: &str, value: &str) -> Request { let encoded: String = byte_serialize(value.as_bytes()).collect(); Request::builder() @@ -640,7 +647,7 @@ let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; state .resolver - .observe(owner_did.clone(), rk.clone(), Some(repo_did.clone())) + .observe(owner_did.clone(), rk.clone(), Some(repo_did.clone()), None) .await; let app = router(state); @@ -664,6 +671,106 @@ ); assert_eq!(body["value"]["name"], "scallop"); assert_eq!(body["value"]["repoDid"], repo_did.as_ref()); +} + +#[tokio::test] +async fn get_repo_by_name_returns_observed_record() { + let server = MockServer::start().await; + let owner_did = did("did:plc:scallop"); + let rk = rkey("core"); + mount_record( + &server, + &owner_did, + &nsid("sh.tangled.repo"), + &rk, + json!({ + "$type": "sh.tangled.repo", + "name": "core", + "knot": "oyster.cafe", + "createdAt": "2026-05-01T00:00:00Z", + }), + ) + .await; + + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + state + .resolver + .observe_rkey(owner_did.clone(), rk.clone()) + .await; + + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", owner_did.as_ref()), + // the query name is the url rkey; the record's `name` is cosmetic + ("name", rk.as_ref()), + )) + .await + .unwrap(); + let (status, body) = json_response(resp).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["uri"], + format!( + "at://{}/sh.tangled.repo/{}", + owner_did.as_ref(), + rk.as_ref() + ) + ); + assert_eq!(body["value"]["name"], "core"); +} + +#[tokio::test] +async fn get_repo_by_name_404_when_unobserved() { + let server = MockServer::start().await; + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", "did:plc:scallop"), + ("name", "core"), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_repo_by_name_404_for_another_owners_name() { + let server = MockServer::start().await; + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + state + .resolver + .observe_rkey(did("did:plc:scallop"), rkey("core")) + .await; + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", "did:plc:whelk"), + ("name", "core"), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_repo_by_name_400_on_invalid_owner() { + let server = MockServer::start().await; + let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await; + let app = router(state); + let resp = app + .oneshot(xrpc_request2( + "sh.tangled.repo.getRepoByName", + ("owner", "not-a-did"), + ("name", "core"), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test]