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
23 kB 659 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 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}