//! Pure invariants for first-class background tasks. use domain::{ BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, }; use uuid::Uuid; fn task_id(n: u128) -> domain::TaskId { domain::TaskId::from_uuid(Uuid::from_u128(n)) } fn project_id(n: u128) -> domain::ProjectId { domain::ProjectId::from_uuid(Uuid::from_u128(n)) } fn agent_id(n: u128) -> domain::AgentId { domain::AgentId::from_uuid(Uuid::from_u128(n)) } fn command_task() -> BackgroundTask { BackgroundTask::new( task_id(1), project_id(2), agent_id(3), BackgroundTaskKind::Command { label: "build".into(), }, BackgroundTaskWakePolicy::WakeOwner, 100, Some(200), ) .unwrap() } fn success_result(at: u64) -> BackgroundTaskResult { BackgroundTaskResult::Success { finished_at_ms: at, exit_code: Some(0), summary: "ok".into(), stdout_tail: None, stderr_tail: None, } } fn failure_result(at: u64) -> BackgroundTaskResult { BackgroundTaskResult::Failure { finished_at_ms: at, exit_code: Some(1), error: "failed".into(), stdout_tail: None, stderr_tail: None, } } #[test] fn new_task_is_queued_without_result_or_delivery() { let task = command_task(); assert_eq!(task.state, BackgroundTaskState::Queued); assert_eq!(task.result, None); assert!(!task.completion_delivered); assert!(!task.is_terminal()); assert!(!task.has_pending_completion_delivery()); } #[test] fn deadline_must_not_precede_creation() { let err = BackgroundTask::new( task_id(1), project_id(2), agent_id(3), BackgroundTaskKind::Command { label: "build".into(), }, BackgroundTaskWakePolicy::WakeOwner, 100, Some(99), ) .unwrap_err(); assert_eq!(err, BackgroundTaskError::InvalidTimestamps); } #[test] fn labels_must_be_non_empty_and_bounded() { let empty = BackgroundTaskKind::Command { label: " ".into() }; assert_eq!(empty.validate(), Err(BackgroundTaskError::EmptyLabel)); let oversized = BackgroundTaskKind::Maintenance { label: "x".repeat(domain::BACKGROUND_TASK_LABEL_MAX_CHARS + 1), }; assert_eq!( oversized.validate(), Err(BackgroundTaskError::FieldTooLarge { field: "label", max: domain::BACKGROUND_TASK_LABEL_MAX_CHARS, }) ); } #[test] fn allowed_non_terminal_transitions_update_timestamp() { let running = command_task() .transition(BackgroundTaskState::Running, 120) .unwrap(); assert_eq!(running.state, BackgroundTaskState::Running); assert_eq!(running.updated_at_ms, 120); let waiting = running .transition(BackgroundTaskState::Waiting, 130) .unwrap(); assert_eq!(waiting.state, BackgroundTaskState::Waiting); let running_again = waiting .transition(BackgroundTaskState::Running, 140) .unwrap(); assert_eq!(running_again.state, BackgroundTaskState::Running); } #[test] fn illegal_transitions_are_rejected() { let task = command_task(); let err = task .transition(BackgroundTaskState::Waiting, 120) .unwrap_err(); assert_eq!( err, BackgroundTaskError::InvalidTransition { from: BackgroundTaskState::Queued, to: BackgroundTaskState::Waiting, } ); } #[test] fn lifecycle_timestamps_must_not_regress() { let running = command_task() .transition(BackgroundTaskState::Running, 120) .unwrap(); assert_eq!( running.transition(BackgroundTaskState::Waiting, 119), Err(BackgroundTaskError::InvalidTimestamps) ); assert_eq!( running.complete(success_result(119)), Err(BackgroundTaskError::InvalidTimestamps) ); } #[test] fn terminal_transition_requires_complete_with_matching_result() { let running = command_task() .transition(BackgroundTaskState::Running, 120) .unwrap(); assert_eq!( running.transition(BackgroundTaskState::Completed, 130), Err(BackgroundTaskError::MissingResult) ); let completed = running.complete(success_result(130)).unwrap(); assert_eq!(completed.state, BackgroundTaskState::Completed); assert_eq!(completed.updated_at_ms, 130); assert!(completed.is_terminal()); assert!(completed.has_pending_completion_delivery()); } #[test] fn terminal_states_are_immutable() { let completed = command_task() .transition(BackgroundTaskState::Running, 120) .unwrap() .complete(success_result(130)) .unwrap(); let err = completed .transition(BackgroundTaskState::Running, 140) .unwrap_err(); assert_eq!( err, BackgroundTaskError::InvalidTransition { from: BackgroundTaskState::Completed, to: BackgroundTaskState::Running, } ); let err = completed.complete(failure_result(140)).unwrap_err(); assert_eq!( err, BackgroundTaskError::InvalidTransition { from: BackgroundTaskState::Completed, to: BackgroundTaskState::Failed, } ); } #[test] fn result_variants_determine_terminal_state() { let running = command_task() .transition(BackgroundTaskState::Running, 120) .unwrap(); assert_eq!( running.complete(failure_result(130)).unwrap().state, BackgroundTaskState::Failed ); assert_eq!( running .complete(BackgroundTaskResult::Cancelled { finished_at_ms: 130, reason: "user".into(), }) .unwrap() .state, BackgroundTaskState::Cancelled ); assert_eq!( running .complete(BackgroundTaskResult::Expired { finished_at_ms: 130, reason: "deadline".into(), }) .unwrap() .state, BackgroundTaskState::Expired ); } #[test] fn completion_delivery_only_once_after_terminal_state() { let running = command_task() .transition(BackgroundTaskState::Running, 120) .unwrap(); assert_eq!( running.mark_completion_delivered(), Err(BackgroundTaskError::MissingResult) ); let delivered = running .complete(success_result(130)) .unwrap() .mark_completion_delivered() .unwrap(); assert!(delivered.completion_delivered); assert!(!delivered.has_pending_completion_delivery()); assert_eq!( delivered.mark_completion_delivered(), Err(BackgroundTaskError::CompletionAlreadyDelivered) ); } #[test] fn result_text_fields_are_bounded() { let summary_too_large = BackgroundTaskResult::Success { finished_at_ms: 1, exit_code: None, summary: "x".repeat(domain::BACKGROUND_TASK_TEXT_MAX_BYTES + 1), stdout_tail: None, stderr_tail: None, }; assert_eq!( summary_too_large.validate(), Err(BackgroundTaskError::FieldTooLarge { field: "summary", max: domain::BACKGROUND_TASK_TEXT_MAX_BYTES, }) ); let tail_too_large = BackgroundTaskResult::Failure { finished_at_ms: 1, exit_code: None, error: "failed".into(), stdout_tail: Some("x".repeat(domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES + 1)), stderr_tail: None, }; assert_eq!( tail_too_large.validate(), Err(BackgroundTaskError::FieldTooLarge { field: "stdout_tail", max: domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, }) ); } #[test] fn background_task_serializes_with_camel_case_fields() { let task = command_task(); let json = serde_json::to_string(&task).unwrap(); assert!(json.contains("\"ownerAgentId\""), "json was {json}"); assert!( json.contains("\"wakePolicy\":\"wakeOwner\""), "json was {json}" ); assert!(json.contains("\"completionDelivered\""), "json was {json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), task); }