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 631 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; 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 sources_scope_which_refs_get_enriched() { 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": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 338 "sources": ["items[].value.repoDid"] 339 }))) 340 .await 341 .unwrap(), 342 ) 343 .await; 344 assert_eq!(status, StatusCode::OK, "{body}"); 345 let stats = &body["data"]; 346 assert_eq!( 347 stats["did:plc:limpet"]["sh.tangled.feed.star:subject"][COUNT], 348 json!(3) 349 ); 350 assert_eq!(stats.as_object().unwrap().len(), 1, "{body}"); 351 352 // a path matching nothing is empty stats, not an error, since selection is vector-matched 353 let app = router(h.state.clone()); 354 let (status, body) = json_response( 355 app.oneshot(enrich_request(json!({ 356 "xrpc": "sh.tangled.repo.listRepos", 357 "params": { "subject": owner.as_ref() }, 358 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 359 "sources": ["items[].value.nope"] 360 }))) 361 .await 362 .unwrap(), 363 ) 364 .await; 365 assert_eq!(status, StatusCode::OK, "{body}"); 366 assert_eq!(body["data"], json!({})); 367} 368 369#[tokio::test] 370async fn inner_record_miss_passes_through_as_404() { 371 let h = Harness::new().await; 372 let app = router(h.state.clone()); 373 let (status, body) = json_response( 374 app.oneshot(enrich_request(json!({ 375 "xrpc": "sh.tangled.repo.getRepo", 376 "params": { "repo": "at://did:plc:nel/sh.tangled.repo/absent" }, 377 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }] 378 }))) 379 .await 380 .unwrap(), 381 ) 382 .await; 383 assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); 384 assert_eq!(body["error"], json!("RecordNotFound")); 385} 386 387#[tokio::test] 388async fn rejects_bad_requests() { 389 let h = Harness::new().await; 390 // semantic rejections: the descriptor parses, the handler refuses it 391 let handler_cases = [ 392 json!({ "xrpc": "sh.tangled.nope.nope", "enrich": [] }), 393 json!({ 394 "xrpc": "sh.tangled.repo.countRepos", 395 "params": { "subject": "did:plc:nel" }, 396 "enrich": [{ "source": "sh.tangled.nope:subject", "type": COUNT }] 397 }), 398 json!({ 399 "xrpc": "sh.tangled.repo.countRepos", 400 "params": { "subject": "did:plc:nel" }, 401 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": "sh.tangled.query.enrichResponse#bogus" }] 402 }), 403 json!({ 404 "xrpc": "sh.tangled.repo.countRepos", 405 "params": { "subject": "did:plc:nel" }, 406 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": COUNT }], 407 "sources": ["items["] 408 }), 409 ]; 410 for case in handler_cases { 411 let app = router(h.state.clone()); 412 let (status, body) = json_response(app.oneshot(enrich_request(case)).await.unwrap()).await; 413 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 414 assert_eq!(body["error"], json!("InvalidRequest"), "{body}"); 415 } 416 417 // structural rejections: serde refuses the descriptor before the handler 418 // sees it, which is plain-text 422 rather than our 400 json body 419 let serde_cases = [ 420 json!({ 421 "xrpc": "sh.tangled.repo.countRepos", 422 "params": { "subject": "did:plc:nel" }, 423 "enrich": [{ "source": "sh.tangled.feed.star:subject" }] 424 }), 425 json!({ 426 "xrpc": "sh.tangled.repo.countRepos", 427 "params": { "subject": "did:plc:nel" }, 428 "enrich": [{ "source": "sh.tangled.feed.star", "type": COUNT }] 429 }), 430 json!({ 431 "xrpc": "sh.tangled.repo.countRepos", 432 "params": { "subject": "did:plc:nel" }, 433 "enrich": [{ "source": "sh.tangled.feed.star:.rkey", "type": COUNT }] 434 }), 435 json!({ 436 "xrpc": "sh.tangled.repo.countRepos", 437 "params": { "subject": "did:plc:nel" }, 438 "enrich": [{ "source": "sh.tangled.feed.star:subject.uri", "type": COUNT }] 439 }), 440 ]; 441 for case in serde_cases { 442 let app = router(h.state.clone()); 443 let response = app.oneshot(enrich_request(case)).await.unwrap(); 444 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 445 } 446} 447 448#[tokio::test] 449async fn viewer_aggregation_uses_explicit_viewer_param() { 450 let h = Harness::new().await; 451 let owner = did("did:plc:abc"); 452 let repo_did = did("did:plc:limpet"); 453 repo_fixture(&h, &owner, &repo_did).await; 454 455 // the viewer already starred this repo, for the checks below 456 let subject = SubjectRef::Did(repo_did.clone()); 457 h.state.edges.add(Edge { 458 kind: nsid("sh.tangled.feed.star"), 459 subject, 460 source: at("at://did:plc:nel/sh.tangled.feed.star/r99"), 461 sort_micros: 99, 462 }); 463 464 let app = router(h.state.clone()); 465 466 // missing viewer param is a 400, viewer descriptors require it 467 let no_viewer = json!({ 468 "xrpc": "sh.tangled.repo.listRepos", 469 "params": { "subject": owner.as_ref() }, 470 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }] 471 }); 472 let (status, _) = json_response( 473 app.clone() 474 .oneshot(enrich_request(no_viewer)) 475 .await 476 .unwrap(), 477 ) 478 .await; 479 assert_eq!(status, StatusCode::BAD_REQUEST); 480 481 // viewer who starred it gets their own star uri back 482 let starred_viewer = json!({ 483 "xrpc": "sh.tangled.repo.listRepos", 484 "params": { "subject": owner.as_ref() }, 485 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }], 486 "viewer": "did:plc:nel" 487 }); 488 let (status, resp) = json_response( 489 app.clone() 490 .oneshot(enrich_request(starred_viewer)) 491 .await 492 .unwrap(), 493 ) 494 .await; 495 assert_eq!(status, StatusCode::OK); 496 let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; 497 assert_eq!( 498 stats[VIEWER], 499 json!("at://did:plc:nel/sh.tangled.feed.star/r99") 500 ); 501 502 // a viewer who never starred it gets an explicit null, not absent 503 let other_viewer = json!({ 504 "xrpc": "sh.tangled.repo.listRepos", 505 "params": { "subject": owner.as_ref() }, 506 "enrich": [{ "source": "sh.tangled.feed.star:subject", "type": VIEWER }], 507 "viewer": "did:plc:someoneelse" 508 }); 509 let (status, resp) = 510 json_response(app.oneshot(enrich_request(other_viewer)).await.unwrap()).await; 511 assert_eq!(status, StatusCode::OK); 512 let stats = &resp["data"][repo_did.as_str()]["sh.tangled.feed.star:subject"]; 513 assert_eq!(stats[VIEWER], Value::Null); 514} 515 516#[tokio::test] 517async fn minidoc_payloads_resolve_record_authors() { 518 let h = Harness::new().await; 519 let owner = did("did:plc:nel"); 520 for (i, fan) in ["did:plc:a", "did:plc:b"].iter().enumerate() { 521 h.add_edge( 522 "sh.tangled.graph.follow", 523 SubjectRef::Did(owner.clone()), 524 &at(&format!("at://{fan}/sh.tangled.graph.follow/f{i}")), 525 ); 526 h.mount( 527 &did(fan), 528 "sh.tangled.graph.follow", 529 &format!("f{i}"), 530 follow_body(&owner), 531 ) 532 .await; 533 } 534 Mock::given(method("GET")) 535 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 536 .and(query_param("identifier", "did:plc:a")) 537 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 538 "did": "did:plc:a", 539 "handle": "a.example.com", 540 "pds": "https://pds.example.com" 541 }))) 542 .expect(1) 543 .mount(&h.server) 544 .await; 545 Mock::given(method("GET")) 546 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 547 .and(query_param("identifier", "did:plc:b")) 548 .respond_with(ResponseTemplate::new(404)) 549 .expect(1) 550 .mount(&h.server) 551 .await; 552 553 let app = router(h.state.clone()); 554 let (status, body) = json_response( 555 app.oneshot(enrich_request(json!({ 556 "xrpc": "sh.tangled.graph.listFollows", 557 "params": { "subject": owner.as_ref() }, 558 "enrich": [ 559 { "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC }, 560 { "source": "sh.tangled.graph.follow:.repo", "type": MINIDOC }, 561 { "source": "sh.tangled.feed.star:.repo", "type": MINIDOC } 562 ] 563 }))) 564 .await 565 .unwrap(), 566 ) 567 .await; 568 569 assert_eq!(status, StatusCode::OK, "{body}"); 570 assert_eq!( 571 body["data"]["did:plc:a"]["sh.tangled.graph.follow:.repo"][MINIDOC]["handle"], 572 json!("a.example.com") 573 ); 574 assert_eq!( 575 body["data"]["did:plc:a"]["sh.tangled.feed.star:.repo"][MINIDOC]["handle"], 576 json!("a.example.com") 577 ); 578 // resolution failures are dropped, the client falls back for misses 579 assert!(body["data"]["did:plc:b"].is_null(), "{body}"); 580 // the profile owner authored nothing here, so it earns no minidoc 581 assert!(body["data"]["did:plc:nel"].is_null(), "{body}"); 582} 583 584#[tokio::test] 585async fn minidoc_repo_sources_skip_the_author_index() { 586 let h = Harness::new().await; 587 let owner = did("did:plc:nel"); 588 let repo_did = did("did:plc:limpet"); 589 repo_fixture(&h, &owner, &repo_did).await; 590 Mock::given(method("GET")) 591 .and(path("/xrpc/com.bad-example.identity.resolveMiniDoc")) 592 .and(query_param("identifier", "did:plc:nel")) 593 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 594 "did": "did:plc:nel", 595 "handle": "nel.example.com" 596 }))) 597 .mount(&h.server) 598 .await; 599 600 let app = router(h.state.clone()); 601 // sh.tangled.repo has no author mirror; stats would 400, minidocs must not 602 let (status, body) = json_response( 603 app.oneshot(enrich_request(json!({ 604 "xrpc": "sh.tangled.repo.listRepos", 605 "params": { "subject": owner.as_ref() }, 606 "enrich": [{ "source": "sh.tangled.repo:.repo", "type": MINIDOC }] 607 }))) 608 .await 609 .unwrap(), 610 ) 611 .await; 612 613 assert_eq!(status, StatusCode::OK, "{body}"); 614 assert_eq!( 615 body["data"]["did:plc:nel"]["sh.tangled.repo:.repo"][MINIDOC]["handle"], 616 json!("nel.example.com") 617 ); 618 619 let app = router(h.state.clone()); 620 let (status, body) = json_response( 621 app.oneshot(enrich_request(json!({ 622 "xrpc": "sh.tangled.repo.listRepos", 623 "params": { "subject": owner.as_ref() }, 624 "enrich": [{ "source": "sh.tangled.repo:.repo", "type": COUNT }] 625 }))) 626 .await 627 .unwrap(), 628 ) 629 .await; 630 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 631}