diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index ca91284ae..99e75c352 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -5351,3 +5351,901 @@ async fn sweep_gives_up_on_a_permanently_rejected_repository_after_one_post() { assert_eq!(memory.settled.len(), 0); assert!(memory.is_given_up(repo.did.as_str())); } + +const COMMENT: &str = "sh.tangled.feed.comment"; + +const LEGACY_COMMENT: &str = "sh.tangled.repo.issue.comment"; + +const MARKUP: &str = "sh.tangled.markup.markdown"; + +const REACTION: &str = "sh.tangled.feed.reaction"; + +fn comment_record( + subject: &AtUri, + cid: &crate::out::CidString, + text: &str, +) -> serde_json::Value { + json!({ + "$type": COMMENT, + "subject": { "uri": subject, "cid": cid }, + "body": { "$type": MARKUP, "text": text }, + "createdAt": "2026-09-11T00:00:00Z" + }) +} + +fn comment_record_replying( + subject: &AtUri, + cid: &crate::out::CidString, + reply: &AtUri, + reply_cid: &crate::out::CidString, + text: &str, +) -> serde_json::Value { + let mut record = comment_record(subject, cid, text); + record["replyTo"] = json!({ "uri": reply, "cid": reply_cid }); + record +} + +struct Contribution<'a> { + world: &'a World, + repo: &'a RepoDid, +} + +impl<'a> Contribution<'a> { + fn on(world: &'a World, repo: &'a RepoDid) -> Self { + Self { world, repo } + } + + async fn comment( + &self, + actor: &Actor, + subject: &AtUri, + cid: &crate::out::CidString, + text: &str, + ) -> (StatusCode, serde_json::Value) { + self.create(actor, comment_record(subject, cid, text)).await + } + + async fn replying( + &self, + actor: &Actor, + subject: &AtUri, + cid: &crate::out::CidString, + reply: &AtUri, + reply_cid: &crate::out::CidString, + text: &str, + ) -> (StatusCode, serde_json::Value) { + self.create( + actor, + comment_record_replying(subject, cid, reply, reply_cid, text), + ) + .await + } + + async fn create( + &self, + actor: &Actor, + record: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + create_as(self.world, actor, self.repo, COMMENT, None, record).await + } + + #[allow(clippy::too_many_arguments)] + async fn edit( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + subject: &AtUri, + subject_cid: &crate::out::CidString, + text: &str, + ) -> (StatusCode, serde_json::Value) { + self.put(actor, uri, from, comment_record(subject, subject_cid, text)) + .await + } + + async fn put( + &self, + actor: &Actor, + uri: &AtUri, + from: &crate::out::CidString, + record: serde_json::Value, + ) -> (StatusCode, serde_json::Value) { + put_as( + self.world, + actor, + self.repo, + COMMENT, + rkey_of(uri), + json!({ "swapRecord": from }), + record, + ) + .await + } + + async fn react( + &self, + actor: &Actor, + subject: &AtUri, + kind: &str, + ) -> (StatusCode, serde_json::Value) { + create_as( + self.world, + actor, + self.repo, + REACTION, + None, + json!({ + "$type": REACTION, + "subject": subject, + "reaction": kind, + "createdAt": "2026-09-11T00:00:00Z" + }), + ) + .await + } + + async fn erase( + &self, + actor: &Actor, + collection: &str, + uri: &AtUri, + from: &crate::out::CidString, + ) -> (StatusCode, serde_json::Value) { + delete_as( + self.world, + actor, + self.repo, + collection, + rkey_of(uri), + json!({ "swapRecord": from }), + ) + .await + } +} + +#[tokio::test] +async fn comment_opens_edits_and_deletes_along_its_version_chain() { + let (world, repo) = issue_world().await; + let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let written = ok(contribution + .comment(&world.stranger, &issue_uri, &issue_cid, "a drifter") + .await); + let comment_uri = uri_of(&written); + let v1 = cid_of(&written); + assert!( + comment_uri + .as_str() + .starts_with(&format!("at://{}/{COMMENT}/", repo.as_str())) + ); + assert_eq!( + records_in(&world, &repo, COMMENT).await, + vec![comment_uri.as_str().to_owned()] + ); + let record = ok(record_at(&world, &repo, &comment_uri, None).await); + assert_eq!(record["value"]["$type"], COMMENT); + assert_eq!(record["value"]["subject"]["uri"], issue_uri.as_str()); + assert_eq!( + record["value"]["subject"]["cid"], + issue_cid.to_string(), + "subject strongRef must name the exact issue versoon the comment was made on" + ); + assert_eq!(record["value"]["body"]["$type"], MARKUP); + assert_eq!(record["value"]["body"]["text"], "a drifter"); + assert_eq!( + record["value"]["x-tngl-editor"], + world.stranger.did.as_str() + ); + assert!(record["value"].get("replyTo").is_none()); + let versions = versions_page(&world, &comment_uri, None).await; + assert_eq!(versions["versions"].as_array().unwrap().len(), 1); + + let reply = ok(contribution + .replying( + &world.stranger, + &issue_uri, + &issue_cid, + &comment_uri, + &v1, + "on the drifter", + ) + .await); + let reply_uri = uri_of(&reply); + let reply_record = ok(record_at(&world, &repo, &reply_uri, None).await); + assert_eq!( + reply_record["value"]["replyTo"]["uri"], + comment_uri.as_str() + ); + + let edited = ok(contribution + .edit( + &world.stranger, + &comment_uri, + &v1, + &issue_uri, + &issue_cid, + "the drifter", + ) + .await); + let v2 = cid_of(&edited); + assert_ne!(v1, v2); + + let stale = failed( + contribution + .edit( + &world.stranger, + &comment_uri, + &v1, + &issue_uri, + &issue_cid, + "the late drifter", + ) + .await, + StatusCode::CONFLICT, + ); + assert_eq!(stale["error"], "InvalidSwap"); + + let maintained = ok(contribution + .edit( + &world.member, + &comment_uri, + &v2, + &issue_uri, + &issue_cid, + "the drifter, kept", + ) + .await); + let v3 = cid_of(&maintained); + assert_eq!( + ok(record_at(&world, &repo, &comment_uri, None).await)["value"]["x-tngl-editor"], + world.member.did.as_str() + ); + + let owned_by_member = ok(contribution + .comment(&world.member, &issue_uri, &issue_cid, "owner's note") + .await); + let owned_uri = uri_of(&owned_by_member); + let owned_v1 = cid_of(&owned_by_member); + let denied = failed( + contribution + .edit( + &world.stranger, + &owned_uri, + &owned_v1, + &issue_uri, + &issue_cid, + "rewritten", + ) + .await, + StatusCode::FORBIDDEN, + ); + assert!(denied["message"].as_str().unwrap().contains("author")); + + let deleted = ok(contribution + .erase(&world.member, COMMENT, &comment_uri, &v3) + .await); + assert!( + deleted.get("uri").is_none() && deleted.get("cid").is_none(), + "the delete response should probably be the commit alone, without uri or cid" + ); + let (status, gone) = record_at(&world, &repo, &comment_uri, None).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(gone["error"], "RecordNotFound"); + assert!( + block_served(&world, &repo, &v1).await, + "the old version's block oughtta stay in storage after the api stops returning it" + ); + let versions = versions_page(&world, &comment_uri, None).await; + let chain = versions["versions"].as_array().unwrap(); + assert_eq!(chain.len(), 4); + assert_eq!(chain[0]["cid"], v1.to_string()); + assert_eq!(chain[2]["cid"], v3.to_string()); + assert!(chain[3].get("cid").is_none()); +} + +#[tokio::test] +async fn comment_rejects_subjects_this_knot_doesnt_serve() { + let (world, repo) = issue_world().await; + let (issue_uri, v1) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let missing = at_of( + &repo, + knot_types::RecordCollection::new(ISSUE).unwrap(), + "3lubrptx57d33", + ); + let rejected = failed( + contribution + .comment(&world.stranger, &missing, &v1, "nope") + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&rejected, &["isn't a record on this knot"]); + + let edited_issue = ok(edit_issue( + &world, + &world.stranger, + &repo, + &issue_uri, + &v1, + "kelp, again", + ) + .await); + let standing = cid_of(&edited_issue); + let stale_subject = failed( + contribution + .comment(&world.stranger, &issue_uri, &v1, "nope") + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&stale_subject, &["strongRef cid is", "now has version"]); + assert!( + stale_subject["message"] + .as_str() + .unwrap() + .contains(&standing.to_string()) + ); + + let on_state = at_of( + &repo, + knot_types::RecordCollection::new(ISSUE_STATE).unwrap(), + "3lubrptx57d44", + ); + let wrong_collection = failed( + contribution + .comment(&world.stranger, &on_state, &standing, "nope") + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&wrong_collection, &["issues as comment subjects"]); + + let legacy = failed( + create_as( + &world, + &world.stranger, + &repo, + LEGACY_COMMENT, + None, + retype( + comment_record(&issue_uri, &standing, "old world"), + LEGACY_COMMENT, + ), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&legacy, &["isn't a collection this knot takes"]); + + let minted = failed( + create_as( + &world, + &world.stranger, + &repo, + COMMENT, + Some("3lubrptx57d55".to_owned()), + comment_record(&issue_uri, &standing, "mine"), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&minted, &["mints the record key"]); + + let empty = failed( + contribution + .comment(&world.stranger, &issue_uri, &standing, "") + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&empty, &["unusable"]); + + let replied_wrong = failed( + contribution + .replying( + &world.stranger, + &issue_uri, + &standing, + &issue_uri, + &standing, + "nope", + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&replied_wrong, &["the uri's collection is", "takes"]); + + ok(transition( + &world, + &world.stranger, + &repo, + &issue_uri, + IssueState::Closed, + ) + .await); + ok(contribution + .comment(&world.stranger, &issue_uri, &standing, "still fine") + .await); +} + +fn at_of(repo: &RepoDid, collection: knot_types::RecordCollection, rkey: &str) -> AtUri { + knot_types::RecordAddress::new(collection, knot_types::RecordRkey::new(rkey).unwrap()) + .at_uri(repo) +} + +#[tokio::test] +async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { + let (world, repo) = issue_world().await; + let (issue_uri, _) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let liked = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let like_uri = uri_of(&liked); + let like_v1 = cid_of(&liked); + assert!( + like_uri + .as_str() + .starts_with(&format!("at://{}/{REACTION}/", repo.as_str())) + ); + let record = ok(record_at(&world, &repo, &like_uri, None).await); + assert_eq!(record["value"]["$type"], REACTION); + assert_eq!(record["value"]["subject"], issue_uri.as_str()); + assert_eq!(record["value"]["reaction"], "❤️"); + assert_eq!( + record["value"]["x-tngl-editor"], + world.stranger.did.as_str() + ); + + let again = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + assert_eq!(again["uri"], like_uri.as_str()); + assert_eq!(again["cid"], like_v1.to_string()); + assert_eq!( + again["commit"]["rev"], liked["commit"]["rev"], + "the second identical react should return the first commit" + ); + assert_eq!(records_in(&world, &repo, REACTION).await.len(), 1); + + let rocket = ok(contribution.react(&world.stranger, &issue_uri, "🚀").await); + let rocket_uri = uri_of(&rocket); + assert_ne!(rocket_uri, like_uri); + let members = ok(contribution.react(&world.member, &issue_uri, "❤️").await); + let members_uri = uri_of(&members); + assert_ne!(members_uri, like_uri); + assert_ne!(members_uri, rocket_uri); + assert_eq!(records_in(&world, &repo, REACTION).await.len(), 3); + + let put = failed( + put_as( + &world, + &world.stranger, + &repo, + REACTION, + rkey_of(&like_uri), + json!({ "swapRecord": like_v1 }), + json!({ "$type": REACTION, "subject": issue_uri, "reaction": "🚀" }), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&put, &["never edited"]); + + let erased = ok(contribution + .erase(&world.member, REACTION, &like_uri, &like_v1) + .await); + assert!(erased.get("uri").is_none()); + let (status, gone) = record_at(&world, &repo, &like_uri, None).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(gone["error"], "RecordNotFound"); + + let reminted = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + assert_ne!( + uri_of(&reminted), + like_uri, + "creating again after a delete has to produce a new record" + ); + + let denied = failed( + contribution + .erase(&world.stranger, REACTION, &members_uri, &cid_of(&members)) + .await, + StatusCode::FORBIDDEN, + ); + assert!(denied["message"].as_str().unwrap().contains("author")); +} + +#[tokio::test] +async fn reaction_opens_only_on_live_subjects() { + let (world, repo) = issue_world().await; + let (issue_uri, v1) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + let liked = ok(contribution.react(&world.member, &issue_uri, "❤️").await); + let like_uri = uri_of(&liked); + + let missing = at_of( + &repo, + knot_types::RecordCollection::new(ISSUE).unwrap(), + "3lubrptx57d33", + ); + let rejected = failed( + contribution.react(&world.stranger, &missing, "👍").await, + StatusCode::BAD_REQUEST, + ); + message_contains(&rejected, &["isn't a record on this knot"]); + + ok(delete_issue(&world, &world.stranger, &repo, &issue_uri, &v1).await); + let erased = failed( + contribution.react(&world.member, &issue_uri, "❤️").await, + StatusCode::BAD_REQUEST, + ); + message_contains(&erased, &["was deleted"]); + assert_eq!( + ok(record_at(&world, &repo, &like_uri, None).await)["cid"], + cid_of(&liked).to_string(), + "the reaction should stay readable after its issue is deleted" + ); +} + +#[tokio::test] +async fn comment_outlives_its_subject_on_record_reads() { + let (world, repo) = issue_world().await; + let (issue_uri, issue_v1) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let written = ok(contribution + .comment(&world.stranger, &issue_uri, &issue_v1, "a drifter") + .await); + let comment_uri = uri_of(&written); + let comment_v1 = cid_of(&written); + let (_, issue_address) = knot_types::RecordAddress::parse_at_uri(&issue_uri).unwrap(); + + ok(delete_issue(&world, &world.stranger, &repo, &issue_uri, &issue_v1).await); + + assert_eq!( + ok(record_at(&world, &repo, &comment_uri, None).await)["cid"], + comment_v1.to_string(), + "the comment must stay readable after its issue is deleted" + ); + assert_eq!( + records_in(&world, &repo, COMMENT).await, + vec![comment_uri.as_str().to_owned()] + ); + assert!(records_in(&world, &repo, ISSUE).await.is_empty()); + assert_eq!( + versions_page(&world, &comment_uri, None).await["versions"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + world.state.index.social_children_of(&repo, &issue_address), + knot_index::Resolved::Ready(Vec::new()), + "the index shouldn't list children of a deleted issue" + ); +} + +#[tokio::test] +async fn comment_rejects_extras_it_cant_store() { + let (world, repo) = issue_world().await; + let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let rejected_with = |record: serde_json::Value| { + let world = &world; + let contribution = &contribution; + async move { + failed( + contribution.create(&world.stranger, record).await, + StatusCode::BAD_REQUEST, + ) + } + }; + + let mut rounded = comment_record(&issue_uri, &issue_cid, "with a round"); + rounded["pullRoundIdx"] = json!(0); + message_contains( + &rejected_with(rounded).await, + &["pullRoundIdx", "this subject is an issue"], + ); + + let mut blank = comment_record(&issue_uri, &issue_cid, "kelp"); + blank["body"]["original"] = json!(" "); + message_contains(&rejected_with(blank).await, &["original is unusable"]); + + let mut embedded = comment_record(&issue_uri, &issue_cid, "with an embed"); + embedded["embed"] = json!({ "$type": "sh.tangled.embed.commit", "commit": {} }); + message_contains( + &rejected_with(embedded).await, + &["embed is an attachment", "leave embed out"], + ); + + let mut blobbed = comment_record(&issue_uri, &issue_cid, "with a blob"); + blobbed["body"]["blobs"] = json!([]); + message_contains( + &rejected_with(blobbed).await, + &["blobs are attachments", "leave blobs out"], + ); +} + +#[tokio::test] +async fn comment_subject_and_reply_are_fixed_at_open() { + let (world, repo) = issue_world().await; + let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let drifter = ok(contribution + .comment(&world.stranger, &issue_uri, &issue_cid, "a drifter") + .await); + let drifter_uri = uri_of(&drifter); + let drifter_v1 = cid_of(&drifter); + let reply = ok(contribution + .replying( + &world.stranger, + &issue_uri, + &issue_cid, + &drifter_uri, + &drifter_v1, + "on the drifter", + ) + .await); + let reply_uri = uri_of(&reply); + let reply_v1 = cid_of(&reply); + let never: crate::out::CidString = + serde_json::from_value(json!(knot_types::dag_cbor_cid(b"never minted").to_string())) + .unwrap(); + + let swapped_subject = failed( + contribution + .put(&world.stranger, &reply_uri, &reply_v1, { + let mut record = comment_record(&issue_uri, &issue_cid, "on the drifter, kept"); + record["subject"]["cid"] = json!(never.to_string()); + record + }) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&swapped_subject, &["subject is set at creation"]); + + let swapped_reply = failed( + contribution + .put( + &world.stranger, + &reply_uri, + &reply_v1, + comment_record_replying( + &issue_uri, + &issue_cid, + &drifter_uri, + &never, + "on the drifter, kept", + ), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&swapped_reply, &["reply is set at creation"]); + + let dropped_reply = failed( + contribution + .put( + &world.stranger, + &reply_uri, + &reply_v1, + comment_record(&issue_uri, &issue_cid, "on the drifter, kept"), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&dropped_reply, &["reply is set at creation"]); + + let gained_reply = failed( + contribution + .put( + &world.stranger, + &drifter_uri, + &drifter_v1, + comment_record_replying( + &issue_uri, + &issue_cid, + &reply_uri, + &reply_v1, + "the drifter, kept", + ), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&gained_reply, &["reply is set at creation"]); + + let kept = ok(contribution + .put( + &world.stranger, + &reply_uri, + &reply_v1, + comment_record_replying( + &issue_uri, + &issue_cid, + &drifter_uri, + &drifter_v1, + "on the drifter, kept", + ), + ) + .await); + assert_ne!(cid_of(&kept), reply_v1); + + let drifter_v2 = cid_of(&ok(contribution + .edit( + &world.stranger, + &drifter_uri, + &drifter_v1, + &issue_uri, + &issue_cid, + "the drifter, kept", + ) + .await)); + let stale = failed( + contribution + .replying( + &world.stranger, + &issue_uri, + &issue_cid, + &drifter_uri, + &drifter_v1, + "on the drifter", + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&stale, &["reply's strongRef cid is", "now has version"]); + assert!( + stale["message"] + .as_str() + .unwrap() + .contains(&drifter_v2.to_string()) + ); + + ok(contribution + .replying( + &world.stranger, + &issue_uri, + &issue_cid, + &drifter_uri, + &drifter_v2, + "on the drifter", + ) + .await); +} + +#[tokio::test] +async fn reaction_accepts_any_live_record_as_subject() { + let (world, repo) = issue_world().await; + let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let drifter = ok(contribution + .comment(&world.stranger, &issue_uri, &issue_cid, "a drifter") + .await); + let drifter_uri = uri_of(&drifter); + let closed = ok(transition( + &world, + &world.stranger, + &repo, + &issue_uri, + IssueState::Closed, + ) + .await); + let state_uri = uri_of(&closed); + + ok(contribution.react(&world.member, &drifter_uri, "👀").await); + ok(contribution.react(&world.member, &state_uri, "🚀").await); + let on_comment = ok(contribution + .react(&world.stranger, &drifter_uri, "👍") + .await); + let reaction_uri = uri_of(&on_comment); + ok(contribution.react(&world.member, &reaction_uri, "❤️").await); + + assert_eq!(records_in(&world, &repo, REACTION).await.len(), 4); + assert_eq!( + ok(record_at(&world, &repo, &reaction_uri, None).await)["value"]["subject"], + drifter_uri.as_str(), + "a reaction can target any existing record in the repository" + ); +} + +#[tokio::test] +async fn no_op_edit_by_stranger_is_still_forbidden() { + let (world, repo) = issue_world().await; + let (issue_uri, issue_cid) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let owned = ok(contribution + .comment(&world.member, &issue_uri, &issue_cid, "owner's note") + .await); + let owned_uri = uri_of(&owned); + let owned_v1 = cid_of(&owned); + + let denied = failed( + contribution + .edit( + &world.stranger, + &owned_uri, + &owned_v1, + &issue_uri, + &issue_cid, + "owner's note", + ) + .await, + StatusCode::FORBIDDEN, + ); + assert!(denied["message"].as_str().unwrap().contains("author")); + assert_eq!( + versions_page(&world, &owned_uri, None).await["versions"] + .as_array() + .unwrap() + .len(), + 1, + "the rejected edit shouldn't add a version" + ); + + let minted = ok(contribution + .edit( + &world.member, + &owned_uri, + &owned_v1, + &issue_uri, + &issue_cid, + "owner's note", + ) + .await); + assert_eq!(cid_of(&minted), owned_v1); + let page = versions_page(&world, &owned_uri, None).await; + let chain = page["versions"].as_array().unwrap(); + assert_eq!(chain.len(), 2); + assert_eq!( + chain[1]["cid"], + owned_v1.to_string(), + "an edit that doesn't change anything still adds a version with the same bytes" + ); +} + +#[tokio::test] +async fn reaction_dedup_wont_answer_when_policy_rejects() { + let (world, repo) = issue_world().await; + let (issue_uri, _) = opened(&world, &world.stranger, &repo, "kelp").await; + let contribution = Contribution::on(&world, &repo); + + let liked = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + let like_uri = uri_of(&liked); + + assert_eq!( + set_policy_as( + &world, + &world.member, + &repo, + ContributionPolicy::Collaborators + ) + .await, + StatusCode::OK + ); + let shut_out = failed( + contribution.react(&world.stranger, &issue_uri, "❤️").await, + StatusCode::FORBIDDEN, + ); + assert!( + shut_out["message"] + .as_str() + .unwrap() + .contains("contribution policy is collaborators") + ); + + assert_eq!( + set_policy_as(&world, &world.member, &repo, ContributionPolicy::Anyone).await, + StatusCode::OK + ); + let again = ok(contribution.react(&world.stranger, &issue_uri, "❤️").await); + assert_eq!(again["uri"], like_uri.as_str()); + assert_eq!( + records_in(&world, &repo, REACTION).await.len(), + 1, + "the rejected second react shouldn't create a duplicate" + ); +}