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