crates/forge/ents-forge/tests/conversations.rs
conversations.rshistorycomment on this file
| 1 | //! Integration coverage for the comment command layer: the broadened |
| 2 | //! `model.comment` (aboutness refused at creation, `model.comment-state` |
| 3 | //! transitions, `model.comment-context`/`model.comment-thread` |
| 4 | //! aggregation). |
| 5 | |
| 6 | #![allow( |
| 7 | clippy::expect_used, |
| 8 | clippy::unwrap_used, |
| 9 | clippy::indexing_slicing, |
| 10 | clippy::panic, |
| 11 | reason = "integration test: fixtures panic on setup failure" |
| 12 | )] |
| 13 | |
| 14 | use ents_forge::comment::{self, ListFilter, NewComment}; |
| 15 | use ents_model::{Member, MemberId, Provenance}; |
| 16 | use ents_receive::{Identity, Mode, NullEventSink, TxResult}; |
| 17 | use ents_testutil::{Keypair, MemRefStore, ObjectStore}; |
| 18 | use gix_ref_store::RefStoreRead as _; |
| 19 | use rstest::rstest; |
| 20 | |
| 21 | /// A throwaway on-disk repository holding one committed file — the |
| 22 | /// content anchors capture against — alongside the in-memory ref/object |
| 23 | /// fixtures every library test uses. |
| 24 | /// A detached signer over some bytes, returning an armored signature. |
| 25 | type Signer = Box<dyn Fn(&[u8]) -> String>; |
| 26 | |
| 27 | struct Fixture { |
| 28 | dir: tempfile::TempDir, |
| 29 | refs: MemRefStore, |
| 30 | objects: ObjectStore, |
| 31 | sign: Signer, |
| 32 | } |
| 33 | |
| 34 | impl Fixture { |
| 35 | fn new() -> Self { |
| 36 | let dir = tempfile::tempdir().expect("tempdir"); |
| 37 | let git = |args: &[&str]| { |
| 38 | let status = std::process::Command::new("git") |
| 39 | .arg("-C") |
| 40 | .arg(dir.path()) |
| 41 | .args(["-c", "user.name=test", "-c", "user.email=test@example.com"]) |
| 42 | .args(args) |
| 43 | .status() |
| 44 | .expect("git runs"); |
| 45 | assert!(status.success()); |
| 46 | }; |
| 47 | git(&["init", "-q"]); |
| 48 | let contents: String = (1..=10).map(|n| format!("line {n}\n")).collect(); |
| 49 | std::fs::write(dir.path().join("file.txt"), contents).unwrap(); |
| 50 | git(&["add", "-A"]); |
| 51 | git(&["commit", "-q", "-m", "seed"]); |
| 52 | let key = Keypair::from_seed(1); |
| 53 | Self { |
| 54 | dir, |
| 55 | refs: MemRefStore::default(), |
| 56 | objects: ObjectStore::default(), |
| 57 | sign: Box::new(move |payload| key.sign(payload)), |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | fn path(&self) -> &std::path::Path { |
| 62 | self.dir.path() |
| 63 | } |
| 64 | |
| 65 | fn identity(&self) -> Identity<'_> { |
| 66 | Identity { |
| 67 | actor: gix::actor::Signature { |
| 68 | name: "test".into(), |
| 69 | email: "test@ents.test".into(), |
| 70 | time: gix::date::Time { |
| 71 | seconds: 1_000, |
| 72 | offset: 0, |
| 73 | }, |
| 74 | }, |
| 75 | author: None, |
| 76 | sign: &*self.sign, |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | fn draft(&self) -> NewComment { |
| 81 | NewComment { |
| 82 | body: "looks off by one".to_owned(), |
| 83 | path: Some("file.txt".to_owned()), |
| 84 | lines: Some("3:4".to_owned()), |
| 85 | rev: "HEAD".to_owned(), |
| 86 | worktree: false, |
| 87 | context: None, |
| 88 | parent: None, |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | fn add(&self, draft: NewComment) -> String { |
| 93 | let (id, outcome) = comment::add( |
| 94 | &self.refs, |
| 95 | &self.objects, |
| 96 | &NullEventSink, |
| 97 | self.path(), |
| 98 | draft, |
| 99 | &self.identity(), |
| 100 | Mode::Advisory, |
| 101 | ) |
| 102 | .expect("adds"); |
| 103 | assert_eq!(outcome.result, TxResult::Applied); |
| 104 | id |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // --------------------------------------------------------------------- |
| 109 | // model.comment: aboutness is required at creation, never by the gate. |
| 110 | // --------------------------------------------------------------------- |
| 111 | |
| 112 | /// The library refuses a comment about nothing and malformed aboutness |
| 113 | /// arguments; every well-formed combination is accepted. |
| 114 | // @relation(model.comment, model.comment-context, scope=function, role=Verifies) |
| 115 | #[rstest] |
| 116 | #[case::about_nothing(None, None, None, false)] |
| 117 | #[case::lines_without_a_path(None, Some("issues/42"), Some("3:4"), false)] |
| 118 | #[case::bad_context(None, Some("not a ref\u{7f}"), None, false)] |
| 119 | #[case::context_only(None, Some("issues/42"), None, true)] |
| 120 | #[case::anchored(Some("file.txt"), None, None, true)] |
| 121 | #[case::anchored_and_contextual(Some("file.txt"), Some("reviews/7"), None, true)] |
| 122 | fn add_refuses_a_comment_about_nothing( |
| 123 | #[case] path: Option<&str>, |
| 124 | #[case] context: Option<&str>, |
| 125 | #[case] lines: Option<&str>, |
| 126 | #[case] accepted: bool, |
| 127 | ) { |
| 128 | let fixture = Fixture::new(); |
| 129 | let draft = NewComment { |
| 130 | body: "b".to_owned(), |
| 131 | path: path.map(str::to_owned), |
| 132 | lines: lines.map(str::to_owned), |
| 133 | rev: "HEAD".to_owned(), |
| 134 | worktree: false, |
| 135 | context: context.map(str::to_owned), |
| 136 | parent: None, |
| 137 | }; |
| 138 | let result = comment::add( |
| 139 | &fixture.refs, |
| 140 | &fixture.objects, |
| 141 | &NullEventSink, |
| 142 | fixture.path(), |
| 143 | draft, |
| 144 | &fixture.identity(), |
| 145 | Mode::Advisory, |
| 146 | ); |
| 147 | match (accepted, result) { |
| 148 | (true, Ok((_, outcome))) => assert_eq!(outcome.result, TxResult::Applied), |
| 149 | (false, Err(error)) => assert!(matches!(error, ents_forge::Error::InvalidArgument(_))), |
| 150 | (expected, got) => panic!("expected accepted={expected}, got {got:?}"), |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | /// A reply's parent must exist when the reply is created |
| 155 | /// (`model.comment-thread`) — both through `reply` and through `add |
| 156 | /// --parent`. |
| 157 | // @relation(model.comment-thread, scope=function, role=Verifies) |
| 158 | #[rstest] |
| 159 | fn a_reply_to_a_missing_parent_is_refused() { |
| 160 | let fixture = Fixture::new(); |
| 161 | let error = comment::reply( |
| 162 | &fixture.refs, |
| 163 | &fixture.objects, |
| 164 | &NullEventSink, |
| 165 | "no-such-id", |
| 166 | "reply".to_owned(), |
| 167 | &fixture.identity(), |
| 168 | Mode::Advisory, |
| 169 | ) |
| 170 | .expect_err("refused"); |
| 171 | assert!(matches!(error, ents_forge::Error::NotFound { .. })); |
| 172 | |
| 173 | let mut draft = fixture.draft(); |
| 174 | draft.parent = Some("no-such-id".to_owned()); |
| 175 | let error = comment::add( |
| 176 | &fixture.refs, |
| 177 | &fixture.objects, |
| 178 | &NullEventSink, |
| 179 | fixture.path(), |
| 180 | draft, |
| 181 | &fixture.identity(), |
| 182 | Mode::Advisory, |
| 183 | ) |
| 184 | .expect_err("refused"); |
| 185 | assert!(matches!(error, ents_forge::Error::NotFound { .. })); |
| 186 | } |
| 187 | |
| 188 | // --------------------------------------------------------------------- |
| 189 | // model.comment-state: resolve and reopen are ordinary ref mutations. |
| 190 | // --------------------------------------------------------------------- |
| 191 | |
| 192 | /// A new comment opens `open`; resolve records `resolved`; reopen records |
| 193 | /// `open` again — three commits on one ref, never a deletion. |
| 194 | // @relation(model.comment-state, scope=function, role=Verifies) |
| 195 | #[rstest] |
| 196 | fn resolve_and_reopen_advance_the_same_ref() { |
| 197 | let fixture = Fixture::new(); |
| 198 | let id = fixture.add(fixture.draft()); |
| 199 | let state = |fixture: &Fixture| { |
| 200 | comment::list(&fixture.refs, &fixture.objects).expect("lists")[0] |
| 201 | .1 |
| 202 | .state |
| 203 | .clone() |
| 204 | }; |
| 205 | assert_eq!(state(&fixture), "open"); |
| 206 | |
| 207 | let outcome = comment::resolve( |
| 208 | &fixture.refs, |
| 209 | &fixture.objects, |
| 210 | &NullEventSink, |
| 211 | &id, |
| 212 | &fixture.identity(), |
| 213 | Mode::Advisory, |
| 214 | None, |
| 215 | ) |
| 216 | .expect("resolves"); |
| 217 | assert_eq!(outcome.result, TxResult::Applied); |
| 218 | assert_eq!(state(&fixture), "resolved"); |
| 219 | |
| 220 | let outcome = comment::reopen( |
| 221 | &fixture.refs, |
| 222 | &fixture.objects, |
| 223 | &NullEventSink, |
| 224 | &id, |
| 225 | &fixture.identity(), |
| 226 | Mode::Advisory, |
| 227 | None, |
| 228 | ) |
| 229 | .expect("reopens"); |
| 230 | assert_eq!(outcome.result, TxResult::Applied); |
| 231 | assert_eq!(state(&fixture), "open"); |
| 232 | } |
| 233 | |
| 234 | // --------------------------------------------------------------------- |
| 235 | // model.comment-context / model.comment-thread: threads are aggregation |
| 236 | // queries over decomposed refs. |
| 237 | // --------------------------------------------------------------------- |
| 238 | |
| 239 | /// `thread` aggregates the comments naming a context plus every reply |
| 240 | /// reachable through parent links — a reply repeats neither anchor nor |
| 241 | /// context, and no entity stored a list of anything. |
| 242 | // @relation(model.comment-context, model.comment-thread, scope=function, role=Verifies) |
| 243 | #[rstest] |
| 244 | fn a_thread_aggregates_context_roots_and_their_replies() { |
| 245 | let fixture = Fixture::new(); |
| 246 | let mut root_draft = fixture.draft(); |
| 247 | root_draft.context = Some("reviews/7".to_owned()); |
| 248 | let root = fixture.add(root_draft); |
| 249 | let (reply, outcome) = comment::reply( |
| 250 | &fixture.refs, |
| 251 | &fixture.objects, |
| 252 | &NullEventSink, |
| 253 | &root, |
| 254 | "agreed".to_owned(), |
| 255 | &fixture.identity(), |
| 256 | Mode::Advisory, |
| 257 | ) |
| 258 | .expect("replies"); |
| 259 | assert_eq!(outcome.result, TxResult::Applied); |
| 260 | // A second-level reply, and an unrelated comment that must stay out. |
| 261 | let (nested, _) = comment::reply( |
| 262 | &fixture.refs, |
| 263 | &fixture.objects, |
| 264 | &NullEventSink, |
| 265 | &reply, |
| 266 | "further".to_owned(), |
| 267 | &fixture.identity(), |
| 268 | Mode::Advisory, |
| 269 | ) |
| 270 | .expect("replies"); |
| 271 | let mut unrelated = fixture.draft(); |
| 272 | unrelated.context = Some("issues/9".to_owned()); |
| 273 | fixture.add(unrelated); |
| 274 | |
| 275 | let thread = comment::thread(&fixture.refs, &fixture.objects, "reviews/7").expect("aggregates"); |
| 276 | let mut ids: Vec<_> = thread.iter().map(|(id, _)| id.clone()).collect(); |
| 277 | ids.sort(); |
| 278 | let mut expected = vec![root.clone(), reply.clone(), nested.clone()]; |
| 279 | expected.sort(); |
| 280 | assert_eq!(ids, expected); |
| 281 | |
| 282 | // The reply carried no anchor and no context of its own — aboutness |
| 283 | // is inherited from the thread root. |
| 284 | let replied = thread |
| 285 | .iter() |
| 286 | .find(|(id, _)| *id == reply) |
| 287 | .map(|(_, c)| c) |
| 288 | .expect("present"); |
| 289 | assert_eq!(replied.anchor, None); |
| 290 | assert_eq!(replied.context, None); |
| 291 | assert_eq!(replied.parent, Some(root)); |
| 292 | } |
| 293 | |
| 294 | // --------------------------------------------------------------------- |
| 295 | // lens.parity: the projected listing is one library call. |
| 296 | // --------------------------------------------------------------------- |
| 297 | |
| 298 | /// `list_projected` filters by state and context and projects each anchor |
| 299 | /// onto the working tree when asked — the exact call the CLI's |
| 300 | /// machine-readable form and the editor lens both consume. |
| 301 | // @relation(lens.parity, anchor.working-tree, scope=function, role=Verifies) |
| 302 | #[rstest] |
| 303 | fn list_projected_filters_and_projects_onto_the_working_tree() { |
| 304 | let fixture = Fixture::new(); |
| 305 | let anchored = fixture.add(fixture.draft()); |
| 306 | let mut contextual = fixture.draft(); |
| 307 | contextual.path = None; |
| 308 | contextual.lines = None; |
| 309 | contextual.context = Some("issues/42".to_owned()); |
| 310 | let unanchored = fixture.add(contextual); |
| 311 | comment::resolve( |
| 312 | &fixture.refs, |
| 313 | &fixture.objects, |
| 314 | &NullEventSink, |
| 315 | &unanchored, |
| 316 | &fixture.identity(), |
| 317 | Mode::Advisory, |
| 318 | None, |
| 319 | ) |
| 320 | .expect("resolves"); |
| 321 | |
| 322 | // Dirty the working tree above the anchored range: the worktree |
| 323 | // projection relocates while a HEAD projection would say Current. |
| 324 | let dirty: String = std::iter::once("inserted\n".to_owned()) |
| 325 | .chain((1..=10).map(|n| format!("line {n}\n"))) |
| 326 | .collect(); |
| 327 | std::fs::write(fixture.path().join("file.txt"), dirty).unwrap(); |
| 328 | |
| 329 | let (open_only, _unreadable) = comment::list_projected( |
| 330 | &fixture.refs, |
| 331 | &fixture.objects, |
| 332 | fixture.path(), |
| 333 | true, |
| 334 | &ListFilter { |
| 335 | state: Some("open".to_owned()), |
| 336 | context: None, |
| 337 | }, |
| 338 | ) |
| 339 | .expect("lists"); |
| 340 | assert_eq!(open_only.len(), 1); |
| 341 | assert_eq!(open_only[0].id, anchored); |
| 342 | assert_eq!( |
| 343 | open_only[0].projection, |
| 344 | Some(ents_anchor::Projection::Relocated { |
| 345 | path: "file.txt".to_owned(), |
| 346 | lines: Some(ents_anchor::LineRange { start: 4, end: 5 }), |
| 347 | }) |
| 348 | ); |
| 349 | |
| 350 | let (by_context, _unreadable) = comment::list_projected( |
| 351 | &fixture.refs, |
| 352 | &fixture.objects, |
| 353 | fixture.path(), |
| 354 | true, |
| 355 | &ListFilter { |
| 356 | state: None, |
| 357 | context: Some("issues/42".to_owned()), |
| 358 | }, |
| 359 | ) |
| 360 | .expect("lists"); |
| 361 | assert_eq!(by_context.len(), 1); |
| 362 | assert_eq!(by_context[0].id, unanchored); |
| 363 | assert_eq!(by_context[0].projection, None, "no anchor, no projection"); |
| 364 | } |
| 365 | |
| 366 | /// `--worktree` end to end at the library layer: a comment anchored to |
| 367 | /// dirty, uncommitted content is Current against the working tree and |
| 368 | /// survives the content being discarded (its content is embedded). |
| 369 | // @relation(anchor.working-tree, model.comment, scope=function, role=Verifies) |
| 370 | #[rstest] |
| 371 | fn a_worktree_anchored_comment_tracks_the_dirty_file() { |
| 372 | let fixture = Fixture::new(); |
| 373 | let dirty: String = (1..=10) |
| 374 | .map(|n| { |
| 375 | if n == 5 { |
| 376 | "line five\n".to_owned() |
| 377 | } else { |
| 378 | format!("line {n}\n") |
| 379 | } |
| 380 | }) |
| 381 | .collect(); |
| 382 | std::fs::write(fixture.path().join("file.txt"), &dirty).unwrap(); |
| 383 | |
| 384 | let mut draft = fixture.draft(); |
| 385 | draft.worktree = true; |
| 386 | draft.lines = Some("5".to_owned()); |
| 387 | let id = fixture.add(draft); |
| 388 | |
| 389 | let (_, projected) = comment::show( |
| 390 | &fixture.refs, |
| 391 | &fixture.objects, |
| 392 | fixture.path(), |
| 393 | &id, |
| 394 | "HEAD", |
| 395 | true, |
| 396 | ) |
| 397 | .expect("shows"); |
| 398 | let (anchor, projection) = projected.expect("anchored"); |
| 399 | assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line five\n"); |
| 400 | assert_eq!(projection, ents_anchor::Projection::Current); |
| 401 | |
| 402 | // Discard the dirty content: the anchor's own text still reads back |
| 403 | // (embedded), and the worktree projection reports the region edited. |
| 404 | let git = std::process::Command::new("git") |
| 405 | .arg("-C") |
| 406 | .arg(fixture.path()) |
| 407 | .args(["checkout", "--", "file.txt"]) |
| 408 | .status() |
| 409 | .expect("git runs"); |
| 410 | assert!(git.success()); |
| 411 | let (_, projected) = comment::show( |
| 412 | &fixture.refs, |
| 413 | &fixture.objects, |
| 414 | fixture.path(), |
| 415 | &id, |
| 416 | "HEAD", |
| 417 | true, |
| 418 | ) |
| 419 | .expect("shows"); |
| 420 | let (anchor, projection) = projected.expect("anchored"); |
| 421 | assert_eq!(ents_anchor::snippet(&anchor).unwrap(), "line five\n"); |
| 422 | assert_eq!( |
| 423 | projection, |
| 424 | ents_anchor::Projection::Outdated { |
| 425 | path: "file.txt".to_owned(), |
| 426 | } |
| 427 | ); |
| 428 | } |
| 429 | |
| 430 | // --------------------------------------------------------------------- |
| 431 | // model.comment-provenance: a state change pins the resolver's record. |
| 432 | // --------------------------------------------------------------------- |
| 433 | |
| 434 | /// The commit message at `id`'s comment ref tip. |
| 435 | fn tip_message(fixture: &Fixture, id: &str) -> String { |
| 436 | use gix_object::Find as _; |
| 437 | let name = ents_model::namespace::comment_ref(id).expect("valid"); |
| 438 | let tip = fixture |
| 439 | .refs |
| 440 | .get(name.as_ref()) |
| 441 | .expect("reads") |
| 442 | .expect("exists"); |
| 443 | let mut buf = Vec::new(); |
| 444 | let data = fixture |
| 445 | .objects |
| 446 | .try_find(&tip, &mut buf) |
| 447 | .expect("finds") |
| 448 | .expect("exists"); |
| 449 | gix_object::CommitRef::from_bytes(data.data, tip.kind()) |
| 450 | .expect("commit") |
| 451 | .message |
| 452 | .to_string() |
| 453 | } |
| 454 | |
| 455 | /// Resolving with an enrolled member's key writes a `Key-for-<id>` |
| 456 | /// trailer naming the member ref's tip commit at resolve time; a signer |
| 457 | /// whose key matches no enrolled member writes none. |
| 458 | #[rstest] |
| 459 | // @relation(model.comment-provenance, scope=function, role=Verifies) |
| 460 | fn resolving_pins_the_resolvers_member_record() { |
| 461 | let fixture = Fixture::new(); |
| 462 | let id = fixture.add(fixture.draft()); |
| 463 | |
| 464 | // Enroll the fixture's signer as member `joey` through the real |
| 465 | // proposal path, then capture the member ref's tip. |
| 466 | let member = Member::new( |
| 467 | MemberId::new("joey"), |
| 468 | Keypair::from_seed(1).public_openssh(), |
| 469 | Provenance::AdminRegistered, |
| 470 | ); |
| 471 | let name = ents_model::namespace::member_ref(&MemberId::new("joey")).expect("valid"); |
| 472 | let outcome = ents_receive::propose_entity( |
| 473 | &fixture.refs, |
| 474 | &fixture.objects, |
| 475 | &NullEventSink, |
| 476 | name.clone(), |
| 477 | &member, |
| 478 | &fixture.identity(), |
| 479 | "Enroll joey", |
| 480 | Mode::Advisory, |
| 481 | ) |
| 482 | .expect("enrolls"); |
| 483 | assert_eq!(outcome.result, TxResult::Applied); |
| 484 | let member_tip = fixture |
| 485 | .refs |
| 486 | .get(name.as_ref()) |
| 487 | .expect("reads") |
| 488 | .expect("exists"); |
| 489 | |
| 490 | comment::resolve( |
| 491 | &fixture.refs, |
| 492 | &fixture.objects, |
| 493 | &NullEventSink, |
| 494 | &id, |
| 495 | &fixture.identity(), |
| 496 | Mode::Advisory, |
| 497 | Some(&Keypair::from_seed(1).public_openssh()), |
| 498 | ) |
| 499 | .expect("resolves"); |
| 500 | let message = tip_message(&fixture, &id); |
| 501 | assert!( |
| 502 | message.contains(&format!("Key-for-joey: {member_tip}")), |
| 503 | "the resolve mutation pins the member record: {message}" |
| 504 | ); |
| 505 | |
| 506 | // A key matching no enrolled member (seed 2) writes no trailer. |
| 507 | comment::reopen( |
| 508 | &fixture.refs, |
| 509 | &fixture.objects, |
| 510 | &NullEventSink, |
| 511 | &id, |
| 512 | &fixture.identity(), |
| 513 | Mode::Advisory, |
| 514 | Some(&Keypair::from_seed(2).public_openssh()), |
| 515 | ) |
| 516 | .expect("reopens"); |
| 517 | let message = tip_message(&fixture, &id); |
| 518 | assert!( |
| 519 | !message.contains("Key-for-"), |
| 520 | "an unenrolled signer writes no trailer: {message}" |
| 521 | ); |
| 522 | } |