This repository has no description
1use std::sync::Arc;
2
3use axum::body::{Body, to_bytes};
4use bobbin_edge_index::{CoverageWatch, EdgeStore, IssueStateKind, PullStatusKind, 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_types::edges::Edge;
12use bobbin_types::ids::SubjectRef;
13use bobbin_xrpc::{AppState, router};
14use http::{Request, StatusCode};
15use jacquard_common::DefaultStr;
16use jacquard_common::types::did::Did;
17use jacquard_common::types::nsid::Nsid;
18use jacquard_common::types::string::AtUri;
19use serde_json::{Value, json};
20use tower::ServiceExt;
21use url::Url;
22use wiremock::matchers::{method, path, query_param};
23use wiremock::{Mock, MockServer, ResponseTemplate};
24
25const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i";
26
27const COUNT: &str = "sh.tangled.query.enrichResponse#count";
28const DISTINCT_AUTHORS: &str = "sh.tangled.query.enrichResponse#distinctAuthors";
29const VIEWER: &str = "sh.tangled.query.enrichResponse#viewer";
30const MINIDOC: &str = "com.bad-example.identity.miniDoc";
31
32fn at(s: &str) -> AtUri<DefaultStr> {
33 AtUri::new_owned(s).unwrap()
34}
35
36fn did(s: &str) -> Did<DefaultStr> {
37 Did::new_owned(s).unwrap()
38}
39
40fn nsid(s: &'static str) -> Nsid<DefaultStr> {
41 Nsid::new_static(s).unwrap()
42}
43
44static EDGE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
45
46fn next_sort_micros() -> u64 {
47 EDGE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
48}
49
50struct Harness {
51 server: MockServer,
52 edges: Arc<EdgeStore>,
53 state: AppState,
54}
55
56impl Harness {
57 async fn new() -> Self {
58 let server = MockServer::start().await;
59 let edges = Arc::new(EdgeStore::new(RuntimeHasher::default()));
60 let coverage = Arc::new(CoverageWatch::new());
61 let state = AppState::new(
62 Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))),
63 SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(),
64 edges.clone(),
65 Arc::new(StateIndex::<IssueStateKind>::new(RuntimeHasher::default())),
66 Arc::new(StateIndex::<PullStatusKind>::new(RuntimeHasher::default())),
67 coverage.clone(),
68 Arc::new(
69 KnotProxy::new(
70 KnotProxyConfig::default(),
71 KnotHttpConfig::default(),
72 Arc::new(SystemClock::new()),
73 RuntimeHasher::default(),
74 )
75 .unwrap(),
76 ),
77 Arc::new(
78 SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(),
79 ) as Arc<dyn SearchReader>,
80 Arc::new(RepoIdResolver::detached(RuntimeHasher::default())),
81 Arc::new(bobbin_xrpc::default_directory()),
82 );
83 Self {
84 server,
85 edges,
86 state,
87 }
88 }
89
90 fn add_edge(&self, kind: &'static str, subject: SubjectRef, source: &AtUri<DefaultStr>) {
91 self.edges.add(Edge {
92 kind: nsid(kind),
93 subject,
94 source: source.clone(),
95 sort_micros: next_sort_micros(),
96 });
97 }
98
99 async fn mount(&self, did: &Did<DefaultStr>, collection: &str, rkey: &str, value: Value) {
100 let uri = format!("at://{}/{}/{}", did.as_ref(), collection, rkey);
101 let body = json!({ "uri": uri, "cid": CID, "value": value });
102 Mock::given(method("GET"))
103 .and(path("/xrpc/com.atproto.repo.getRecord"))
104 .and(query_param("repo", did.as_ref()))
105 .and(query_param("collection", collection))
106 .and(query_param("rkey", rkey))
107 .respond_with(ResponseTemplate::new(200).set_body_json(body))
108 .mount(&self.server)
109 .await;
110 }
111}
112
113fn enrich_request(body: Value) -> Request<Body> {
114 Request::builder()
115 .method("POST")
116 .uri("/xrpc/sh.tangled.query.enrichResponse")
117 .header("content-type", "application/json")
118 .body(Body::from(serde_json::to_vec(&body).unwrap()))
119 .unwrap()
120}
121
122async fn json_response(resp: axum::response::Response) -> (StatusCode, Value) {
123 let status = resp.status();
124 let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap();
125 let parsed: Value = serde_json::from_slice(&bytes).expect("JSON body");
126 (status, parsed)
127}
128
129fn repo_body(name: &str, repo_did: &Did<DefaultStr>) -> Value {
130 json!({
131 "$type": "sh.tangled.repo",
132 "name": name,
133 "knot": "oyster.cafe",
134 "repoDid": repo_did.as_ref(),
135 "createdAt": "2026-05-01T00:00:00Z"
136 })
137}
138
139fn follow_body(subject: &Did<DefaultStr>) -> Value {
140 json!({
141 "$type": "sh.tangled.graph.follow",
142 "createdAt": "2026-05-01T00:00:00Z",
143 "subject": subject.as_ref()
144 })
145}
146
147/// one repo owned by `owner`, with `stars`/`issues` counts against its repo did
148async fn repo_fixture(h: &Harness, owner: &Did<DefaultStr>, repo_did: &Did<DefaultStr>) {
149 let repo_uri = at(&format!("at://{}/sh.tangled.repo/reef", owner.as_ref()));
150 h.add_edge("sh.tangled.repo", SubjectRef::Did(owner.clone()), &repo_uri);
151 h.mount(
152 owner,
153 "sh.tangled.repo",
154 "reef",
155 repo_body("reef", repo_did),
156 )
157 .await;
158 for (i, stargazer) in ["did:plc:a", "did:plc:b", "did:plc:a"].iter().enumerate() {
159 h.add_edge(
160 "sh.tangled.feed.star",
161 SubjectRef::Did(repo_did.clone()),
162 &at(&format!("at://{stargazer}/sh.tangled.feed.star/s{i}")),
163 );
164 }
165 h.add_edge(
166 "sh.tangled.repo.issue",
167 SubjectRef::Did(repo_did.clone()),
168 &at("at://did:plc:a/sh.tangled.repo.issue/i0"),
169 );
170}
171
172#[tokio::test]
173async fn zero_config_counts_stars_and_issues_for_repo_did() {
174 let h = Harness::new().await;
175 let owner = did("did:plc:nel");
176 let repo_did = did("did:plc:limpet");
177 repo_fixture(&h, &owner, &repo_did).await;
178
179 let app = router(h.state.clone());
180 let (status, body) = json_response(
181 app.oneshot(enrich_request(json!({
182 "xrpc": "sh.tangled.repo.listRepos",
183 "params": { "subject": owner.as_ref() },
184 "enrich": [
185 { "source": "sh.tangled.feed.star:subject", "type": COUNT },
186 { "source": "sh.tangled.feed.star:subject", "type": DISTINCT_AUTHORS },
187 { "source": "sh.tangled.repo.issue:subject", "type": COUNT }
188 ]
189 })))
190 .await
191 .unwrap(),
192 )
193 .await;
194
195 assert_eq!(status, StatusCode::OK, "{body}");
196 assert_eq!(body["output"]["items"].as_array().unwrap().len(), 1);
197 let stats = &body["data"];
198 assert_eq!(
199 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT],
200 json!(3)
201 );
202 assert_eq!(
203 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][DISTINCT_AUTHORS],
204 json!(2)
205 );
206 assert_eq!(
207 stats["did:plc:limpet"]["sh.tangled.repo.issue:subject"][COUNT],
208 json!(1)
209 );
210 assert!(stats["at://did:plc:nel/sh.tangled.repo/reef"].is_null());
211}
212
213#[tokio::test]
214async fn follow_counts_cover_both_directions() {
215 let h = Harness::new().await;
216 let owner = did("did:plc:nel");
217 // followers, edges pointing at owner
218 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
219 h.add_edge(
220 "sh.tangled.graph.follow",
221 SubjectRef::Did(owner.clone()),
222 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")),
223 );
224 h.mount(
225 &did(fan),
226 "sh.tangled.graph.follow",
227 &format!("f{i}"),
228 follow_body(&owner),
229 )
230 .await;
231 }
232 // following, via the .by mirror edge since owner is the author here
233 h.add_edge(
234 "sh.tangled.graph.follow.by",
235 SubjectRef::Did(owner.clone()),
236 &at("at://did:plc:nel/sh.tangled.graph.follow/f0"),
237 );
238
239 let app = router(h.state.clone());
240 let (status, body) = json_response(
241 app.oneshot(enrich_request(json!({
242 "xrpc": "sh.tangled.graph.listFollows",
243 "params": { "subject": owner.as_ref() },
244 "enrich": [{ "source": "sh.tangled.graph.follow:subject", "type": COUNT }, { "source": "sh.tangled.graph.follow:.repo", "type": COUNT }]
245 })))
246 .await
247 .unwrap(),
248 )
249 .await;
250
251 assert_eq!(status, StatusCode::OK, "{body}");
252 let nel = &body["data"]["did:plc:nel"];
253 assert_eq!(
254 nel["sh.tangled.graph.follow:subject"][COUNT],
255 json!(2),
256 "{body}"
257 );
258 assert_eq!(
259 nel["sh.tangled.graph.follow:.repo"][COUNT],
260 json!(1),
261 "{body}"
262 );
263}
264
265// at-uri authorities join the ref set, so record authors get stats keyed by
266// their bare did without appearing as a value anywhere in the response
267#[tokio::test]
268async fn authorities_of_record_uris_become_refs() {
269 let h = Harness::new().await;
270 let owner = did("did:plc:nel");
271 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
272 h.add_edge(
273 "sh.tangled.graph.follow",
274 SubjectRef::Did(owner.clone()),
275 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")),
276 );
277 h.mount(
278 &did(fan),
279 "sh.tangled.graph.follow",
280 &format!("f{i}"),
281 follow_body(&owner),
282 )
283 .await;
284 }
285 // each fan also follows one other person
286 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
287 h.add_edge(
288 "sh.tangled.graph.follow.by",
289 SubjectRef::Did(did(fan)),
290 &at(&format!("at://{fan}/sh.tangled.graph.follow/g{i}")),
291 );
292 }
293
294 let app = router(h.state.clone());
295 let (status, body) = json_response(
296 app.oneshot(enrich_request(json!({
297 "xrpc": "sh.tangled.graph.listFollows",
298 "params": { "subject": owner.as_ref() },
299 "enrich": [
300 { "source": "sh.tangled.graph.follow:subject", "type": COUNT },
301 { "source": "sh.tangled.graph.follow:.repo", "type": COUNT }
302 ]
303 })))
304 .await
305 .unwrap(),
306 )
307 .await;
308
309 assert_eq!(status, StatusCode::OK, "{body}");
310 for fan in ["did:plc:a", "did:plc:b"] {
311 let entry = &body["data"][fan];
312 assert_eq!(
313 entry["sh.tangled.graph.follow:subject"][COUNT],
314 json!(0),
315 "{body}"
316 );
317 assert_eq!(
318 entry["sh.tangled.graph.follow:.repo"][COUNT],
319 json!(1),
320 "{body}"
321 );
322 }
323}
324
325#[tokio::test]
326async fn targets_scope_each_payload_independently() {
327 let h = Harness::new().await;
328 let owner = did("did:plc:nel");
329 let repo_did = did("did:plc:limpet");
330 repo_fixture(&h, &owner, &repo_did).await;
331
332 let app = router(h.state.clone());
333 let (status, body) = json_response(
334 app.oneshot(enrich_request(json!({
335 "xrpc": "sh.tangled.repo.listRepos",
336 "params": { "subject": owner.as_ref() },
337 "enrich": [
338 {
339 "source": "sh.tangled.feed.star:subject",
340 "type": COUNT,
341 "targets": ["items[].value.repoDid"]
342 },
343 {
344 "source": "sh.tangled.repo.issue:subject",
345 "type": COUNT,
346 "targets": ["items[].uri"]
347 }
348 ]
349 })))
350 .await
351 .unwrap(),
352 )
353 .await;
354 assert_eq!(status, StatusCode::OK, "{body}");
355 let stats = &body["data"];
356 assert_eq!(
357 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT],
358 json!(3)
359 );
360 assert_eq!(stats.as_object().unwrap().len(), 2, "{body}");
361 assert_eq!(
362 stats[owner.as_str()]["sh.tangled.repo.issue:subject"][COUNT],
363 json!(0)
364 );
365 assert!(
366 stats[repo_did.as_str()]["sh.tangled.repo.issue:subject"].is_null(),
367 "{body}"
368 );
369 assert!(
370 stats[owner.as_str()]["sh.tangled.feed.star:subject"].is_null(),
371 "{body}"
372 );
373
374 // a path matching nothing is empty stats, not an error, since selection is vector-matched
375 let app = router(h.state.clone());
376 let (status, body) = json_response(
377 app.oneshot(enrich_request(json!({
378 "xrpc": "sh.tangled.repo.listRepos",
379 "params": { "subject": owner.as_ref() },
380 "enrich": [{
381 "source": "sh.tangled.feed.star:subject",
382 "type": COUNT,
383 "targets": ["items[].value.nope"]
384 }]
385 })))
386 .await
387 .unwrap(),
388 )
389 .await;
390 assert_eq!(status, StatusCode::OK, "{body}");
391 assert_eq!(body["data"], json!({}));
392}
393
394#[tokio::test]
395async fn inner_record_miss_passes_through_as_404() {
396 let h = Harness::new().await;
397 let app = router(h.state.clone());
398 let (status, body) = json_response(
399 app.oneshot(enrich_request(json!({
400 "xrpc": "sh.tangled.repo.getRepo",
401 "params": { "repo": "at://did:plc:nel/sh.tangled.repo/absent" },
402 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }]
403 })))
404 .await
405 .unwrap(),
406 )
407 .await;
408 assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
409 assert_eq!(body["error"], json!("RecordNotFound"));
410}
411
412#[tokio::test]
413async fn rejects_bad_requests() {
414 let h = Harness::new().await;
415 // semantic rejections: the descriptor parses, the handler refuses it
416 let handler_cases = [
417 json!({ "xrpc": "sh.tangled.nope.nope", "enrich": [] }),
418 json!({
419 "xrpc": "sh.tangled.repo.countRepos",
420 "params": { "subject": "did:plc:nel" },
421 "enrich": [{ "source": "sh.tangled.nope:subject", "type": COUNT }]
422 }),
423 json!({
424 "xrpc": "sh.tangled.repo.countRepos",
425 "params": { "subject": "did:plc:nel" },
426 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "sh.tangled.query.enrichResponse#bogus" }]
427 }),
428 json!({
429 "xrpc": "sh.tangled.repo.countRepos",
430 "params": { "subject": "did:plc:nel" },
431 "enrich": [{
432 "source": "sh.tangled.feed.star:subject",
433 "type": COUNT,
434 "targets": ["items["]
435 }]
436 }),
437 ];
438 for case in handler_cases {
439 let app = router(h.state.clone());
440 let (status, body) = json_response(app.oneshot(enrich_request(case)).await.unwrap()).await;
441 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
442 assert_eq!(body["error"], json!("InvalidRequest"), "{body}");
443 }
444
445 // structural rejections: serde refuses the descriptor before the handler
446 // sees it, which is plain-text 422 rather than our 400 json body
447 let serde_cases = [
448 json!({
449 "xrpc": "sh.tangled.repo.countRepos",
450 "params": { "subject": "did:plc:nel" },
451 "enrich": [{ "source": "sh.tangled.feed.star:subject" }]
452 }),
453 json!({
454 "xrpc": "sh.tangled.repo.countRepos",
455 "params": { "subject": "did:plc:nel" },
456 "enrich": [{ "source": "sh.tangled.feed.star", "type": COUNT }]
457 }),
458 json!({
459 "xrpc": "sh.tangled.repo.countRepos",
460 "params": { "subject": "did:plc:nel" },
461 "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": COUNT }]
462 }),
463 json!({
464 "xrpc": "sh.tangled.repo.countRepos",
465 "params": { "subject": "did:plc:nel" },
466 "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": COUNT }]
467 }),
468 ];
469 for case in serde_cases {
470 let app = router(h.state.clone());
471 let response = app.oneshot(enrich_request(case)).await.unwrap();
472 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
473 }
474}
475
476#[tokio::test]
477async fn viewer_aggregation_uses_explicit_viewer_param() {
478 let h = Harness::new().await;
479 let owner = did("did:plc:abc");
480 let repo_did = did("did:plc:limpet");
481 repo_fixture(&h, &owner, &repo_did).await;
482
483 // the viewer already starred this repo, for the checks below
484 let subject = SubjectRef::Did(repo_did.clone());
485 h.state.edges.add(Edge {
486 kind: nsid("sh.tangled.feed.star"),
487 subject,
488 source: at("at://did:plc:nel/sh.tangled.feed.star/r99"),
489 sort_micros: 99,
490 });
491
492 let app = router(h.state.clone());
493
494 // missing viewer param is a 400, viewer descriptors require it
495 let no_viewer = json!({
496 "xrpc": "sh.tangled.repo.listRepos",
497 "params": { "subject": owner.as_ref() },
498 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }]
499 });
500 let (status, _) = json_response(
501 app.clone()
502 .oneshot(enrich_request(no_viewer))
503 .await
504 .unwrap(),
505 )
506 .await;
507 assert_eq!(status, StatusCode::BAD_REQUEST);
508
509 // viewer who starred it gets their own star uri back
510 let starred_viewer = json!({
511 "xrpc": "sh.tangled.repo.listRepos",
512 "params": { "subject": owner.as_ref() },
513 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }],
514 "viewer": "did:plc:nel"
515 });
516 let (status, resp) = json_response(
517 app.clone()
518 .oneshot(enrich_request(starred_viewer))
519 .await
520 .unwrap(),
521 )
522 .await;
523 assert_eq!(status, StatusCode::OK);
524 let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"];
525 assert_eq!(
526 stats[VIEWER],
527 json!("at://did:plc:nel/sh.tangled.feed.star/r99")
528 );
529
530 // a viewer who never starred it gets an explicit null, not absent
531 let other_viewer = json!({
532 "xrpc": "sh.tangled.repo.listRepos",
533 "params": { "subject": owner.as_ref() },
534 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }],
535 "viewer": "did:plc:someoneelse"
536 });
537 let (status, resp) =
538 json_response(app.oneshot(enrich_request(other_viewer)).await.unwrap()).await;
539 assert_eq!(status, StatusCode::OK);
540 let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"];
541 assert_eq!(stats[VIEWER], Value::Null);
542}
543
544#[tokio::test]
545async fn minidoc_payloads_resolve_record_authors() {
546 let h = Harness::new().await;
547 let owner = did("did:plc:nel");
548 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() {
549 h.add_edge(
550 "sh.tangled.graph.follow",
551 SubjectRef::Did(owner.clone()),
552 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")),
553 );
554 h.mount(
555 &did(fan),
556 "sh.tangled.graph.follow",
557 &format!("f{i}"),
558 follow_body(&owner),
559 )
560 .await;
561 }
562 Mock::given(method("GET"))
563 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc"))
564 .and(query_param("identifier", "did:plc:a"))
565 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
566 "did": "did:plc:a",
567 "handle": "a.example.com",
568 "pds": "https://pds.example.com"
569 })))
570 .expect(1)
571 .mount(&h.server)
572 .await;
573 Mock::given(method("GET"))
574 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc"))
575 .and(query_param("identifier", "did:plc:b"))
576 .respond_with(ResponseTemplate::new(404))
577 .expect(1)
578 .mount(&h.server)
579 .await;
580
581 let app = router(h.state.clone());
582 let (status, body) = json_response(
583 app.oneshot(enrich_request(json!({
584 "xrpc": "sh.tangled.graph.listFollows",
585 "params": { "subject": owner.as_ref() },
586 "enrich": [
587 { "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC },
588 { "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC },
589 { "source": "sh.tangled.feed.star:.repo", "type": MINIDOC }
590 ]
591 })))
592 .await
593 .unwrap(),
594 )
595 .await;
596
597 assert_eq!(status, StatusCode::OK, "{body}");
598 assert_eq!(
599 body["data"]["did:plc:a"]["sh.tangled.graph.follow:.repo"][MINIDOC]["handle"],
600 json!("a.example.com")
601 );
602 assert_eq!(
603 body["data"]["did:plc:a"]["sh.tangled.feed.star:.repo"][MINIDOC]["handle"],
604 json!("a.example.com")
605 );
606 // resolution failures are dropped, the client falls back for misses
607 assert!(body["data"]["did:plc:b"].is_null(), "{body}");
608 // the profile owner authored nothing here, so it earns no minidoc
609 assert!(body["data"]["did:plc:nel"].is_null(), "{body}");
610}
611
612#[tokio::test]
613async fn minidoc_repo_sources_skip_the_author_index() {
614 let h = Harness::new().await;
615 let owner = did("did:plc:nel");
616 let repo_did = did("did:plc:limpet");
617 repo_fixture(&h, &owner, &repo_did).await;
618 Mock::given(method("GET"))
619 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc"))
620 .and(query_param("identifier", "did:plc:nel"))
621 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
622 "did": "did:plc:nel",
623 "handle": "nel.example.com"
624 })))
625 .mount(&h.server)
626 .await;
627
628 let app = router(h.state.clone());
629 // sh.tangled.repo has no author mirror; stats would 400, minidocs must not
630 let (status, body) = json_response(
631 app.oneshot(enrich_request(json!({
632 "xrpc": "sh.tangled.repo.listRepos",
633 "params": { "subject": owner.as_ref() },
634 "enrich": [{ "source": "sh.tangled.repo:.repo", "type": MINIDOC }]
635 })))
636 .await
637 .unwrap(),
638 )
639 .await;
640
641 assert_eq!(status, StatusCode::OK, "{body}");
642 assert_eq!(
643 body["data"]["did:plc:nel"]["sh.tangled.repo:.repo"][MINIDOC]["handle"],
644 json!("nel.example.com")
645 );
646
647 let app = router(h.state.clone());
648 let (status, body) = json_response(
649 app.oneshot(enrich_request(json!({
650 "xrpc": "sh.tangled.repo.listRepos",
651 "params": { "subject": owner.as_ref() },
652 "enrich": [{ "source": "sh.tangled.repo:.repo", "type": COUNT }]
653 })))
654 .await
655 .unwrap(),
656 )
657 .await;
658 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
659}