ec80337389
Adds a pure, monotonic execution heartbeat and caller-supplied staleness cutoff without inventing alert thresholds. Evidence: targeted Release tests 5/5 passed; TRX SHA256 2ADBB526FAF6E5D924EB3F53C7E582E736199A59E0DA4E4FD25DCAA82A661BBC. WBS remains IN_PROGRESS pending approved alert contract.
66 lines
3.3 KiB
C#
66 lines
3.3 KiB
C#
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_GAP", "h1", now));
|
|
|
|
execution.MoveTo(ModelOperationExecutionState.Running, "STARTED", "h2", now.AddMinutes(1));
|
|
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => execution.RecordHeartbeat(now));
|
|
}
|
|
}
|