801 lines
26 KiB
Rust
801 lines
26 KiB
Rust
//! 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);
|
|
}
|