//! Saying things on pulls and issues from the pages, as the account logged //! in to them: commenting and reviewing, editing and deleting what one //! wrote, closing and reopening, labelling, hiding and reporting, through //! the same code as the command line. use super::*; /// The `name=value` of the session cookie a login set. fn session(set: &[String]) -> String { let found = set.iter().find(|c| c.starts_with("knotless_session=")); pair(found.unwrap()).to_string() } #[test] fn talking_on_pulls_and_issues_from_the_pages() { // Started first, so its index sees the repositories made after. let server = gateway_with(None, true); let owner = person(account()); let url = owner.url("talk"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main", "main:feature"]); assert_eq!(owner.cli(&["label", "define", &url, "kind/bug"], b"").0, 0); let mut author = person(account()); let (code, issue) = author.cli(&["issue", "create", &url, "Broken", "-m", "It is."], b""); assert_eq!(code, 0, "{issue}"); let (code, pull) = author.cli( &["pull", "create", &url, "main", &url, "feature", "Fix"], b"", ); assert_eq!(code, 0, "{pull}"); let repo = format!("at://{}/dev.knotless.repo/talk", owner.account.did); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let thread = |uri: &str, cookie: Option<&str>| { let url = format!("{}?repo={repo}&uri={uri}", xrpc("getThread")); browse("GET", &url, cookie, None, None).2 }; let write = |nsid: &str, cookie: Option<&str>, body: Value| { browse("POST", &xrpc(nsid), cookie, None, Some(body)) }; wait_until(|| thread(&pull, None)["item"]["title"] == "Fix"); wait_until(|| thread(&issue, None)["item"]["title"] == "Broken"); let mine = session(&log_in(&server, &author.account, "")); let me = Some(mine.as_str()); let theirs = session(&log_in(&server, &owner.account, "")); let owning = Some(theirs.as_str()); // What each may do, past saying something: no one asking, nothing. assert_eq!(thread(&issue, None).get("viewer"), None); let author_may = json!({"setState": true, "hide": false, "notifiedFor": "author"}); assert_eq!(thread(&issue, me)["viewer"], author_may); let owner_may = &thread(&issue, owning)["viewer"]; assert_eq!( (&owner_may["setState"], &owner_may["hide"]), (&json!(true), &json!(true)) ); assert_eq!(owner_may["labels"][0]["name"], "kind/bug"); // Commenting, as the account logged in, and only so. let said = json!({"repo": repo, "subject": issue, "body": "Me too."}); let (status, _, wrote) = write("createComment", me, said.clone()); assert_eq!(status, 200, "{wrote}"); let comment = wrote["uri"].as_str().unwrap().to_string(); assert!(comment.starts_with(&format!( "at://{}/dev.knotless.comment/", author.account.did ))); let shown = thread(&issue, me); assert_eq!(shown["comments"][0]["uri"], comment.as_str()); assert_eq!(shown["comments"][0]["body"], "Me too."); assert_eq!(write("createComment", None, said.clone()).0, 401); let away = browse( "POST", &xrpc("createComment"), me, Some("https://evil.example"), Some(said), ); assert_eq!(away.0, 403); let empty = json!({"repo": repo, "subject": issue, "body": " "}); assert_eq!(write("createComment", me, empty).0, 400); let nowhere = json!({"repo": repo, "subject": format!("{issue}x"), "body": "?"}); assert_eq!(write("createComment", me, nowhere).0, 400); let odd = json!({"repo": repo, "subject": issue, "body": "Hm", "verdict": "maybe"}); let (status, _, said) = write("createComment", me, odd); assert_eq!(status, 400, "{said}"); assert!( said.to_string().contains("a verdict is approve or changes"), "{said}" ); // A review, by the owner, says what it found, and the pull's author // hears of it, on the pages and the command line. let review = json!({"repo": repo, "subject": pull, "body": "", "verdict": "changes"}); let (status, _, reviewed) = write("createComment", owning, review); assert_eq!(status, 200, "{reviewed}"); assert_eq!(thread(&pull, None)["comments"][0]["verdict"], "changes"); let heard = || browse("GET", &xrpc("listNotifications"), me, None, None).2; wait_until(|| heard()["notifications"][0]["verdict"] == "changes"); assert_eq!(heard()["notifications"][0]["reasonSubject"], pull.as_str()); author.set("KNOTLESS_APPVIEW", &server.base); let (code, out) = author.cli(&["notification", "list", "--peek"], b""); assert_eq!(code, 0, "{out}"); assert!(out.lines().next().unwrap().contains("\tchanges\t"), "{out}"); // On its lines, with no review, and the thread it starts resolved by // the pull's author. let at = json!([{"path": "f", "side": "new", "line": 1, "body": "This"}]); let lined = json!({"repo": repo, "subject": pull, "body": "", "lines": at}); let (status, _, on) = write("createComment", owning, lined); assert_eq!(status, 200, "{on}"); let on = on["uri"].as_str().unwrap().to_string(); let resolving = json!({"repo": repo, "subject": on, "state": "resolved"}); assert_eq!(write("setItemState", me, resolving).0, 200); let resolved = |c: &Value| c["uri"] == on.as_str() && c["resolved"] == true; wait_until(|| { thread(&pull, None)["comments"] .as_array() .unwrap() .iter() .any(resolved) }); // Mentions: written as facets by whoever writes, linked in the thread // and heard of by whom they name, once the edit that names them is // in; a handle no one has stays a word, and one named twice is two. let named = person(account()); let said = json!({"repo": repo, "subject": issue, "body": "Who knows?"}); let (_, _, asking) = write("createComment", me, said); let asking = asking["uri"].as_str().unwrap().to_string(); let body = format!( "Ask @{} (not @nobody.invalid), or `@{0}`; @{0} knows.", named.account.handle ); let edit = json!({"repo": repo, "subject": asking, "body": body}); assert_eq!(write("editItem", me, edit).0, 200); let rkey = asking.rsplit('/').next().unwrap(); let records = author.records("dev.knotless.comment"); let (_, record) = records.iter().find(|(k, _)| k == rkey).unwrap(); let facets = &record["facets"]; assert_eq!(facets.as_array().unwrap().len(), 2, "{record}"); assert_eq!(facets[0]["features"][0]["did"], named.account.did); assert_eq!(facets[1]["features"][0]["did"], named.account.did); let link = format!( r#"@{}"#, named.account.did, named.account.handle ); wait_until(|| { let shown = thread(&issue, None); let comments = shown["comments"].as_array().unwrap().clone(); comments .iter() .any(|c| c["html"].as_str().unwrap_or_default().contains(&link)) }); let theirs_named = session(&log_in(&server, &named.account, "")); let heard_named = || { browse( "GET", &xrpc("listNotifications"), Some(theirs_named.as_str()), None, None, ) .2 }; wait_until(|| heard_named()["notifications"][0]["uri"] == asking.as_str()); let mention = &heard_named()["notifications"][0]; assert_eq!( ( &mention["reason"], &mention["reasonSubject"], &mention["title"] ), (&json!("mention"), &json!(issue), &json!("Broken")) ); // A comment naming the author of what it is on is heard of once, as a // comment. let body = format!("Thanks, @{}.", author.account.handle); let thanks = json!({"repo": repo, "subject": issue, "body": body}); let (status, _, thanked) = write("createComment", owning, thanks); assert_eq!(status, 200, "{thanked}"); let thanked = thanked["uri"].as_str().unwrap().to_string(); wait_until(|| heard()["notifications"][0]["uri"] == thanked.as_str()); let all = heard()["notifications"].as_array().unwrap().clone(); let of = all.iter().filter(|n| n["uri"] == thanked.as_str()); let reasons: Vec<&Value> = of.map(|n| &n["reason"]).collect(); assert_eq!(reasons, [&json!("comment")]); // Editing what one wrote, and only that. let edit = json!({"repo": repo, "subject": comment, "body": "Me too, still."}); let (status, _, edited) = write("editItem", me, edit.clone()); assert_eq!((status, &edited["uri"]), (200, &json!(comment))); let shown = thread(&issue, None); assert_eq!(shown["comments"][0]["body"], "Me too, still."); assert!(shown["comments"][0]["updatedAt"].is_string()); assert_eq!(write("editItem", owning, edit).0, 400); let retitle = json!({"repo": repo, "subject": issue, "title": "Broken badly"}); assert_eq!(write("editItem", me, retitle).0, 200); assert_eq!(thread(&issue, None)["item"]["title"], "Broken badly"); // Closing and reopening, as its author. let closing = |state: &str| json!({"repo": repo, "subject": issue, "state": state}); let (status, _, closed) = write("setItemState", me, closing("closed")); assert_eq!(status, 200, "{closed}"); assert!( closed["uri"] .as_str() .unwrap() .contains("/dev.knotless.state/") ); assert_eq!(thread(&issue, None)["item"]["state"], "closed"); assert_eq!(write("setItemState", me, closing("merged")).0, 400); assert_eq!(write("setItemState", me, closing("open")).0, 200); assert_eq!(thread(&issue, None)["item"]["state"], "open"); // Labels, by the owner; not by its author, a stranger to its labels. let labelling = |removed: bool| json!({"repo": repo, "subject": issue, "label": "kind/bug", "removed": removed}); let (status, _, labelled) = write("labelItem", owning, labelling(false)); assert_eq!(status, 200, "{labelled}"); assert_eq!(labelled["labels"][0]["name"], "kind/bug"); assert_eq!( thread(&issue, None)["item"]["labels"][0]["name"], "kind/bug" ); assert_eq!(write("labelItem", me, labelling(false)).0, 400); let (_, _, unlabelled) = write("labelItem", owning, labelling(true)); assert_eq!(unlabelled, json!({"labels": []})); assert_eq!(thread(&issue, None)["item"].get("labels"), None); // Hiding, by the owner alone, and showing again. let hiding = |hidden: bool| json!({"repo": repo, "subject": comment, "hidden": hidden}); assert_eq!(write("hideItem", me, hiding(true)).0, 400); let (status, _, hidden) = write( "hideItem", owning, json!({"repo": repo, "subject": comment}), ); assert_eq!((status, hidden), (200, json!({"hidden": true}))); let shown = thread(&issue, None)["comments"].as_array().unwrap().clone(); assert!( !shown.iter().any(|c| c["uri"] == comment.as_str()), "{shown:?}" ); assert_eq!( write("hideItem", owning, hiding(false)).2, json!({"hidden": false}) ); assert_eq!(thread(&issue, None)["comments"][0]["uri"], comment.as_str()); // Reporting it, to the moderation service. let report = json!({"subject": comment, "reason": "spam", "text": "Again and again."}); let (status, _, sent) = write("createReport", owning, report); assert_eq!(status, 200, "{sent}"); assert!(sent["reportId"].as_i64().is_some(), "{sent}"); let boring = json!({"subject": comment, "reason": "boring"}); assert_eq!(write("createReport", owning, boring).0, 400); // Taking one's comment back. let taken = json!({"repo": repo, "subject": comment}); assert_eq!(write("deleteComment", owning, taken.clone()).0, 400); let (status, _, deleted) = write("deleteComment", me, taken); assert_eq!((status, &deleted["uri"]), (200, &json!(comment))); let shown = thread(&issue, None)["comments"].as_array().unwrap().clone(); assert!( !shown.iter().any(|c| c["uri"] == comment.as_str()), "{shown:?}" ); // A server keeping no index writes all the same, with nothing to // index. let plain = gateway(); let cookie = session(&log_in(&plain, &owner.account, "")); let at = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", plain.base); let post = |nsid: &str, body: Value| browse("POST", &at(nsid), Some(&cookie), None, Some(body)); let said = json!({"repo": repo, "subject": issue, "body": "Here too."}); let (status, _, wrote) = post("createComment", said); assert_eq!(status, 200, "{wrote}"); let comment = wrote["uri"].as_str().unwrap(); let hiding = |hidden: bool| json!({"repo": repo, "subject": comment, "hidden": hidden}); assert_eq!(post("hideItem", hiding(true)).2, json!({"hidden": true})); assert_eq!(post("hideItem", hiding(false)).2, json!({"hidden": false})); } /// Pictures in what is said: uploaded from the pages or the command line, /// attached to the record that shows them while it does, and shown from /// here rather than from the author's PDS. #[test] fn pictures_in_what_is_said() { let server = gateway_with(None, true); let owner = person(account()); let url = owner.url("shots"); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let author = person(account()); let repo = format!("at://{}/dev.knotless.repo/shots", owner.account.did); let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base); let thread = |uri: &str| { let url = format!("{}?repo={repo}&uri={uri}", xrpc("getThread")); browse("GET", &url, None, None, None).2 }; let mine = session(&log_in(&server, &author.account, "")); let me = Some(mine.as_str()); let write = |nsid: &str, body: Value| browse("POST", &xrpc(nsid), me, None, Some(body)); let upload = |bytes: &[u8], cookie: Option<&str>| { let asked = format!("{}?repo={repo}", xrpc("uploadImage")); let req = reqwest::blocking::Client::new().post(asked); let mut req = req.header("content-type", "image/png").body(bytes.to_vec()); if let Some(cookie) = cookie { req = req.header("cookie", cookie); } let res = req.send().unwrap(); (res.status().as_u16(), res.json::().unwrap()) }; let image = |uri: &str, cid: &str| { let asked = format!("{}?repo={repo}&uri={uri}&cid={cid}", xrpc("getAttachment")); let res = reqwest::blocking::get(asked).unwrap(); let kind = res.headers().get("content-type").cloned(); let kind = kind.map(|k| k.to_str().unwrap().to_string()); (res.status().as_u16(), kind, res.bytes().unwrap().to_vec()) }; // Uploaded from the pages, as the account logged in, and only a picture. let png = b"\x89PNG\r\n\x1a\nshot"; let (status, up) = upload(png, me); assert_eq!(status, 200, "{up}"); let cid = up["blob"]["ref"]["$link"].as_str().unwrap().to_string(); let link = up["url"].as_str().unwrap().to_string(); assert!( link.ends_with(&format!("did={}&cid={cid}", author.account.did)), "{link}" ); assert_eq!(upload(png, None).0, 401); assert_eq!(upload(b"%PDF-1.7", me).0, 400); // A comment showing it attaches it, and is drawn showing it from here. let (code, issue) = author.cli(&["issue", "create", &url, "Looks wrong"], b""); assert_eq!(code, 0, "{issue}"); wait_until(|| thread(&issue)["item"]["title"] == "Looks wrong"); let body = format!("Like this:\n\n![shot]({link})"); let images = json!([{"image": up["blob"]}, {"image": up["blob"]}]); let said = json!({"repo": repo, "subject": issue, "body": body, "images": images}); let (status, _, wrote) = write("createComment", said); assert_eq!(status, 200, "{wrote}"); let comment = wrote["uri"].as_str().unwrap().to_string(); let rkey = comment.rsplit('/').next().unwrap(); let record = author.record("dev.knotless.comment", rkey); assert_eq!( record["attachments"], json!([{"$type": "dev.knotless.defs#image", "image": up["blob"]}]) ); let html = thread(&issue)["comments"][0]["html"] .as_str() .unwrap() .to_string(); assert!(html.contains("/xrpc/dev.knotless.getAttachment?"), "{html}"); let (status, kind, bytes) = image(&comment, &cid); assert_eq!( (status, kind.as_deref(), &bytes[..]), (200, Some("image/png"), &png[..]) ); // Only one its record attaches, of a pull, issue or comment. assert_eq!(image(&comment, "bafkreinone").0, 404); assert_eq!(image(&repo, &cid).0, 404); // Edited not to show it any more, it lets it go. let edit = json!({"repo": repo, "subject": comment, "body": "Never mind."}); assert_eq!(write("editItem", edit).0, 200); let record = author.record("dev.knotless.comment", rkey); assert_eq!(record.get("attachments"), None, "{record}"); // From the command line, each named shown after the body. let shot = author.path("screen shot.png"); std::fs::write(&shot, png).unwrap(); let shot = shot.to_str().unwrap(); let opened = [ "issue", "create", &url, "Seen here", "-m", "Look:", "--image", shot, ]; let (code, issue) = author.cli(&opened, b""); assert_eq!(code, 0, "{issue}"); let rkey = issue.rsplit('/').next().unwrap(); let record = author.record("dev.knotless.issue", rkey); assert!( record["body"] .as_str() .unwrap() .starts_with("Look:\n\n![screen shot.png](http"), "{record}" ); assert_eq!( record["attachments"][0]["image"]["ref"]["$link"], cid.as_str() ); wait_until(|| thread(&issue)["item"]["title"] == "Seen here"); let html = thread(&issue)["html"].as_str().unwrap().to_string(); assert!(html.contains("/xrpc/dev.knotless.getAttachment?"), "{html}"); assert_eq!(image(&issue, &cid).0, 200); let commented = ["comment", "add", &url, &issue, "Again:", "--image", shot]; // Only a pull is reviewed. let reviewed = author.cli(&[&commented[..], &["--approve"]].concat(), b""); assert_ne!(reviewed.0, 0, "{reviewed:?}"); assert_eq!(author.cli(&commented, b"").0, 0); let edited = [ "issue", "edit", &url, &issue, "-m", "Still:", "--image", shot, ]; assert_eq!(author.cli(&edited, b"").0, 0); let record = author.record("dev.knotless.issue", rkey); assert_eq!( record["attachments"].as_array().unwrap().len(), 1, "{record}" ); for refused in [ &["issue", "edit", &url, &issue, "--image", shot][..], &["issue", "create", &url, "Gone", "--image", "/nowhere.png"], &["comment", "add", &url, &issue, "x", "--bogus"], ] { assert_ne!(author.cli(refused, b"").0, 0, "{refused:?}"); } }