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