This repository has no description
1use std::sync::Arc;
2
3use axum::body::{Body, to_bytes};
4use bobbin_edge_index::{CoverageWatch, EdgeStore, StateIndex};
5use bobbin_knot_proxy::{KnotHttpConfig, KnotProxy, KnotProxyConfig};
6use bobbin_record_lru::{CacheCapacity, LruRecordStore};
7use bobbin_resolver::RepoIdResolver;
8use bobbin_runtime::{RuntimeHasher, SystemClock};
9use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader};
10use bobbin_slingshot_client::SlingshotClient;
11use bobbin_xrpc::{AppState, router};
12use futures::stream::{self, StreamExt};
13use http::{Request, StatusCode};
14use jacquard_common::DefaultStr;
15use jacquard_common::types::did::Did;
16use jacquard_common::types::nsid::Nsid;
17use jacquard_common::types::recordkey::Rkey;
18use serde_json::{Value, json};
19use tower::ServiceExt;
20use url::Url;
21use url::form_urlencoded::byte_serialize;
22use wiremock::matchers::{method, path, query_param};
23use wiremock::{Mock, MockServer, ResponseTemplate};
24
25const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i";
26
27fn did(s: &str) -> Did<DefaultStr> {
28 Did::new_owned(s).unwrap()
29}
30
31fn rkey(s: &str) -> Rkey<DefaultStr> {
32 Rkey::new_owned(s).unwrap()
33}
34
35fn nsid(s: &'static str) -> Nsid<DefaultStr> {
36 Nsid::new_static(s).unwrap()
37}
38
39async fn fresh_app(server_uri: &Url) -> AppState {
40 AppState::new(
41 Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))),
42 SlingshotClient::with_default_http(server_uri.clone()).unwrap(),
43 Arc::new(EdgeStore::new(RuntimeHasher::default())),
44 Arc::new(StateIndex::new(RuntimeHasher::default())),
45 Arc::new(StateIndex::new(RuntimeHasher::default())),
46 Arc::new(CoverageWatch::new()),
47 Arc::new(
48 KnotProxy::new(
49 KnotProxyConfig::default(),
50 KnotHttpConfig::default(),
51 Arc::new(SystemClock::new()),
52 RuntimeHasher::default(),
53 )
54 .unwrap(),
55 ),
56 Arc::new(SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap())
57 as Arc<dyn SearchReader>,
58 Arc::new(RepoIdResolver::detached(RuntimeHasher::default())),
59 Arc::new(bobbin_xrpc::default_directory()),
60 )
61}
62
63async fn mount_record(
64 server: &MockServer,
65 did: &Did<DefaultStr>,
66 collection: &Nsid<DefaultStr>,
67 rkey: &Rkey<DefaultStr>,
68 value: Value,
69) {
70 let uri = format!(
71 "at://{}/{}/{}",
72 did.as_ref(),
73 collection.as_ref(),
74 rkey.as_ref()
75 );
76 let body = json!({ "uri": uri, "cid": CID, "value": value });
77 Mock::given(method("GET"))
78 .and(path("/xrpc/com.atproto.repo.getRecord"))
79 .and(query_param("repo", did.as_ref()))
80 .and(query_param("collection", collection.as_ref()))
81 .and(query_param("rkey", rkey.as_ref()))
82 .respond_with(ResponseTemplate::new(200).set_body_json(body))
83 .mount(server)
84 .await;
85}
86
87fn xrpc_request(endpoint: &str, param: &str, value: &str) -> Request<Body> {
88 Request::builder()
89 .uri(format!("/xrpc/{endpoint}?{param}={value}"))
90 .body(Body::empty())
91 .unwrap()
92}
93
94fn xrpc_request2(endpoint: &str, a: (&str, &str), b: (&str, &str)) -> Request<Body> {
95 Request::builder()
96 .uri(format!("/xrpc/{endpoint}?{}={}&{}={}", a.0, a.1, b.0, b.1))
97 .body(Body::empty())
98 .unwrap()
99}
100
101fn xrpc_request_escaped(endpoint: &str, param: &str, value: &str) -> Request<Body> {
102 let encoded: String = byte_serialize(value.as_bytes()).collect();
103 Request::builder()
104 .uri(format!("/xrpc/{endpoint}?{param}={encoded}"))
105 .body(Body::empty())
106 .unwrap()
107}
108
109async fn json_response(resp: axum::response::Response) -> (StatusCode, Value) {
110 let status = resp.status();
111 let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap();
112 let parsed: Value = serde_json::from_slice(&bytes).expect("response is JSON");
113 (status, parsed)
114}
115
116#[tokio::test]
117async fn cold_start_serves_all_four_point_lookups() {
118 let server = MockServer::start().await;
119 let clam = did("did:plc:clam");
120
121 mount_record(
122 &server,
123 &clam,
124 &nsid("sh.tangled.repo"),
125 &rkey("r1"),
126 json!({
127 "$type": "sh.tangled.repo",
128 "name": "clam",
129 "knot": "oyster.cafe",
130 "createdAt": "2026-05-01T00:00:00Z"
131 }),
132 )
133 .await;
134
135 mount_record(
136 &server,
137 &clam,
138 &nsid("sh.tangled.actor.profile"),
139 &rkey("self"),
140 json!({
141 "$type": "sh.tangled.actor.profile",
142 "bluesky": false,
143 "description": "clam shell"
144 }),
145 )
146 .await;
147
148 mount_record(
149 &server,
150 &clam,
151 &nsid("sh.tangled.repo.issue"),
152 &rkey("i1"),
153 json!({
154 "$type": "sh.tangled.repo.issue",
155 "repo": "did:plc:limpet",
156 "title": "broken",
157 "createdAt": "2026-05-01T00:00:00Z"
158 }),
159 )
160 .await;
161
162 mount_record(
163 &server,
164 &clam,
165 &nsid("sh.tangled.repo.pull"),
166 &rkey("p1"),
167 json!({
168 "$type": "sh.tangled.repo.pull",
169 "title": "ship",
170 "createdAt": "2026-05-01T00:00:00Z",
171 "rounds": [],
172 "target": {"repo": "did:plc:limpet", "branch": "main"}
173 }),
174 )
175 .await;
176
177 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
178 let app = router(state);
179
180 let cases = [
181 (
182 "sh.tangled.repo.getRepo",
183 "repo",
184 format!("at://{}/sh.tangled.repo/r1", clam.as_ref()),
185 "knot",
186 json!("oyster.cafe"),
187 ),
188 (
189 "sh.tangled.actor.getProfile",
190 "actor",
191 format!("at://{}/sh.tangled.actor.profile/self", clam.as_ref()),
192 "description",
193 json!("clam shell"),
194 ),
195 (
196 "sh.tangled.repo.getIssue",
197 "issue",
198 format!("at://{}/sh.tangled.repo.issue/i1", clam.as_ref()),
199 "title",
200 json!("broken"),
201 ),
202 (
203 "sh.tangled.repo.getPull",
204 "pull",
205 format!("at://{}/sh.tangled.repo.pull/p1", clam.as_ref()),
206 "title",
207 json!("ship"),
208 ),
209 ];
210
211 stream::iter(cases)
212 .for_each(|(endpoint, param, at_uri, field, expected)| {
213 let app = app.clone();
214 async move {
215 let resp = app
216 .oneshot(xrpc_request(endpoint, param, &at_uri))
217 .await
218 .unwrap();
219 let (status, body) = json_response(resp).await;
220 assert_eq!(status, StatusCode::OK, "{endpoint} status");
221 assert_eq!(body["uri"], at_uri, "{endpoint} uri");
222 assert_eq!(body["cid"], CID, "{endpoint} cid");
223 assert_eq!(
224 body["value"][field], expected,
225 "{endpoint} body field {field}"
226 );
227 }
228 })
229 .await;
230}
231
232#[tokio::test]
233async fn percent_escaped_at_uri_resolves_identically_to_raw() {
234 let server = MockServer::start().await;
235 let clam = did("did:plc:clam");
236 mount_record(
237 &server,
238 &clam,
239 &nsid("sh.tangled.actor.profile"),
240 &rkey("self"),
241 json!({
242 "$type": "sh.tangled.actor.profile",
243 "bluesky": false,
244 "description": "clam shell"
245 }),
246 )
247 .await;
248
249 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
250 let app = router(state);
251
252 let at_uri = format!("at://{}/sh.tangled.actor.profile/self", clam.as_ref());
253
254 let (raw_status, raw_body) = json_response(
255 app.clone()
256 .oneshot(xrpc_request(
257 "sh.tangled.actor.getProfile",
258 "actor",
259 &at_uri,
260 ))
261 .await
262 .unwrap(),
263 )
264 .await;
265 let (escaped_status, escaped_body) = json_response(
266 app.oneshot(xrpc_request_escaped(
267 "sh.tangled.actor.getProfile",
268 "actor",
269 &at_uri,
270 ))
271 .await
272 .unwrap(),
273 )
274 .await;
275
276 assert_eq!(raw_status, StatusCode::OK, "raw at-uri status");
277 assert_eq!(escaped_status, StatusCode::OK, "escaped at-uri status");
278 assert_eq!(
279 raw_body, escaped_body,
280 "raw and escaped must resolve identically"
281 );
282 assert_eq!(escaped_body["uri"], at_uri);
283}
284
285#[tokio::test]
286async fn second_call_is_served_from_lru() {
287 let server = MockServer::start().await;
288 let uni = did("did:plc:uni");
289 let mock = Mock::given(method("GET"))
290 .and(path("/xrpc/com.atproto.repo.getRecord"))
291 .and(query_param("repo", uni.as_ref()))
292 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
293 "uri": format!("at://{}/sh.tangled.repo/r1", uni.as_ref()),
294 "cid": CID,
295 "value": {
296 "$type": "sh.tangled.repo",
297 "name": "uni",
298 "knot": "witchcraft.systems",
299 "createdAt": "2026-05-01T00:00:00Z"
300 }
301 })))
302 .expect(1)
303 .mount_as_scoped(&server)
304 .await;
305
306 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
307 let app = router(state);
308 let req_uri = format!("at://{}/sh.tangled.repo/r1", uni.as_ref());
309
310 stream::iter(0..3)
311 .for_each(|_| {
312 let app = app.clone();
313 let req_uri = req_uri.clone();
314 async move {
315 let resp = app
316 .oneshot(xrpc_request("sh.tangled.repo.getRepo", "repo", &req_uri))
317 .await
318 .unwrap();
319 assert_eq!(resp.status(), StatusCode::OK);
320 }
321 })
322 .await;
323
324 drop(mock);
325}
326
327#[tokio::test]
328async fn collection_mismatch_is_400() {
329 let server = MockServer::start().await;
330 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
331 let app = router(state);
332 let resp = app
333 .oneshot(xrpc_request(
334 "sh.tangled.repo.getRepo",
335 "repo",
336 "at://did:plc:clam/sh.tangled.actor.profile/self",
337 ))
338 .await
339 .unwrap();
340 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
341}
342
343#[tokio::test]
344async fn handle_authority_is_400() {
345 let server = MockServer::start().await;
346 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
347 let app = router(state);
348 let resp = app
349 .oneshot(xrpc_request(
350 "sh.tangled.repo.getRepo",
351 "repo",
352 "at://witchcraft.systems/sh.tangled.repo/r1",
353 ))
354 .await
355 .unwrap();
356 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
357}
358
359#[tokio::test]
360async fn slingshot_404_propagates_as_404() {
361 let server = MockServer::start().await;
362 Mock::given(method("GET"))
363 .and(path("/xrpc/com.atproto.repo.getRecord"))
364 .respond_with(ResponseTemplate::new(404))
365 .mount(&server)
366 .await;
367 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
368 let app = router(state);
369 let resp = app
370 .oneshot(xrpc_request(
371 "sh.tangled.repo.getRepo",
372 "repo",
373 "at://did:plc:clam/sh.tangled.repo/missing",
374 ))
375 .await
376 .unwrap();
377 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
378}
379
380#[tokio::test]
381async fn wrong_record_type_is_502() {
382 let server = MockServer::start().await;
383 mount_record(
384 &server,
385 &did("did:plc:clam"),
386 &nsid("sh.tangled.repo"),
387 &rkey("r1"),
388 json!({
389 "$type": "sh.tangled.knot",
390 "knot": "oyster.cafe",
391 "createdAt": "2026-05-01T00:00:00Z"
392 }),
393 )
394 .await;
395 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
396 let app = router(state);
397 let resp = app
398 .oneshot(xrpc_request(
399 "sh.tangled.repo.getRepo",
400 "repo",
401 "at://did:plc:clam/sh.tangled.repo/r1",
402 ))
403 .await
404 .unwrap();
405 let (status, body) = json_response(resp).await;
406 assert_eq!(status, StatusCode::BAD_GATEWAY);
407 assert_eq!(body["error"], "InvalidRecord");
408}
409
410#[tokio::test]
411async fn wrong_type_does_not_poison_cache() {
412 let server = MockServer::start().await;
413 let mock = Mock::given(method("GET"))
414 .and(path("/xrpc/com.atproto.repo.getRecord"))
415 .and(query_param("repo", "did:plc:clam"))
416 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
417 "uri": "at://did:plc:clam/sh.tangled.repo/r1",
418 "cid": CID,
419 "value": {
420 "$type": "sh.tangled.knot",
421 "knot": "oyster.cafe",
422 "createdAt": "2026-05-01T00:00:00Z"
423 }
424 })))
425 .expect(2)
426 .mount_as_scoped(&server)
427 .await;
428 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
429 let app = router(state);
430 let req = || {
431 xrpc_request(
432 "sh.tangled.repo.getRepo",
433 "repo",
434 "at://did:plc:clam/sh.tangled.repo/r1",
435 )
436 };
437 let first = app.clone().oneshot(req()).await.unwrap();
438 assert_eq!(first.status(), StatusCode::BAD_GATEWAY);
439 let second = app.clone().oneshot(req()).await.unwrap();
440 assert_eq!(second.status(), StatusCode::BAD_GATEWAY);
441 drop(mock);
442}
443
444#[tokio::test]
445async fn profile_with_empty_preferred_handle_is_tolerated() {
446 let server = MockServer::start().await;
447 let nel = did("did:plc:nel");
448 mount_record(
449 &server,
450 &nel,
451 &nsid("sh.tangled.actor.profile"),
452 &rkey("self"),
453 json!({
454 "$type": "sh.tangled.actor.profile",
455 "bluesky": true,
456 "preferredHandle": "",
457 "description": "empty handle, valid profile"
458 }),
459 )
460 .await;
461 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
462 let app = router(state);
463 let at_uri = format!("at://{}/sh.tangled.actor.profile/self", nel.as_ref());
464 let resp = app
465 .oneshot(xrpc_request(
466 "sh.tangled.actor.getProfile",
467 "actor",
468 &at_uri,
469 ))
470 .await
471 .unwrap();
472 let (status, body) = json_response(resp).await;
473 assert_eq!(status, StatusCode::OK, "status: {body}");
474 assert_eq!(body["uri"], at_uri);
475 assert_eq!(body["value"]["description"], "empty handle, valid profile");
476 assert!(body["value"]["preferredHandle"].is_null());
477}
478
479#[tokio::test]
480async fn missing_uri_param_returns_json_envelope() {
481 let server = MockServer::start().await;
482 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
483 let app = router(state);
484 let req = Request::builder()
485 .uri("/xrpc/sh.tangled.repo.getRepo")
486 .body(Body::empty())
487 .unwrap();
488 let resp = app.oneshot(req).await.unwrap();
489 let (status, body) = json_response(resp).await;
490 assert_eq!(status, StatusCode::BAD_REQUEST);
491 assert_eq!(body["error"], "InvalidRequest");
492 assert!(body["message"].is_string());
493}
494
495#[tokio::test]
496async fn malformed_at_uri_returns_400_envelope() {
497 let server = MockServer::start().await;
498 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
499 let app = router(state);
500 let resp = app
501 .oneshot(xrpc_request(
502 "sh.tangled.repo.getRepo",
503 "repo",
504 "definitely-not-an-at-uri",
505 ))
506 .await
507 .unwrap();
508 let (status, body) = json_response(resp).await;
509 assert_eq!(status, StatusCode::BAD_REQUEST);
510 assert_eq!(body["error"], "InvalidRequest");
511}
512
513#[tokio::test]
514async fn upstream_uri_mismatch_routes_to_invalid_record() {
515 let server = MockServer::start().await;
516 Mock::given(method("GET"))
517 .and(path("/xrpc/com.atproto.repo.getRecord"))
518 .and(query_param("repo", "did:plc:clam"))
519 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
520 "uri": "at://did:plc:limpet/sh.tangled.repo/elsewhere",
521 "cid": CID,
522 "value": {
523 "$type": "sh.tangled.repo",
524 "knot": "oyster.cafe",
525 "createdAt": "2026-05-01T00:00:00Z"
526 }
527 })))
528 .mount(&server)
529 .await;
530 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
531 let app = router(state);
532 let resp = app
533 .oneshot(xrpc_request(
534 "sh.tangled.repo.getRepo",
535 "repo",
536 "at://did:plc:clam/sh.tangled.repo/r1",
537 ))
538 .await
539 .unwrap();
540 let (status, body) = json_response(resp).await;
541 assert_eq!(status, StatusCode::BAD_GATEWAY);
542 assert_eq!(body["error"], "InvalidRecord");
543}
544
545#[tokio::test]
546async fn upstream_garbage_cid_routes_to_invalid_record() {
547 let server = MockServer::start().await;
548 Mock::given(method("GET"))
549 .and(path("/xrpc/com.atproto.repo.getRecord"))
550 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
551 "uri": "at://did:plc:clam/sh.tangled.repo/r1",
552 "cid": "not-a-real-cid",
553 "value": {
554 "$type": "sh.tangled.repo",
555 "knot": "oyster.cafe",
556 "createdAt": "2026-05-01T00:00:00Z"
557 }
558 })))
559 .mount(&server)
560 .await;
561 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
562 let app = router(state);
563 let resp = app
564 .oneshot(xrpc_request(
565 "sh.tangled.repo.getRepo",
566 "repo",
567 "at://did:plc:clam/sh.tangled.repo/r1",
568 ))
569 .await
570 .unwrap();
571 let (status, body) = json_response(resp).await;
572 assert_eq!(status, StatusCode::BAD_GATEWAY);
573 assert_eq!(body["error"], "InvalidRecord");
574}
575
576#[tokio::test]
577async fn oversize_upstream_body_routes_to_upstream_failed() {
578 let server = MockServer::start().await;
579 let payload = vec![b'x'; 8 * 1024 * 1024];
580 Mock::given(method("GET"))
581 .and(path("/xrpc/com.atproto.repo.getRecord"))
582 .respond_with(
583 ResponseTemplate::new(200)
584 .insert_header("content-type", "application/json")
585 .set_body_bytes(payload),
586 )
587 .mount(&server)
588 .await;
589 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
590 let app = router(state);
591 let resp = app
592 .oneshot(xrpc_request(
593 "sh.tangled.repo.getRepo",
594 "repo",
595 "at://did:plc:clam/sh.tangled.repo/r1",
596 ))
597 .await
598 .unwrap();
599 let (status, body) = json_response(resp).await;
600 assert_eq!(status, StatusCode::BAD_GATEWAY);
601 assert_eq!(body["error"], "UpstreamFailed");
602}
603
604#[tokio::test]
605async fn upstream_503_routes_to_upstream_failed() {
606 let server = MockServer::start().await;
607 Mock::given(method("GET"))
608 .and(path("/xrpc/com.atproto.repo.getRecord"))
609 .respond_with(ResponseTemplate::new(503))
610 .mount(&server)
611 .await;
612 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
613 let app = router(state);
614 let resp = app
615 .oneshot(xrpc_request(
616 "sh.tangled.repo.getRepo",
617 "repo",
618 "at://did:plc:clam/sh.tangled.repo/r1",
619 ))
620 .await
621 .unwrap();
622 let (status, body) = json_response(resp).await;
623 assert_eq!(status, StatusCode::BAD_GATEWAY);
624 assert_eq!(body["error"], "UpstreamFailed");
625}
626
627#[tokio::test]
628async fn get_repo_by_repo_did_returns_observed_record() {
629 let server = MockServer::start().await;
630 let owner_did = did("did:plc:scallop");
631 let rk = rkey("r1");
632 let repo_did = did("did:plc:limpet");
633 mount_record(
634 &server,
635 &owner_did,
636 &nsid("sh.tangled.repo"),
637 &rk,
638 json!({
639 "$type": "sh.tangled.repo",
640 "name": "scallop",
641 "knot": "oyster.cafe",
642 "createdAt": "2026-05-01T00:00:00Z",
643 "repoDid": repo_did.as_ref(),
644 }),
645 )
646 .await;
647
648 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
649 state
650 .resolver
651 .observe(owner_did.clone(), rk.clone(), Some(repo_did.clone()), None)
652 .await;
653
654 let app = router(state);
655 let resp = app
656 .oneshot(xrpc_request(
657 "sh.tangled.repo.getRepoByRepoDid",
658 "repoDid",
659 repo_did.as_ref(),
660 ))
661 .await
662 .unwrap();
663 let (status, body) = json_response(resp).await;
664 assert_eq!(status, StatusCode::OK);
665 assert_eq!(
666 body["uri"],
667 format!(
668 "at://{}/sh.tangled.repo/{}",
669 owner_did.as_ref(),
670 rk.as_ref()
671 )
672 );
673 assert_eq!(body["value"]["name"], "scallop");
674 assert_eq!(body["value"]["repoDid"], repo_did.as_ref());
675}
676
677#[tokio::test]
678async fn get_repo_by_name_returns_observed_record() {
679 let server = MockServer::start().await;
680 let owner_did = did("did:plc:scallop");
681 let rk = rkey("core");
682 mount_record(
683 &server,
684 &owner_did,
685 &nsid("sh.tangled.repo"),
686 &rk,
687 json!({
688 "$type": "sh.tangled.repo",
689 "name": "core",
690 "knot": "oyster.cafe",
691 "createdAt": "2026-05-01T00:00:00Z",
692 }),
693 )
694 .await;
695
696 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
697 state
698 .resolver
699 .observe_rkey(owner_did.clone(), rk.clone())
700 .await;
701
702 let app = router(state);
703 let resp = app
704 .oneshot(xrpc_request2(
705 "sh.tangled.repo.getRepoByName",
706 ("owner", owner_did.as_ref()),
707 // the query name is the url rkey; the record's `name` is cosmetic
708 ("name", rk.as_ref()),
709 ))
710 .await
711 .unwrap();
712 let (status, body) = json_response(resp).await;
713 assert_eq!(status, StatusCode::OK);
714 assert_eq!(
715 body["uri"],
716 format!(
717 "at://{}/sh.tangled.repo/{}",
718 owner_did.as_ref(),
719 rk.as_ref()
720 )
721 );
722 assert_eq!(body["value"]["name"], "core");
723}
724
725#[tokio::test]
726async fn get_repo_by_name_404_when_unobserved() {
727 let server = MockServer::start().await;
728 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
729 let app = router(state);
730 let resp = app
731 .oneshot(xrpc_request2(
732 "sh.tangled.repo.getRepoByName",
733 ("owner", "did:plc:scallop"),
734 ("name", "core"),
735 ))
736 .await
737 .unwrap();
738 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
739}
740
741#[tokio::test]
742async fn get_repo_by_name_404_for_another_owners_name() {
743 let server = MockServer::start().await;
744 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
745 state
746 .resolver
747 .observe_rkey(did("did:plc:scallop"), rkey("core"))
748 .await;
749 let app = router(state);
750 let resp = app
751 .oneshot(xrpc_request2(
752 "sh.tangled.repo.getRepoByName",
753 ("owner", "did:plc:whelk"),
754 ("name", "core"),
755 ))
756 .await
757 .unwrap();
758 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
759}
760
761#[tokio::test]
762async fn get_repo_by_name_400_on_invalid_owner() {
763 let server = MockServer::start().await;
764 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
765 let app = router(state);
766 let resp = app
767 .oneshot(xrpc_request2(
768 "sh.tangled.repo.getRepoByName",
769 ("owner", "not-a-did"),
770 ("name", "core"),
771 ))
772 .await
773 .unwrap();
774 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
775}
776
777#[tokio::test]
778async fn get_repo_by_repo_did_404_when_unobserved() {
779 let server = MockServer::start().await;
780 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
781 let app = router(state);
782 let resp = app
783 .oneshot(xrpc_request(
784 "sh.tangled.repo.getRepoByRepoDid",
785 "repoDid",
786 "did:plc:whelk",
787 ))
788 .await
789 .unwrap();
790 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
791}
792
793#[tokio::test]
794async fn get_repo_by_repo_did_400_on_invalid_did() {
795 let server = MockServer::start().await;
796 let state = fresh_app(&Url::parse(&server.uri()).unwrap()).await;
797 let app = router(state);
798 let resp = app
799 .oneshot(xrpc_request(
800 "sh.tangled.repo.getRepoByRepoDid",
801 "repoDid",
802 "not-a-did",
803 ))
804 .await
805 .unwrap();
806 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
807}
808
809#[tokio::test]
810async fn record_values_serialize_a_single_type_key() {
811 let server = MockServer::start().await;
812
813 mount_record(
814 &server,
815 &did("did:plc:teq"),
816 &nsid("sh.tangled.repo"),
817 &rkey("r1"),
818 json!({
819 "$type": "sh.tangled.repo",
820 "name": "clam",
821 "knot": "oyster.cafe",
822 "createdAt": "2026-05-01T00:00:00Z"
823 }),
824 )
825 .await;
826
827 let app = router(fresh_app(&Url::parse(&server.uri()).unwrap()).await);
828 let resp = app
829 .oneshot(xrpc_request(
830 "sh.tangled.repo.getRepo",
831 "repo",
832 "at://did:plc:teq/sh.tangled.repo/r1",
833 ))
834 .await
835 .unwrap();
836 assert_eq!(resp.status(), StatusCode::OK);
837 let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap();
838 let raw = String::from_utf8(bytes.to_vec()).unwrap();
839 assert_eq!(raw.matches("\"$type\"").count(), 1, "body: {raw}");
840 assert!(raw.contains("\"$type\":\"sh.tangled.repo\""), "body: {raw}");
841}