This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / bobbin / crates / resolver / src / legacy_upgrade.rs
45 kB 1088 lines
1use alloc::collections::BTreeMap; 2use bobbin_types::com_atproto::repo::strong_ref::StrongRef; 3use bobbin_types::edges::{ExtractError, Record}; 4use bobbin_types::legacy::{ 5 LEGACY_COMMENT_SENTINEL_CID, LegacyCollaborator, LegacyIssue, LegacyIssueComment, 6 LegacyKnotMember, LegacyPublicKey, LegacyPull, LegacyPullComment, LegacyRecord, 7 LegacyRefUpdate, LegacyRepo, LegacySource, LegacyStar, LegacyTarget, 8}; 9use bobbin_types::sh_tangled::feed::comment::Comment as FeedComment; 10use bobbin_types::sh_tangled::feed::star::{Repo as StarRepo, Star, StarString, StarSubject}; 11use bobbin_types::sh_tangled::git::ref_update::RefUpdate; 12use bobbin_types::sh_tangled::knot::member::Member as KnotMember; 13use bobbin_types::sh_tangled::markup::markdown::Markdown; 14use bobbin_types::sh_tangled::public_key::PublicKey; 15use bobbin_types::sh_tangled::repo::Repo; 16use bobbin_types::sh_tangled::repo::collaborator::Collaborator; 17use bobbin_types::sh_tangled::repo::issue::Issue; 18use bobbin_types::sh_tangled::repo::pull::{Pull, Round, Source, Target}; 19use jacquard_common::deps::smol_str::SmolStr; 20use jacquard_common::types::did::Did; 21use jacquard_common::types::nsid::Nsid; 22use jacquard_common::types::string::{AtUri, AtprotoStr, Cid}; 23use jacquard_common::types::value::{Array, Data}; 24use jacquard_common::{BosStr, DefaultStr}; 25 26use crate::normalize::{is_repo_at_uri, resolve_repo_uri}; 27use crate::{RepoIdResolver, Resolution}; 28use jacquard_common::IntoStatic; 29use jacquard_common::types::ident::AtIdentifier; 30use jacquard_common::types::recordkey::Rkey; 31 32#[derive(Debug)] 33pub enum DecodedRecord { 34 Canon(Record), 35 Legacy(LegacyRecord), 36} 37 38impl DecodedRecord { 39 pub fn try_decode<S: BosStr + AsRef<str>>( 40 nsid: &Nsid<S>, 41 bytes: &[u8], 42 ) -> Result<Self, ExtractError> { 43 match Record::from_json_bytes(nsid, bytes) { 44 Ok(record) => Ok(Self::Canon(record)), 45 Err(canon_err) => { 46 let normalized = normalize_record_fields(bytes); 47 let working: &[u8] = normalized.as_deref().unwrap_or(bytes); 48 if normalized.is_some() 49 && let Ok(record) = Record::from_json_bytes(nsid, working) 50 { 51 return Ok(Self::Canon(record)); 52 } 53 if let Some(scrubbed) = scrub_record_bytes(nsid, working) { 54 if let Ok(record) = Record::from_json_bytes(nsid, &scrubbed) { 55 return Ok(Self::Canon(record)); 56 } 57 if let Ok(legacy) = LegacyRecord::from_json_bytes(nsid, &scrubbed) { 58 return Ok(Self::Legacy(legacy)); 59 } 60 } 61 match LegacyRecord::from_json_bytes(nsid, working) { 62 Ok(legacy) => Ok(Self::Legacy(legacy)), 63 Err(_) => Err(canon_err), 64 } 65 } 66 } 67 } 68} 69 70pub fn normalize_record_fields(bytes: &[u8]) -> Option<alloc::vec::Vec<u8>> { 71 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?; 72 let reserialized = serde_json::to_vec(&value).ok()?; 73 (reserialized.as_slice() != bytes).then_some(reserialized) 74} 75 76pub fn synthesize_created_at(bytes: &[u8], fallback_rfc3339: &str) -> Option<alloc::vec::Vec<u8>> { 77 let mut value: serde_json::Value = serde_json::from_slice(bytes).ok()?; 78 let obj = value.as_object_mut()?; 79 let needs_fill = match obj.get("createdAt") { 80 None => true, 81 Some(serde_json::Value::String(s)) if s.is_empty() => true, 82 _ => false, 83 }; 84 if !needs_fill { 85 return None; 86 } 87 obj.insert( 88 "createdAt".to_owned(), 89 serde_json::Value::String(fallback_rfc3339.to_owned()), 90 ); 91 serde_json::to_vec(&value).ok() 92} 93 94#[derive(Clone, Copy, Debug)] 95enum FieldRule { 96 DropIfEmptyString, 97 NullToEmptyArray, 98} 99 100fn scrub_rules(nsid: &str) -> &'static [(&'static str, FieldRule)] { 101 match nsid { 102 "sh.tangled.actor.profile" => &[("preferredHandle", FieldRule::DropIfEmptyString)], 103 "sh.tangled.label.op" => &[ 104 ("add", FieldRule::NullToEmptyArray), 105 ("delete", FieldRule::NullToEmptyArray), 106 ], 107 "sh.tangled.repo.pull" => &[("rounds", FieldRule::NullToEmptyArray)], 108 _ => &[], 109 } 110} 111 112pub fn scrub_record_bytes<S: BosStr + AsRef<str>>( 113 nsid: &Nsid<S>, 114 bytes: &[u8], 115) -> Option<alloc::vec::Vec<u8>> { 116 let rules = scrub_rules(nsid.as_ref()); 117 if rules.is_empty() { 118 return None; 119 } 120 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?; 121 let mut obj = value.as_object()?.clone(); 122 let touched: alloc::vec::Vec<(&str, FieldRule)> = rules 123 .iter() 124 .filter_map(|(field, rule)| match (rule, obj.get(*field)) { 125 (FieldRule::DropIfEmptyString, Some(serde_json::Value::String(s))) if s.is_empty() => { 126 Some((*field, *rule)) 127 } 128 (FieldRule::NullToEmptyArray, Some(serde_json::Value::Null)) => Some((*field, *rule)), 129 _ => None, 130 }) 131 .collect(); 132 if touched.is_empty() { 133 return None; 134 } 135 touched.iter().for_each(|(field, rule)| match rule { 136 FieldRule::DropIfEmptyString => { 137 obj.remove(*field); 138 } 139 FieldRule::NullToEmptyArray => { 140 obj.insert( 141 (*field).to_owned(), 142 serde_json::Value::Array(alloc::vec::Vec::new()), 143 ); 144 } 145 }); 146 tracing::debug!(nsid = %nsid.as_ref(), ?touched, "scrubbing fields before record retry"); 147 serde_json::to_vec(&serde_json::Value::Object(obj)).ok() 148} 149 150async fn upgrade_repo_did( 151 resolver: &RepoIdResolver, 152 at_uri: Option<AtUri<DefaultStr>>, 153 explicit_did: Option<Did<DefaultStr>>, 154) -> Option<Did<DefaultStr>> { 155 if let Some(d) = explicit_did { 156 return Some(d); 157 } 158 let uri = at_uri?; 159 resolve_repo_uri(resolver, &uri).await 160} 161 162pub async fn upgrade_wire_bytes<S: BosStr + AsRef<str>>( 163 nsid: &Nsid<S>, 164 bytes: &[u8], 165 resolver: &RepoIdResolver, 166) -> Result<alloc::vec::Vec<u8>, ExtractError> { 167 let legacy = LegacyRecord::from_json_bytes(nsid, bytes)?; 168 let canon = upgrade(legacy, resolver) 169 .await 170 .ok_or_else(|| upgrade_failed(nsid))?; 171 serialize_canon_variant(&canon).map_err(ExtractError::DecodeJson) 172} 173 174fn upgrade_failed<S: BosStr + AsRef<str>>(nsid: &Nsid<S>) -> ExtractError { 175 ExtractError::UnknownCollection(alloc::format!("{}: legacy upgrade failed", nsid.as_ref())) 176} 177 178pub async fn decode_canon_or_upgrade<S: BosStr + AsRef<str>>( 179 nsid: &Nsid<S>, 180 bytes: &[u8], 181 resolver: &RepoIdResolver, 182) -> Result<Record, ExtractError> { 183 match DecodedRecord::try_decode(nsid, bytes)? { 184 DecodedRecord::Canon(r) => Ok(r), 185 DecodedRecord::Legacy(legacy) => upgrade(legacy, resolver) 186 .await 187 .ok_or_else(|| upgrade_failed(nsid)), 188 } 189} 190 191pub async fn decode_canon_or_upgrade_bytes<'a, S: BosStr + AsRef<str>>( 192 nsid: &Nsid<S>, 193 bytes: &'a [u8], 194 resolver: &RepoIdResolver, 195) -> Result<(Record, alloc::borrow::Cow<'a, [u8]>), ExtractError> { 196 let decoded = DecodedRecord::try_decode(nsid, bytes)?; 197 match decoded { 198 DecodedRecord::Canon(r) => Ok((r, alloc::borrow::Cow::Borrowed(bytes))), 199 DecodedRecord::Legacy(legacy) => { 200 let canon = upgrade(legacy, resolver) 201 .await 202 .ok_or_else(|| upgrade_failed(nsid))?; 203 let canon_bytes = serialize_canon_variant(&canon).map_err(ExtractError::DecodeJson)?; 204 Ok((canon, alloc::borrow::Cow::Owned(canon_bytes))) 205 } 206 } 207} 208 209fn serialize_canon_variant(record: &Record) -> Result<alloc::vec::Vec<u8>, serde_json::Error> { 210 match record { 211 Record::FeedComment(r) => serde_json::to_vec(r), 212 Record::Issue(r) => serde_json::to_vec(r), 213 Record::Pull(r) => serde_json::to_vec(r), 214 Record::Collaborator(r) => serde_json::to_vec(r), 215 Record::RefUpdate(r) => serde_json::to_vec(r), 216 Record::Star(r) => serde_json::to_vec(r), 217 Record::PublicKey(r) => serde_json::to_vec(r), 218 Record::Repo(r) => serde_json::to_vec(r), 219 Record::KnotMember(r) => serde_json::to_vec(r), 220 _ => unreachable!( 221 "upgrade only produces FeedComment/Issue/Pull/Collaborator/RefUpdate/Star/PublicKey/Repo/KnotMember" 222 ), 223 } 224} 225 226pub async fn upgrade(legacy: LegacyRecord, resolver: &RepoIdResolver) -> Option<Record> { 227 match legacy { 228 LegacyRecord::Issue(l) => upgrade_issue(l, resolver).await.map(Record::Issue), 229 LegacyRecord::IssueComment(l) => Some(Record::FeedComment(upgrade_issue_comment(l))), 230 LegacyRecord::Pull(l) => upgrade_pull(l, resolver).await.map(Record::Pull), 231 LegacyRecord::PullComment(l) => Some(Record::FeedComment(upgrade_pull_comment(l))), 232 LegacyRecord::Collaborator(l) => upgrade_collaborator(l, resolver) 233 .await 234 .map(Record::Collaborator), 235 LegacyRecord::RefUpdate(l) => Some(Record::RefUpdate(upgrade_ref_update(l))), 236 LegacyRecord::Star(l) => upgrade_star(l, resolver).await.map(Record::Star), 237 LegacyRecord::PublicKey(l) => Some(Record::PublicKey(upgrade_public_key(l))), 238 LegacyRecord::Repo(l) => Some(Record::Repo(upgrade_repo(l))), 239 LegacyRecord::KnotMember(l) => Some(Record::KnotMember(upgrade_knot_member(l))), 240 } 241} 242 243fn sentinel_strong_ref(uri: AtUri<DefaultStr>) -> StrongRef<DefaultStr> { 244 let cid = Cid::<DefaultStr>::new_owned(LEGACY_COMMENT_SENTINEL_CID.as_bytes()) 245 .expect("LEGACY_COMMENT_SENTINEL_CID is a valid CID literal"); 246 StrongRef { 247 uri, 248 cid, 249 extra_data: None, 250 } 251} 252 253fn legacy_body_markdown(text: DefaultStr) -> Markdown<DefaultStr> { 254 Markdown { 255 blobs: None, 256 original: None, 257 text, 258 extra_data: None, 259 } 260} 261 262fn upgrade_issue_comment(l: LegacyIssueComment<DefaultStr>) -> FeedComment<DefaultStr> { 263 FeedComment { 264 body: legacy_body_markdown(l.body), 265 created_at: l.created_at, 266 pull_round_idx: None, 267 reply_to: l.reply_to.map(sentinel_strong_ref), 268 subject: sentinel_strong_ref(l.issue), 269 extra_data: legacy_comment_extras(l.extra_data, l.mentions, l.references), 270 } 271} 272 273fn upgrade_pull_comment(l: LegacyPullComment<DefaultStr>) -> FeedComment<DefaultStr> { 274 FeedComment { 275 body: legacy_body_markdown(l.body), 276 created_at: l.created_at, 277 pull_round_idx: None, 278 reply_to: None, 279 subject: sentinel_strong_ref(l.pull), 280 extra_data: legacy_comment_extras(l.extra_data, l.mentions, l.references), 281 } 282} 283 284fn legacy_comment_extras<S: BosStr>( 285 base: Option<BTreeMap<SmolStr, Data<S>>>, 286 mentions: Option<Vec<Did<S>>>, 287 references: Option<Vec<AtUri<S>>>, 288) -> Option<BTreeMap<SmolStr, Data<S>>> { 289 let mention_entry = mentions.filter(|v| !v.is_empty()).map(|items| { 290 let arr = items 291 .into_iter() 292 .map(|d| Data::String(AtprotoStr::Did(d))) 293 .collect(); 294 (SmolStr::new_static("mentions"), Data::Array(Array(arr))) 295 }); 296 let reference_entry = references.filter(|v| !v.is_empty()).map(|items| { 297 let arr = items 298 .into_iter() 299 .map(|u| Data::String(AtprotoStr::AtUri(u))) 300 .collect(); 301 (SmolStr::new_static("references"), Data::Array(Array(arr))) 302 }); 303 let combined: BTreeMap<SmolStr, Data<S>> = base 304 .into_iter() 305 .flatten() 306 .chain(mention_entry) 307 .chain(reference_entry) 308 .collect(); 309 if combined.is_empty() { 310 None 311 } else { 312 Some(combined) 313 } 314} 315 316async fn upgrade_issue( 317 l: LegacyIssue<DefaultStr>, 318 resolver: &RepoIdResolver, 319) -> Option<Issue<DefaultStr>> { 320 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?; 321 Some(Issue { 322 created_at: l.created_at, 323 blobs: None, 324 body: l.body, 325 mentions: l.mentions, 326 references: l.references, 327 repo, 328 title: l.title, 329 blobs: None, 330 extra_data: l.extra_data, 331 }) 332} 333 334async fn upgrade_target( 335 l: LegacyTarget<DefaultStr>, 336 resolver: &RepoIdResolver, 337) -> Option<Target<DefaultStr>> { 338 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?; 339 Some(Target { 340 branch: l.branch, 341 repo, 342 extra_data: None, 343 }) 344} 345 346async fn upgrade_source( 347 l: LegacySource<DefaultStr>, 348 resolver: &RepoIdResolver, 349) -> Source<DefaultStr> { 350 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await; 351 Source { 352 branch: l.branch, 353 repo, 354 extra_data: None, 355 } 356} 357 358async fn upgrade_pull( 359 l: LegacyPull<DefaultStr>, 360 resolver: &RepoIdResolver, 361) -> Option<Pull<DefaultStr>> { 362 let target = upgrade_target(l.target, resolver).await?; 363 let source = match l.source { 364 Some(s) => Some(upgrade_source(s, resolver).await), 365 None => None, 366 }; 367 let rounds = if l.rounds.is_empty() { 368 l.patch_blob 369 .map(|patch_blob| { 370 alloc::vec![Round { 371 created_at: l.created_at.clone(), 372 patch_blob, 373 extra_data: None, 374 }] 375 }) 376 .unwrap_or_default() 377 } else { 378 l.rounds 379 }; 380 Some(Pull { 381 created_at: l.created_at, 382 blobs: None, 383 body: l.body, 384 dependent_on: l.dependent_on, 385 mentions: l.mentions, 386 references: l.references, 387 rounds, 388 source, 389 target, 390 title: l.title, 391 blobs: None, 392 extra_data: l.extra_data, 393 }) 394} 395 396fn upgrade_public_key(l: LegacyPublicKey<DefaultStr>) -> PublicKey<DefaultStr> { 397 PublicKey { 398 created_at: l.created, 399 key: l.key, 400 name: l.name, 401 extra_data: l.extra_data, 402 } 403} 404 405fn upgrade_repo(l: LegacyRepo<DefaultStr>) -> Repo<DefaultStr> { 406 let _ = l.owner; 407 Repo { 408 created_at: l.added_at, 409 description: l.description, 410 knot: l.knot, 411 labels: None, 412 name: l.name, 413 repo_did: None, 414 source: None, 415 spindle: None, 416 topics: None, 417 website: None, 418 extra_data: l.extra_data, 419 } 420} 421 422fn upgrade_knot_member(l: LegacyKnotMember<DefaultStr>) -> KnotMember<DefaultStr> { 423 KnotMember { 424 created_at: l.added_at, 425 domain: l.domain, 426 subject: l.member, 427 extra_data: l.extra_data, 428 } 429} 430 431async fn upgrade_collaborator( 432 l: LegacyCollaborator<DefaultStr>, 433 resolver: &RepoIdResolver, 434) -> Option<Collaborator<DefaultStr>> { 435 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?; 436 Some(Collaborator { 437 created_at: l.created_at, 438 repo, 439 subject: l.subject, 440 extra_data: l.extra_data, 441 }) 442} 443 444fn upgrade_ref_update(l: LegacyRefUpdate<DefaultStr>) -> RefUpdate<DefaultStr> { 445 RefUpdate { 446 changed_files: None, 447 committer_did: l.committer_did, 448 meta: l.meta, 449 new_sha: l.new_sha, 450 old_sha: l.old_sha, 451 owner_did: l.owner_did, 452 push_options: None, 453 r#ref: l.r#ref, 454 repo: l.repo_did, 455 extra_data: l.extra_data, 456 } 457} 458 459async fn upgrade_star( 460 l: LegacyStar<DefaultStr>, 461 resolver: &RepoIdResolver, 462) -> Option<Star<DefaultStr>> { 463 let subject = if let Some(did) = l.subject_did { 464 StarSubject::Repo(alloc::boxed::Box::new(StarRepo { 465 did, 466 extra_data: None, 467 })) 468 } else { 469 let uri = l.subject?; 470 let resolved = if is_repo_at_uri(&uri) { 471 cached_repo_did(resolver, &uri).await 472 } else { 473 None 474 }; 475 match resolved { 476 Some(did) => StarSubject::Repo(alloc::boxed::Box::new(StarRepo { 477 did, 478 extra_data: None, 479 })), 480 None => StarSubject::String(alloc::boxed::Box::new(StarString { 481 uri, 482 extra_data: None, 483 })), 484 } 485 }; 486 Some(Star { 487 created_at: l.created_at, 488 subject, 489 extra_data: l.extra_data, 490 }) 491} 492 493async fn cached_repo_did( 494 resolver: &RepoIdResolver, 495 uri: &jacquard_common::types::string::AtUri<DefaultStr>, 496) -> Option<Did<DefaultStr>> { 497 let owner = match uri.authority() { 498 AtIdentifier::Did(d) => d.clone().into_static(), 499 AtIdentifier::Handle(_) => return None, 500 }; 501 let rkey: Rkey<DefaultStr> = uri.rkey()?.clone().into_static(); 502 match resolver.cached_resolution(&owner, &rkey).await? { 503 Resolution::Mapped(did) => Some(did), 504 Resolution::NoRepoDid | Resolution::Unresolvable => None, 505 } 506} 507 508extern crate alloc; 509 510#[cfg(test)] 511mod tests { 512 use super::*; 513 use crate::RepoIdResolver; 514 use bobbin_runtime::RuntimeHasher; 515 use bobbin_types::edges::Record; 516 use jacquard_common::DefaultStr; 517 use jacquard_common::types::did::Did; 518 use jacquard_common::types::recordkey::Rkey; 519 520 fn did(s: &str) -> Did<DefaultStr> { 521 Did::new_owned(s).unwrap() 522 } 523 524 fn rkey(s: &str) -> Rkey<DefaultStr> { 525 Rkey::new_owned(s).unwrap() 526 } 527 528 fn nsid(s: &'static str) -> Nsid<DefaultStr> { 529 Nsid::new_static(s).unwrap() 530 } 531 532 #[test] 533 fn legacy_decode_routes_through_try_decode_for_known_nsids() { 534 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; 535 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json) 536 .expect("legacy issue must decode"); 537 assert!(matches!( 538 decoded, 539 DecodedRecord::Legacy(LegacyRecord::Issue(_)) 540 )); 541 } 542 543 #[test] 544 fn canon_decode_wins_when_wire_matches_new_shape() { 545 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; 546 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json) 547 .expect("canon issue must decode"); 548 match decoded { 549 DecodedRecord::Canon(Record::Issue(i)) => { 550 assert_eq!(i.repo.as_ref(), "did:plc:squid") 551 } 552 other => panic!("expected canon issue, got {other:?}"), 553 } 554 } 555 556 #[test] 557 fn scrub_returns_none_for_unknown_nsid() { 558 let json = br#"{"$type":"sh.tangled.repo.issue","preferredHandle":""}"#; 559 assert!(scrub_record_bytes(&nsid("sh.tangled.repo.issue"), json).is_none()); 560 } 561 562 #[test] 563 fn scrub_returns_none_when_target_field_is_non_empty() { 564 let json = 565 br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"nel.pet"}"#; 566 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none()); 567 } 568 569 #[test] 570 fn scrub_returns_none_when_target_field_is_absent() { 571 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true}"#; 572 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none()); 573 } 574 575 #[test] 576 fn scrub_returns_none_for_non_string_value() { 577 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":42}"#; 578 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none()); 579 } 580 581 #[test] 582 fn scrub_returns_none_for_non_object_json() { 583 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"[]").is_none()); 584 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"null").is_none()); 585 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"123").is_none()); 586 } 587 588 #[test] 589 fn scrub_returns_none_for_invalid_json() { 590 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"{not json").is_none()); 591 } 592 593 #[test] 594 fn scrub_drops_empty_preferred_handle_and_preserves_other_fields() { 595 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#; 596 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json) 597 .expect("empty preferredHandle must trigger scrub"); 598 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json"); 599 let obj = value.as_object().expect("object"); 600 assert!(!obj.contains_key("preferredHandle")); 601 assert_eq!(obj.get("bluesky"), Some(&serde_json::json!(true))); 602 assert_eq!(obj.get("description"), Some(&serde_json::json!("hi"))); 603 assert_eq!( 604 obj.get("$type"), 605 Some(&serde_json::json!("sh.tangled.actor.profile")) 606 ); 607 } 608 609 #[test] 610 fn scrub_replaces_null_arrays_with_empty_for_label_op() { 611 let json = br#"{"$type":"sh.tangled.label.op","add":[{"key":"at://did:plc:limpet/sh.tangled.label.definition/k","value":"v"}],"delete":null,"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#; 612 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.label.op"), json) 613 .expect("null delete must trigger scrub"); 614 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json"); 615 let obj = value.as_object().expect("object"); 616 assert_eq!(obj.get("delete"), Some(&serde_json::json!([]))); 617 assert!(obj.get("add").is_some_and(|v| v.is_array())); 618 } 619 620 #[test] 621 fn scrub_passes_through_when_label_op_arrays_are_non_null() { 622 let json = br#"{"$type":"sh.tangled.label.op","add":[],"delete":[],"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#; 623 assert!(scrub_record_bytes(&nsid("sh.tangled.label.op"), json).is_none()); 624 } 625 626 #[test] 627 fn try_decode_recovers_label_op_with_null_delete() { 628 let json = br#"{"$type":"sh.tangled.label.op","add":[{"key":"at://did:plc:limpet/sh.tangled.label.definition/k","value":"v"}],"delete":null,"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#; 629 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.label.op"), json) 630 .expect("label.op with null delete must scrub-recover"); 631 assert!(matches!(decoded, DecodedRecord::Canon(Record::LabelOp(_)))); 632 } 633 634 #[test] 635 fn try_decode_recovers_profile_with_empty_preferred_handle() { 636 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#; 637 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.actor.profile"), json) 638 .expect("profile with empty preferredHandle must scrub-recover"); 639 match decoded { 640 DecodedRecord::Canon(Record::Profile(p)) => { 641 assert!(p.preferred_handle.is_none()); 642 assert_eq!(p.description.as_deref(), Some("hi")); 643 } 644 other => panic!("expected canon profile, got {other:?}"), 645 } 646 } 647 648 #[test] 649 fn legacy_decode_passes_through_for_unaffected_nsids() { 650 let json = br#"{"$type":"sh.tangled.graph.follow","subject":"did:plc:bailey","createdAt":"2026-05-01T00:00:00Z"}"#; 651 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.graph.follow"), json) 652 .expect("follow has no legacy form, must decode canon"); 653 assert!(matches!(decoded, DecodedRecord::Canon(Record::Follow(_)))); 654 } 655 656 #[test] 657 fn normalize_returns_none_for_invalid_json() { 658 assert!(normalize_record_fields(b"{not json").is_none()); 659 } 660 661 #[test] 662 fn normalize_drops_repeated_dollar_type_key() { 663 let json = 664 br#"{"$type":"sh.tangled.repo.pull","title":"t","$type":"sh.tangled.repo.pull"}"#; 665 let normalized = 666 normalize_record_fields(json).expect("duplicate key must produce normalized output"); 667 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json"); 668 let obj = value.as_object().expect("object"); 669 assert_eq!(obj.len(), 2); 670 assert_eq!( 671 obj.get("$type"), 672 Some(&serde_json::json!("sh.tangled.repo.pull")) 673 ); 674 assert_eq!(obj.get("title"), Some(&serde_json::json!("t"))); 675 } 676 677 #[test] 678 fn normalize_keeps_last_value_for_repeated_keys() { 679 let json = br#"{"$type":"sh.tangled.repo.issue","$type":"sh.tangled.repo.pull"}"#; 680 let normalized = 681 normalize_record_fields(json).expect("duplicate key must produce normalized output"); 682 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json"); 683 assert_eq!( 684 value.get("$type"), 685 Some(&serde_json::json!("sh.tangled.repo.pull")), 686 ); 687 } 688 689 #[test] 690 fn synthesize_fills_empty_created_at_with_fallback() { 691 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow","createdAt":""}"#; 692 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z") 693 .expect("empty createdAt must be filled"); 694 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json"); 695 assert_eq!( 696 value.get("createdAt"), 697 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")), 698 ); 699 } 700 701 #[test] 702 fn synthesize_fills_missing_created_at_with_fallback() { 703 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow"}"#; 704 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z") 705 .expect("missing createdAt must be filled"); 706 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json"); 707 assert_eq!( 708 value.get("createdAt"), 709 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")), 710 ); 711 } 712 713 #[test] 714 fn synthesize_returns_none_when_created_at_already_set() { 715 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":"2026-05-01T00:00:00Z"}"#; 716 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none()); 717 } 718 719 #[test] 720 fn synthesize_returns_none_for_non_string_created_at() { 721 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":null}"#; 722 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none()); 723 } 724 725 #[tokio::test] 726 async fn try_decode_recovers_legacy_issue_after_synthesized_created_at() { 727 let json = br#"{"$type":"sh.tangled.repo.issue","body":"a bug","createdAt":"","repo":"at://did:plc:scallop/sh.tangled.repo/limpet","repoDid":"did:plc:scallop","title":"a bug"}"#; 728 let patched = synthesize_created_at(json, "2025-08-01T12:00:00.000000Z") 729 .expect("empty createdAt must be filled"); 730 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), &patched) 731 .expect("issue must legacy-decode after createdAt fill"); 732 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 733 let canon = match decoded { 734 DecodedRecord::Canon(r) => r, 735 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"), 736 }; 737 match canon { 738 Record::Issue(i) => { 739 assert_eq!(AsRef::<str>::as_ref(&i.title), "a bug"); 740 assert_eq!(i.repo.as_ref(), "did:plc:scallop"); 741 } 742 other => panic!("expected issue, got {other:?}"), 743 } 744 } 745 746 #[tokio::test] 747 async fn try_decode_recovers_canon_pull_with_duplicate_dollar_type() { 748 let json = br#"{"$type":"sh.tangled.repo.pull","createdAt":"2026-05-01T00:00:00Z","title":"meow","target":{"branch":"main","repo":"at://did:plc:scallop/sh.tangled.repo/limpet"},"rounds":[],"$type":"sh.tangled.repo.pull"}"#; 749 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.pull"), json) 750 .expect("duplicate $type pull must normalize-recover"); 751 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 752 resolver 753 .observe( 754 did("did:plc:scallop"), 755 rkey("limpet"), 756 Some(did("did:plc:scallop")), 757 None, 758 ) 759 .await; 760 let canon = match decoded { 761 DecodedRecord::Canon(r) => r, 762 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"), 763 }; 764 match canon { 765 Record::Pull(p) => assert_eq!(AsRef::<str>::as_ref(&p.title), "meow"), 766 other => panic!("expected pull, got {other:?}"), 767 } 768 } 769 770 #[tokio::test] 771 async fn upgrade_issue_uses_repo_did_directly() { 772 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 773 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:scallop","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; 774 let legacy = 775 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode"); 776 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 777 match canon { 778 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")), 779 other => panic!("expected canon issue, got {other:?}"), 780 } 781 } 782 783 #[tokio::test] 784 async fn upgrade_issue_resolves_repo_uri_via_observed_resolver() { 785 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 786 let owner = did("did:plc:nel"); 787 let key = rkey("abcabcabcabcz"); 788 resolver 789 .observe( 790 owner.clone(), 791 key.clone(), 792 Some(did("did:plc:scallop")), 793 None, 794 ) 795 .await; 796 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; 797 let legacy = 798 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode"); 799 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 800 match canon { 801 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")), 802 other => panic!("expected canon issue, got {other:?}"), 803 } 804 } 805 806 #[tokio::test] 807 async fn upgrade_issue_drops_when_resolver_cannot_map() { 808 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 809 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#; 810 let legacy = 811 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode"); 812 assert!( 813 upgrade(legacy, &resolver).await.is_none(), 814 "no resolver entry and no repoDid means the canon Did cannot be constructed", 815 ); 816 } 817 818 #[tokio::test] 819 async fn upgrade_pull_propagates_target_resolution() { 820 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 821 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"}}"#; 822 let legacy = 823 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode"); 824 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 825 match canon { 826 Record::Pull(p) => { 827 assert_eq!(p.target.repo, did("did:plc:scallop")); 828 assert!(p.source.is_none()); 829 } 830 other => panic!("expected canon pull, got {other:?}"), 831 } 832 } 833 834 #[tokio::test] 835 async fn upgrade_pull_pre_rounds_synthesizes_round_from_top_level_patch_blob() { 836 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 837 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"},"patchBlob":{"$type":"blob","mimeType":"application/gzip","ref":{"$link":"bafkreibpatvbeajtwzlr4jwr4s2hnwo5l7sgdbfnqu6n7ctd2bcbtluw4a"},"size":920}}"#; 838 let legacy = 839 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode"); 840 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 841 match canon { 842 Record::Pull(p) => { 843 assert_eq!( 844 p.rounds.len(), 845 1, 846 "pre-rounds wire must yield exactly one synthesized round" 847 ); 848 assert_eq!( 849 p.rounds[0].patch_blob.blob().mime_type.as_ref(), 850 "application/gzip" 851 ); 852 assert_eq!(p.rounds[0].created_at, p.created_at); 853 } 854 other => panic!("expected canon pull, got {other:?}"), 855 } 856 } 857 858 #[tokio::test] 859 async fn upgrade_pull_omits_round_when_neither_rounds_nor_patch_blob_present() { 860 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 861 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"}}"#; 862 let legacy = 863 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode"); 864 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 865 match canon { 866 Record::Pull(p) => assert!(p.rounds.is_empty()), 867 other => panic!("expected canon pull, got {other:?}"), 868 } 869 } 870 871 #[tokio::test] 872 async fn upgrade_public_key_renames_created_to_created_at() { 873 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 874 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"ssh-ed25519 AAAA","name":"laptop"}"#; 875 let legacy = 876 LegacyRecord::from_json_bytes(&nsid("sh.tangled.publicKey"), json).expect("decode"); 877 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 878 match canon { 879 Record::PublicKey(k) => { 880 assert_eq!(k.created_at.as_str(), "2025-04-15T18:35:38Z"); 881 assert_eq!(k.key.as_str(), "ssh-ed25519 AAAA"); 882 assert_eq!(k.name.as_str(), "laptop"); 883 } 884 other => panic!("expected canon publicKey, got {other:?}"), 885 } 886 } 887 888 #[tokio::test] 889 async fn upgrade_repo_renames_added_at_and_drops_owner() { 890 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 891 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","description":"hi","knot":"knot1.tangled.sh","name":"site","owner":"did:plc:nel"}"#; 892 let legacy = LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo"), json).expect("decode"); 893 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 894 match canon { 895 Record::Repo(r) => { 896 assert_eq!(r.created_at.as_str(), "2025-03-21T10:18:58Z"); 897 assert_eq!(r.description.as_deref(), Some("hi")); 898 assert_eq!(r.knot.as_str(), "knot1.tangled.sh"); 899 assert_eq!(r.name.as_deref(), Some("site")); 900 assert!(r.repo_did.is_none(), "legacy repos have no repo_did"); 901 } 902 other => panic!("expected canon repo, got {other:?}"), 903 } 904 } 905 906 #[tokio::test] 907 async fn upgrade_knot_member_renames_added_at_and_member() { 908 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 909 let json = br#"{"$type":"sh.tangled.knot.member","addedAt":"2025-03-31T05:14:09Z","domain":"knot.example","member":"did:plc:nel"}"#; 910 let legacy = 911 LegacyRecord::from_json_bytes(&nsid("sh.tangled.knot.member"), json).expect("decode"); 912 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 913 match canon { 914 Record::KnotMember(m) => { 915 assert_eq!(m.created_at.as_str(), "2025-03-31T05:14:09Z"); 916 assert_eq!(m.domain.as_str(), "knot.example"); 917 assert_eq!(m.subject, did("did:plc:nel")); 918 } 919 other => panic!("expected canon knot.member, got {other:?}"), 920 } 921 } 922 923 #[tokio::test] 924 async fn legacy_pull_target_with_empty_repo_did_treats_as_none() { 925 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 926 resolver 927 .observe( 928 did("did:plc:nel"), 929 rkey("abcabcabcabcz"), 930 Some(did("did:plc:scallop")), 931 None, 932 ) 933 .await; 934 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","repoDid":""}}"#; 935 let legacy = 936 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode"); 937 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 938 match canon { 939 Record::Pull(p) => assert_eq!(p.target.repo, did("did:plc:scallop")), 940 other => panic!("expected canon pull, got {other:?}"), 941 } 942 } 943 944 #[tokio::test] 945 async fn try_decode_recovers_publickey_with_created_field() { 946 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"k","name":"n"}"#; 947 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.publicKey"), json) 948 .expect("legacy publicKey must decode"); 949 assert!(matches!( 950 decoded, 951 DecodedRecord::Legacy(LegacyRecord::PublicKey(_)) 952 )); 953 } 954 955 #[tokio::test] 956 async fn try_decode_recovers_repo_with_added_at_field() { 957 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","knot":"knot1.tangled.sh","owner":"did:plc:nel"}"#; 958 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo"), json) 959 .expect("legacy repo must decode"); 960 assert!(matches!( 961 decoded, 962 DecodedRecord::Legacy(LegacyRecord::Repo(_)) 963 )); 964 } 965 966 #[tokio::test] 967 async fn upgrade_pull_source_repo_resolution_is_independent_of_target() { 968 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 969 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"},"source":{"branch":"feat","repo":"at://did:plc:nel/sh.tangled.repo/missing"}}"#; 970 let legacy = 971 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode"); 972 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 973 match canon { 974 Record::Pull(p) => { 975 assert_eq!(p.target.repo, did("did:plc:scallop")); 976 let source = p.source.expect("source struct retained"); 977 assert_eq!(source.branch.as_str(), "feat"); 978 assert!( 979 source.repo.is_none(), 980 "unresolvable source repo at-uri leaves the source.repo None rather than dropping the whole pull", 981 ); 982 } 983 other => panic!("expected canon pull, got {other:?}"), 984 } 985 } 986 987 #[tokio::test] 988 async fn upgrade_ref_update_renames_repo_did_to_repo() { 989 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 990 let json = br#"{"$type":"sh.tangled.git.refUpdate","ref":"refs/heads/main","committerDid":"did:plc:olaren","repoDid":"did:plc:scallop","oldSha":"0000000000000000000000000000000000000000","newSha":"1111111111111111111111111111111111111111","meta":{"isDefaultRef":true,"commitCount":{}}}"#; 991 let legacy = 992 LegacyRecord::from_json_bytes(&nsid("sh.tangled.git.refUpdate"), json).expect("decode"); 993 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 994 match canon { 995 Record::RefUpdate(r) => { 996 assert_eq!(r.repo, did("did:plc:scallop")); 997 assert!(r.changed_files.is_none()); 998 assert!(r.push_options.is_none()); 999 } 1000 other => panic!("expected canon ref update, got {other:?}"), 1001 } 1002 } 1003 1004 #[tokio::test] 1005 async fn upgrade_star_prefers_subject_did_over_subject_uri() { 1006 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 1007 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.string/k1","subjectDid":"did:plc:scallop"}"#; 1008 let legacy = 1009 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode"); 1010 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 1011 match canon { 1012 Record::Star(s) => match s.subject { 1013 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")), 1014 StarSubject::String(_) => panic!("subjectDid must win"), 1015 }, 1016 other => panic!("expected canon star, got {other:?}"), 1017 } 1018 } 1019 1020 #[tokio::test] 1021 async fn upgrade_star_falls_back_to_string_when_repo_uri_not_in_cache() { 1022 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 1023 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#; 1024 let legacy = 1025 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode"); 1026 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 1027 match canon { 1028 Record::Star(s) => match s.subject { 1029 StarSubject::String(s) => assert_eq!( 1030 s.uri.as_ref(), 1031 "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz", 1032 "cache-miss on repo uri preserves the uri under the #string variant for later normalization", 1033 ), 1034 StarSubject::Repo(_) => panic!("cold cache must not upgrade to Repo variant"), 1035 }, 1036 other => panic!("expected canon star, got {other:?}"), 1037 } 1038 } 1039 1040 #[tokio::test] 1041 async fn upgrade_star_uses_cached_repo_did_when_observed() { 1042 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 1043 let owner = did("did:plc:nel"); 1044 let key = rkey("abcabcabcabcz"); 1045 resolver 1046 .observe( 1047 owner.clone(), 1048 key.clone(), 1049 Some(did("did:plc:scallop")), 1050 None, 1051 ) 1052 .await; 1053 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#; 1054 let legacy = 1055 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode"); 1056 let canon = upgrade(legacy, &resolver).await.expect("upgrade"); 1057 match canon { 1058 Record::Star(s) => match s.subject { 1059 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")), 1060 StarSubject::String(_) => panic!("observed cache must upgrade to Repo variant"), 1061 }, 1062 other => panic!("expected canon star, got {other:?}"), 1063 } 1064 } 1065 1066 #[tokio::test] 1067 async fn upgrade_collaborator_requires_repo_did() { 1068 let resolver = RepoIdResolver::detached(RuntimeHasher::default()); 1069 let with_did = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repoDid":"did:plc:scallop"}"#; 1070 let canon = upgrade( 1071 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), with_did) 1072 .expect("decode"), 1073 &resolver, 1074 ) 1075 .await 1076 .expect("upgrade"); 1077 match canon { 1078 Record::Collaborator(c) => assert_eq!(c.repo, did("did:plc:scallop")), 1079 other => panic!("expected canon collaborator, got {other:?}"), 1080 } 1081 1082 let no_resolution = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#; 1083 let legacy = 1084 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), no_resolution) 1085 .expect("decode"); 1086 assert!(upgrade(legacy, &resolver).await.is_none()); 1087 } 1088}