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