This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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