using KArtSell.Modules.ModelOperations.Domain; namespace KArtSell.ModelOperations.UnitTests; public sealed class ModelOperationExecutionTests { [Fact] public void Business_hold_can_resume_but_success_is_terminal() { var now = DateTimeOffset.UtcNow; var execution = new ModelOperationExecution(Guid.NewGuid(), "J32:KR:1", now); execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_NOT_READY", "h1", now, now.AddMinutes(5)); execution.MoveTo(ModelOperationExecutionState.Running, "DATA_READY", "h2", now.AddMinutes(1)); execution.MoveTo(ModelOperationExecutionState.Succeeded, "OUTPUT_FROZEN", "h3", now.AddMinutes(2)); Assert.Throws(() => execution.MoveTo(ModelOperationExecutionState.Running, "REOPEN", "h4", now.AddMinutes(3))); } [Fact] public void Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one() { var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero); var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now); Assert.Throws(() => execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_GAP", "h1", now)); execution.MoveTo(ModelOperationExecutionState.Running, "STARTED", "h2", now.AddMinutes(1)); Assert.Throws(() => execution.MoveTo(ModelOperationExecutionState.Failed, "NETWORK_TIMEOUT", "h3", now.AddMinutes(2), now.AddMinutes(3))); } [Fact] public void Business_hold_persists_reason_and_expiry_until_an_explicit_resume() { var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero); var holdUntil = now.AddHours(1); var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now); var transition = execution.MoveTo(ModelOperationExecutionState.BusinessHold, "CALENDAR_NOT_APPROVED", "h1", now.AddMinutes(1), holdUntil); Assert.Equal("CALENDAR_NOT_APPROVED", transition.ReasonCode); Assert.Equal(holdUntil, transition.HoldUntil); Assert.Equal(holdUntil, execution.HoldUntil); execution.MoveTo(ModelOperationExecutionState.Running, "CALENDAR_APPROVED", "h2", holdUntil); Assert.Null(execution.HoldUntil); } [Fact] public void Running_execution_records_monotonic_heartbeats_and_uses_the_caller_cutoff() { var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero); var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now); execution.MoveTo(ModelOperationExecutionState.Running, "STARTED", "h1", now); execution.RecordHeartbeat(now.AddMinutes(2)); Assert.False(execution.HasExceededHeartbeatCutoff(now.AddMinutes(6), TimeSpan.FromMinutes(5))); Assert.True(execution.HasExceededHeartbeatCutoff(now.AddMinutes(8), TimeSpan.FromMinutes(5))); Assert.Throws(() => execution.RecordHeartbeat(now.AddMinutes(1))); } [Fact] public void Non_running_execution_cannot_record_a_heartbeat() { var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero); var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now); Assert.Throws(() => execution.RecordHeartbeat(now)); } }