feat: separate Ticket case state and immutable closure judgments
This commit is contained in:
@@ -0,0 +1,800 @@
|
||||
//! Public Ticket case authority contracts over isolated SQLite stores.
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use tempfile::TempDir;
|
||||
use ticket::*;
|
||||
|
||||
fn backend(temp: &TempDir) -> SqliteTicketBackend {
|
||||
SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap()
|
||||
}
|
||||
fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(),
|
||||
references: vec![TicketReference {
|
||||
kind: "result".into(),
|
||||
target: "document:example".into(),
|
||||
}],
|
||||
author: Some("human:alice".into()),
|
||||
}
|
||||
}
|
||||
fn close(reason: TicketClosureReason) -> TicketCaseAction {
|
||||
TicketCaseAction::Close { reason }
|
||||
}
|
||||
fn create(backend: &SqliteTicketBackend) -> Ticket {
|
||||
let mut input = NewTicket::new("Independent case");
|
||||
input.assignee = Some("historical-assignee".into());
|
||||
backend.create_with_snapshot(input).unwrap().1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(closed.meta.targets.is_empty());
|
||||
assert_eq!(closed.meta.workflow_state, TicketState::Closed);
|
||||
let first_judgment = closed.current_closure.clone().unwrap();
|
||||
assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned);
|
||||
assert_eq!(first_judgment.author, "human:alice");
|
||||
assert_eq!(
|
||||
first_judgment.references,
|
||||
request(&initial, "close", close(TicketClosureReason::NotPlanned)).references
|
||||
);
|
||||
let corrected = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Duplicate,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
corrected.current_closure.as_ref().unwrap().reason,
|
||||
TicketClosureReason::Duplicate
|
||||
);
|
||||
assert_eq!(corrected.case_history[0], closed.case_history[0]);
|
||||
assert_eq!(
|
||||
corrected.case_history[1]
|
||||
.previous_closure_operation_key
|
||||
.as_deref(),
|
||||
Some("close")
|
||||
);
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.meta.workflow_state, TicketState::Open);
|
||||
assert!(reopened.current_closure.is_none());
|
||||
assert!(reopened.resolution.is_none());
|
||||
assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct"));
|
||||
assert_eq!(reopened.case_history.len(), 3);
|
||||
assert_eq!(reopened.meta.assignee, initial.meta.assignee);
|
||||
assert_eq!(reopened.meta.resource_key, initial.meta.resource_key);
|
||||
assert_eq!(
|
||||
&reopened.events[..initial.events.len()],
|
||||
initial.events.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
store.show(initial.meta.id.clone().into()).unwrap(),
|
||||
reopened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
for reason in [
|
||||
TicketClosureReason::Completed,
|
||||
TicketClosureReason::NotPlanned,
|
||||
TicketClosureReason::Duplicate,
|
||||
TicketClosureReason::Unknown,
|
||||
TicketClosureReason::Other("Custom decision".into()),
|
||||
] {
|
||||
let initial = create(&store);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(reason.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(closed.current_closure.unwrap().reason, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let base = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let mut stale = base.clone();
|
||||
stale.expected_content_digest = "stale".into();
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleContent { .. })
|
||||
));
|
||||
let mut stale = base.clone();
|
||||
stale.expected_state = TicketState::Closed;
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleWorkflowState { .. })
|
||||
));
|
||||
let mut stale = base;
|
||||
stale.expected_closure_operation_key = Some("unobserved".into());
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_keys_protect_closed_correction_and_open_closed_open_aba() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let stale_open = request(
|
||||
&initial,
|
||||
"late-close",
|
||||
close(TicketClosureReason::Completed),
|
||||
);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
let stale_correction = request(
|
||||
&closed,
|
||||
"late-correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
);
|
||||
let corrected = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::NotPlanned,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale_correction),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ticket_content_digest(&reopened),
|
||||
ticket_content_digest(&initial)
|
||||
);
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale_open),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
store.show(initial.meta.id.clone().into()).unwrap(),
|
||||
reopened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let closed = store
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&closed, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
let edited = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
title: Some("Changed requirements".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edited.case_history, reopened.case_history);
|
||||
let restored =
|
||||
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap();
|
||||
assert_eq!(
|
||||
restored.update_case(&initial.meta.id, operation).unwrap(),
|
||||
edited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let closed = store
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
let mut changed = Vec::new();
|
||||
let mut x = operation.clone();
|
||||
x.expected_content_digest.push('x');
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.expected_state = TicketState::Closed;
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.expected_closure_operation_key = Some("close".into());
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.action = close(TicketClosureReason::NotPlanned);
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.action = TicketCaseAction::Reopen;
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.explanation.push('x');
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.references.clear();
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.author = None;
|
||||
changed.push(x);
|
||||
for input in changed {
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, input),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
}
|
||||
let other_source = store.clone().with_event_attributes(BTreeMap::from([(
|
||||
"source_runtime_id".into(),
|
||||
"other-runtime".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_source.update_case(&initial.meta.id, operation),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
for action in [
|
||||
TicketCaseAction::Reopen,
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.update_case(&initial.meta.id, request(&initial, "invalid", action))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"double-close",
|
||||
close(TicketClosureReason::Completed)
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
for reason in [
|
||||
"".into(),
|
||||
" \n".into(),
|
||||
"bad\0reason".into(),
|
||||
"あ".repeat(342),
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "bad", close(TicketClosureReason::Other(reason)))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut valid = request(
|
||||
&initial,
|
||||
"close",
|
||||
close(TicketClosureReason::Other("x".repeat(1024))),
|
||||
);
|
||||
valid.explanation = format!(
|
||||
"## Decision\n{}\n<!-- typed SQLite text -->",
|
||||
"long".repeat(8000)
|
||||
);
|
||||
let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap();
|
||||
assert_eq!(
|
||||
closed.current_closure.as_ref().unwrap().explanation,
|
||||
valid.explanation
|
||||
);
|
||||
assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let base = request(&initial, "key", close(TicketClosureReason::Unknown));
|
||||
let mut cases = Vec::new();
|
||||
let mut x = base.clone();
|
||||
x.explanation = " ".into();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.explanation = "bad\0text".into();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.author = Some(" ".into());
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.references[0].target.clear();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.references[0].kind.clear();
|
||||
cases.push(x);
|
||||
let mut x = base;
|
||||
x.operation_key.clear();
|
||||
cases.push(x);
|
||||
for input in cases {
|
||||
assert!(store.update_case(&initial.meta.id, input).is_err());
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| {
|
||||
Err(TicketError::Conflict("hook rejected".into()))
|
||||
}));
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Unknown));
|
||||
assert!(
|
||||
rejected
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
let closed = store.update_case(&initial.meta.id, operation).unwrap();
|
||||
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
|
||||
for sql in [
|
||||
"UPDATE typed_ticket_case_history SET judgment_json = '{}'",
|
||||
"DELETE FROM typed_ticket_case_history",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err());
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let mut joins = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let operation = operation.clone();
|
||||
let id = initial.meta.id.clone();
|
||||
joins.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.update_case(&id, operation)
|
||||
}));
|
||||
}
|
||||
for join in joins {
|
||||
join.join().unwrap().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
store
|
||||
.show(initial.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.case_history
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let initial = create(&store);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let mut joins = Vec::new();
|
||||
for key in ["winner-a", "winner-b"] {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let operation = request(&initial, key, close(TicketClosureReason::Unknown));
|
||||
let id = initial.meta.id.clone();
|
||||
joins.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.update_case(&id, operation)
|
||||
}));
|
||||
}
|
||||
let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect();
|
||||
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert!(
|
||||
outcomes
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. })))
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.show(initial.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.case_history
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let ready = store
|
||||
.mark_ready(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketMarkReady {
|
||||
operation_key: "ready".into(),
|
||||
reason: None,
|
||||
intake_summary: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ready.meta.workflow_state, TicketState::Open);
|
||||
assert_eq!(ready.meta.readiness.as_deref(), Some("ready"));
|
||||
store
|
||||
.queue_ready(initial.meta.id.clone().into(), "router")
|
||||
.unwrap();
|
||||
let queued = store.show(initial.meta.id.clone().into()).unwrap();
|
||||
assert_eq!(queued.meta.workflow_state, TicketState::Open);
|
||||
assert_eq!(queued.meta.queued_by.as_deref(), Some("router"));
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&queued, "close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.queue_ready(initial.meta.id.clone().into(), "router")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let other =
|
||||
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace")
|
||||
.unwrap();
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
assert!(matches!(
|
||||
other.update_case(&initial.meta.id, operation.clone()),
|
||||
Err(TicketError::NotFound(_))
|
||||
));
|
||||
let output = execute_ticket_backend_operation(
|
||||
&store,
|
||||
TicketBackendOperation::UpdateCase {
|
||||
ticket: initial.meta.id.clone(),
|
||||
request: operation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let historical = store.clone().with_event_attributes(BTreeMap::from([
|
||||
("operation_key".into(), "old-completion".into()),
|
||||
("merge_request_id".into(), "MR-existing".into()),
|
||||
]));
|
||||
let mut event = NewTicketEvent::new(
|
||||
TicketEventKind::Other("ticket_completion".into()),
|
||||
"Historic completed result",
|
||||
);
|
||||
event.references = vec![TicketReference {
|
||||
kind: "merge_request".into(),
|
||||
target: "MR-existing".into(),
|
||||
}];
|
||||
historical
|
||||
.add_event(initial.meta.id.clone().into(), event)
|
||||
.unwrap();
|
||||
let before = store.show(initial.meta.id.clone().into()).unwrap();
|
||||
let after = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&before, "new-close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&after.events[..before.events.len()],
|
||||
before.events.as_slice()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&before,
|
||||
"old-completion",
|
||||
close(TicketClosureReason::Completed)
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() {
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"in_progress",
|
||||
] {
|
||||
assert!(serde_json::from_value::<TicketState>(serde_json::json!(state)).is_err());
|
||||
}
|
||||
let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}});
|
||||
let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(action).unwrap(), encoded);
|
||||
assert!(
|
||||
serde_json::from_value::<TicketCaseAction>(
|
||||
serde_json::json!({"action": "reopen", "reason": "completed"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn assignment_source() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime-a".into()),
|
||||
("source_worker_id".into(), "worker-a".into()),
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_assignment_id".into(), "assignment-a".into()),
|
||||
])
|
||||
}
|
||||
fn assignment_fixture(temp: &TempDir, id: &str) -> Connection {
|
||||
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
|
||||
conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT);
|
||||
CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap();
|
||||
conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let conn = assignment_fixture(&temp, &initial.meta.id);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
for key in [
|
||||
"source_runtime_id",
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
"source_actor_role",
|
||||
] {
|
||||
let mut wrong = assignment_source();
|
||||
wrong.insert(key.into(), "mismatch".into());
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.with_event_attributes(wrong)
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for key in [
|
||||
"source_runtime_id",
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
] {
|
||||
let mut missing = assignment_source();
|
||||
missing.remove(key);
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.with_event_attributes(missing)
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
let source = store.clone().with_event_attributes(assignment_source());
|
||||
let closed = source
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
// The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this.
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"active"
|
||||
);
|
||||
conn.execute("UPDATE test_worker_assignments SET status = 'released'", [])
|
||||
.unwrap();
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&closed, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
source.update_case(&initial.meta.id, operation).unwrap(),
|
||||
reopened
|
||||
);
|
||||
assert!(
|
||||
source
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&reopened, "new-close", close(TicketClosureReason::Unknown))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"released"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let digest = ticket_content_digest(&initial);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_content_digest(&closed), digest);
|
||||
let edited = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(MarkdownText::new("New criteria")),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_content_digest(&edited), digest);
|
||||
let restored = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(initial.document.body.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_content_digest(&restored), digest);
|
||||
let targeted = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
targets: Some(TicketTargetsEdit::Set {
|
||||
targets: vec![TicketTarget {
|
||||
repository_key: "main".into(),
|
||||
ref_selector: None,
|
||||
access: TicketTargetAccess::ReadOnly,
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_content_digest(&targeted), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_case_request_requires_an_explicit_nullable_closure_observation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let original = request(&initial, "close", close(TicketClosureReason::Unknown));
|
||||
let mut json = serde_json::to_value(&original).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TicketCaseUpdate>(json.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
json.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("expected_closure_operation_key");
|
||||
assert!(serde_json::from_value::<TicketCaseUpdate>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let context = store.clone().with_event_attributes(BTreeMap::from([(
|
||||
"operation_key".into(),
|
||||
"wrong-key".into(),
|
||||
)]));
|
||||
assert!(
|
||||
context
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract.
|
||||
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
|
||||
CREATE TABLE typed_ticket_artifacts (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_event_attributes (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
|
||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_event_references (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
event_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
author TEXT,
|
||||
at TEXT,
|
||||
status TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
reason TEXT,
|
||||
state_field TEXT,
|
||||
heading TEXT,
|
||||
body TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_labels (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_orchestration_plans (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
related_ticket TEXT,
|
||||
note TEXT,
|
||||
accepted_summary TEXT,
|
||||
accepted_branch TEXT,
|
||||
accepted_worktree TEXT,
|
||||
accepted_role_plan TEXT,
|
||||
author TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, record_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_raw_frontmatter (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, key),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_relations (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, kind, target),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_risk_flags (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_tickets (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
assignee TEXT,
|
||||
readiness TEXT,
|
||||
workflow_state TEXT NOT NULL,
|
||||
workflow_state_explicit INTEGER NOT NULL,
|
||||
queued_by TEXT,
|
||||
queued_at TEXT,
|
||||
resolution TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id)
|
||||
);
|
||||
CREATE TABLE typed_ticket_targets (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
repository_key TEXT NOT NULL,
|
||||
ref_selector TEXT,
|
||||
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
UNIQUE (workspace_id, ticket_id, repository_key),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE "workspace_resource_key_counters" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
||||
PRIMARY KEY (workspace_id, resource_kind)
|
||||
);
|
||||
CREATE TABLE "workspace_resource_keys" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||
resource_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||
resource_key TEXT NOT NULL,
|
||||
allocated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
||||
UNIQUE (workspace_id, resource_kind, sequence),
|
||||
UNIQUE (workspace_id, resource_key)
|
||||
);
|
||||
CREATE INDEX idx_workspace_resource_keys_reverse
|
||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||
CREATE INDEX typed_ticket_relations_workspace_source_kind
|
||||
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
|
||||
CREATE INDEX typed_ticket_relations_workspace_target_kind
|
||||
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_created
|
||||
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_state_updated
|
||||
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_title
|
||||
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_updated
|
||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
|
||||
//! migrate independently to schema9, preserving original evidence and routing handoff.
|
||||
use rusqlite::{Connection, params};
|
||||
use tempfile::TempDir;
|
||||
use ticket::*;
|
||||
|
||||
fn schema7(connection: &Connection) {
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema7.sql"))
|
||||
.unwrap();
|
||||
connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference()
|
||||
{
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("tickets.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);")
|
||||
.unwrap();
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap();
|
||||
// Actual pre-content receipt spelling: preserve it as historical input,
|
||||
// never promote an old counter fingerprint into a current content CAS.
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap();
|
||||
connection.execute(
|
||||
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
|
||||
[state],
|
||||
).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO legacy_worker_status VALUES (?1, 'released')",
|
||||
params![state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Snapshot evidence before migration, including untyped `done` and existing MR references.
|
||||
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
let old_attributes: Vec<(String, String, String)> = connection
|
||||
.prepare(
|
||||
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let preserved_attributes: Vec<(String, String, String)> = connection
|
||||
.prepare(
|
||||
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(old_attributes, preserved_attributes);
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
let marker: i64 = connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(marker, 9);
|
||||
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
assert_eq!(after_events, historical_events);
|
||||
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
let ticket = store.show(state.into()).unwrap();
|
||||
let expected_state = if matches!(state, "done" | "closed") {
|
||||
TicketState::Closed
|
||||
} else {
|
||||
TicketState::Open
|
||||
};
|
||||
assert_eq!(ticket.meta.workflow_state, expected_state);
|
||||
let old_key_request = TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: "legacy-receipt".into(),
|
||||
action: if expected_state == TicketState::Closed {
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
}
|
||||
} else {
|
||||
TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
}
|
||||
},
|
||||
explanation: "Cannot reinterpret a frozen operation receipt".into(),
|
||||
references: vec![],
|
||||
author: Some("human".into()),
|
||||
};
|
||||
assert!(matches!(
|
||||
store.update_case(state, old_key_request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(store.show(state.into()).unwrap(), ticket);
|
||||
let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap();
|
||||
assert_eq!(legacy.workflow_state, state);
|
||||
assert!(legacy.workflow_state_explicit);
|
||||
assert_eq!(legacy.status, "open");
|
||||
assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router"));
|
||||
assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time"));
|
||||
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
|
||||
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
|
||||
assert_eq!(ticket.meta.targets[0].repository_key, "main");
|
||||
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
|
||||
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
|
||||
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
|
||||
assert_eq!(
|
||||
ticket.events[0].attributes["request_fingerprint"],
|
||||
"legacy-fingerprint"
|
||||
);
|
||||
if expected_state == TicketState::Closed {
|
||||
let judgment = ticket.current_closure.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
judgment.reason,
|
||||
TicketClosureReason::Unknown,
|
||||
"old done must never mean successful completion"
|
||||
);
|
||||
assert_eq!(judgment.explanation, "Original decision text");
|
||||
let reopened = store
|
||||
.update_case(
|
||||
state,
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: "reopen-migrated".into(),
|
||||
action: TicketCaseAction::Reopen,
|
||||
explanation: "Reconsider the case".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("human".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reopened.current_closure.is_none());
|
||||
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
|
||||
assert_eq!(
|
||||
reopened.legacy_workflow_snapshot,
|
||||
ticket.legacy_workflow_snapshot
|
||||
);
|
||||
} else {
|
||||
assert!(ticket.current_closure.is_none());
|
||||
}
|
||||
}
|
||||
let released: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(released, 6);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT workflow_state FROM typed_tickets", [], |row| row
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"future-state"
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema8_tag_composition_is_rejected_until_explicitly_integrated() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version = 8", [])
|
||||
.unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user