diff --git a/knot2/crates/knot-cobs/Cargo.toml b/knot2/crates/knot-cobs/Cargo.toml index e380f0803..3a1f3bb46 100644 --- a/knot2/crates/knot-cobs/Cargo.toml +++ b/knot2/crates/knot-cobs/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] knot-types = { workspace = true } knot-cob = { workspace = true } +knot-record = { workspace = true } knot-runtime = { workspace = true } serde = { workspace = true } serde_ipld_dagcbor = { workspace = true } diff --git a/knot2/crates/knot-cobs/src/issue.rs b/knot2/crates/knot-cobs/src/issue.rs new file mode 100644 index 000000000..dd0c646c2 --- /dev/null +++ b/knot2/crates/knot-cobs/src/issue.rs @@ -0,0 +1,111 @@ +use knot_record::issue::{ISSUE_COLLECTION, ISSUE_STATE_COLLECTION, IssueState}; +use serde::{Deserialize, Serialize}; + +use crate::Opening; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Issue; + +crate::social_cob! { + body = Issue, + change = IssueChange, + cob = IssueCob, + collection = ISSUE_COLLECTION, + opening = Opening::Contribution, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Transition { + pub state: IssueState, +} + +crate::social_cob! { + body = Transition, + change = IssueStateChange, + cob = IssueStateCob, + collection = ISSUE_STATE_COLLECTION, + opening = Opening::Moderation, +} + +pub fn social_collections() -> [knot_types::RecordCollection; 2] { + [ + knot_record::issue::issue_collection(), + knot_record::issue::issue_state_collection(), + ] +} + +pub fn social_state( + store: &knot_cob::CobStore, + type_name: &knot_types::TypeName, + object: knot_cob::CobId, +) -> Result, knot_cob::CobError> { + match type_name.as_str() { + ISSUE_COLLECTION => store.materialize::(object).map(Some), + ISSUE_STATE_COLLECTION => store.materialize::(object).map(Some), + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Creation, SocialChange}; + use knot_cob::{ChangePayload, Emits}; + use knot_record::issue::{issue_collection, issue_state_collection}; + use knot_types::{ + AccountDid, RecordAddress, RecordBody, RecordCollection, RecordRkey, SubjectNumber, + UnixSeconds, + }; + + fn creation(collection: RecordCollection) -> Creation { + Creation { + address: RecordAddress::new(collection, RecordRkey::new("3lubrptx57d22").unwrap()), + author: AccountDid::new("did:plc:nel").unwrap(), + number: SubjectNumber::FIRST, + parent: None, + created_at: UnixSeconds::new(1), + produces: RecordBody::new(vec![0x64, b'k', b'e', b'l', b'p']) + .unwrap() + .cid(), + } + } + + fn open_changes() -> (SocialChange, SocialChange) { + ( + SocialChange::Open { + creation: creation(issue_collection()), + body: Issue, + }, + SocialChange::Open { + creation: creation(issue_state_collection()), + body: Transition { + state: IssueState::Closed, + }, + }, + ) + } + + #[test] + fn issue_and_transition_versions_decode_faithfully() { + let (issue, transition) = open_changes(); + assert_eq!( + issue.emits(), + Emits::Version(creation(issue_collection()).produces) + ); + assert_eq!( + transition.emits(), + Emits::Version(creation(issue_state_collection()).produces) + ); + let (issue_bytes, transition_bytes) = + (issue.encode().unwrap(), transition.encode().unwrap()); + assert_eq!(IssueChange::decode(&issue_bytes).unwrap(), issue); + assert_eq!( + IssueStateChange::decode(&transition_bytes).unwrap(), + transition + ); + assert!( + IssueStateChange::decode(&issue_bytes).is_err(), + "an issue's null body mustn't be a transition" + ); + } +} diff --git a/knot2/crates/knot-cobs/src/lib.rs b/knot2/crates/knot-cobs/src/lib.rs index 5e7b170d8..cdba0ccfe 100644 --- a/knot2/crates/knot-cobs/src/lib.rs +++ b/knot2/crates/knot-cobs/src/lib.rs @@ -2,6 +2,7 @@ mod blocklist; mod collaborators; mod grant; mod import; +mod issue; mod members; mod registry; mod social; @@ -13,6 +14,10 @@ pub use grant::{ Roll, }; pub use import::{ImportError, verify_cob_ref}; +pub use issue::{ + Issue, IssueChange, IssueCob, IssueStateChange, IssueStateCob, Transition, social_collections, + social_state, +}; pub use members::{Members, MembersChange, MembersCob}; pub use registry::{ Policy, Registration, RegistrationField, Registry, RegistryChange, RegistryError, Rename, @@ -20,8 +25,8 @@ pub use registry::{ set_repo_policy, }; pub use social::{ - AnyBody, Creation, Editing, Emission, Erasure, Social, SocialBody, SocialChange, SocialCob, - SocialObject, SocialState, SocialWire, decode_social, + AnyBody, AnySocialChange, Creation, Editing, Emission, Erasure, Opening, Social, SocialBody, + SocialChange, SocialCob, SocialObject, SocialState, decode_social, }; #[doc(hidden)] diff --git a/knot2/crates/knot-cobs/src/social.rs b/knot2/crates/knot-cobs/src/social.rs index 3f0dd3cb8..8a55bb1f1 100644 --- a/knot2/crates/knot-cobs/src/social.rs +++ b/knot2/crates/knot-cobs/src/social.rs @@ -6,8 +6,8 @@ use knot_cob::{ SnapshotStride, StateSize, WriteContext, }; use knot_types::{ - AccountDid, ActorId, Blocked, Decision, RecordAddress, RecordCid, RecordCollection, - SubjectNumber, UnixSeconds, + AccountDid, ActorId, Blocked, ContributionPermission, Decision, RecordAddress, RecordCid, + RecordCollection, SubjectNumber, UnixSeconds, }; use serde::de::{DeserializeOwned, DeserializeSeed, MapAccess, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; @@ -37,10 +37,28 @@ pub struct Erasure { pub erased_at: UnixSeconds, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Opening { + Contribution, + Moderation, +} + +impl Opening { + pub fn decide(self, permission: ContributionPermission) -> Decision { + match self { + Self::Contribution => permission.contributes(), + Self::Moderation => Decision::allow_if( + permission.contributes().is_allowed() || permission.moderates().is_allowed(), + ), + } + } +} + pub trait SocialBody: Serialize + DeserializeOwned + Debug + Clone + PartialEq + Eq + Sized { const COLLECTION: &'static str; + const OPENING: Opening; } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "op", content = "data", rename_all = "snake_case")] @@ -133,7 +151,7 @@ impl Checkpoint for SocialCob { impl Authorize for SocialCob { fn may_create(cx: &WriteContext<'_>) -> Decision { - cx.permission().contributes() + B::OPENING.decide(cx.permission()) } fn may_append(cx: &WriteContext<'_>, state: &Self::State, change: &Self::Change) -> Append { @@ -147,10 +165,12 @@ macro_rules! social_cob { body = $body:ty, change = $change:ident, cob = $cob:ident, - collection = $collection:literal $(,)? + collection = $collection:expr, + opening = $opening:expr $(,)? ) => { impl $crate::SocialBody for $body { const COLLECTION: &'static str = $collection; + const OPENING: $crate::Opening = $opening; } pub type $change = $crate::SocialChange<$body>; @@ -298,7 +318,10 @@ impl SocialState { let own = object.author == *cx.actor() && matches!(permission.blocked(), Blocked::No); let permitted = match emission { Emission::Open(_) => false, - Emission::Edit(_) => own && permission.contributes().is_allowed(), + Emission::Edit(_) => { + (own && permission.contributes().is_allowed()) + || permission.moderates().is_allowed() + } Emission::Erase(_) => own || permission.moderates().is_allowed(), }; match (permitted, object.version) { @@ -418,9 +441,9 @@ impl<'de> Visitor<'de> for Bounded { } } -pub type SocialWire = SocialChange; +pub type AnySocialChange = SocialChange; -pub fn decode_social(payload: &[u8]) -> Result { +pub fn decode_social(payload: &[u8]) -> Result { knot_cob::decode_payload(payload) } @@ -440,6 +463,7 @@ mod tests { change = NoteChange, cob = NoteCob, collection = "sh.tangled.test.note", + opening = Opening::Contribution, } fn who(suffix: u8) -> AccountDid { @@ -468,15 +492,15 @@ mod tests { } } - fn opened(author: u8, seed: u8) -> SocialWire { - SocialWire::Open { + fn opened(author: u8, seed: u8) -> AnySocialChange { + AnySocialChange::Open { creation: creation(author, seed), body: AnyBody, } } - fn edited(author: u8, from: u8, seed: u8, at: i64) -> SocialWire { - SocialWire::Edit { + fn edited(author: u8, from: u8, seed: u8, at: i64) -> AnySocialChange { + AnySocialChange::Edit { editing: Editing { author: who(author), from: version(from), @@ -487,8 +511,8 @@ mod tests { } } - fn erased(author: u8, from: u8, at: i64) -> SocialWire { - SocialWire::Erase { + fn erased(author: u8, from: u8, at: i64) -> AnySocialChange { + AnySocialChange::Erase { erasure: Erasure { author: who(author), from: version(from), @@ -497,7 +521,7 @@ mod tests { } } - fn folded(changes: &[SocialWire]) -> SocialState { + fn folded(changes: &[AnySocialChange]) -> SocialState { changes .iter() .fold(SocialState::Unopened, |state, change| state.fold(change)) @@ -540,7 +564,7 @@ mod tests { let stale = folded(&[opened(1, 1), edited(1, 1, 2, 2), edited(1, 1, 3, 3)]); assert_eq!( live, stale, - "Edit names the version it was made from, and fold pins that version there" + "the projection ought to pin the version Edit states it was made from" ); } @@ -613,7 +637,7 @@ mod tests { state: &SocialState, actor: u8, permission: knot_types::ContributionPermission, - change: &SocialWire, + change: &AnySocialChange, ) -> Append { let actor = who(actor); let repo = knot_types::RepoDid::new("did:plc:squid").unwrap(); @@ -675,43 +699,106 @@ mod tests { Append::Allow, "but words are still the author's to take back under any policy" ); + let banned = knot_types::ContributionPermission::new( + Blocked::Yes, + RepoRole::Stranger, + knot_types::ContributionPolicy::Anyone, + ); + assert_eq!( + decide(&live, 1, banned, &erased(1, 1, 2)), + Append::Deny, + "a blocked account mustn't write anything, retraction included" + ); + } + + #[test] + fn openings_decide_from_role_policy_and_block() { + use knot_types::{ContributionPolicy, Membership}; + [ + ( + Opening::Contribution, + RepoRole::KnotAdmin(Membership::Outsider), + ContributionPolicy::Collaborators, + Blocked::No, + Decision::Deny, + "an admin can't open an issue on a collaborators-only repo", + ), + ( + Opening::Moderation, + RepoRole::KnotAdmin(Membership::Outsider), + ContributionPolicy::Collaborators, + Blocked::No, + Decision::Allow, + "but closes one", + ), + ( + Opening::Moderation, + RepoRole::Stranger, + ContributionPolicy::Collaborators, + Blocked::No, + Decision::Deny, + "the policy that shut the author out of editing shuts them out of transitions too", + ), + ( + Opening::Moderation, + RepoRole::Stranger, + ContributionPolicy::Anyone, + Blocked::No, + Decision::Allow, + "and a contributor opens one", + ), + ( + Opening::Moderation, + RepoRole::Owner, + ContributionPolicy::Anyone, + Blocked::Yes, + Decision::Deny, + "the blocklist fronts both roles", + ), + ] + .into_iter() + .for_each(|(opening, role, policy, blocked, expected, why)| { + let permission = knot_types::ContributionPermission::new(blocked, role, policy); + assert_eq!(opening.decide(permission), expected, "{why}"); + }); } #[test] - fn an_edit_is_the_author_s_and_an_erasure_is_the_author_s_or_a_moderator_s() { + fn edit_and_erasure_belong_to_author_or_moderator() { let live = folded(&[opened(1, 1)]); let open = opened(2, 5); let good = permission(RepoRole::Stranger, Blocked::No); - assert_eq!( - decide(&live, 2, good, &edited(2, 1, 2, 2)), - Append::Deny, - "Stranger doesn't edit somebody else's text" - ); - assert_eq!( - decide(&live, 2, good, &erased(2, 1, 2)), - Append::Deny, - "and doesn't erase text without moderation carve-out" - ); - assert_eq!( - decide( - &live, - 2, - permission(RepoRole::Collaborator, Blocked::No), - &erased(2, 1, 2) + let collaborator = permission(RepoRole::Collaborator, Blocked::No); + [ + ( + good, + edited(2, 1, 2, 2), + Append::Deny, + "stranger doesn't edit somebody else's text", ), - Append::Allow, - "Collaborator takes down the text the repository publishes" - ); - assert_eq!( - decide( - &live, - 2, - permission(RepoRole::Collaborator, Blocked::No), - &edited(2, 1, 2, 2) + ( + good, + erased(2, 1, 2), + Append::Deny, + "doesn't erase text without a moderation carve-out either", ), - Append::Deny, - "moderation is a takedown, not a rewrite of somebody else's words" - ); + ( + collaborator, + erased(2, 1, 2), + Append::Allow, + "collaborator takes down the text the repository publishes", + ), + ( + collaborator, + edited(2, 1, 2, 2), + Append::Allow, + "and rewrites it, with the collaborator as the new version's editor", + ), + ] + .into_iter() + .for_each(|(permission, change, expected, why)| { + assert_eq!(decide(&live, 2, permission, &change), expected, "{why}"); + }); assert_eq!( decide(&live, 1, good, &open), Append::Deny, @@ -720,7 +807,7 @@ mod tests { assert_eq!( decide(&SocialState::Unopened, 1, good, &edited(1, 1, 2, 2)), Append::Deny, - "Object nobody opened doesn't take appends" + "An object no one opened doesn't take appends" ); } @@ -744,7 +831,7 @@ mod tests { ); } - fn wires() -> impl Strategy> { + fn changes() -> impl Strategy> { proptest::collection::vec((0u8..3, 0u8..4, 1u8..5, 1u8..5), 0..8).prop_map(|ops| { std::iter::once(opened(0, 0)) .chain( @@ -793,9 +880,9 @@ mod tests { #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] #[test] - fn applying_a_change_twice_is_applying_it_once(changes in wires()) { + fn replayed_changes_fold_to_first_pass_state(changes in changes()) { let once = folded(&changes); - let doubled: Vec = changes + let doubled: Vec = changes .iter() .flat_map(|change| [change.clone(), change.clone()]) .collect(); @@ -807,7 +894,7 @@ mod tests { } #[test] - fn the_author_is_fixed_at_the_root_whatever_follows(changes in wires()) { + fn author_is_fixed_at_root_whatever_follows(changes in changes()) { prop_assume!(changes.len() > 1); let whole = folded(&changes); let prefix = folded(&changes[..changes.len().saturating_sub(1)]); diff --git a/knot2/crates/knot-cobs/tests/social.rs b/knot2/crates/knot-cobs/tests/social.rs index 271c38a19..59da2c619 100644 --- a/knot2/crates/knot-cobs/tests/social.rs +++ b/knot2/crates/knot-cobs/tests/social.rs @@ -22,6 +22,7 @@ social_cob! { change = NoteChange, cob = NoteCob, collection = "sh.tangled.test.note", + opening = knot_cobs::Opening::Contribution, } fn squid() -> RepoDid { diff --git a/knot2/crates/knot-record/src/issue.rs b/knot2/crates/knot-record/src/issue.rs new file mode 100644 index 000000000..687f06934 --- /dev/null +++ b/knot2/crates/knot-record/src/issue.rs @@ -0,0 +1,372 @@ +use std::fmt; + +use knot_types::{ + AccountDid, AtUri, RecordAddress, RecordBody, RecordBodyError, RepoDid, UnixSeconds, +}; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::{CreatedAt, RecordError, encoded}; + +pub const ISSUE_COLLECTION: &str = "sh.tangled.repo.issue"; + +pub const ISSUE_STATE_COLLECTION: &str = "sh.tangled.repo.issue.state"; + +pub fn issue_collection() -> knot_types::RecordCollection { + knot_types::RecordCollection::new(ISSUE_COLLECTION).expect("Issue collection is valid nsid") +} + +pub fn issue_state_collection() -> knot_types::RecordCollection { + knot_types::RecordCollection::new(ISSUE_STATE_COLLECTION) + .expect("Issue state collection is valid nsid") +} + +#[derive(Debug, thiserror::Error)] +pub enum ProseError { + #[error("title is empty")] + EmptyTitle, + #[error("title of {0} bytes is over the {MAX_TITLE_BYTES} byte limit")] + TitleTooLong(usize), + #[error("body is empty")] + EmptyBody, + #[error("body of {0} bytes is over the {MAX_BODY_BYTES} byte limit")] + BodyTooLong(usize), + #[error("{0} mentions are over the {MAX_MENTIONS} limit")] + TooManyMentions(usize), + #[error("{0} references are over the {MAX_REFERENCES} limit")] + TooManyReferences(usize), +} + +pub const MAX_TITLE_BYTES: usize = 1_024; + +pub const MAX_BODY_BYTES: usize = 500_000; + +macro_rules! prose_text { + (text = $name:ident, limit = $limit:expr, empty = $empty:ident, long = $long:ident) => { + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(text: impl Into) -> Result { + let text = text.into(); + match (text.trim().is_empty(), text.len()) { + (true, _) => Err(ProseError::$empty), + (false, len) if len > $limit => Err(ProseError::$long(len)), + (false, _) => Ok(Self(text)), + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + }; +} + +prose_text!( + text = IssueTitle, + limit = MAX_TITLE_BYTES, + empty = EmptyTitle, + long = TitleTooLong +); + +prose_text!( + text = MarkdownBody, + limit = MAX_BODY_BYTES, + empty = EmptyBody, + long = BodyTooLong +); + +pub const MAX_MENTIONS: usize = 64; + +pub const MAX_REFERENCES: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueProse { + title: IssueTitle, + body: Option, + mentions: Option>, + references: Option>>, +} + +impl IssueProse { + pub fn new( + title: IssueTitle, + body: Option, + mentions: Option>, + references: Option>>, + ) -> Result { + let mentioned = mentions.as_ref().map_or(0, Vec::len); + let referenced = references.as_ref().map_or(0, Vec::len); + match (mentioned > MAX_MENTIONS, referenced > MAX_REFERENCES) { + (true, _) => Err(ProseError::TooManyMentions(mentioned)), + (_, true) => Err(ProseError::TooManyReferences(referenced)), + (false, false) => Ok(Self { + title, + body, + mentions, + references, + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueRecord { + body: Option, + repo: RepoDid, + title: IssueTitle, + mentions: Option>, + created_at: CreatedAt, + references: Option>>, + editor: AccountDid, +} + +impl IssueRecord { + pub fn new( + repo: RepoDid, + prose: IssueProse, + created_at: UnixSeconds, + editor: AccountDid, + ) -> Result { + Ok(Self { + body: prose.body, + repo, + title: prose.title, + mentions: prose.mentions, + created_at: CreatedAt::at(created_at)?, + references: prose.references, + editor, + }) + } + + pub fn body(&self) -> Result { + encoded(self) + } +} + +#[derive(Serialize)] +struct IssueEncoding<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + body: &'a Option, + repo: &'a RepoDid, + #[serde(rename = "$type")] + record_type: knot_types::RecordCollection, + title: &'a IssueTitle, + #[serde(skip_serializing_if = "Option::is_none")] + mentions: &'a Option>, + #[serde(rename = "createdAt")] + created_at: &'a CreatedAt, + #[serde(skip_serializing_if = "Option::is_none")] + references: &'a Option>>, + #[serde(rename = "x-tngl-editor")] + editor: &'a AccountDid, +} + +impl Serialize for IssueRecord { + fn serialize(&self, serializer: S) -> Result { + IssueEncoding { + body: &self.body, + repo: &self.repo, + record_type: issue_collection(), + title: &self.title, + mentions: &self.mentions, + created_at: &self.created_at, + references: &self.references, + editor: &self.editor, + } + .serialize(serializer) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum IssueState { + #[serde(rename = "sh.tangled.repo.issue.state.open")] + Open, + #[serde(rename = "sh.tangled.repo.issue.state.closed")] + Closed, +} + +impl fmt::Display for IssueState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(match self { + Self::Open => "sh.tangled.repo.issue.state.open", + Self::Closed => "sh.tangled.repo.issue.state.closed", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueStateRecord { + repo: RepoDid, + issue: RecordAddress, + state: IssueState, + created_at: CreatedAt, + editor: AccountDid, +} + +impl IssueStateRecord { + pub fn new( + repo: RepoDid, + issue: RecordAddress, + state: IssueState, + created_at: UnixSeconds, + editor: AccountDid, + ) -> Result { + if issue.collection() != &issue_collection() { + return Err(RecordError::Malformed(format!( + "a state transition belongs to an issue in {}, not {}", + issue_collection(), + issue.collection() + ))); + } + Ok(Self { + repo, + issue, + state, + created_at: CreatedAt::at(created_at)?, + editor, + }) + } + + pub fn body(&self) -> Result { + encoded(self) + } +} + +#[derive(Serialize)] +struct IssueStateEncoding<'a> { + #[serde(rename = "$type")] + record_type: knot_types::RecordCollection, + issue: AtUri, + state: &'a IssueState, + #[serde(rename = "createdAt")] + created_at: &'a CreatedAt, + #[serde(rename = "x-tngl-editor")] + editor: &'a AccountDid, +} + +impl Serialize for IssueStateRecord { + fn serialize(&self, serializer: S) -> Result { + IssueStateEncoding { + record_type: issue_state_collection(), + issue: self.issue.at_uri(&self.repo), + state: &self.state, + created_at: &self.created_at, + editor: &self.editor, + } + .serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frozen; + use knot_types::RecordRkey; + + const ISSUE: &str = "a664626f647966666f72657374647265706f6d6469643a706c633a73717569646524747970657573682e74616e676c65642e7265706f2e6973737565657469746c65646b656c706963726561746564417474323032352d30382d31335431333a30313a33365a6d782d746e676c2d656469746f7278206469643a706c633a3366776563646e76746373636a6e72783270346e37616c7a"; + + fn editor() -> AccountDid { + AccountDid::new("did:plc:3fwecdnvtcscjnrx2p4n7alz").unwrap() + } + + fn squid() -> RepoDid { + RepoDid::new("did:plc:squid").unwrap() + } + + fn prose(body: Option<&str>, mentions: Option>) -> IssueProse { + IssueProse::new( + IssueTitle::new("kelp").unwrap(), + body.map(|text| MarkdownBody::new(text).unwrap()), + mentions, + None, + ) + .unwrap() + } + + #[test] + fn issue_record_bytes_frozen() { + let record = IssueRecord::new( + squid(), + prose(Some("forest"), None), + UnixSeconds::new(1_755_090_096), + editor(), + ) + .unwrap(); + let body = record.body().unwrap(); + assert_eq!( + body.as_bytes(), + frozen(ISSUE).as_slice(), + "the first emitted issue must freeze these bytes: length-first key order, absent optionals omitted, seconds precision, utc z" + ); + } + + #[test] + fn absent_body_is_omitted_but_sent_empty_list_is_kept() { + let sent = prose(None, Some(Vec::new())); + let record = + IssueRecord::new(squid(), sent, UnixSeconds::new(1_755_090_096), editor()).unwrap(); + let bytes = record.body().unwrap(); + let value: serde_json::Value = serde_ipld_dagcbor::from_slice(bytes.as_bytes()).unwrap(); + assert_eq!(value.get("body"), None); + assert_eq!(value["mentions"], serde_json::json!([])); + assert_eq!(value.get("references"), None); + } + + #[test] + fn state_record_embeds_lexicon_token_and_parent_uri() { + let record = IssueStateRecord::new( + squid(), + RecordAddress::new( + issue_collection(), + RecordRkey::new("3lubrptx57d22").unwrap(), + ), + IssueState::Closed, + UnixSeconds::new(1_755_090_096), + editor(), + ) + .unwrap(); + let value: serde_json::Value = + serde_ipld_dagcbor::from_slice(record.body().unwrap().as_bytes()).unwrap(); + assert_eq!(value["$type"], ISSUE_STATE_COLLECTION); + assert_eq!(value["state"], "sh.tangled.repo.issue.state.closed"); + assert_eq!( + value["issue"], + "at://did:plc:squid/sh.tangled.repo.issue/3lubrptx57d22" + ); + assert_eq!(value["x-tngl-editor"], editor().as_str()); + } + + #[test] + fn state_transition_rejects_foreign_collection() { + let rejected = IssueStateRecord::new( + squid(), + RecordAddress::new( + knot_types::RecordCollection::new("sh.tangled.repo.gitRef").unwrap(), + RecordRkey::new("3lubrptx57d22").unwrap(), + ), + IssueState::Open, + UnixSeconds::new(1_755_090_096), + editor(), + ); + assert!(rejected.is_err()); + } + + #[test] + fn issue_prose_must_say_something_and_not_too_much() { + assert!(matches!(IssueTitle::new(" "), Err(ProseError::EmptyTitle))); + assert!(matches!( + MarkdownBody::new("\n"), + Err(ProseError::EmptyBody) + )); + assert!(matches!( + IssueTitle::new("k".repeat(MAX_TITLE_BYTES + 1)), + Err(ProseError::TitleTooLong(_)) + )); + assert!(matches!( + MarkdownBody::new("k".repeat(MAX_BODY_BYTES + 1)), + Err(ProseError::BodyTooLong(_)) + )); + } +}