| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use std::collections::HashMap; |
| 8 | use std::sync::Arc; |
| 9 | |
| 10 | use forge_core::{EditVersion, Issue, Thread}; |
| 11 | use similar::{ChangeTag, TextDiff}; |
| 12 | use topcoat::Result; |
| 13 | use topcoat::context::{Cx, app_context}; |
| 14 | use topcoat::router::error::not_found; |
| 15 | use topcoat::router::{page, path_param}; |
| 16 | use topcoat::view::{View, component, view}; |
| 17 | |
| 18 | use crate::i18n::issues::{self as g, K}; |
| 19 | use crate::i18n::{self, Lang}; |
| 20 | use crate::pages::issue_parts::{ItemKind, When, item_for, parse_number}; |
| 21 | use crate::pages::repo::repo_head; |
| 22 | use crate::pages::repo_ctx::{RepoCtx, load_repo}; |
| 23 | use crate::state::AppState; |
| 24 | use crate::views::{display_name, now_unix, repo_href, shell}; |
| 25 | |
| 26 | path_param!(owner); |
| 27 | path_param!(repo); |
| 28 | path_param!(number); |
| 29 | path_param!(kind); |
| 30 | path_param!(target_id); |
| 31 | |
| 32 | |
| 33 | const CONTEXT_LINES: usize = 3; |
| 34 | |
| 35 | |
| 36 | const KINDS: [&str; 3] = ["issue", "comment", "review_comment"]; |
| 37 | |
| 38 | |
| 39 | pub(crate) fn history_href( |
| 40 | owner: &str, |
| 41 | repo_name: &str, |
| 42 | kind: ItemKind, |
| 43 | number: i64, |
| 44 | target_kind: &str, |
| 45 | target: &str, |
| 46 | ) -> String { |
| 47 | repo_href( |
| 48 | owner, |
| 49 | repo_name, |
| 50 | kind.segment(), |
| 51 | &format!("{number}/history/{target_kind}/{target}"), |
| 52 | ) |
| 53 | } |
| 54 | |
| 55 | |
| 56 | pub(crate) async fn edit_counts( |
| 57 | state: &AppState, |
| 58 | target_kind: &str, |
| 59 | ids: &[&str], |
| 60 | ) -> Result<HashMap<String, u64>> { |
| 61 | Ok(state.store.edit_count(target_kind, ids).await?) |
| 62 | } |
| 63 | |
| 64 | |
| 65 | pub(crate) async fn thread_edit_counts( |
| 66 | state: &AppState, |
| 67 | threads: &[Thread], |
| 68 | ) -> Result<Arc<HashMap<String, u64>>> { |
| 69 | let ids: Vec<&str> = threads |
| 70 | .iter() |
| 71 | .flat_map(|thread| std::iter::once(&thread.root).chain(thread.replies.iter())) |
| 72 | .map(|comment| comment.id.as_str()) |
| 73 | .collect(); |
| 74 | Ok(Arc::new(edit_counts(state, "review_comment", &ids).await?)) |
| 75 | } |
| 76 | |
| 77 | |
| 78 | struct DiffRow { |
| 79 | class: &'static str, |
| 80 | text: String, |
| 81 | } |
| 82 | |
| 83 | |
| 84 | |
| 85 | fn diff_rows(old: &str, new: &str) -> Vec<DiffRow> { |
| 86 | let diff = TextDiff::from_lines(old, new); |
| 87 | let mut rows = Vec::new(); |
| 88 | for (index, group) in diff.grouped_ops(CONTEXT_LINES).iter().enumerate() { |
| 89 | if index > 0 { |
| 90 | rows.push(DiffRow { |
| 91 | class: "diff-line hunk", |
| 92 | text: "@@".to_owned(), |
| 93 | }); |
| 94 | } |
| 95 | for op in group { |
| 96 | for change in diff.iter_changes(op) { |
| 97 | let (class, sign) = match change.tag() { |
| 98 | ChangeTag::Delete => ("diff-line del", '-'), |
| 99 | ChangeTag::Insert => ("diff-line add", '+'), |
| 100 | ChangeTag::Equal => ("diff-line ctx", ' '), |
| 101 | }; |
| 102 | let line = change.value().trim_end_matches(['\n', '\r']); |
| 103 | rows.push(DiffRow { |
| 104 | class, |
| 105 | text: format!("{sign}{line}"), |
| 106 | }); |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | rows |
| 111 | } |
| 112 | |
| 113 | |
| 114 | struct HistoryEntry { |
| 115 | editor: String, |
| 116 | when: When, |
| 117 | |
| 118 | title_change: Option<(String, String)>, |
| 119 | rows: Vec<DiffRow>, |
| 120 | raw: String, |
| 121 | |
| 122 | body_unchanged: bool, |
| 123 | } |
| 124 | |
| 125 | |
| 126 | |
| 127 | fn entries_for( |
| 128 | lang: Lang, |
| 129 | versions: &[EditVersion], |
| 130 | current_title: Option<&str>, |
| 131 | current_body: &str, |
| 132 | tz: &str, |
| 133 | now: i64, |
| 134 | ) -> Vec<HistoryEntry> { |
| 135 | versions |
| 136 | .iter() |
| 137 | .enumerate() |
| 138 | .map(|(k, version)| { |
| 139 | let (newer_title, newer_body) = match k.checked_sub(1) { |
| 140 | Some(prev) => ( |
| 141 | versions[prev].old_title.as_deref(), |
| 142 | versions[prev].old_body.as_str(), |
| 143 | ), |
| 144 | None => (current_title, current_body), |
| 145 | }; |
| 146 | let title_change = match (version.old_title.as_deref(), newer_title) { |
| 147 | (Some(old), Some(new)) if old != new => Some((old.to_owned(), new.to_owned())), |
| 148 | _ => None, |
| 149 | }; |
| 150 | let body_unchanged = version.old_body == newer_body; |
| 151 | HistoryEntry { |
| 152 | editor: display_name(lang, &version.editor_name), |
| 153 | when: When::new(lang, &version.edited_at, tz, now), |
| 154 | title_change, |
| 155 | rows: diff_rows(&version.old_body, newer_body), |
| 156 | raw: version.old_body.clone(), |
| 157 | body_unchanged, |
| 158 | } |
| 159 | }) |
| 160 | .collect() |
| 161 | } |
| 162 | |
| 163 | |
| 164 | async fn history_page( |
| 165 | cx: &Cx, |
| 166 | ctx: RepoCtx, |
| 167 | kind_item: ItemKind, |
| 168 | item: Issue, |
| 169 | target_kind: &'static str, |
| 170 | target: String, |
| 171 | ) -> Result<impl View> { |
| 172 | let target = target.as_str(); |
| 173 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); |
| 174 | let state = app_context::<AppState>(cx); |
| 175 | let owner = ctx.repo.owner_name.clone(); |
| 176 | let name = ctx.repo.name.clone(); |
| 177 | let number = item.number; |
| 178 | |
| 179 | |
| 180 | let belongs = state.store.edit_target_issue(target_kind, target).await?; |
| 181 | if belongs.as_deref() != Some(item.id.as_str()) { |
| 182 | return Err(not_found().into()); |
| 183 | } |
| 184 | |
| 185 | let (current_title, current_body): (Option<String>, String) = match target_kind { |
| 186 | "issue" => (Some(item.title.clone()), item.body.clone()), |
| 187 | "comment" => { |
| 188 | let Some(comment) = state.store.comment_by_id(&item.id, target).await? else { |
| 189 | return Err(not_found().into()); |
| 190 | }; |
| 191 | (None, comment.body) |
| 192 | } |
| 193 | _ => { |
| 194 | let Some(comment) = state.store.review_comment(target).await? else { |
| 195 | return Err(not_found().into()); |
| 196 | }; |
| 197 | (None, comment.body) |
| 198 | } |
| 199 | }; |
| 200 | |
| 201 | let versions = state.store.edit_history(target_kind, target).await?; |
| 202 | let tz = ctx.viewer_tz().to_owned(); |
| 203 | let now = now_unix(); |
| 204 | let entries = entries_for( |
| 205 | lang, |
| 206 | &versions, |
| 207 | current_title.as_deref(), |
| 208 | ¤t_body, |
| 209 | &tz, |
| 210 | now, |
| 211 | ); |
| 212 | |
| 213 | let what = g::history_what(lang, kind_item == ItemKind::Pull, target_kind); |
| 214 | let back = repo_href(&owner, &name, kind_item.segment(), &number.to_string()); |
| 215 | let page_title = g::history_title(lang, what, &format!("{owner}/{name} #{number}")); |
| 216 | let header = ctx |
| 217 | .head( |
| 218 | state, |
| 219 | if kind_item == ItemKind::Pull { |
| 220 | "pulls" |
| 221 | } else { |
| 222 | "issues" |
| 223 | }, |
| 224 | ) |
| 225 | .await?; |
| 226 | let heading = g::history_heading(lang, what); |
| 227 | let back_label = g::back_to_item(lang, number); |
| 228 | let count_label = g::earlier_versions(lang, entries.len()); |
| 229 | |
| 230 | Ok(view! { cx => |
| 231 | shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 232 | repo_head(head: header) |
| 233 | <section class="issue-head"> |
| 234 | <h1 class="issue-heading">(heading.as_str())</h1> |
| 235 | <p class="issue-byline"> |
| 236 | <a href=(back.as_str())>(back_label.as_str())</a> |
| 237 | " · " |
| 238 | (count_label.as_str()) |
| 239 | </p> |
| 240 | </section> |
| 241 | <div class="issue-timeline"> |
| 242 | if entries.is_empty() { |
| 243 | <p class="muted">(g::t(lang, K::NoEarlierVersions))</p> |
| 244 | } |
| 245 | for entry in entries.into_iter() { |
| 246 | history_entry(lang: lang, entry: entry) |
| 247 | } |
| 248 | </div> |
| 249 | ) |
| 250 | }) |
| 251 | } |
| 252 | |
| 253 | |
| 254 | #[component] |
| 255 | async fn history_entry(lang: Lang, entry: HistoryEntry) -> Result<impl View> { |
| 256 | let HistoryEntry { |
| 257 | editor, |
| 258 | when, |
| 259 | title_change, |
| 260 | rows, |
| 261 | raw, |
| 262 | body_unchanged, |
| 263 | } = entry; |
| 264 | let title_text = title_change.map(|(from, to)| g::title_change(lang, &from, &to)); |
| 265 | Ok(view! { |
| 266 | <article class="issue-item"> |
| 267 | <header class="issue-item-head"> |
| 268 | <strong>(editor.as_str())</strong> |
| 269 | (g::t(lang, K::EditedBy)) |
| 270 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 271 | (g::edited_after(lang)) |
| 272 | </header> |
| 273 | if let Some(text) = title_text { |
| 274 | <p class="mono">(text.as_str())</p> |
| 275 | } |
| 276 | if body_unchanged { |
| 277 | <p class="muted">(g::t(lang, K::NoTextChange))</p> |
| 278 | } else { |
| 279 | <div class="diff-file"> |
| 280 | <div class="diff-file-body"> |
| 281 | <table class="diff-table"> |
| 282 | <tbody> |
| 283 | for row in rows.into_iter() { |
| 284 | <tr class=(row.class)> |
| 285 | <td class="diff-code">(row.text.as_str())</td> |
| 286 | </tr> |
| 287 | } |
| 288 | </tbody> |
| 289 | </table> |
| 290 | </div> |
| 291 | </div> |
| 292 | } |
| 293 | <details> |
| 294 | <summary>(g::t(lang, K::Raw))</summary> |
| 295 | <pre class="review-hunk"><code>(raw.as_str())</code></pre> |
| 296 | </details> |
| 297 | </article> |
| 298 | }) |
| 299 | } |
| 300 | |
| 301 | |
| 302 | fn parse_kind(raw: &str) -> Result<&'static str> { |
| 303 | match KINDS.into_iter().find(|known| *known == raw) { |
| 304 | Some(known) => Ok(known), |
| 305 | None => Err(not_found().into()), |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | async fn history_route(cx: &Cx, item_kind: ItemKind) -> Result<impl View> { |
| 310 | let number = parse_number(path_param::<Number>(cx))?; |
| 311 | let target_kind = parse_kind(path_param::<Kind>(cx))?; |
| 312 | let target = path_param::<TargetId>(cx).to_owned(); |
| 313 | let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?; |
| 314 | let state = app_context::<AppState>(cx); |
| 315 | let item = item_for(state, &ctx, item_kind, number).await?; |
| 316 | history_page(cx, ctx, item_kind, item, target_kind, target).await |
| 317 | } |
| 318 | |
| 319 | #[page("/{owner}/{repo}/issues/{number}/history/{kind}/{target_id}")] |
| 320 | async fn issue_history(cx: &Cx) -> Result<impl View> { |
| 321 | history_route(cx, ItemKind::Issue).await |
| 322 | } |
| 323 | |
| 324 | #[page("/{owner}/{repo}/pulls/{number}/history/{kind}/{target_id}")] |
| 325 | async fn pull_history(cx: &Cx) -> Result<impl View> { |
| 326 | history_route(cx, ItemKind::Pull).await |
| 327 | } |
| 328 | |
| 329 | #[cfg(test)] |
| 330 | mod tests { |
| 331 | use forge_core::{Side, Visibility}; |
| 332 | use topcoat::router::request::Request; |
| 333 | use topcoat::router::{Body, Router, to_bytes}; |
| 334 | |
| 335 | use crate::auth::SESSION_COOKIE; |
| 336 | use crate::pages::commits::test_support::{Fixture, OWNER, REPO}; |
| 337 | |
| 338 | |
| 339 | async fn send(router: &Router, uri: &str, token: Option<&str>) -> (u16, String) { |
| 340 | let mut req = Request::builder().method("GET").uri(uri); |
| 341 | if let Some(token) = token { |
| 342 | req = req.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); |
| 343 | } |
| 344 | let res = router.handle(req.body(Body::empty()).unwrap()).await; |
| 345 | let status = res.status().as_u16(); |
| 346 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 347 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 348 | } |
| 349 | |
| 350 | |
| 351 | async fn alice(fx: &Fixture) -> forge_core::User { |
| 352 | let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); |
| 353 | crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await; |
| 354 | owner |
| 355 | } |
| 356 | |
| 357 | async fn repo_id(fx: &Fixture) -> String { |
| 358 | fx.store |
| 359 | .repo_by_owner_and_name(OWNER, REPO) |
| 360 | .await |
| 361 | .unwrap() |
| 362 | .unwrap() |
| 363 | .id |
| 364 | } |
| 365 | |
| 366 | fn hex_oid(c: char) -> String { |
| 367 | std::iter::repeat_n(c, 40).collect() |
| 368 | } |
| 369 | |
| 370 | #[tokio::test] |
| 371 | async fn comment_edited_twice_shows_marker_and_diffs() { |
| 372 | let fx = Fixture::new(Visibility::Public).await; |
| 373 | let owner = alice(&fx).await; |
| 374 | let id = repo_id(&fx).await; |
| 375 | let issue = fx |
| 376 | .store |
| 377 | .create_issue(&id, &owner.id, "bug", "body") |
| 378 | .await |
| 379 | .unwrap(); |
| 380 | let comment = fx |
| 381 | .store |
| 382 | .add_comment(&issue.id, &owner.id, "one\ntwo\nthree") |
| 383 | .await |
| 384 | .unwrap(); |
| 385 | fx.store |
| 386 | .edit_comment(&comment.id, &owner.id, "one\nTWO\nthree") |
| 387 | .await |
| 388 | .unwrap(); |
| 389 | fx.store |
| 390 | .edit_comment(&comment.id, &owner.id, "one\nTWO\nthree\nfour") |
| 391 | .await |
| 392 | .unwrap(); |
| 393 | |
| 394 | let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await; |
| 395 | assert_eq!(status, 200); |
| 396 | let link = format!("/alice/demo/issues/1/history/comment/{}", comment.id); |
| 397 | assert!(page.contains("issue-edited"), "{page}"); |
| 398 | assert!(page.contains(&format!("href=\"{link}\"")), "{page}"); |
| 399 | |
| 400 | let (status, history) = send(&fx.router, &link, None).await; |
| 401 | assert_eq!(status, 200); |
| 402 | assert_eq!(history.matches("<article").count(), 2, "{history}"); |
| 403 | assert!(history.contains("diff-line del"), "{history}"); |
| 404 | assert!(history.contains("-two"), "{history}"); |
| 405 | assert!(history.contains("+TWO"), "{history}"); |
| 406 | assert!(history.contains("+four"), "{history}"); |
| 407 | assert!(history.contains("alice"), "{history}"); |
| 408 | } |
| 409 | |
| 410 | #[tokio::test] |
| 411 | async fn unedited_comment_has_no_marker() { |
| 412 | let fx = Fixture::new(Visibility::Public).await; |
| 413 | let owner = alice(&fx).await; |
| 414 | let id = repo_id(&fx).await; |
| 415 | let issue = fx |
| 416 | .store |
| 417 | .create_issue(&id, &owner.id, "bug", "body") |
| 418 | .await |
| 419 | .unwrap(); |
| 420 | fx.store |
| 421 | .add_comment(&issue.id, &owner.id, "plain") |
| 422 | .await |
| 423 | .unwrap(); |
| 424 | let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await; |
| 425 | assert_eq!(status, 200); |
| 426 | assert!(!page.contains("issue-edited"), "{page}"); |
| 427 | } |
| 428 | |
| 429 | #[tokio::test] |
| 430 | async fn body_edit_history_is_listed_and_linked() { |
| 431 | let fx = Fixture::new(Visibility::Public).await; |
| 432 | let owner = alice(&fx).await; |
| 433 | let id = repo_id(&fx).await; |
| 434 | let issue = fx |
| 435 | .store |
| 436 | .create_issue(&id, &owner.id, "old title", "a\nb\nc") |
| 437 | .await |
| 438 | .unwrap(); |
| 439 | fx.store |
| 440 | .edit_issue(&issue.id, &owner.id, "new title", "a\nB\nc") |
| 441 | .await |
| 442 | .unwrap(); |
| 443 | |
| 444 | let link = format!("/alice/demo/issues/1/history/issue/{}", issue.id); |
| 445 | let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await; |
| 446 | assert_eq!(status, 200); |
| 447 | assert!(page.contains(&format!("href=\"{link}\"")), "{page}"); |
| 448 | |
| 449 | let (status, history) = send(&fx.router, &link, None).await; |
| 450 | assert_eq!(status, 200); |
| 451 | assert!(history.contains("Title: old title"), "{history}"); |
| 452 | assert!(history.contains("-b"), "{history}"); |
| 453 | assert!(history.contains("+B"), "{history}"); |
| 454 | } |
| 455 | |
| 456 | #[tokio::test] |
| 457 | async fn target_from_another_issue_is_not_found() { |
| 458 | let fx = Fixture::new(Visibility::Public).await; |
| 459 | let owner = alice(&fx).await; |
| 460 | let id = repo_id(&fx).await; |
| 461 | fx.store |
| 462 | .create_issue(&id, &owner.id, "first", "body") |
| 463 | .await |
| 464 | .unwrap(); |
| 465 | let other = fx |
| 466 | .store |
| 467 | .create_issue(&id, &owner.id, "second", "body") |
| 468 | .await |
| 469 | .unwrap(); |
| 470 | let comment = fx |
| 471 | .store |
| 472 | .add_comment(&other.id, &owner.id, "x") |
| 473 | .await |
| 474 | .unwrap(); |
| 475 | fx.store |
| 476 | .edit_comment(&comment.id, &owner.id, "y") |
| 477 | .await |
| 478 | .unwrap(); |
| 479 | |
| 480 | let (status, _) = send( |
| 481 | &fx.router, |
| 482 | &format!("/alice/demo/issues/1/history/comment/{}", comment.id), |
| 483 | None, |
| 484 | ) |
| 485 | .await; |
| 486 | assert_eq!(status, 404); |
| 487 | let (status, _) = send( |
| 488 | &fx.router, |
| 489 | &format!("/alice/demo/issues/2/history/comment/{}", comment.id), |
| 490 | None, |
| 491 | ) |
| 492 | .await; |
| 493 | assert_eq!(status, 200); |
| 494 | } |
| 495 | |
| 496 | #[tokio::test] |
| 497 | async fn unknown_kind_and_missing_target_are_not_found() { |
| 498 | let fx = Fixture::new(Visibility::Public).await; |
| 499 | let owner = alice(&fx).await; |
| 500 | let id = repo_id(&fx).await; |
| 501 | let issue = fx |
| 502 | .store |
| 503 | .create_issue(&id, &owner.id, "bug", "body") |
| 504 | .await |
| 505 | .unwrap(); |
| 506 | let (status, _) = send( |
| 507 | &fx.router, |
| 508 | &format!("/alice/demo/issues/1/history/label/{}", issue.id), |
| 509 | None, |
| 510 | ) |
| 511 | .await; |
| 512 | assert_eq!(status, 404); |
| 513 | let (status, _) = send( |
| 514 | &fx.router, |
| 515 | "/alice/demo/issues/1/history/comment/missing", |
| 516 | None, |
| 517 | ) |
| 518 | .await; |
| 519 | assert_eq!(status, 404); |
| 520 | } |
| 521 | |
| 522 | #[tokio::test] |
| 523 | async fn private_repo_is_not_found_for_anonymous() { |
| 524 | let fx = Fixture::new(Visibility::Private).await; |
| 525 | let owner = alice(&fx).await; |
| 526 | let id = repo_id(&fx).await; |
| 527 | let issue = fx |
| 528 | .store |
| 529 | .create_issue(&id, &owner.id, "secret", "body") |
| 530 | .await |
| 531 | .unwrap(); |
| 532 | fx.store |
| 533 | .edit_issue(&issue.id, &owner.id, "secret", "body 2") |
| 534 | .await |
| 535 | .unwrap(); |
| 536 | let (status, _) = send( |
| 537 | &fx.router, |
| 538 | &format!("/alice/demo/issues/1/history/issue/{}", issue.id), |
| 539 | None, |
| 540 | ) |
| 541 | .await; |
| 542 | assert_eq!(status, 404); |
| 543 | let (status, _) = send( |
| 544 | &fx.router, |
| 545 | &format!("/alice/demo/issues/1/history/issue/{}", issue.id), |
| 546 | Some("alice"), |
| 547 | ) |
| 548 | .await; |
| 549 | assert_eq!(status, 200); |
| 550 | } |
| 551 | |
| 552 | #[tokio::test] |
| 553 | async fn review_comment_history_on_a_pull_request() { |
| 554 | let fx = Fixture::new(Visibility::Public).await; |
| 555 | let owner = alice(&fx).await; |
| 556 | let id = repo_id(&fx).await; |
| 557 | let pr = fx |
| 558 | .store |
| 559 | .create_pull( |
| 560 | &id, |
| 561 | &owner.id, |
| 562 | "change", |
| 563 | "", |
| 564 | "main", |
| 565 | &id, |
| 566 | "feature", |
| 567 | &hex_oid('a'), |
| 568 | &hex_oid('b'), |
| 569 | ) |
| 570 | .await |
| 571 | .unwrap(); |
| 572 | let comment = fx |
| 573 | .store |
| 574 | .add_review_comment( |
| 575 | &pr.issue.id, |
| 576 | &owner.id, |
| 577 | false, |
| 578 | "a.txt", |
| 579 | Side::New, |
| 580 | 2, |
| 581 | &hex_oid('a'), |
| 582 | "+x", |
| 583 | "first", |
| 584 | ) |
| 585 | .await |
| 586 | .unwrap(); |
| 587 | fx.store |
| 588 | .edit_review_comment(&comment.id, "second") |
| 589 | .await |
| 590 | .unwrap(); |
| 591 | |
| 592 | let link = format!("/alice/demo/pulls/1/history/review_comment/{}", comment.id); |
| 593 | let (status, history) = send(&fx.router, &link, None).await; |
| 594 | assert_eq!(status, 200); |
| 595 | assert!(history.contains("-first"), "{history}"); |
| 596 | assert!(history.contains("+second"), "{history}"); |
| 597 | |
| 598 | |
| 599 | let (status, _) = send( |
| 600 | &fx.router, |
| 601 | &format!("/alice/demo/issues/1/history/review_comment/{}", comment.id), |
| 602 | None, |
| 603 | ) |
| 604 | .await; |
| 605 | assert_eq!(status, 404); |
| 606 | } |
| 607 | } |