use crate::domain::model::event::{Event, EventAction};
use crate::domain::model::issue::IssueEdit;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::Status;
use crate::domain::usecases::edit::issue::{commit_issue_edits, lower_status_transition};
use crate::domain::usecases::issue::IssueRepository;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateIssueOutcome {
StatusChanged { from: Status, to: Status },
NoOp,
EventAppended,
}
pub fn update_issue(
repo: &dyn IssueRepository,
id: &IssueRef,
event: Event,
) -> anyhow::Result<UpdateIssueOutcome> {
let issue = repo
.find_by_id(id)?
.ok_or_else(|| anyhow::anyhow!("issue {id} not found"))?;
if let EventAction::StatusChanged { to, .. } = &event.action {
if issue.status.as_str() == to.as_str() {
return Ok(UpdateIssueOutcome::NoOp);
}
let from = issue.status.clone();
let to_status = Status::unresolved(to.as_str());
let edits = lower_status_transition(&issue, to_status.clone(), event.timestamp.clone());
commit_issue_edits(repo, edits)?;
return Ok(UpdateIssueOutcome::StatusChanged {
from,
to: to_status,
});
}
commit_issue_edits(
repo,
vec![IssueEdit::AppendEvent {
issue: issue.id.clone(),
event,
}],
)?;
Ok(UpdateIssueOutcome::EventAppended)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::issue::tests::{
feature, other_event, status_changed_event, FakeIssueRepository, IssueFixture,
};
#[test]
fn status_change_transitions_the_issue_status() {
scenario()
.given(feature("Add login").with_id("ISSUE-0001").status("open"))
.when_status_changed("ISSUE-0001", "open", "in-progress")
.then_saved_status("in-progress");
}
#[test]
fn status_change_to_current_status_is_a_noop() {
scenario()
.given(feature("Add login").with_id("ISSUE-0001").status("open"))
.when_status_changed("ISSUE-0001", "open", "open")
.then_noop();
}
#[test]
fn updating_an_unknown_issue_returns_an_error() {
scenario()
.when_status_changed("ISSUE-0099", "open", "closed")
.then_err_contains("not found");
}
#[test]
fn created_event_is_appended_without_changing_status() {
scenario()
.given(feature("Add login").with_id("ISSUE-0001").status("open"))
.when_event("ISSUE-0001", other_event())
.then_saved_has_event("created")
.then_status_unchanged("open");
}
fn scenario() -> Scenario {
Scenario {
repo: FakeIssueRepository::new(),
}
}
struct Scenario {
repo: FakeIssueRepository,
}
impl Scenario {
fn given(mut self, fixture: IssueFixture) -> Self {
let raw = fixture
.id
.as_deref()
.expect("given() requires an explicit id")
.to_string();
let numeric = IssueRef::new(&raw).unwrap();
self.repo.push_issue(fixture.build(numeric));
self
}
fn when_status_changed(self, id: &str, from: &str, to: &str) -> UpdateOutcome {
let issue_ref = IssueRef::new(id).unwrap();
let result = update_issue(&self.repo, &issue_ref, status_changed_event(from, to));
UpdateOutcome {
repo: self.repo,
result,
}
}
fn when_event(self, id: &str, event: Event) -> UpdateOutcome {
let issue_ref = IssueRef::new(id).unwrap();
let result = update_issue(&self.repo, &issue_ref, event);
UpdateOutcome {
repo: self.repo,
result,
}
}
}
struct UpdateOutcome {
repo: FakeIssueRepository,
result: anyhow::Result<UpdateIssueOutcome>,
}
impl UpdateOutcome {
fn then_saved_status(self, expected: &str) -> Self {
let outcome = self.result.as_ref().expect("expected Ok");
assert!(
matches!(outcome, UpdateIssueOutcome::StatusChanged { to, .. } if to.as_str() == expected),
"expected StatusChanged to {expected:?}, got {outcome:?}"
);
let saved = self.repo.last_saved().expect("expected a save");
assert_eq!(saved.status.as_str(), expected);
self
}
fn then_saved_has_event(self, action: &str) -> Self {
assert_eq!(
self.result.as_ref().expect("expected Ok"),
&UpdateIssueOutcome::EventAppended
);
let saved = self.repo.last_saved().expect("expected a save");
assert!(
saved.events.iter().any(|e| e.action.as_str() == action),
"expected event {action:?}"
);
self
}
fn then_status_unchanged(self, expected: &str) -> Self {
let saved = self.repo.last_saved().expect("expected a save");
assert_eq!(saved.status.as_str(), expected);
self
}
fn then_noop(self) -> Self {
assert_eq!(
self.result.as_ref().expect("expected Ok"),
&UpdateIssueOutcome::NoOp
);
assert!(self.repo.last_saved().is_none());
self
}
fn then_err_contains(self, substring: &str) {
let msg = self.result.expect_err("expected Err").to_string();
assert!(msg.contains(substring), "expected {substring:?} in {msg:?}");
}
}
}