using StellaOps.Scheduler.Models; namespace StellaOps.Scheduler.Models.Tests; public sealed class RunStateMachineTests { [Fact] public void EnsureTransition_FromQueuedToRunningSetsStartedAt() { var run = new Run( id: "run-queued", tenantId: "tenant-alpha", trigger: RunTrigger.Manual, state: RunState.Queued, stats: RunStats.Empty, createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z")); var transitionTime = DateTimeOffset.Parse("2025-10-18T03:05:00Z"); var updated = RunStateMachine.EnsureTransition( run, RunState.Running, transitionTime, mutateStats: builder => builder.SetQueued(1)); Assert.Equal(RunState.Running, updated.State); Assert.Equal(transitionTime.ToUniversalTime(), updated.StartedAt); Assert.Equal(1, updated.Stats.Queued); Assert.Null(updated.Error); } [Fact] public void EnsureTransition_ToCompletedPopulatesFinishedAt() { var run = new Run( id: "run-running", tenantId: "tenant-alpha", trigger: RunTrigger.Manual, state: RunState.Running, stats: RunStats.Empty, createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"), startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z")); var completedAt = DateTimeOffset.Parse("2025-10-18T03:10:00Z"); var updated = RunStateMachine.EnsureTransition( run, RunState.Completed, completedAt, mutateStats: builder => { builder.SetQueued(1); builder.SetCompleted(1); }); Assert.Equal(RunState.Completed, updated.State); Assert.Equal(completedAt.ToUniversalTime(), updated.FinishedAt); Assert.Equal(1, updated.Stats.Completed); } [Fact] public void EnsureTransition_ErrorRequiresMessage() { var run = new Run( id: "run-running", tenantId: "tenant-alpha", trigger: RunTrigger.Manual, state: RunState.Running, stats: RunStats.Empty, createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"), startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z")); var timestamp = DateTimeOffset.Parse("2025-10-18T03:06:00Z"); var ex = Assert.Throws( () => RunStateMachine.EnsureTransition(run, RunState.Error, timestamp)); Assert.Contains("requires a non-empty error message", ex.Message, StringComparison.Ordinal); } [Fact] public void Validate_ThrowsWhenTerminalWithoutFinishedAt() { var run = new Run( id: "run-bad", tenantId: "tenant-alpha", trigger: RunTrigger.Manual, state: RunState.Completed, stats: RunStats.Empty, createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"), startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z")); Assert.Throws(() => RunStateMachine.Validate(run)); } [Fact] public void RunReasonExtension_NormalizesImpactWindow() { var reason = new RunReason(manualReason: "delta"); var from = DateTimeOffset.Parse("2025-10-18T01:00:00+02:00"); var to = DateTimeOffset.Parse("2025-10-18T03:30:00+02:00"); var updated = reason.WithImpactWindow(from, to); Assert.Equal(from.ToUniversalTime().ToString("O"), updated.ImpactWindowFrom); Assert.Equal(to.ToUniversalTime().ToString("O"), updated.ImpactWindowTo); } }