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 / aggregation.rs
80 kB 2656 lines
1use std::sync::Arc; 2 3use axum::body::{Body, to_bytes}; 4use bobbin_edge_index::{ 5 Coverage, CoverageWatch, EdgeStore, HydrantCursor, IssueStateKind, PageToken, PullStatusKind, 6 StateIndex, 7}; 8use bobbin_knot_proxy::{KnotHttpConfig, KnotProxy, KnotProxyConfig}; 9use bobbin_record_lru::{CacheCapacity, LruRecordStore}; 10use bobbin_resolver::RepoIdResolver; 11use bobbin_runtime::{RuntimeHasher, SystemClock}; 12use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; 13use bobbin_slingshot_client::SlingshotClient; 14use bobbin_types::edges::Edge; 15use bobbin_types::ids::SubjectRef; 16use bobbin_xrpc::{AppState, router}; 17use futures::stream::{self, StreamExt}; 18use http::{Request, StatusCode}; 19use jacquard_common::DefaultStr; 20use jacquard_common::types::did::Did; 21use jacquard_common::types::nsid::Nsid; 22use jacquard_common::types::recordkey::Rkey; 23use jacquard_common::types::string::AtUri; 24use serde_json::{Value, json}; 25use tower::ServiceExt; 26use url::Url; 27use url::form_urlencoded::byte_serialize; 28use wiremock::matchers::{method, path, query_param}; 29use wiremock::{Mock, MockServer, ResponseTemplate}; 30 31const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i"; 32 33fn at(s: &str) -> AtUri<DefaultStr> { 34 AtUri::new_owned(s).unwrap() 35} 36 37fn did(s: &str) -> Did<DefaultStr> { 38 Did::new_owned(s).unwrap() 39} 40 41fn rkey(s: &str) -> Rkey<DefaultStr> { 42 Rkey::new_owned(s).unwrap() 43} 44 45fn nsid(s: &'static str) -> Nsid<DefaultStr> { 46 Nsid::new_static(s).unwrap() 47} 48 49fn subj(s: &str) -> SubjectRef { 50 Did::<DefaultStr>::new_owned(s) 51 .map(SubjectRef::Did) 52 .unwrap_or_else(|_| SubjectRef::Uri(AtUri::new_owned(s).unwrap())) 53} 54 55struct Harness { 56 server: MockServer, 57 edges: Arc<EdgeStore>, 58 coverage: Arc<CoverageWatch>, 59 state: AppState, 60} 61 62static EDGE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); 63 64fn next_sort_micros() -> u64 { 65 EDGE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) 66} 67 68impl Harness { 69 async fn new() -> Self { 70 let server = MockServer::start().await; 71 let edges = Arc::new(EdgeStore::new(RuntimeHasher::default())); 72 let issue_states = Arc::new(StateIndex::new(RuntimeHasher::default())); 73 let pull_statuses = Arc::new(StateIndex::new(RuntimeHasher::default())); 74 let coverage = Arc::new(CoverageWatch::new()); 75 let state = AppState::new( 76 Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), 77 SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), 78 edges.clone(), 79 issue_states.clone(), 80 pull_statuses.clone(), 81 coverage.clone(), 82 Arc::new( 83 KnotProxy::new( 84 KnotProxyConfig::default(), 85 KnotHttpConfig::default(), 86 Arc::new(SystemClock::new()), 87 RuntimeHasher::default(), 88 ) 89 .unwrap(), 90 ), 91 Arc::new( 92 SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), 93 ) as Arc<dyn SearchReader>, 94 Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), 95 ); 96 Self { 97 server, 98 edges, 99 coverage, 100 state, 101 } 102 } 103 104 fn add_edge( 105 &self, 106 kind: &Nsid<DefaultStr>, 107 subject: &AtUri<DefaultStr>, 108 source: &AtUri<DefaultStr>, 109 ) { 110 self.edges.add(Edge { 111 kind: kind.clone(), 112 subject: subj(subject.as_ref()), 113 source: source.clone(), 114 sort_micros: next_sort_micros(), 115 }); 116 match kind.as_ref() { 117 "sh.tangled.repo.issue" => self 118 .edges 119 .refresh_issue_counts(&self.state.issue_states, source), 120 "sh.tangled.repo.pull" => self 121 .edges 122 .refresh_pull_counts(&self.state.pull_statuses, source), 123 _ => {} 124 } 125 } 126 127 fn upsert_issue_state( 128 &self, 129 source: AtUri<DefaultStr>, 130 issue: AtUri<DefaultStr>, 131 sort_micros: u64, 132 kind: IssueStateKind, 133 ) { 134 self.state 135 .issue_states 136 .upsert(source, issue.clone(), sort_micros, kind); 137 self.edges 138 .refresh_issue_counts(&self.state.issue_states, &issue); 139 } 140 141 fn upsert_pull_status( 142 &self, 143 source: AtUri<DefaultStr>, 144 pull: AtUri<DefaultStr>, 145 sort_micros: u64, 146 kind: PullStatusKind, 147 ) { 148 self.state 149 .pull_statuses 150 .upsert(source, pull.clone(), sort_micros, kind); 151 self.edges 152 .refresh_pull_counts(&self.state.pull_statuses, &pull); 153 } 154 155 async fn mount( 156 &self, 157 did: &Did<DefaultStr>, 158 collection: &Nsid<DefaultStr>, 159 rkey: &Rkey<DefaultStr>, 160 value: Value, 161 ) { 162 let uri = format!( 163 "at://{}/{}/{}", 164 did.as_ref(), 165 collection.as_ref(), 166 rkey.as_ref() 167 ); 168 let body = json!({ "uri": uri, "cid": CID, "value": value }); 169 Mock::given(method("GET")) 170 .and(path("/xrpc/com.atproto.repo.getRecord")) 171 .and(query_param("repo", did.as_ref())) 172 .and(query_param("collection", collection.as_ref())) 173 .and(query_param("rkey", rkey.as_ref())) 174 .respond_with(ResponseTemplate::new(200).set_body_json(body)) 175 .mount(&self.server) 176 .await; 177 } 178 179 fn promote_ready(&self, events: u64, cursor: u64) { 180 self.coverage.update(|_| Coverage::Ready { 181 events_processed: events, 182 last_cursor: HydrantCursor::new(cursor), 183 }); 184 } 185 186 fn warming(&self, events: u64, cursor: u64) { 187 self.coverage.update(|_| Coverage::Warming { 188 events_processed: events, 189 last_cursor: HydrantCursor::new(cursor), 190 }); 191 } 192} 193 194fn list_request(endpoint: &str, subject: &str, extras: &[(&str, &str)]) -> Request<Body> { 195 let mut qs = format!("subject={subject}"); 196 extras.iter().for_each(|(k, v)| { 197 qs.push('&'); 198 qs.push_str(k); 199 qs.push('='); 200 qs.push_str(&encode(v)); 201 }); 202 Request::builder() 203 .uri(format!("/xrpc/{endpoint}?{qs}")) 204 .body(Body::empty()) 205 .unwrap() 206} 207 208fn encode(s: &str) -> String { 209 byte_serialize(s.as_bytes()).collect() 210} 211 212async fn json_response(resp: axum::response::Response) -> (StatusCode, Value) { 213 let status = resp.status(); 214 let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); 215 let parsed: Value = serde_json::from_slice(&bytes).expect("JSON body"); 216 (status, parsed) 217} 218 219fn issue_body(repo_did: &Did<DefaultStr>, title: &str) -> Value { 220 json!({ 221 "$type": "sh.tangled.repo.issue", 222 "repo": repo_did.as_ref(), 223 "title": title, 224 "createdAt": "2026-05-01T00:00:00Z" 225 }) 226} 227 228fn pull_body(repo_did: &Did<DefaultStr>, title: &str) -> Value { 229 json!({ 230 "$type": "sh.tangled.repo.pull", 231 "title": title, 232 "createdAt": "2026-05-01T00:00:00Z", 233 "rounds": [], 234 "target": { 235 "repo": repo_did.as_ref(), 236 "branch": "main" 237 } 238 }) 239} 240 241fn star_body(subject_did: &Did<DefaultStr>) -> Value { 242 json!({ 243 "$type": "sh.tangled.feed.star", 244 "createdAt": "2026-05-01T00:00:00Z", 245 "subject": { 246 "$type": "sh.tangled.feed.star#repo", 247 "did": subject_did.as_ref() 248 } 249 }) 250} 251 252fn follow_body(subject_did: &Did<DefaultStr>) -> Value { 253 json!({ 254 "$type": "sh.tangled.graph.follow", 255 "createdAt": "2026-05-01T00:00:00Z", 256 "subject": subject_did.as_ref() 257 }) 258} 259 260#[tokio::test] 261async fn list_issues_with_no_edges_returns_empty_items() { 262 let h = Harness::new().await; 263 let app = router(h.state.clone()); 264 let resp = app 265 .oneshot(list_request( 266 "sh.tangled.repo.listIssues", 267 "at://did:plc:abalone", 268 &[], 269 )) 270 .await 271 .unwrap(); 272 let (status, body) = json_response(resp).await; 273 assert_eq!(status, StatusCode::OK); 274 assert_eq!(body["items"], json!([])); 275 assert!(body["cursor"].is_null()); 276} 277 278#[tokio::test] 279async fn count_issues_with_no_edges_returns_zero() { 280 let h = Harness::new().await; 281 let app = router(h.state.clone()); 282 let resp = app 283 .oneshot(list_request( 284 "sh.tangled.repo.countIssues", 285 "at://did:plc:abalone", 286 &[], 287 )) 288 .await 289 .unwrap(); 290 let (status, body) = json_response(resp).await; 291 assert_eq!(status, StatusCode::OK); 292 assert_eq!(body["count"], json!(0)); 293 assert_eq!(body["distinctAuthors"], json!(0)); 294} 295 296#[tokio::test] 297async fn list_issues_hydrates_via_slingshot_when_edges_present() { 298 let h = Harness::new().await; 299 let repo = did("did:plc:abalone"); 300 let subject = at(&format!("at://{}", repo.as_ref())); 301 let owners = [ 302 ("did:plc:nel", "i1", "first"), 303 ("did:plc:olaren", "i2", "second"), 304 ]; 305 stream::iter(owners) 306 .for_each(|(d, r, title)| { 307 let h = &h; 308 let subject = subject.clone(); 309 let repo = repo.clone(); 310 async move { 311 let d_did = did(d); 312 let rk = rkey(r); 313 h.add_edge( 314 &nsid("sh.tangled.repo.issue"), 315 &subject, 316 &at(&format!( 317 "at://{}/sh.tangled.repo.issue/{}", 318 d_did.as_ref(), 319 rk.as_ref() 320 )), 321 ); 322 h.mount( 323 &d_did, 324 &nsid("sh.tangled.repo.issue"), 325 &rk, 326 issue_body(&repo, title), 327 ) 328 .await; 329 } 330 }) 331 .await; 332 333 let app = router(h.state.clone()); 334 let resp = app 335 .oneshot(list_request( 336 "sh.tangled.repo.listIssues", 337 subject.as_ref(), 338 &[], 339 )) 340 .await 341 .unwrap(); 342 let (status, body) = json_response(resp).await; 343 assert_eq!(status, StatusCode::OK); 344 let items = body["items"].as_array().expect("items array"); 345 assert_eq!(items.len(), 2); 346 let titles: Vec<&str> = items 347 .iter() 348 .map(|v| v["value"]["title"].as_str().unwrap()) 349 .collect(); 350 assert!(titles.contains(&"first")); 351 assert!(titles.contains(&"second")); 352 assert_eq!(items[0]["cid"], CID); 353 assert!(items[0]["uri"].as_str().unwrap().starts_with("at://")); 354} 355 356#[tokio::test] 357async fn count_distinct_authors_dedupes_per_author() { 358 let h = Harness::new().await; 359 let subject = at("at://did:plc:abalone"); 360 h.add_edge( 361 &nsid("sh.tangled.feed.star"), 362 &subject, 363 &at("at://did:plc:nel/sh.tangled.feed.star/s1"), 364 ); 365 h.add_edge( 366 &nsid("sh.tangled.feed.star"), 367 &subject, 368 &at("at://did:plc:nel/sh.tangled.feed.star/s2"), 369 ); 370 h.add_edge( 371 &nsid("sh.tangled.feed.star"), 372 &subject, 373 &at("at://did:plc:olaren/sh.tangled.feed.star/s3"), 374 ); 375 376 let app = router(h.state.clone()); 377 let resp = app 378 .oneshot(list_request( 379 "sh.tangled.feed.countStars", 380 subject.as_ref(), 381 &[], 382 )) 383 .await 384 .unwrap(); 385 let (_, body) = json_response(resp).await; 386 assert_eq!(body["count"], json!(3)); 387 assert_eq!(body["distinctAuthors"], json!(2)); 388} 389 390#[tokio::test] 391async fn count_forks_counts_repos_pointing_at_the_source() { 392 let h = Harness::new().await; 393 let subject = at("at://did:plc:abalone"); 394 h.add_edge( 395 &nsid("sh.tangled.repo.source"), 396 &subject, 397 &at("at://did:plc:nel/sh.tangled.repo/f1"), 398 ); 399 h.add_edge( 400 &nsid("sh.tangled.repo.source"), 401 &subject, 402 &at("at://did:plc:olaren/sh.tangled.repo/f2"), 403 ); 404 // the source owner's other repos are not forks of it 405 h.add_edge( 406 &nsid("sh.tangled.repo"), 407 &subject, 408 &at("at://did:plc:nel/sh.tangled.repo/r1"), 409 ); 410 411 let app = router(h.state.clone()); 412 let resp = app 413 .oneshot(list_request( 414 "sh.tangled.repo.countForks", 415 subject.as_ref(), 416 &[], 417 )) 418 .await 419 .unwrap(); 420 let (status, body) = json_response(resp).await; 421 assert_eq!(status, StatusCode::OK); 422 assert_eq!(body["count"], json!(2)); 423 assert_eq!(body["distinctAuthors"], json!(2)); 424} 425 426#[tokio::test] 427async fn count_forks_of_an_unforked_repo_is_zero() { 428 let h = Harness::new().await; 429 let app = router(h.state.clone()); 430 let resp = app 431 .oneshot(list_request( 432 "sh.tangled.repo.countForks", 433 "did:plc:abalone", 434 &[], 435 )) 436 .await 437 .unwrap(); 438 let (status, body) = json_response(resp).await; 439 assert_eq!(status, StatusCode::OK); 440 assert_eq!(body["count"], json!(0)); 441} 442 443#[tokio::test] 444async fn count_forks_rejects_an_at_uri_subject() { 445 let h = Harness::new().await; 446 let app = router(h.state.clone()); 447 let resp = app 448 .oneshot(list_request( 449 "sh.tangled.repo.countForks", 450 "at://did:plc:nel/sh.tangled.repo/core", 451 &[], 452 )) 453 .await 454 .unwrap(); 455 let (status, body) = json_response(resp).await; 456 assert_eq!(status, StatusCode::BAD_REQUEST); 457 assert_eq!(body["error"], "InvalidRequest"); 458} 459 460#[tokio::test] 461async fn list_items_stable_across_coverage_promotion() { 462 let h = Harness::new().await; 463 let subject = at("at://did:plc:abalone"); 464 let nel = did("did:plc:nel"); 465 h.add_edge( 466 &nsid("sh.tangled.feed.star"), 467 &subject, 468 &at(&format!("at://{}/sh.tangled.feed.star/s1", nel.as_ref())), 469 ); 470 h.mount( 471 &nel, 472 &nsid("sh.tangled.feed.star"), 473 &rkey("s1"), 474 star_body(&did("did:plc:abalone")), 475 ) 476 .await; 477 478 let app = router(h.state.clone()); 479 h.warming(1, 5); 480 let (_, before) = json_response( 481 app.clone() 482 .oneshot(list_request( 483 "sh.tangled.feed.listStars", 484 subject.as_ref(), 485 &[], 486 )) 487 .await 488 .unwrap(), 489 ) 490 .await; 491 assert_eq!(before["items"].as_array().unwrap().len(), 1); 492 493 h.promote_ready(2, 9); 494 let (_, after) = json_response( 495 app.oneshot(list_request( 496 "sh.tangled.feed.listStars", 497 subject.as_ref(), 498 &[], 499 )) 500 .await 501 .unwrap(), 502 ) 503 .await; 504 assert_eq!( 505 after["items"].as_array().unwrap().len(), 506 before["items"].as_array().unwrap().len(), 507 ); 508 assert_eq!(after["items"], before["items"]); 509} 510 511#[tokio::test] 512async fn list_paginates_via_cursor() { 513 let h = Harness::new().await; 514 let subject = at("at://did:plc:abalone"); 515 let repo = did("did:plc:abalone"); 516 let owners = [ 517 ("did:plc:nel", "i1"), 518 ("did:plc:olaren", "i2"), 519 ("did:plc:teq", "i3"), 520 ("did:plc:lyna", "i4"), 521 ("did:plc:bailey", "i5"), 522 ]; 523 stream::iter(owners) 524 .for_each(|(d, r)| { 525 let h = &h; 526 let subject = subject.clone(); 527 let repo = repo.clone(); 528 async move { 529 let d_did = did(d); 530 let rk = rkey(r); 531 h.add_edge( 532 &nsid("sh.tangled.repo.issue"), 533 &subject, 534 &at(&format!( 535 "at://{}/sh.tangled.repo.issue/{}", 536 d_did.as_ref(), 537 rk.as_ref() 538 )), 539 ); 540 h.mount( 541 &d_did, 542 &nsid("sh.tangled.repo.issue"), 543 &rk, 544 issue_body(&repo, &format!("issue-{}", rk.as_ref())), 545 ) 546 .await; 547 } 548 }) 549 .await; 550 551 let app = router(h.state.clone()); 552 let (_, page1) = json_response( 553 app.clone() 554 .oneshot(list_request( 555 "sh.tangled.repo.listIssues", 556 subject.as_ref(), 557 &[("limit", "2")], 558 )) 559 .await 560 .unwrap(), 561 ) 562 .await; 563 let page1_items = page1["items"].as_array().unwrap().clone(); 564 assert_eq!(page1_items.len(), 2); 565 let cursor = page1["cursor"] 566 .as_str() 567 .expect("first page must yield a cursor") 568 .to_owned(); 569 assert!( 570 PageToken::decode_token(&cursor).is_ok(), 571 "cursor must be a TID-shaped token" 572 ); 573 574 let (_, page2) = json_response( 575 app.oneshot(list_request( 576 "sh.tangled.repo.listIssues", 577 subject.as_ref(), 578 &[("limit", "10"), ("cursor", &cursor)], 579 )) 580 .await 581 .unwrap(), 582 ) 583 .await; 584 let page2_items = page2["items"].as_array().unwrap().clone(); 585 assert_eq!(page2_items.len(), 3); 586 assert!(page2["cursor"].is_null(), "tail page must not promise more"); 587 588 let union: Vec<&str> = page1_items 589 .iter() 590 .chain(page2_items.iter()) 591 .map(|item| item["uri"].as_str().unwrap()) 592 .collect(); 593 assert_eq!(union.len(), owners.len(), "union covers every owner"); 594 let mut sorted = union.clone(); 595 sorted.sort(); 596 sorted.dedup(); 597 assert_eq!(sorted.len(), owners.len(), "no duplicates across pages"); 598} 599 600#[tokio::test] 601async fn pagination_unaffected_by_coverage_promotion() { 602 let h = Harness::new().await; 603 let subject = at("at://did:plc:abalone"); 604 let repo = did("did:plc:abalone"); 605 let owners = [("did:plc:nel", "i1"), ("did:plc:olaren", "i2")]; 606 stream::iter(owners) 607 .for_each(|(d, r)| { 608 let h = &h; 609 let subject = subject.clone(); 610 let repo = repo.clone(); 611 async move { 612 let d_did = did(d); 613 let rk = rkey(r); 614 h.add_edge( 615 &nsid("sh.tangled.repo.issue"), 616 &subject, 617 &at(&format!( 618 "at://{}/sh.tangled.repo.issue/{}", 619 d_did.as_ref(), 620 rk.as_ref() 621 )), 622 ); 623 h.mount( 624 &d_did, 625 &nsid("sh.tangled.repo.issue"), 626 &rk, 627 issue_body(&repo, &format!("issue-{}", rk.as_ref())), 628 ) 629 .await; 630 } 631 }) 632 .await; 633 634 h.warming(1, 5); 635 let app = router(h.state.clone()); 636 let (_, page1) = json_response( 637 app.clone() 638 .oneshot(list_request( 639 "sh.tangled.repo.listIssues", 640 subject.as_ref(), 641 &[("limit", "1")], 642 )) 643 .await 644 .unwrap(), 645 ) 646 .await; 647 let cursor = page1["cursor"].as_str().unwrap().to_owned(); 648 649 h.promote_ready(2, 9); 650 let (_, page2) = json_response( 651 app.oneshot(list_request( 652 "sh.tangled.repo.listIssues", 653 subject.as_ref(), 654 &[("limit", "10"), ("cursor", &cursor)], 655 )) 656 .await 657 .unwrap(), 658 ) 659 .await; 660 assert_eq!(page2["items"].as_array().unwrap().len(), 1); 661} 662 663#[tokio::test] 664async fn invalid_cursor_returns_400() { 665 let h = Harness::new().await; 666 let app = router(h.state.clone()); 667 let resp = app 668 .oneshot(list_request( 669 "sh.tangled.repo.listIssues", 670 "at://did:plc:abalone", 671 &[("cursor", "not-a-number")], 672 )) 673 .await 674 .unwrap(); 675 let (status, body) = json_response(resp).await; 676 assert_eq!(status, StatusCode::BAD_REQUEST); 677 assert_eq!(body["error"], "InvalidRequest"); 678} 679 680#[tokio::test] 681async fn list_follows_subject_is_followee_did() { 682 let h = Harness::new().await; 683 let followee = did("did:plc:bailey"); 684 let subject = at(&format!("at://{}", followee.as_ref())); 685 h.add_edge( 686 &nsid("sh.tangled.graph.follow"), 687 &subject, 688 &at("at://did:plc:nel/sh.tangled.graph.follow/f1"), 689 ); 690 h.mount( 691 &did("did:plc:nel"), 692 &nsid("sh.tangled.graph.follow"), 693 &rkey("f1"), 694 follow_body(&followee), 695 ) 696 .await; 697 698 let app = router(h.state.clone()); 699 let (status, body) = json_response( 700 app.oneshot(list_request( 701 "sh.tangled.graph.listFollows", 702 subject.as_ref(), 703 &[], 704 )) 705 .await 706 .unwrap(), 707 ) 708 .await; 709 assert_eq!(status, StatusCode::OK); 710 let items = body["items"].as_array().unwrap(); 711 assert_eq!(items.len(), 1); 712 assert_eq!(items[0]["value"]["subject"], followee.as_ref()); 713} 714 715#[tokio::test] 716async fn get_follow_returns_uri_when_present_and_404_otherwise() { 717 let h = Harness::new().await; 718 let followee = did("did:plc:bailey"); 719 let subject = at(&format!("at://{}", followee.as_ref())); 720 h.add_edge( 721 &nsid("sh.tangled.graph.follow"), 722 &subject, 723 &at("at://did:plc:nel/sh.tangled.graph.follow/f1"), 724 ); 725 726 let app = router(h.state.clone()); 727 let (status, body) = json_response( 728 app.clone() 729 .oneshot(list_request( 730 "sh.tangled.graph.getFollow", 731 followee.as_ref(), 732 &[("actor", "did:plc:nel")], 733 )) 734 .await 735 .unwrap(), 736 ) 737 .await; 738 assert_eq!(status, StatusCode::OK); 739 assert_eq!(body["uri"], "at://did:plc:nel/sh.tangled.graph.follow/f1"); 740 741 // different actor never followed them, so this is 404 not a zero-ish success 742 let (status, _) = json_response( 743 app.oneshot(list_request( 744 "sh.tangled.graph.getFollow", 745 followee.as_ref(), 746 &[("actor", "did:plc:someoneelse")], 747 )) 748 .await 749 .unwrap(), 750 ) 751 .await; 752 assert_eq!(status, StatusCode::NOT_FOUND); 753} 754 755#[tokio::test] 756async fn get_star_returns_uri_when_present_and_404_otherwise() { 757 let h = Harness::new().await; 758 let repo_did = did("did:plc:limpet"); 759 let subject = at(&format!("at://{}", repo_did.as_ref())); 760 h.add_edge( 761 &nsid("sh.tangled.feed.star"), 762 &subject, 763 &at("at://did:plc:nel/sh.tangled.feed.star/s1"), 764 ); 765 766 let app = router(h.state.clone()); 767 let (status, body) = json_response( 768 app.clone() 769 .oneshot(list_request( 770 "sh.tangled.feed.getStar", 771 repo_did.as_ref(), 772 &[("actor", "did:plc:nel")], 773 )) 774 .await 775 .unwrap(), 776 ) 777 .await; 778 assert_eq!(status, StatusCode::OK); 779 assert_eq!(body["uri"], "at://did:plc:nel/sh.tangled.feed.star/s1"); 780 781 let (status, _) = json_response( 782 app.oneshot(list_request( 783 "sh.tangled.feed.getStar", 784 repo_did.as_ref(), 785 &[("actor", "did:plc:someoneelse")], 786 )) 787 .await 788 .unwrap(), 789 ) 790 .await; 791 assert_eq!(status, StatusCode::NOT_FOUND); 792} 793 794#[tokio::test] 795async fn upstream_failure_during_hydration_drops_only_that_item() { 796 let h = Harness::new().await; 797 let subject = at("at://did:plc:squid"); 798 let kind = nsid("sh.tangled.repo.issue"); 799 let repo = did("did:plc:squid"); 800 h.add_edge( 801 &kind, 802 &subject, 803 &at("at://did:plc:nel/sh.tangled.repo.issue/ok"), 804 ); 805 h.add_edge( 806 &kind, 807 &subject, 808 &at("at://did:plc:teq/sh.tangled.repo.issue/flaky"), 809 ); 810 h.mount( 811 &did("did:plc:nel"), 812 &kind, 813 &rkey("ok"), 814 issue_body(&repo, "kelp survey"), 815 ) 816 .await; 817 Mock::given(method("GET")) 818 .and(path("/xrpc/com.atproto.repo.getRecord")) 819 .and(query_param("repo", "did:plc:teq")) 820 .and(query_param("collection", "sh.tangled.repo.issue")) 821 .and(query_param("rkey", "flaky")) 822 .respond_with(ResponseTemplate::new(503)) 823 .mount(&h.server) 824 .await; 825 826 let app = router(h.state.clone()); 827 let (status, body) = json_response( 828 app.oneshot(list_request( 829 "sh.tangled.repo.listIssues", 830 subject.as_ref(), 831 &[], 832 )) 833 .await 834 .unwrap(), 835 ) 836 .await; 837 assert_eq!(status, StatusCode::OK); 838 let items = body["items"].as_array().expect("items array"); 839 assert_eq!(items.len(), 1, "flaky item dropped, healthy sibling kept"); 840 assert_eq!( 841 items[0]["uri"].as_str().unwrap(), 842 "at://did:plc:nel/sh.tangled.repo.issue/ok", 843 ); 844} 845 846#[tokio::test] 847async fn transient_failure_keeps_edge_so_count_stays_whole() { 848 let h = Harness::new().await; 849 let subject = at("at://did:plc:squid"); 850 let kind = nsid("sh.tangled.repo.issue"); 851 let repo = did("did:plc:squid"); 852 h.add_edge( 853 &kind, 854 &subject, 855 &at("at://did:plc:nel/sh.tangled.repo.issue/ok"), 856 ); 857 h.add_edge( 858 &kind, 859 &subject, 860 &at("at://did:plc:teq/sh.tangled.repo.issue/flaky"), 861 ); 862 h.mount( 863 &did("did:plc:nel"), 864 &kind, 865 &rkey("ok"), 866 issue_body(&repo, "kelp survey"), 867 ) 868 .await; 869 Mock::given(method("GET")) 870 .and(path("/xrpc/com.atproto.repo.getRecord")) 871 .and(query_param("repo", "did:plc:teq")) 872 .and(query_param("collection", "sh.tangled.repo.issue")) 873 .and(query_param("rkey", "flaky")) 874 .respond_with(ResponseTemplate::new(503)) 875 .mount(&h.server) 876 .await; 877 878 let app = router(h.state.clone()); 879 let (status, body) = json_response( 880 app.clone() 881 .oneshot(list_request( 882 "sh.tangled.repo.listIssues", 883 subject.as_ref(), 884 &[], 885 )) 886 .await 887 .unwrap(), 888 ) 889 .await; 890 assert_eq!(status, StatusCode::OK); 891 assert_eq!(body["items"].as_array().unwrap().len(), 1); 892 893 let (cstatus, cbody) = json_response( 894 app.oneshot(list_request( 895 "sh.tangled.repo.countIssues", 896 subject.as_ref(), 897 &[], 898 )) 899 .await 900 .unwrap(), 901 ) 902 .await; 903 assert_eq!(cstatus, StatusCode::OK); 904 assert_eq!( 905 cbody["count"], 906 json!(2), 907 "a transient 503 must not evict the edge, count stays whole", 908 ); 909} 910 911#[tokio::test] 912async fn gone_item_is_evicted_so_count_converges_to_list() { 913 let h = Harness::new().await; 914 let subject = at("at://did:plc:squid"); 915 let kind = nsid("sh.tangled.repo.issue"); 916 let repo = did("did:plc:squid"); 917 h.add_edge( 918 &kind, 919 &subject, 920 &at("at://did:plc:nel/sh.tangled.repo.issue/ok"), 921 ); 922 h.add_edge( 923 &kind, 924 &subject, 925 &at("at://did:plc:teq/sh.tangled.repo.issue/gone"), 926 ); 927 h.mount( 928 &did("did:plc:nel"), 929 &kind, 930 &rkey("ok"), 931 issue_body(&repo, "kelp survey"), 932 ) 933 .await; 934 Mock::given(method("GET")) 935 .and(path("/xrpc/com.atproto.repo.getRecord")) 936 .and(query_param("repo", "did:plc:teq")) 937 .and(query_param("collection", "sh.tangled.repo.issue")) 938 .and(query_param("rkey", "gone")) 939 .respond_with(ResponseTemplate::new(404)) 940 .mount(&h.server) 941 .await; 942 943 let app = router(h.state.clone()); 944 let (status, body) = json_response( 945 app.clone() 946 .oneshot(list_request( 947 "sh.tangled.repo.listIssues", 948 subject.as_ref(), 949 &[], 950 )) 951 .await 952 .unwrap(), 953 ) 954 .await; 955 assert_eq!(status, StatusCode::OK); 956 assert_eq!( 957 body["items"].as_array().unwrap().len(), 958 1, 959 "gone item dropped from the page" 960 ); 961 962 let (cstatus, cbody) = json_response( 963 app.oneshot(list_request( 964 "sh.tangled.repo.countIssues", 965 subject.as_ref(), 966 &[], 967 )) 968 .await 969 .unwrap(), 970 ) 971 .await; 972 assert_eq!(cstatus, StatusCode::OK); 973 assert_eq!( 974 cbody["count"], 975 json!(1), 976 "a definitive 404 must evict the dead edge so count matches the list", 977 ); 978} 979 980#[tokio::test] 981async fn handle_authority_subject_is_400() { 982 let h = Harness::new().await; 983 let app = router(h.state.clone()); 984 let cases = [ 985 "sh.tangled.feed.listStars", 986 "sh.tangled.feed.countStars", 987 "sh.tangled.graph.listFollows", 988 "sh.tangled.graph.countFollows", 989 "sh.tangled.repo.listIssues", 990 "sh.tangled.repo.countIssues", 991 "sh.tangled.repo.listPulls", 992 "sh.tangled.repo.countPulls", 993 "sh.tangled.feed.listComments", 994 "sh.tangled.feed.countComments", 995 ]; 996 stream::iter(cases) 997 .for_each(|endpoint| { 998 let app = app.clone(); 999 async move { 1000 let resp = app 1001 .oneshot(list_request(endpoint, "at://oyster.cafe", &[])) 1002 .await 1003 .unwrap(); 1004 let (status, body) = json_response(resp).await; 1005 assert_eq!(status, StatusCode::BAD_REQUEST, "{endpoint}"); 1006 assert_eq!(body["error"], "InvalidRequest", "{endpoint}"); 1007 assert!( 1008 body["message"] 1009 .as_str() 1010 .unwrap_or_default() 1011 .contains("did, not a handle"), 1012 "{endpoint}: {}", 1013 body["message"] 1014 ); 1015 } 1016 }) 1017 .await; 1018} 1019 1020#[tokio::test] 1021async fn empty_subject_is_400() { 1022 let h = Harness::new().await; 1023 let app = router(h.state.clone()); 1024 let resp = app 1025 .oneshot(list_request("sh.tangled.repo.listIssues", "", &[])) 1026 .await 1027 .unwrap(); 1028 let (status, body) = json_response(resp).await; 1029 assert_eq!(status, StatusCode::BAD_REQUEST); 1030 assert_eq!(body["error"], "InvalidRequest"); 1031} 1032 1033#[tokio::test] 1034async fn limit_below_min_or_above_max_is_400() { 1035 let h = Harness::new().await; 1036 let app = router(h.state.clone()); 1037 let cases = [("0", "below"), ("1001", "above")]; 1038 stream::iter(cases) 1039 .for_each(|(limit, label)| { 1040 let app = app.clone(); 1041 async move { 1042 let resp = app 1043 .oneshot(list_request( 1044 "sh.tangled.repo.listIssues", 1045 "at://did:plc:abalone", 1046 &[("limit", limit)], 1047 )) 1048 .await 1049 .unwrap(); 1050 let (status, body) = json_response(resp).await; 1051 assert_eq!(status, StatusCode::BAD_REQUEST, "limit {label}"); 1052 assert_eq!(body["error"], "InvalidRequest", "limit {label}"); 1053 } 1054 }) 1055 .await; 1056} 1057 1058#[tokio::test] 1059async fn count_after_remove_source_returns_zero() { 1060 let h = Harness::new().await; 1061 let subject = at("at://did:plc:abalone"); 1062 let source = at("at://did:plc:nel/sh.tangled.feed.star/s1"); 1063 h.add_edge(&nsid("sh.tangled.feed.star"), &subject, &source); 1064 h.edges.remove_source(&source); 1065 1066 let app = router(h.state.clone()); 1067 let (_, body) = json_response( 1068 app.oneshot(list_request( 1069 "sh.tangled.feed.countStars", 1070 subject.as_ref(), 1071 &[], 1072 )) 1073 .await 1074 .unwrap(), 1075 ) 1076 .await; 1077 assert_eq!(body["count"], json!(0)); 1078 assert_eq!(body["distinctAuthors"], json!(0)); 1079} 1080 1081#[tokio::test] 1082async fn list_feed_comments_hydrates_end_to_end() { 1083 let h = Harness::new().await; 1084 let issue_uri = at("at://did:plc:abalone/sh.tangled.repo.issue/i1"); 1085 let nel = did("did:plc:nel"); 1086 let rk = rkey("c1"); 1087 h.add_edge( 1088 &nsid("sh.tangled.feed.comment"), 1089 &issue_uri, 1090 &at(&format!( 1091 "at://{}/sh.tangled.feed.comment/{}", 1092 nel.as_ref(), 1093 rk.as_ref() 1094 )), 1095 ); 1096 h.mount( 1097 &nel, 1098 &nsid("sh.tangled.feed.comment"), 1099 &rk, 1100 json!({ 1101 "$type": "sh.tangled.feed.comment", 1102 "subject": { "uri": issue_uri.as_ref(), "cid": "bafkqaaa" }, 1103 "body": { "$type": "sh.tangled.markup.markdown", "text": "thoughts" }, 1104 "createdAt": "2026-05-01T00:00:00Z" 1105 }), 1106 ) 1107 .await; 1108 1109 let app = router(h.state.clone()); 1110 let (status, body) = json_response( 1111 app.oneshot(list_request( 1112 "sh.tangled.feed.listComments", 1113 issue_uri.as_ref(), 1114 &[], 1115 )) 1116 .await 1117 .unwrap(), 1118 ) 1119 .await; 1120 assert_eq!(status, StatusCode::OK); 1121 let items = body["items"].as_array().unwrap(); 1122 assert_eq!(items.len(), 1); 1123 assert_eq!(items[0]["value"]["body"]["text"], json!("thoughts")); 1124 assert_eq!( 1125 items[0]["value"]["subject"]["uri"], 1126 json!(issue_uri.as_ref()) 1127 ); 1128} 1129 1130#[tokio::test] 1131async fn list_item_cid_is_present() { 1132 let h = Harness::new().await; 1133 let subject = at("at://did:plc:abalone"); 1134 let nel = did("did:plc:nel"); 1135 h.add_edge( 1136 &nsid("sh.tangled.feed.star"), 1137 &subject, 1138 &at(&format!("at://{}/sh.tangled.feed.star/s1", nel.as_ref())), 1139 ); 1140 h.mount( 1141 &nel, 1142 &nsid("sh.tangled.feed.star"), 1143 &rkey("s1"), 1144 star_body(&did("did:plc:abalone")), 1145 ) 1146 .await; 1147 1148 let app = router(h.state.clone()); 1149 let (_, body) = json_response( 1150 app.oneshot(list_request( 1151 "sh.tangled.feed.listStars", 1152 subject.as_ref(), 1153 &[], 1154 )) 1155 .await 1156 .unwrap(), 1157 ) 1158 .await; 1159 let item = &body["items"][0]; 1160 assert!( 1161 item.as_object().unwrap().contains_key("cid"), 1162 "list items must mirror getRecord output shape and include cid" 1163 ); 1164 assert_eq!(item["cid"], json!(CID)); 1165} 1166 1167#[tokio::test] 1168async fn count_feed_comments_subjects_on_issue_uri() { 1169 let h = Harness::new().await; 1170 let issue_uri = at("at://did:plc:abalone/sh.tangled.repo.issue/i1"); 1171 h.add_edge( 1172 &nsid("sh.tangled.feed.comment"), 1173 &issue_uri, 1174 &at("at://did:plc:nel/sh.tangled.feed.comment/c1"), 1175 ); 1176 h.add_edge( 1177 &nsid("sh.tangled.feed.comment"), 1178 &issue_uri, 1179 &at("at://did:plc:olaren/sh.tangled.feed.comment/c2"), 1180 ); 1181 1182 let app = router(h.state.clone()); 1183 let (status, body) = json_response( 1184 app.oneshot(list_request( 1185 "sh.tangled.feed.countComments", 1186 issue_uri.as_ref(), 1187 &[], 1188 )) 1189 .await 1190 .unwrap(), 1191 ) 1192 .await; 1193 assert_eq!(status, StatusCode::OK); 1194 assert_eq!(body["count"], json!(2)); 1195 assert_eq!(body["distinctAuthors"], json!(2)); 1196} 1197 1198#[tokio::test] 1199async fn list_item_404_dropped_not_404_for_subject() { 1200 let h = Harness::new().await; 1201 let subject = at("at://did:plc:squid"); 1202 let kind = nsid("sh.tangled.repo.issue"); 1203 let repo = did("did:plc:squid"); 1204 h.add_edge( 1205 &kind, 1206 &subject, 1207 &at("at://did:plc:nel/sh.tangled.repo.issue/live"), 1208 ); 1209 h.add_edge( 1210 &kind, 1211 &subject, 1212 &at("at://did:plc:teq/sh.tangled.repo.issue/missing"), 1213 ); 1214 h.mount( 1215 &did("did:plc:nel"), 1216 &kind, 1217 &rkey("live"), 1218 issue_body(&repo, "kelp survives"), 1219 ) 1220 .await; 1221 Mock::given(method("GET")) 1222 .and(path("/xrpc/com.atproto.repo.getRecord")) 1223 .and(query_param("repo", "did:plc:teq")) 1224 .and(query_param("collection", "sh.tangled.repo.issue")) 1225 .and(query_param("rkey", "missing")) 1226 .respond_with(ResponseTemplate::new(404).set_body_json(json!({ 1227 "error": "RecordNotFound", 1228 "message": "could not find record" 1229 }))) 1230 .mount(&h.server) 1231 .await; 1232 1233 let app = router(h.state.clone()); 1234 let (status, body) = json_response( 1235 app.oneshot(list_request( 1236 "sh.tangled.repo.listIssues", 1237 subject.as_ref(), 1238 &[], 1239 )) 1240 .await 1241 .unwrap(), 1242 ) 1243 .await; 1244 assert_eq!( 1245 status, 1246 StatusCode::OK, 1247 "a stale-index 404 drops that item, it must not 404 or 502 the subject's list", 1248 ); 1249 let items = body["items"].as_array().expect("items array"); 1250 assert_eq!(items.len(), 1, "stale 404 item dropped, live sibling kept"); 1251 assert_eq!( 1252 items[0]["uri"].as_str().unwrap(), 1253 "at://did:plc:nel/sh.tangled.repo.issue/live", 1254 ); 1255} 1256 1257#[tokio::test] 1258async fn list_item_with_wrong_type_tag_dropped() { 1259 let h = Harness::new().await; 1260 let subject = at("at://did:plc:squid"); 1261 let kind = nsid("sh.tangled.feed.star"); 1262 h.add_edge( 1263 &kind, 1264 &subject, 1265 &at("at://did:plc:nel/sh.tangled.feed.star/good"), 1266 ); 1267 h.add_edge( 1268 &kind, 1269 &subject, 1270 &at("at://did:plc:teq/sh.tangled.feed.star/wrong"), 1271 ); 1272 h.mount( 1273 &did("did:plc:nel"), 1274 &kind, 1275 &rkey("good"), 1276 star_body(&did("did:plc:squid")), 1277 ) 1278 .await; 1279 Mock::given(method("GET")) 1280 .and(path("/xrpc/com.atproto.repo.getRecord")) 1281 .and(query_param("repo", "did:plc:teq")) 1282 .and(query_param("collection", "sh.tangled.feed.star")) 1283 .and(query_param("rkey", "wrong")) 1284 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 1285 "uri": "at://did:plc:teq/sh.tangled.feed.star/wrong", 1286 "cid": CID, 1287 "value": { 1288 "$type": "sh.tangled.feed.reaction", 1289 "createdAt": "2026-05-01T00:00:00Z", 1290 "subject": "at://did:plc:squid" 1291 } 1292 }))) 1293 .mount(&h.server) 1294 .await; 1295 1296 let app = router(h.state.clone()); 1297 let (status, body) = json_response( 1298 app.oneshot(list_request( 1299 "sh.tangled.feed.listStars", 1300 subject.as_ref(), 1301 &[], 1302 )) 1303 .await 1304 .unwrap(), 1305 ) 1306 .await; 1307 assert_eq!(status, StatusCode::OK); 1308 let items = body["items"].as_array().expect("items array"); 1309 assert_eq!(items.len(), 1, "wrong-type item dropped, valid star kept"); 1310 assert_eq!( 1311 items[0]["uri"].as_str().unwrap(), 1312 "at://did:plc:nel/sh.tangled.feed.star/good", 1313 ); 1314} 1315 1316#[tokio::test] 1317async fn list_item_with_mismatched_collection_dropped() { 1318 let h = Harness::new().await; 1319 let subject = at("at://did:plc:squid"); 1320 let kind = nsid("sh.tangled.repo.issue"); 1321 let repo = did("did:plc:squid"); 1322 h.add_edge( 1323 &kind, 1324 &subject, 1325 &at("at://did:plc:nel/sh.tangled.repo.issue/live"), 1326 ); 1327 h.add_edge( 1328 &kind, 1329 &subject, 1330 &at("at://did:plc:teq/sh.tangled.feed.star/whelk"), 1331 ); 1332 h.mount( 1333 &did("did:plc:nel"), 1334 &kind, 1335 &rkey("live"), 1336 issue_body(&repo, "kelp survives"), 1337 ) 1338 .await; 1339 1340 let app = router(h.state.clone()); 1341 let (status, body) = json_response( 1342 app.oneshot(list_request( 1343 "sh.tangled.repo.listIssues", 1344 subject.as_ref(), 1345 &[], 1346 )) 1347 .await 1348 .unwrap(), 1349 ) 1350 .await; 1351 assert_eq!( 1352 status, 1353 StatusCode::OK, 1354 "a mismatched-collection index edge must not 400 the subject's list", 1355 ); 1356 let items = body["items"].as_array().expect("items array"); 1357 assert_eq!( 1358 items.len(), 1359 1, 1360 "mismatched-collection edge dropped, live sibling kept" 1361 ); 1362 assert_eq!( 1363 items[0]["uri"].as_str().unwrap(), 1364 "at://did:plc:nel/sh.tangled.repo.issue/live", 1365 ); 1366} 1367 1368#[tokio::test] 1369async fn bare_did_endpoints_reject_at_uri_subject() { 1370 let h = Harness::new().await; 1371 let app = router(h.state.clone()); 1372 let cases = [ 1373 "sh.tangled.graph.listFollows", 1374 "sh.tangled.graph.countFollows", 1375 ]; 1376 stream::iter(cases) 1377 .for_each(|endpoint| { 1378 let app = app.clone(); 1379 async move { 1380 let resp = app 1381 .oneshot(list_request( 1382 endpoint, 1383 "at://did:plc:abalone/sh.tangled.repo/r1", 1384 &[], 1385 )) 1386 .await 1387 .unwrap(); 1388 let (status, body) = json_response(resp).await; 1389 assert_eq!(status, StatusCode::BAD_REQUEST, "{endpoint}"); 1390 assert_eq!(body["error"], "InvalidRequest", "{endpoint}"); 1391 assert!( 1392 body["message"] 1393 .as_str() 1394 .unwrap_or_default() 1395 .contains("bare did"), 1396 "{endpoint}: {}", 1397 body["message"], 1398 ); 1399 } 1400 }) 1401 .await; 1402} 1403 1404#[tokio::test] 1405async fn repo_pointing_endpoints_reject_at_uri_subject() { 1406 let h = Harness::new().await; 1407 let app = router(h.state.clone()); 1408 let cases = [ 1409 "sh.tangled.repo.listIssues", 1410 "sh.tangled.repo.countIssues", 1411 "sh.tangled.repo.listPulls", 1412 "sh.tangled.repo.countPulls", 1413 "sh.tangled.repo.listArtifacts", 1414 "sh.tangled.repo.countArtifacts", 1415 ]; 1416 stream::iter(cases) 1417 .for_each(|endpoint| { 1418 let app = app.clone(); 1419 async move { 1420 let resp = app 1421 .oneshot(list_request( 1422 endpoint, 1423 "at://did:plc:abalone/sh.tangled.repo/r1", 1424 &[], 1425 )) 1426 .await 1427 .unwrap(); 1428 let (status, body) = json_response(resp).await; 1429 assert_eq!( 1430 status, 1431 StatusCode::BAD_REQUEST, 1432 "{endpoint} must reject rkey-form subjects since rkeys are unstable; clients must send the repoDID", 1433 ); 1434 assert!( 1435 body["message"] 1436 .as_str() 1437 .unwrap_or_default() 1438 .contains("bare did"), 1439 "{endpoint}: {}", 1440 body["message"], 1441 ); 1442 } 1443 }) 1444 .await; 1445} 1446 1447#[tokio::test] 1448async fn repo_pointing_endpoints_accept_bare_did() { 1449 let h = Harness::new().await; 1450 let app = router(h.state.clone()); 1451 let cases = [ 1452 "sh.tangled.repo.listIssues", 1453 "sh.tangled.repo.countIssues", 1454 "sh.tangled.repo.listPulls", 1455 "sh.tangled.repo.countPulls", 1456 "sh.tangled.repo.listArtifacts", 1457 "sh.tangled.repo.countArtifacts", 1458 ]; 1459 stream::iter(cases) 1460 .for_each(|endpoint| { 1461 let app = app.clone(); 1462 async move { 1463 let resp = app 1464 .oneshot(list_request(endpoint, "did:plc:abalone", &[])) 1465 .await 1466 .unwrap(); 1467 let (status, _body) = json_response(resp).await; 1468 assert_eq!(status, StatusCode::OK, "{endpoint} must accept bare did"); 1469 } 1470 }) 1471 .await; 1472} 1473 1474#[tokio::test] 1475async fn feed_comment_endpoints_reject_bare_did_or_wrong_collection() { 1476 let h = Harness::new().await; 1477 let app = router(h.state.clone()); 1478 let endpoints = [ 1479 "sh.tangled.feed.listComments", 1480 "sh.tangled.feed.countComments", 1481 ]; 1482 let inputs = [ 1483 "at://did:plc:abalone", 1484 "at://did:plc:abalone/sh.tangled.repo/r1", 1485 ]; 1486 let cases = endpoints 1487 .iter() 1488 .copied() 1489 .flat_map(|endpoint| inputs.iter().copied().map(move |input| (endpoint, input))); 1490 stream::iter(cases) 1491 .for_each(|(endpoint, input)| { 1492 let app = app.clone(); 1493 async move { 1494 let resp = app 1495 .oneshot(list_request(endpoint, input, &[])) 1496 .await 1497 .unwrap(); 1498 let (status, body) = json_response(resp).await; 1499 assert_eq!(status, StatusCode::BAD_REQUEST, "{endpoint} input={input}"); 1500 let msg = body["message"].as_str().unwrap_or_default(); 1501 assert!( 1502 msg.contains("sh.tangled.repo.issue") 1503 && msg.contains("sh.tangled.repo.pull") 1504 && msg.contains("sh.tangled.string"), 1505 "{endpoint} input={input}: {msg}", 1506 ); 1507 } 1508 }) 1509 .await; 1510} 1511 1512#[tokio::test] 1513async fn star_endpoints_reject_unrelated_collection() { 1514 let h = Harness::new().await; 1515 let app = router(h.state.clone()); 1516 let endpoints = ["sh.tangled.feed.listStars", "sh.tangled.feed.countStars"]; 1517 stream::iter(endpoints) 1518 .for_each(|endpoint| { 1519 let app = app.clone(); 1520 async move { 1521 let resp = app 1522 .oneshot(list_request( 1523 endpoint, 1524 "at://did:plc:abalone/sh.tangled.knot/k1", 1525 &[], 1526 )) 1527 .await 1528 .unwrap(); 1529 let (status, body) = json_response(resp).await; 1530 assert_eq!(status, StatusCode::BAD_REQUEST, "{endpoint}"); 1531 let msg = body["message"].as_str().unwrap_or_default(); 1532 assert!(msg.contains("sh.tangled.string"), "{endpoint}: {msg}",); 1533 } 1534 }) 1535 .await; 1536} 1537 1538#[tokio::test] 1539async fn star_endpoints_reject_repo_uri_subject() { 1540 let h = Harness::new().await; 1541 let app = router(h.state.clone()); 1542 let resp = app 1543 .oneshot(list_request( 1544 "sh.tangled.feed.countStars", 1545 "at://did:plc:abalone/sh.tangled.repo/r1", 1546 &[], 1547 )) 1548 .await 1549 .unwrap(); 1550 let (status, body) = json_response(resp).await; 1551 assert_eq!( 1552 status, 1553 StatusCode::BAD_REQUEST, 1554 "rkey-form repo URI must be rejected; clients must send the repoDID directly", 1555 ); 1556 let msg = body["message"].as_str().unwrap_or_default(); 1557 assert!(msg.contains("sh.tangled.string"), "{msg}"); 1558} 1559 1560#[tokio::test] 1561async fn star_endpoints_accept_string_subject_form() { 1562 let h = Harness::new().await; 1563 let app = router(h.state.clone()); 1564 let resp = app 1565 .oneshot(list_request( 1566 "sh.tangled.feed.countStars", 1567 "at://did:plc:abalone/sh.tangled.string/k1", 1568 &[], 1569 )) 1570 .await 1571 .unwrap(); 1572 let (status, body) = json_response(resp).await; 1573 assert_eq!(status, StatusCode::OK); 1574 assert_eq!(body["count"], json!(0)); 1575} 1576 1577#[tokio::test] 1578async fn list_after_remove_source_returns_empty_items() { 1579 let h = Harness::new().await; 1580 let subject = at("at://did:plc:abalone"); 1581 let source = at("at://did:plc:nel/sh.tangled.feed.star/s1"); 1582 h.add_edge(&nsid("sh.tangled.feed.star"), &subject, &source); 1583 h.edges.remove_source(&source); 1584 1585 let app = router(h.state.clone()); 1586 let (status, body) = json_response( 1587 app.oneshot(list_request( 1588 "sh.tangled.feed.listStars", 1589 subject.as_ref(), 1590 &[], 1591 )) 1592 .await 1593 .unwrap(), 1594 ) 1595 .await; 1596 assert_eq!(status, StatusCode::OK); 1597 assert_eq!(body["items"], json!([])); 1598 assert!(body["cursor"].is_null()); 1599} 1600 1601#[tokio::test] 1602async fn list_pulls_hydrates_via_slingshot_when_edges_present() { 1603 let h = Harness::new().await; 1604 let target_did = did("did:plc:abalone"); 1605 let subject = at(&format!("at://{}", target_did.as_ref())); 1606 let source_did = did("did:plc:nel"); 1607 let rk = rkey("p1"); 1608 h.add_edge( 1609 &nsid("sh.tangled.repo.pull"), 1610 &subject, 1611 &at(&format!( 1612 "at://{}/sh.tangled.repo.pull/{}", 1613 source_did.as_ref(), 1614 rk.as_ref() 1615 )), 1616 ); 1617 h.mount( 1618 &source_did, 1619 &nsid("sh.tangled.repo.pull"), 1620 &rk, 1621 json!({ 1622 "$type": "sh.tangled.repo.pull", 1623 "title": "ship it", 1624 "createdAt": "2026-05-01T00:00:00Z", 1625 "rounds": [], 1626 "target": {"repo": target_did.as_ref(), "branch": "main"}, 1627 }), 1628 ) 1629 .await; 1630 let app = router(h.state.clone()); 1631 let (status, body) = json_response( 1632 app.oneshot(list_request( 1633 "sh.tangled.repo.listPulls", 1634 subject.as_ref(), 1635 &[], 1636 )) 1637 .await 1638 .unwrap(), 1639 ) 1640 .await; 1641 assert_eq!(status, StatusCode::OK); 1642 let items = body["items"].as_array().unwrap(); 1643 assert_eq!(items.len(), 1); 1644 assert_eq!(items[0]["value"]["title"], json!("ship it")); 1645 assert_eq!( 1646 items[0]["value"]["target"]["repo"], 1647 json!(target_did.as_ref()) 1648 ); 1649} 1650 1651#[tokio::test] 1652async fn count_pulls_returns_distinct_authors() { 1653 let h = Harness::new().await; 1654 let subject = at("at://did:plc:abalone"); 1655 h.add_edge( 1656 &nsid("sh.tangled.repo.pull"), 1657 &subject, 1658 &at("at://did:plc:nel/sh.tangled.repo.pull/p1"), 1659 ); 1660 h.add_edge( 1661 &nsid("sh.tangled.repo.pull"), 1662 &subject, 1663 &at("at://did:plc:olaren/sh.tangled.repo.pull/p2"), 1664 ); 1665 h.add_edge( 1666 &nsid("sh.tangled.repo.pull"), 1667 &subject, 1668 &at("at://did:plc:nel/sh.tangled.repo.pull/p3"), 1669 ); 1670 let app = router(h.state.clone()); 1671 let (_, body) = json_response( 1672 app.oneshot(list_request( 1673 "sh.tangled.repo.countPulls", 1674 subject.as_ref(), 1675 &[], 1676 )) 1677 .await 1678 .unwrap(), 1679 ) 1680 .await; 1681 assert_eq!(body["count"], json!(3)); 1682 assert_eq!(body["distinctAuthors"], json!(2)); 1683} 1684 1685#[tokio::test] 1686async fn extractor_to_xrpc_round_trip_for_star() { 1687 let h = Harness::new().await; 1688 let subject_did = did("did:plc:abalone"); 1689 let source_did = did("did:plc:nel"); 1690 let rk = rkey("s1"); 1691 let source = at(&format!( 1692 "at://{}/sh.tangled.feed.star/{}", 1693 source_did.as_ref(), 1694 rk.as_ref() 1695 )); 1696 let body = star_body(&subject_did); 1697 let parsed = 1698 bobbin_types::edges::Record::from_json_value(&nsid("sh.tangled.feed.star"), body.clone()) 1699 .expect("parse star record"); 1700 parsed 1701 .extract_edges(&source) 1702 .expect("extract") 1703 .into_iter() 1704 .for_each(|e| h.edges.add(e)); 1705 h.mount(&source_did, &nsid("sh.tangled.feed.star"), &rk, body) 1706 .await; 1707 1708 let app = router(h.state.clone()); 1709 let (status, json) = json_response( 1710 app.oneshot(list_request( 1711 "sh.tangled.feed.listStars", 1712 &format!("at://{}", subject_did.as_ref()), 1713 &[], 1714 )) 1715 .await 1716 .unwrap(), 1717 ) 1718 .await; 1719 assert_eq!( 1720 status, 1721 StatusCode::OK, 1722 "extractor key must match handler subject, body was {json}", 1723 ); 1724 let items = json["items"].as_array().unwrap(); 1725 assert_eq!(items.len(), 1, "expected exactly one star edge"); 1726 assert_eq!( 1727 items[0]["value"]["subject"]["did"], 1728 json!(subject_did.as_ref()) 1729 ); 1730} 1731 1732#[tokio::test] 1733async fn list_issues_includes_state_comment_count_and_state_updated_at() { 1734 let h = Harness::new().await; 1735 let repo = did("did:plc:limpet"); 1736 let subject = at(&format!("at://{}", repo.as_ref())); 1737 let issue_uri = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 1738 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 1739 h.mount( 1740 &did("did:plc:nel"), 1741 &nsid("sh.tangled.repo.issue"), 1742 &rkey("i1"), 1743 issue_body(&repo, "hi"), 1744 ) 1745 .await; 1746 h.add_edge( 1747 &nsid("sh.tangled.feed.comment"), 1748 &issue_uri, 1749 &at("at://did:plc:olaren/sh.tangled.feed.comment/c1"), 1750 ); 1751 h.add_edge( 1752 &nsid("sh.tangled.feed.comment"), 1753 &issue_uri, 1754 &at("at://did:plc:teq/sh.tangled.feed.comment/c2"), 1755 ); 1756 1757 h.upsert_issue_state( 1758 at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"), 1759 issue_uri.clone(), 1760 1_777_593_600_000_000, 1761 IssueStateKind::Open, 1762 ); 1763 h.upsert_issue_state( 1764 at("at://did:plc:nel/sh.tangled.repo.issue.state/s2"), 1765 issue_uri.clone(), 1766 1_777_593_700_000_000, 1767 IssueStateKind::Closed, 1768 ); 1769 1770 let app = router(h.state.clone()); 1771 let resp = app 1772 .oneshot(list_request( 1773 "sh.tangled.repo.listIssues", 1774 subject.as_ref(), 1775 &[], 1776 )) 1777 .await 1778 .unwrap(); 1779 let (status, body) = json_response(resp).await; 1780 assert_eq!(status, StatusCode::OK); 1781 let item = &body["items"][0]; 1782 assert_eq!(item["state"], json!("closed")); 1783 assert_eq!(item["commentCount"], json!(2)); 1784 let updated = item["stateUpdatedAt"] 1785 .as_str() 1786 .expect("stateUpdatedAt must serialize as RFC3339 string"); 1787 assert!( 1788 updated.starts_with("2026-"), 1789 "expected 2026 timestamp, got {updated}" 1790 ); 1791} 1792 1793#[tokio::test] 1794async fn list_issues_defaults_to_open_when_no_state_record() { 1795 let h = Harness::new().await; 1796 let repo = did("did:plc:limpet"); 1797 let subject = at(&format!("at://{}", repo.as_ref())); 1798 let issue_uri = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 1799 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 1800 h.mount( 1801 &did("did:plc:nel"), 1802 &nsid("sh.tangled.repo.issue"), 1803 &rkey("i1"), 1804 issue_body(&repo, "no state yet"), 1805 ) 1806 .await; 1807 1808 let app = router(h.state.clone()); 1809 let (_status, body) = json_response( 1810 app.oneshot(list_request( 1811 "sh.tangled.repo.listIssues", 1812 subject.as_ref(), 1813 &[], 1814 )) 1815 .await 1816 .unwrap(), 1817 ) 1818 .await; 1819 let item = &body["items"][0]; 1820 assert_eq!( 1821 item["state"], 1822 json!("open"), 1823 "absent state record defaults to open" 1824 ); 1825 assert!( 1826 item.get("stateUpdatedAt").is_none(), 1827 "stateUpdatedAt must be absent without a state record", 1828 ); 1829 assert_eq!(item["commentCount"], json!(0)); 1830} 1831 1832#[tokio::test] 1833async fn list_issues_author_filter_restricts_to_matching_did() { 1834 let h = Harness::new().await; 1835 let repo = did("did:plc:limpet"); 1836 let subject = at(&format!("at://{}", repo.as_ref())); 1837 let owners = [ 1838 ("did:plc:nel", "n1"), 1839 ("did:plc:nel", "n2"), 1840 ("did:plc:olaren", "o1"), 1841 ("did:plc:olaren", "o2"), 1842 ]; 1843 stream::iter(owners) 1844 .for_each(|(d, r)| { 1845 let h = &h; 1846 let subject = subject.clone(); 1847 let repo = repo.clone(); 1848 async move { 1849 let d_did = did(d); 1850 let rk = rkey(r); 1851 h.add_edge( 1852 &nsid("sh.tangled.repo.issue"), 1853 &subject, 1854 &at(&format!( 1855 "at://{}/sh.tangled.repo.issue/{}", 1856 d_did.as_ref(), 1857 rk.as_ref() 1858 )), 1859 ); 1860 h.mount( 1861 &d_did, 1862 &nsid("sh.tangled.repo.issue"), 1863 &rk, 1864 issue_body(&repo, &format!("issue-{}", rk.as_ref())), 1865 ) 1866 .await; 1867 } 1868 }) 1869 .await; 1870 1871 let app = router(h.state.clone()); 1872 let (status, body) = json_response( 1873 app.oneshot(list_request( 1874 "sh.tangled.repo.listIssues", 1875 subject.as_ref(), 1876 &[("author", "did:plc:nel")], 1877 )) 1878 .await 1879 .unwrap(), 1880 ) 1881 .await; 1882 assert_eq!(status, StatusCode::OK); 1883 let items = body["items"].as_array().expect("items array"); 1884 assert_eq!(items.len(), 2, "two issues authored by nel"); 1885 let all_nel = items 1886 .iter() 1887 .all(|i| i["uri"].as_str().unwrap().starts_with("at://did:plc:nel/")); 1888 assert!(all_nel, "every returned uri must be authored by nel"); 1889} 1890 1891#[tokio::test] 1892async fn list_issues_invalid_author_returns_400() { 1893 let h = Harness::new().await; 1894 let subject = "at://did:plc:limpet".to_owned(); 1895 let app = router(h.state.clone()); 1896 let resp = app 1897 .oneshot(list_request( 1898 "sh.tangled.repo.listIssues", 1899 &subject, 1900 &[("author", "not-a-did")], 1901 )) 1902 .await 1903 .unwrap(); 1904 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1905} 1906 1907#[tokio::test] 1908async fn list_pulls_includes_merged_state_and_comment_count() { 1909 let h = Harness::new().await; 1910 let repo = did("did:plc:limpet"); 1911 let subject = at(&format!("at://{}", repo.as_ref())); 1912 let pull_uri = at("at://did:plc:nel/sh.tangled.repo.pull/p1"); 1913 h.add_edge(&nsid("sh.tangled.repo.pull"), &subject, &pull_uri); 1914 h.mount( 1915 &did("did:plc:nel"), 1916 &nsid("sh.tangled.repo.pull"), 1917 &rkey("p1"), 1918 pull_body(&repo, "fix bug"), 1919 ) 1920 .await; 1921 h.add_edge( 1922 &nsid("sh.tangled.feed.comment"), 1923 &pull_uri, 1924 &at("at://did:plc:teq/sh.tangled.feed.comment/c1"), 1925 ); 1926 h.upsert_pull_status( 1927 at("at://did:plc:nel/sh.tangled.repo.pull.status/s1"), 1928 pull_uri.clone(), 1929 1_777_593_600_000_000, 1930 PullStatusKind::Open, 1931 ); 1932 h.upsert_pull_status( 1933 at("at://did:plc:nel/sh.tangled.repo.pull.status/s2"), 1934 pull_uri.clone(), 1935 1_777_593_800_000_000, 1936 PullStatusKind::Merged, 1937 ); 1938 1939 let app = router(h.state.clone()); 1940 let (status, body) = json_response( 1941 app.oneshot(list_request( 1942 "sh.tangled.repo.listPulls", 1943 subject.as_ref(), 1944 &[], 1945 )) 1946 .await 1947 .unwrap(), 1948 ) 1949 .await; 1950 assert_eq!(status, StatusCode::OK); 1951 let item = &body["items"][0]; 1952 assert_eq!(item["state"], json!("merged")); 1953 assert_eq!(item["commentCount"], json!(1)); 1954} 1955 1956#[tokio::test] 1957async fn list_issues_state_filter_open_includes_records_without_state() { 1958 let h = Harness::new().await; 1959 let repo = did("did:plc:limpet"); 1960 let subject = at(&format!("at://{}", repo.as_ref())); 1961 let issue_uri = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 1962 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 1963 h.mount( 1964 &did("did:plc:nel"), 1965 &nsid("sh.tangled.repo.issue"), 1966 &rkey("i1"), 1967 issue_body(&repo, "fresh"), 1968 ) 1969 .await; 1970 1971 let app = router(h.state.clone()); 1972 let (status, body) = json_response( 1973 app.oneshot(list_request( 1974 "sh.tangled.repo.listIssues", 1975 subject.as_ref(), 1976 &[("state", "open")], 1977 )) 1978 .await 1979 .unwrap(), 1980 ) 1981 .await; 1982 assert_eq!(status, StatusCode::OK); 1983 let items = body["items"].as_array().expect("items array"); 1984 assert_eq!( 1985 items.len(), 1986 1, 1987 "absent state record still matches state=open" 1988 ); 1989} 1990 1991#[tokio::test] 1992async fn count_issues_splits_open_from_total() { 1993 let h = Harness::new().await; 1994 let repo = did("did:plc:limpet"); 1995 let subject = at(&format!("at://{}", repo.as_ref())); 1996 for rk in ["i1", "i2", "i3"] { 1997 h.add_edge( 1998 &nsid("sh.tangled.repo.issue"), 1999 &subject, 2000 &at(&format!("at://did:plc:nel/sh.tangled.repo.issue/{rk}")), 2001 ); 2002 } 2003 // the repo owner closing it counts. with no record at all it is open 2004 h.upsert_issue_state( 2005 at("at://did:plc:limpet/sh.tangled.repo.issue.state/s1"), 2006 at("at://did:plc:nel/sh.tangled.repo.issue/i1"), 2007 1_777_593_600_000_000, 2008 IssueStateKind::Closed, 2009 ); 2010 2011 let app = router(h.state.clone()); 2012 let counts = |args: &'static [(&'static str, &'static str)]| { 2013 let app = app.clone(); 2014 let subject = subject.clone(); 2015 async move { 2016 let (status, body) = json_response( 2017 app.oneshot(list_request( 2018 "sh.tangled.repo.countIssues", 2019 subject.as_ref(), 2020 args, 2021 )) 2022 .await 2023 .unwrap(), 2024 ) 2025 .await; 2026 assert_eq!(status, StatusCode::OK); 2027 body["count"].as_u64().expect("count") 2028 } 2029 }; 2030 2031 assert_eq!(counts(&[]).await, 3, "no filter is every issue"); 2032 assert_eq!(counts(&[("state", "open")]).await, 2); 2033 assert_eq!(counts(&[("state", "closed")]).await, 1); 2034} 2035 2036#[tokio::test] 2037async fn count_issues_open_ignores_third_party_state_source() { 2038 let h = Harness::new().await; 2039 let repo = did("did:plc:limpet"); 2040 let subject = at(&format!("at://{}", repo.as_ref())); 2041 let issue_uri = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 2042 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 2043 h.upsert_issue_state( 2044 at("at://did:plc:nautilus/sh.tangled.repo.issue.state/spoof"), 2045 issue_uri, 2046 1_777_593_800_000_000, 2047 IssueStateKind::Closed, 2048 ); 2049 2050 let app = router(h.state.clone()); 2051 let (status, body) = json_response( 2052 app.oneshot(list_request( 2053 "sh.tangled.repo.countIssues", 2054 subject.as_ref(), 2055 &[("state", "open")], 2056 )) 2057 .await 2058 .unwrap(), 2059 ) 2060 .await; 2061 assert_eq!(status, StatusCode::OK); 2062 assert_eq!( 2063 body["count"], 2064 json!(1), 2065 "a stranger closing an issue must not take it out of the open count", 2066 ); 2067} 2068 2069#[tokio::test] 2070async fn filtered_count_follows_state_changes_and_repo_rekeying() { 2071 let h = Harness::new().await; 2072 let old_repo = did("did:plc:limpet"); 2073 let new_repo = did("did:plc:scallop"); 2074 let old_subject = at(&format!("at://{}", old_repo.as_ref())); 2075 let new_subject = at(&format!("at://{}", new_repo.as_ref())); 2076 let issue = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 2077 let kind = nsid("sh.tangled.repo.issue"); 2078 h.edges.upsert_source( 2079 &issue, 2080 vec![Edge { 2081 kind: kind.clone(), 2082 subject: SubjectRef::Did(old_repo), 2083 source: issue.clone(), 2084 sort_micros: next_sort_micros(), 2085 }], 2086 ); 2087 h.edges.refresh_issue_counts(&h.state.issue_states, &issue); 2088 2089 let app = router(h.state.clone()); 2090 let count_open = |subject: AtUri<DefaultStr>| { 2091 let app = app.clone(); 2092 async move { 2093 let (status, body) = json_response( 2094 app.oneshot(list_request( 2095 "sh.tangled.repo.countIssues", 2096 subject.as_ref(), 2097 &[("state", "open")], 2098 )) 2099 .await 2100 .unwrap(), 2101 ) 2102 .await; 2103 assert_eq!(status, StatusCode::OK); 2104 body["count"].as_u64().expect("count") 2105 } 2106 }; 2107 2108 assert_eq!(count_open(old_subject.clone()).await, 1); 2109 assert_eq!(count_open(old_subject.clone()).await, 1, "repeated read"); 2110 assert_eq!( 2111 count_open(new_subject.clone()).await, 2112 0, 2113 "the issue does not belong to the destination yet", 2114 ); 2115 2116 h.upsert_issue_state( 2117 at("at://did:plc:limpet/sh.tangled.repo.issue.state/s1"), 2118 issue.clone(), 2119 1_777_593_600_000_000, 2120 IssueStateKind::Closed, 2121 ); 2122 assert_eq!( 2123 count_open(old_subject.clone()).await, 2124 0, 2125 "state changes are visible without cached classification", 2126 ); 2127 2128 h.edges.upsert_source( 2129 &issue, 2130 vec![Edge { 2131 kind, 2132 subject: SubjectRef::Did(new_repo), 2133 source: issue.clone(), 2134 sort_micros: next_sort_micros(), 2135 }], 2136 ); 2137 h.edges.refresh_issue_counts(&h.state.issue_states, &issue); 2138 assert_eq!(count_open(old_subject).await, 0); 2139 assert_eq!( 2140 count_open(new_subject).await, 2141 1, 2142 "re-keying reprojects acceptance against the new repo owner", 2143 ); 2144} 2145 2146#[tokio::test] 2147async fn count_pulls_splits_by_status() { 2148 let h = Harness::new().await; 2149 let repo = did("did:plc:limpet"); 2150 let subject = at(&format!("at://{}", repo.as_ref())); 2151 for rk in ["p1", "p2", "p3"] { 2152 h.add_edge( 2153 &nsid("sh.tangled.repo.pull"), 2154 &subject, 2155 &at(&format!("at://did:plc:nel/sh.tangled.repo.pull/{rk}")), 2156 ); 2157 } 2158 h.upsert_pull_status( 2159 at("at://did:plc:limpet/sh.tangled.repo.pull.status/s1"), 2160 at("at://did:plc:nel/sh.tangled.repo.pull/p1"), 2161 1_777_593_600_000_000, 2162 PullStatusKind::Merged, 2163 ); 2164 h.upsert_pull_status( 2165 at("at://did:plc:limpet/sh.tangled.repo.pull.status/s2"), 2166 at("at://did:plc:nel/sh.tangled.repo.pull/p2"), 2167 1_777_593_600_000_000, 2168 PullStatusKind::Closed, 2169 ); 2170 2171 let app = router(h.state.clone()); 2172 let counts = |args: &'static [(&'static str, &'static str)]| { 2173 let app = app.clone(); 2174 let subject = subject.clone(); 2175 async move { 2176 let (_, body) = json_response( 2177 app.oneshot(list_request( 2178 "sh.tangled.repo.countPulls", 2179 subject.as_ref(), 2180 args, 2181 )) 2182 .await 2183 .unwrap(), 2184 ) 2185 .await; 2186 body["count"].as_u64().expect("count") 2187 } 2188 }; 2189 2190 assert_eq!(counts(&[]).await, 3); 2191 assert_eq!(counts(&[("status", "open")]).await, 1); 2192 assert_eq!(counts(&[("status", "closed")]).await, 1); 2193 assert_eq!(counts(&[("status", "merged")]).await, 1); 2194} 2195 2196#[tokio::test] 2197async fn count_issues_distinct_authors_follows_the_filter() { 2198 let h = Harness::new().await; 2199 let repo = did("did:plc:limpet"); 2200 let subject = at(&format!("at://{}", repo.as_ref())); 2201 h.add_edge( 2202 &nsid("sh.tangled.repo.issue"), 2203 &subject, 2204 &at("at://did:plc:nel/sh.tangled.repo.issue/i1"), 2205 ); 2206 h.add_edge( 2207 &nsid("sh.tangled.repo.issue"), 2208 &subject, 2209 &at("at://did:plc:olaren/sh.tangled.repo.issue/i2"), 2210 ); 2211 h.upsert_issue_state( 2212 at("at://did:plc:limpet/sh.tangled.repo.issue.state/s1"), 2213 at("at://did:plc:olaren/sh.tangled.repo.issue/i2"), 2214 1_777_593_600_000_000, 2215 IssueStateKind::Closed, 2216 ); 2217 2218 let app = router(h.state.clone()); 2219 let (_, body) = json_response( 2220 app.oneshot(list_request( 2221 "sh.tangled.repo.countIssues", 2222 subject.as_ref(), 2223 &[("state", "open")], 2224 )) 2225 .await 2226 .unwrap(), 2227 ) 2228 .await; 2229 assert_eq!(body["count"], json!(1)); 2230 assert_eq!( 2231 body["distinctAuthors"], 2232 json!(1), 2233 "authors of filtered-out issues must not be counted", 2234 ); 2235 2236 for (args, count, distinct) in [ 2237 (&[("author", "did:plc:nel")][..], 1, 1), 2238 (&[("author", "did:plc:nel"), ("state", "open")][..], 1, 1), 2239 (&[("author", "did:plc:olaren"), ("state", "open")][..], 0, 0), 2240 ] { 2241 let (_, body) = json_response( 2242 router(h.state.clone()) 2243 .oneshot(list_request( 2244 "sh.tangled.repo.countIssues", 2245 subject.as_ref(), 2246 args, 2247 )) 2248 .await 2249 .unwrap(), 2250 ) 2251 .await; 2252 assert_eq!(body["count"], json!(count)); 2253 assert_eq!(body["distinctAuthors"], json!(distinct)); 2254 } 2255} 2256 2257#[tokio::test] 2258async fn list_issues_state_filter_ignores_third_party_state_source() { 2259 let h = Harness::new().await; 2260 let repo = did("did:plc:limpet"); 2261 let subject = at(&format!("at://{}", repo.as_ref())); 2262 let issue_uri = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 2263 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 2264 h.mount( 2265 &did("did:plc:nel"), 2266 &nsid("sh.tangled.repo.issue"), 2267 &rkey("i1"), 2268 issue_body(&repo, "open issue"), 2269 ) 2270 .await; 2271 h.upsert_issue_state( 2272 at("at://did:plc:nautilus/sh.tangled.repo.issue.state/spoof"), 2273 issue_uri.clone(), 2274 1_777_593_800_000_000, 2275 IssueStateKind::Closed, 2276 ); 2277 2278 let app = router(h.state.clone()); 2279 let (status, body) = json_response( 2280 app.oneshot(list_request( 2281 "sh.tangled.repo.listIssues", 2282 subject.as_ref(), 2283 &[("state", "open")], 2284 )) 2285 .await 2286 .unwrap(), 2287 ) 2288 .await; 2289 assert_eq!(status, StatusCode::OK); 2290 let items = body["items"].as_array().expect("items array"); 2291 assert_eq!( 2292 items.len(), 2293 1, 2294 "third-party Closed record must not flip filter result for state=open", 2295 ); 2296 assert_eq!(items[0]["state"], json!("open")); 2297 assert!( 2298 items[0].get("stateUpdatedAt").is_none(), 2299 "third-party state source must not surface stateUpdatedAt", 2300 ); 2301} 2302 2303#[tokio::test] 2304async fn list_pulls_status_filter_ignores_third_party_status_source() { 2305 let h = Harness::new().await; 2306 let repo = did("did:plc:limpet"); 2307 let subject = at(&format!("at://{}", repo.as_ref())); 2308 let pull_uri = at("at://did:plc:nel/sh.tangled.repo.pull/p1"); 2309 h.add_edge(&nsid("sh.tangled.repo.pull"), &subject, &pull_uri); 2310 h.mount( 2311 &did("did:plc:nel"), 2312 &nsid("sh.tangled.repo.pull"), 2313 &rkey("p1"), 2314 pull_body(&repo, "wip"), 2315 ) 2316 .await; 2317 h.upsert_pull_status( 2318 at("at://did:plc:nautilus/sh.tangled.repo.pull.status/spoof"), 2319 pull_uri.clone(), 2320 1_777_593_800_000_000, 2321 PullStatusKind::Merged, 2322 ); 2323 2324 let app = router(h.state.clone()); 2325 let (status, body) = json_response( 2326 app.oneshot(list_request( 2327 "sh.tangled.repo.listPulls", 2328 subject.as_ref(), 2329 &[("status", "merged")], 2330 )) 2331 .await 2332 .unwrap(), 2333 ) 2334 .await; 2335 assert_eq!(status, StatusCode::OK); 2336 let items = body["items"].as_array().expect("items array"); 2337 assert_eq!( 2338 items.len(), 2339 0, 2340 "third-party Merged record must not satisfy status=merged" 2341 ); 2342} 2343 2344#[tokio::test] 2345async fn list_issues_state_filter_accepts_repo_owner_state_source() { 2346 let h = Harness::new().await; 2347 let repo_owner = did("did:plc:limpet"); 2348 let subject = at(&format!("at://{}", repo_owner.as_ref())); 2349 let issue_uri = at("at://did:plc:nel/sh.tangled.repo.issue/i1"); 2350 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 2351 h.mount( 2352 &did("did:plc:nel"), 2353 &nsid("sh.tangled.repo.issue"), 2354 &rkey("i1"), 2355 issue_body(&repo_owner, "owner closed"), 2356 ) 2357 .await; 2358 h.upsert_issue_state( 2359 at("at://did:plc:limpet/sh.tangled.repo.issue.state/legit"), 2360 issue_uri.clone(), 2361 1_777_593_800_000_000, 2362 IssueStateKind::Closed, 2363 ); 2364 2365 let app = router(h.state.clone()); 2366 let (status, body) = json_response( 2367 app.oneshot(list_request( 2368 "sh.tangled.repo.listIssues", 2369 subject.as_ref(), 2370 &[("state", "closed")], 2371 )) 2372 .await 2373 .unwrap(), 2374 ) 2375 .await; 2376 assert_eq!(status, StatusCode::OK); 2377 let items = body["items"].as_array().expect("items array"); 2378 assert_eq!( 2379 items.len(), 2380 1, 2381 "repo-owner state record must satisfy state=closed" 2382 ); 2383 assert_eq!(items[0]["state"], json!("closed")); 2384} 2385 2386#[tokio::test] 2387async fn list_issues_order_asc_returns_oldest_first() { 2388 let h = Harness::new().await; 2389 let repo = did("did:plc:limpet"); 2390 let subject = at(&format!("at://{}", repo.as_ref())); 2391 let rkeys = ["a", "b", "c"]; 2392 stream::iter(rkeys) 2393 .for_each(|r| { 2394 let h = &h; 2395 let subject = subject.clone(); 2396 let repo = repo.clone(); 2397 async move { 2398 let rk = rkey(r); 2399 let issue_uri = at(&format!( 2400 "at://did:plc:nel/sh.tangled.repo.issue/{}", 2401 rk.as_ref() 2402 )); 2403 h.add_edge(&nsid("sh.tangled.repo.issue"), &subject, &issue_uri); 2404 h.mount( 2405 &did("did:plc:nel"), 2406 &nsid("sh.tangled.repo.issue"), 2407 &rk, 2408 issue_body(&repo, &format!("issue-{}", rk.as_ref())), 2409 ) 2410 .await; 2411 } 2412 }) 2413 .await; 2414 2415 let app = router(h.state.clone()); 2416 let (_, asc) = json_response( 2417 app.clone() 2418 .oneshot(list_request( 2419 "sh.tangled.repo.listIssues", 2420 subject.as_ref(), 2421 &[("order", "asc")], 2422 )) 2423 .await 2424 .unwrap(), 2425 ) 2426 .await; 2427 let (_, desc) = json_response( 2428 app.oneshot(list_request( 2429 "sh.tangled.repo.listIssues", 2430 subject.as_ref(), 2431 &[("order", "desc")], 2432 )) 2433 .await 2434 .unwrap(), 2435 ) 2436 .await; 2437 let asc_uris: Vec<_> = asc["items"] 2438 .as_array() 2439 .unwrap() 2440 .iter() 2441 .map(|i| i["uri"].as_str().unwrap().to_owned()) 2442 .collect(); 2443 let desc_uris: Vec<_> = desc["items"] 2444 .as_array() 2445 .unwrap() 2446 .iter() 2447 .map(|i| i["uri"].as_str().unwrap().to_owned()) 2448 .collect(); 2449 let mut reversed = asc_uris.clone(); 2450 reversed.reverse(); 2451 assert_eq!(asc_uris.len(), 3); 2452 assert_eq!(desc_uris, reversed, "desc must be exact reverse of asc"); 2453} 2454 2455#[tokio::test] 2456async fn list_issues_by_state_filter_narrows_results() { 2457 let h = Harness::new().await; 2458 let author = did("did:plc:nel"); 2459 let repo = did("did:plc:limpet"); 2460 let open_uri = at("at://did:plc:nel/sh.tangled.repo.issue/open1"); 2461 let closed_uri = at("at://did:plc:nel/sh.tangled.repo.issue/closed1"); 2462 let author_subject = at(&format!("at://{}", author.as_ref())); 2463 h.edges.add(Edge { 2464 kind: nsid("sh.tangled.repo.issue.by"), 2465 subject: SubjectRef::Did(author.clone()), 2466 source: open_uri.clone(), 2467 sort_micros: next_sort_micros(), 2468 }); 2469 h.edges.add(Edge { 2470 kind: nsid("sh.tangled.repo.issue.by"), 2471 subject: SubjectRef::Did(author.clone()), 2472 source: closed_uri.clone(), 2473 sort_micros: next_sort_micros(), 2474 }); 2475 h.mount( 2476 &author, 2477 &nsid("sh.tangled.repo.issue"), 2478 &rkey("open1"), 2479 issue_body(&repo, "still open"), 2480 ) 2481 .await; 2482 h.mount( 2483 &author, 2484 &nsid("sh.tangled.repo.issue"), 2485 &rkey("closed1"), 2486 issue_body(&repo, "shut"), 2487 ) 2488 .await; 2489 h.upsert_issue_state( 2490 at("at://did:plc:nel/sh.tangled.repo.issue.state/s1"), 2491 closed_uri.clone(), 2492 1_777_593_800_000_000, 2493 IssueStateKind::Closed, 2494 ); 2495 2496 let app = router(h.state.clone()); 2497 let (status, body) = json_response( 2498 app.oneshot(list_request( 2499 "sh.tangled.repo.listIssuesBy", 2500 author_subject.as_ref(), 2501 &[("state", "closed")], 2502 )) 2503 .await 2504 .unwrap(), 2505 ) 2506 .await; 2507 assert_eq!(status, StatusCode::OK); 2508 let items = body["items"].as_array().expect("items array"); 2509 assert_eq!( 2510 items.len(), 2511 1, 2512 "only the closed issue survives state=closed" 2513 ); 2514 assert_eq!(items[0]["uri"], json!(closed_uri.as_ref())); 2515} 2516 2517#[tokio::test] 2518async fn knot_owned_member_is_synthesized_without_slingshot() { 2519 let harness = Harness::new().await; 2520 let knot = bobbin_types::knot_acl::host_to_knot_did("kt.oyster.cafe").unwrap(); 2521 let subject = did("did:plc:boltless"); 2522 let created = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z").unwrap(); 2523 let micros = created.timestamp_micros() as u64; 2524 let (source, edges) = bobbin_types::knot_acl::member_upsert(&knot, &subject, micros).unwrap(); 2525 harness.edges.upsert_source(&source, edges); 2526 harness.promote_ready(1, 1); 2527 2528 let (status, body) = json_response( 2529 router(harness.state.clone()) 2530 .oneshot(list_request( 2531 "sh.tangled.knot.listMembers", 2532 subject.as_ref(), 2533 &[], 2534 )) 2535 .await 2536 .unwrap(), 2537 ) 2538 .await; 2539 2540 assert_eq!(status, StatusCode::OK); 2541 let items = body["items"].as_array().expect("items array"); 2542 assert_eq!( 2543 items.len(), 2544 1, 2545 "synthesized member must hydrate with no slingshot mock mounted" 2546 ); 2547 assert_eq!(items[0]["uri"], json!(source.as_ref())); 2548 assert!(items[0]["cid"].is_null()); 2549 assert_eq!(items[0]["value"]["domain"], json!("kt.oyster.cafe")); 2550 assert_eq!(items[0]["value"]["subject"], json!("did:plc:boltless")); 2551 let got = chrono::DateTime::parse_from_rfc3339( 2552 items[0]["value"]["createdAt"] 2553 .as_str() 2554 .expect("createdAt string"), 2555 ) 2556 .unwrap(); 2557 assert_eq!(got.timestamp_micros(), micros as i64); 2558} 2559 2560#[tokio::test] 2561async fn knot_owned_member_lists_by_knot_did() { 2562 let harness = Harness::new().await; 2563 let knot = bobbin_types::knot_acl::host_to_knot_did("kt.oyster.cafe").unwrap(); 2564 let subject = did("did:plc:boltless"); 2565 let created = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z").unwrap(); 2566 let micros = created.timestamp_micros() as u64; 2567 let (source, edges) = bobbin_types::knot_acl::member_upsert(&knot, &subject, micros).unwrap(); 2568 harness.edges.upsert_source(&source, edges); 2569 harness.promote_ready(1, 1); 2570 2571 let (status, body) = json_response( 2572 router(harness.state.clone()) 2573 .oneshot(list_request( 2574 "sh.tangled.knot.listMembersBy", 2575 knot.as_ref(), 2576 &[], 2577 )) 2578 .await 2579 .unwrap(), 2580 ) 2581 .await; 2582 2583 assert_eq!(status, StatusCode::OK); 2584 let items = body["items"].as_array().expect("items array"); 2585 assert_eq!(items.len(), 1); 2586 assert_eq!(items[0]["uri"], json!(source.as_ref())); 2587 assert!(items[0]["cid"].is_null()); 2588 assert_eq!(items[0]["value"]["domain"], json!("kt.oyster.cafe")); 2589 assert_eq!(items[0]["value"]["subject"], json!("did:plc:boltless")); 2590} 2591 2592#[tokio::test] 2593async fn knot_owned_collaborator_is_synthesized_without_slingshot() { 2594 let harness = Harness::new().await; 2595 let repo = did("did:plc:scallop"); 2596 let subject = did("did:plc:olaren"); 2597 let created = chrono::DateTime::parse_from_rfc3339("2026-06-03T12:00:00Z").unwrap(); 2598 let micros = created.timestamp_micros() as u64; 2599 let (source, edges) = 2600 bobbin_types::knot_acl::collaborator_upsert(&repo, &subject, micros).unwrap(); 2601 harness.edges.upsert_source(&source, edges); 2602 harness.promote_ready(1, 1); 2603 2604 let (status, body) = json_response( 2605 router(harness.state.clone()) 2606 .oneshot(list_request( 2607 "sh.tangled.repo.listCollaborators", 2608 repo.as_ref(), 2609 &[], 2610 )) 2611 .await 2612 .unwrap(), 2613 ) 2614 .await; 2615 2616 assert_eq!(status, StatusCode::OK); 2617 let items = body["items"].as_array().expect("items array"); 2618 assert_eq!(items.len(), 1); 2619 assert_eq!(items[0]["uri"], json!(source.as_ref())); 2620 assert!(items[0]["cid"].is_null()); 2621 assert_eq!(items[0]["value"]["repo"], json!("did:plc:scallop")); 2622 assert_eq!(items[0]["value"]["subject"], json!("did:plc:olaren")); 2623} 2624 2625#[tokio::test] 2626async fn knot_owned_collaborator_lists_by_subject_did() { 2627 let harness = Harness::new().await; 2628 let repo = did("did:plc:scallop"); 2629 let subject = did("did:plc:olaren"); 2630 let created = chrono::DateTime::parse_from_rfc3339("2026-06-03T12:00:00Z").unwrap(); 2631 let micros = created.timestamp_micros() as u64; 2632 let (source, edges) = 2633 bobbin_types::knot_acl::collaborator_upsert(&repo, &subject, micros).unwrap(); 2634 harness.edges.upsert_source(&source, edges); 2635 harness.promote_ready(1, 1); 2636 2637 let (status, body) = json_response( 2638 router(harness.state.clone()) 2639 .oneshot(list_request( 2640 "sh.tangled.repo.listCollaboratorsBy", 2641 subject.as_ref(), 2642 &[], 2643 )) 2644 .await 2645 .unwrap(), 2646 ) 2647 .await; 2648 2649 assert_eq!(status, StatusCode::OK); 2650 let items = body["items"].as_array().expect("items array"); 2651 assert_eq!(items.len(), 1); 2652 assert_eq!(items[0]["uri"], json!(source.as_ref())); 2653 assert!(items[0]["cid"].is_null()); 2654 assert_eq!(items[0]["value"]["repo"], json!("did:plc:scallop")); 2655 assert_eq!(items[0]["value"]["subject"], json!("did:plc:olaren")); 2656}