diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 48ba26ed..b3e94fc4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -9,7 +9,7 @@ icon.jpg true 9.1.1 - [9.0.0,10.0.0) + [10.0.0,11.0.0) default diff --git a/tests/TickerQ.Tests/PaginationResultTests.cs b/tests/TickerQ.Tests/PaginationResultTests.cs new file mode 100644 index 00000000..3bdba89e --- /dev/null +++ b/tests/TickerQ.Tests/PaginationResultTests.cs @@ -0,0 +1,156 @@ +using FluentAssertions; +using TickerQ.Utilities.Models; + +namespace TickerQ.Tests; + +public class PaginationResultTests +{ + [Fact] + public void DefaultConstructor_InitializesEmptyItems() + { + var result = new PaginationResult(); + + result.Items.Should().NotBeNull(); + result.Items.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + result.PageNumber.Should().Be(0); + result.PageSize.Should().Be(0); + } + + [Fact] + public void ParameterizedConstructor_SetsAllProperties() + { + var items = new[] { "a", "b", "c" }; + var result = new PaginationResult(items, totalCount: 10, pageNumber: 2, pageSize: 3); + + result.Items.Should().BeEquivalentTo(items); + result.TotalCount.Should().Be(10); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(3); + } + + [Fact] + public void ParameterizedConstructor_HandlesNullItems() + { + var result = new PaginationResult(null, totalCount: 5, pageNumber: 1, pageSize: 5); + + result.Items.Should().NotBeNull(); + result.Items.Should().BeEmpty(); + } + + [Fact] + public void TotalPages_CalculatesCorrectly() + { + var result = new PaginationResult + { + TotalCount = 25, + PageSize = 10 + }; + + result.TotalPages.Should().Be(3); // ceil(25/10) + } + + [Fact] + public void TotalPages_ReturnsOne_WhenItemsFitInOnePage() + { + var result = new PaginationResult + { + TotalCount = 5, + PageSize = 10 + }; + + result.TotalPages.Should().Be(1); + } + + [Fact] + public void TotalPages_ReturnsExact_WhenEvenlySplit() + { + var result = new PaginationResult + { + TotalCount = 20, + PageSize = 10 + }; + + result.TotalPages.Should().Be(2); + } + + [Fact] + public void HasPreviousPage_ReturnsFalse_OnFirstPage() + { + var result = new PaginationResult { PageNumber = 1 }; + + result.HasPreviousPage.Should().BeFalse(); + } + + [Fact] + public void HasPreviousPage_ReturnsTrue_OnLaterPages() + { + var result = new PaginationResult { PageNumber = 2 }; + + result.HasPreviousPage.Should().BeTrue(); + } + + [Fact] + public void HasNextPage_ReturnsTrue_WhenMorePagesExist() + { + var result = new PaginationResult + { + PageNumber = 1, + TotalCount = 20, + PageSize = 10 + }; + + result.HasNextPage.Should().BeTrue(); + } + + [Fact] + public void HasNextPage_ReturnsFalse_OnLastPage() + { + var result = new PaginationResult + { + PageNumber = 2, + TotalCount = 20, + PageSize = 10 + }; + + result.HasNextPage.Should().BeFalse(); + } + + [Fact] + public void FirstItemIndex_CalculatesCorrectly() + { + var result = new PaginationResult + { + PageNumber = 3, + PageSize = 10 + }; + + result.FirstItemIndex.Should().Be(21); // (3-1)*10 + 1 + } + + [Fact] + public void LastItemIndex_CappedByTotalCount() + { + var result = new PaginationResult + { + PageNumber = 3, + PageSize = 10, + TotalCount = 25 + }; + + result.LastItemIndex.Should().Be(25); // Min(30, 25) + } + + [Fact] + public void LastItemIndex_EqualsPageEnd_WhenFullPage() + { + var result = new PaginationResult + { + PageNumber = 2, + PageSize = 10, + TotalCount = 30 + }; + + result.LastItemIndex.Should().Be(20); + } +} diff --git a/tests/TickerQ.Tests/RestartThrottleManagerTests.cs b/tests/TickerQ.Tests/RestartThrottleManagerTests.cs new file mode 100644 index 00000000..5d3ba2ad --- /dev/null +++ b/tests/TickerQ.Tests/RestartThrottleManagerTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; + +namespace TickerQ.Tests; + +public class RestartThrottleManagerTests +{ + [Fact] + public async Task RequestRestart_TriggersCallback_AfterDebounceWindow() + { + var triggered = false; + using var manager = new RestartThrottleManager(() => triggered = true); + + manager.RequestRestart(); + + // Debounce window is 50ms, give some extra time + await Task.Delay(200); + + triggered.Should().BeTrue(); + } + + [Fact] + public async Task MultipleRequests_CoalesceIntoSingleCallback() + { + var triggerCount = 0; + using var manager = new RestartThrottleManager(() => Interlocked.Increment(ref triggerCount)); + + // Multiple rapid requests should coalesce + manager.RequestRestart(); + manager.RequestRestart(); + manager.RequestRestart(); + + await Task.Delay(200); + + triggerCount.Should().Be(1); + } + + [Fact] + public async Task RequestRestart_ResetsTimer_OnSubsequentCalls() + { + var triggerCount = 0; + using var manager = new RestartThrottleManager(() => Interlocked.Increment(ref triggerCount)); + + manager.RequestRestart(); + await Task.Delay(30); // Less than debounce window (50ms) + manager.RequestRestart(); // Should reset the timer + await Task.Delay(30); // Still less than full window from second request + + // Should not have triggered yet since timer was reset + // After full debounce from the last request it should trigger + await Task.Delay(100); + + triggerCount.Should().Be(1); + } + + [Fact] + public void Dispose_CanBeCalledSafely() + { + var manager = new RestartThrottleManager(() => { }); + var act = () => manager.Dispose(); + + act.Should().NotThrow(); + } + + [Fact] + public void Dispose_BeforeAnyRequest_DoesNotThrow() + { + var manager = new RestartThrottleManager(() => { }); + + // Timer hasn't been created yet (lazy creation) + var act = () => manager.Dispose(); + act.Should().NotThrow(); + } + + [Fact] + public void Dispose_AfterRequest_DoesNotThrow() + { + var manager = new RestartThrottleManager(() => { }); + manager.RequestRestart(); + + var act = () => manager.Dispose(); + act.Should().NotThrow(); + } +} diff --git a/tests/TickerQ.Tests/SoftSchedulerNotifyDebounceTests.cs b/tests/TickerQ.Tests/SoftSchedulerNotifyDebounceTests.cs new file mode 100644 index 00000000..1d757e5a --- /dev/null +++ b/tests/TickerQ.Tests/SoftSchedulerNotifyDebounceTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; + +namespace TickerQ.Tests; + +public class SoftSchedulerNotifyDebounceTests +{ + [Fact] + public void NotifySafely_InvokesCallback_WithLatestValue() + { + var receivedValues = new List(); + using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v)); + + debounce.NotifySafely(5); + + receivedValues.Should().Contain("5"); + } + + [Fact] + public void NotifySafely_SuppressesDuplicateValues() + { + var receivedValues = new List(); + using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v)); + + debounce.NotifySafely(3); + var countAfterFirst = receivedValues.Count; + + debounce.NotifySafely(3); + var countAfterSecond = receivedValues.Count; + + // Second call with same non-zero value should be suppressed + countAfterSecond.Should().Be(countAfterFirst); + } + + [Fact] + public void NotifySafely_AllowsDifferentValues() + { + var receivedValues = new List(); + using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v)); + + debounce.NotifySafely(1); + debounce.NotifySafely(2); + + receivedValues.Should().Contain("1"); + receivedValues.Should().Contain("2"); + } + + [Fact] + public void Flush_InvokesCallbackImmediately() + { + var receivedValues = new List(); + using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v)); + + debounce.NotifySafely(10); + receivedValues.Clear(); + + debounce.NotifySafely(20); + debounce.Flush(); + + // Flush should ensure the latest value is pushed + receivedValues.Should().NotBeEmpty(); + } + + [Fact] + public void NotifySafely_DoesNotInvoke_AfterDispose() + { + var receivedValues = new List(); + var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v)); + + debounce.Dispose(); + var countBeforeNotify = receivedValues.Count; + + debounce.NotifySafely(100); + + receivedValues.Count.Should().Be(countBeforeNotify); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + var debounce = new SoftSchedulerNotifyDebounce(_ => { }); + + var act = () => + { + debounce.Dispose(); + debounce.Dispose(); + }; + + act.Should().NotThrow(); + } + + [Fact] + public void NotifySafely_AlwaysInvokes_ForZeroValue() + { + var callCount = 0; + using var debounce = new SoftSchedulerNotifyDebounce(_ => callCount++); + + // Zero is special: it always triggers the callback + // (the code checks `latest != 0 && latest == last` for suppression) + debounce.NotifySafely(0); + debounce.NotifySafely(0); + + callCount.Should().BeGreaterOrEqualTo(2); + } + + [Fact] + public void Constructor_DoesNotInvoke_Callback() + { + var callCount = 0; + using var debounce = new SoftSchedulerNotifyDebounce(_ => callCount++); + + callCount.Should().Be(0); + } +} diff --git a/tests/TickerQ.Tests/TerminateExecutionExceptionTests.cs b/tests/TickerQ.Tests/TerminateExecutionExceptionTests.cs new file mode 100644 index 00000000..2a15dcba --- /dev/null +++ b/tests/TickerQ.Tests/TerminateExecutionExceptionTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using TickerQ.Exceptions; +using TickerQ.Utilities.Enums; + +namespace TickerQ.Tests; + +public class TerminateExecutionExceptionTests +{ + [Fact] + public void Constructor_MessageOnly_SetsSkippedStatus() + { + var ex = new TerminateExecutionException("test message"); + + ex.Message.Should().Be("test message"); + ex.Status.Should().Be(TickerStatus.Skipped); + ex.InnerException.Should().BeNull(); + } + + [Fact] + public void Constructor_WithStatus_SetsCustomStatus() + { + var ex = new TerminateExecutionException(TickerStatus.Cancelled, "cancelled"); + + ex.Message.Should().Be("cancelled"); + ex.Status.Should().Be(TickerStatus.Cancelled); + ex.InnerException.Should().BeNull(); + } + + [Fact] + public void Constructor_WithInnerException_PreservesInner() + { + var inner = new InvalidOperationException("inner"); + var ex = new TerminateExecutionException("outer", inner); + + ex.Message.Should().Be("outer"); + ex.InnerException.Should().BeSameAs(inner); + ex.Status.Should().Be(TickerStatus.Skipped); + } + + [Fact] + public void Constructor_WithStatusAndInnerException_SetsAll() + { + var inner = new InvalidOperationException("inner"); + var ex = new TerminateExecutionException(TickerStatus.Failed, "failed", inner); + + ex.Message.Should().Be("failed"); + ex.Status.Should().Be(TickerStatus.Failed); + ex.InnerException.Should().BeSameAs(inner); + } + + [Fact] + public void IsException_InheritsFromException() + { + var ex = new TerminateExecutionException("test"); + + ex.Should().BeAssignableTo(); + } + + [Theory] + [InlineData(TickerStatus.Done)] + [InlineData(TickerStatus.Failed)] + [InlineData(TickerStatus.Cancelled)] + [InlineData(TickerStatus.InProgress)] + [InlineData(TickerStatus.Idle)] + public void Constructor_WithStatus_SupportsAllStatusValues(TickerStatus status) + { + var ex = new TerminateExecutionException(status, "msg"); + ex.Status.Should().Be(status); + } +} diff --git a/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs b/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs new file mode 100644 index 00000000..ebc2444d --- /dev/null +++ b/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs @@ -0,0 +1,572 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using TickerQ.Exceptions; +using TickerQ.Utilities; +using TickerQ.Utilities.Enums; +using TickerQ.Utilities.Instrumentation; +using TickerQ.Utilities.Interfaces; +using TickerQ.Utilities.Interfaces.Managers; +using TickerQ.Utilities.Models; + +namespace TickerQ.Tests; + +public class TickerExecutionTaskHandlerTests +{ + private readonly ITickerClock _clock; + private readonly IInternalTickerManager _internalManager; + private readonly ITickerQInstrumentation _instrumentation; + private readonly ServiceProvider _serviceProvider; + private readonly TickerExecutionTaskHandler _handler; + + public TickerExecutionTaskHandlerTests() + { + _clock = Substitute.For(); + _clock.UtcNow.Returns(DateTime.UtcNow); + _internalManager = Substitute.For(); + _instrumentation = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(_internalManager); + services.AddSingleton(_instrumentation); + _serviceProvider = services.BuildServiceProvider(); + + _handler = new TickerExecutionTaskHandler(_serviceProvider, _clock, _instrumentation, _internalManager); + } + + #region Success Path + + [Fact] + public async Task ExecuteTaskAsync_SetsStatusDone_WhenSucceeds_NotDue() + { + var context = CreateContext(ct: (_, _, _) => Task.CompletedTask); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.Status.Should().Be(TickerStatus.Done); + } + + [Fact] + public async Task ExecuteTaskAsync_SetsStatusDueDone_WhenSucceeds_IsDue() + { + var context = CreateContext(ct: (_, _, _) => Task.CompletedTask); + + await _handler.ExecuteTaskAsync(context, isDue: true); + + context.Status.Should().Be(TickerStatus.DueDone); + } + + [Fact] + public async Task ExecuteTaskAsync_CallsUpdateTickerAsync_OnSuccess() + { + var context = CreateContext(ct: (_, _, _) => Task.CompletedTask); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + await _internalManager.Received(1).UpdateTickerAsync( + Arg.Is(c => c.TickerId == context.TickerId), + Arg.Any()); + } + + [Fact] + public async Task ExecuteTaskAsync_SetsElapsedTime_OnSuccess() + { + var context = CreateContext(ct: async (_, _, _) => await Task.Delay(10)); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.ElapsedTime.Should().BeGreaterOrEqualTo(0); + } + + [Fact] + public async Task ExecuteTaskAsync_SetsExecutedAt_OnSuccess() + { + var now = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc); + _clock.UtcNow.Returns(now); + var context = CreateContext(ct: (_, _, _) => Task.CompletedTask); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.ExecutedAt.Should().Be(now); + } + + #endregion + + #region Failure Path + + [Fact] + public async Task ExecuteTaskAsync_SetsStatusFailed_WhenDelegateThrows() + { + var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("boom")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.Status.Should().Be(TickerStatus.Failed); + } + + [Fact] + public async Task ExecuteTaskAsync_RecordsExceptionDetails_WhenDelegateThrows() + { + var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("boom")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.ExceptionDetails.Should().NotBeNullOrWhiteSpace(); + context.ExceptionDetails.Should().Contain("boom"); + } + + [Fact] + public async Task ExecuteTaskAsync_CallsExceptionHandler_WhenRegistered_AndDelegateThrows() + { + var exceptionHandler = Substitute.For(); + var services = new ServiceCollection(); + services.AddSingleton(_internalManager); + services.AddSingleton(_instrumentation); + services.AddSingleton(exceptionHandler); + var sp = services.BuildServiceProvider(); + var handler = new TickerExecutionTaskHandler(sp, _clock, _instrumentation, _internalManager); + + var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("boom")); + + await handler.ExecuteTaskAsync(context, isDue: false); + + await exceptionHandler.Received(1).HandleExceptionAsync( + Arg.Any(), + Arg.Is(context.TickerId), + Arg.Is(context.Type)); + } + + #endregion + + #region Cancellation Path + + [Fact] + public async Task ExecuteTaskAsync_SetsStatusCancelled_WhenTaskCancelledExceptionThrown() + { + var context = CreateContext(ct: (_, _, _) => throw new TaskCanceledException("cancelled")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.Status.Should().Be(TickerStatus.Cancelled); + } + + [Fact] + public async Task ExecuteTaskAsync_CallsCancelledExceptionHandler_WhenRegistered() + { + var exceptionHandler = Substitute.For(); + var services = new ServiceCollection(); + services.AddSingleton(_internalManager); + services.AddSingleton(_instrumentation); + services.AddSingleton(exceptionHandler); + var sp = services.BuildServiceProvider(); + var handler = new TickerExecutionTaskHandler(sp, _clock, _instrumentation, _internalManager); + + var context = CreateContext(ct: (_, _, _) => throw new TaskCanceledException("cancelled")); + + await handler.ExecuteTaskAsync(context, isDue: false); + + await exceptionHandler.Received(1).HandleCanceledExceptionAsync( + Arg.Any(), + Arg.Is(context.TickerId), + Arg.Is(context.Type)); + } + + #endregion + + #region TerminateExecutionException Path + + [Fact] + public async Task ExecuteTaskAsync_SetsStatusSkipped_WhenTerminateExecutionExceptionThrown() + { + var context = CreateContext(ct: (_, _, _) => throw new TerminateExecutionException("skip me")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.Status.Should().Be(TickerStatus.Skipped); + } + + [Fact] + public async Task ExecuteTaskAsync_SetsCustomStatus_WhenTerminateExecutionWithStatusThrown() + { + var context = CreateContext(ct: (_, _, _) => + throw new TerminateExecutionException(TickerStatus.Cancelled, "terminate as cancelled")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.Status.Should().Be(TickerStatus.Cancelled); + } + + [Fact] + public async Task ExecuteTaskAsync_RecordsExceptionMessage_WhenTerminateExecutionThrown() + { + var context = CreateContext(ct: (_, _, _) => + throw new TerminateExecutionException("skip reason")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.ExceptionDetails.Should().Contain("skip reason"); + } + + [Fact] + public async Task ExecuteTaskAsync_RecordsInnerExceptionMessage_WhenTerminateExecutionHasInner() + { + var inner = new InvalidOperationException("inner reason"); + var context = CreateContext(ct: (_, _, _) => + throw new TerminateExecutionException("outer", inner)); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.ExceptionDetails.Should().Contain("inner reason"); + } + + #endregion + + #region Null CachedDelegate + + [Fact] + public async Task ExecuteTaskAsync_SetsStatusFailed_WhenCachedDelegateIsNull() + { + var context = CreateContext(ct: null); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + context.Status.Should().Be(TickerStatus.Failed); + context.ExceptionDetails.Should().Contain("was not found"); + } + + #endregion + + #region Parent-Child Execution (TimeTicker) + + [Fact] + public async Task ExecuteTaskAsync_RunsInProgressChildren_ConcurrentlyWithParent() + { + var childExecuted = false; + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => + { + childExecuted = true; + return Task.CompletedTask; + }); + childContext.RunCondition = RunCondition.InProgress; + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + childExecuted.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteTaskAsync_RunsOnSuccessChild_AfterParentSucceeds() + { + var childExecuted = false; + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => + { + childExecuted = true; + return Task.CompletedTask; + }); + childContext.RunCondition = RunCondition.OnSuccess; + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + childExecuted.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteTaskAsync_SkipsOnSuccessChild_WhenParentFails() + { + var childExecuted = false; + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => throw new InvalidOperationException("parent fail")); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => + { + childExecuted = true; + return Task.CompletedTask; + }); + childContext.RunCondition = RunCondition.OnSuccess; + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + childExecuted.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteTaskAsync_RunsOnFailureChild_WhenParentFails() + { + var childExecuted = false; + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => throw new InvalidOperationException("parent fail")); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => + { + childExecuted = true; + return Task.CompletedTask; + }); + childContext.RunCondition = RunCondition.OnFailure; + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + childExecuted.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteTaskAsync_SkipsOnFailureChild_WhenParentSucceeds() + { + var childExecuted = false; + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => + { + childExecuted = true; + return Task.CompletedTask; + }); + childContext.RunCondition = RunCondition.OnFailure; + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + childExecuted.Should().BeFalse(); + } + + [Fact] + public async Task ExecuteTaskAsync_RunsOnAnyCompletedStatus_RegardlessOfParentOutcome() + { + var childExecutedOnSuccess = false; + var childExecutedOnFail = false; + + // Test with parent success + var parentSuccess = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + var childSuccess = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => { childExecutedOnSuccess = true; return Task.CompletedTask; }); + childSuccess.RunCondition = RunCondition.OnAnyCompletedStatus; + parentSuccess.TimeTickerChildren.Add(childSuccess); + await _handler.ExecuteTaskAsync(parentSuccess, isDue: false); + + // Test with parent failure + var parentFail = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => throw new InvalidOperationException("fail")); + var childFail = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => { childExecutedOnFail = true; return Task.CompletedTask; }); + childFail.RunCondition = RunCondition.OnAnyCompletedStatus; + parentFail.TimeTickerChildren.Add(childFail); + await _handler.ExecuteTaskAsync(parentFail, isDue: false); + + childExecutedOnSuccess.Should().BeTrue(); + childExecutedOnFail.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteTaskAsync_RunsOnFailureOrCancelled_WhenParentFails() + { + var childExecuted = false; + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => throw new InvalidOperationException("fail")); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => { childExecuted = true; return Task.CompletedTask; }); + childContext.RunCondition = RunCondition.OnFailureOrCancelled; + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + childExecuted.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteTaskAsync_BulkSkipsDescendants_WhenChildConditionNotMet() + { + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + + var childContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + childContext.RunCondition = RunCondition.OnFailure; + + var grandChild = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + grandChild.RunCondition = RunCondition.OnSuccess; + childContext.TimeTickerChildren.Add(grandChild); + + parentContext.TimeTickerChildren.Add(childContext); + + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + + // The skipped children should be bulk-updated + await _internalManager.Received().UpdateSkipTimeTickersWithUnifiedContextAsync( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteTaskAsync_ChildWithNullDelegate_IsSkipped() + { + var parentContext = CreateContext( + type: TickerType.TimeTicker, + ct: (_, _, _) => Task.CompletedTask); + + var nullDelegateChild = new InternalFunctionContext + { + TickerId = Guid.NewGuid(), + FunctionName = "NullChild", + Type = TickerType.TimeTicker, + CachedDelegate = null, + RunCondition = RunCondition.OnSuccess, + TimeTickerChildren = [] + }; + parentContext.TimeTickerChildren.Add(nullDelegateChild); + + // Should not throw + await _handler.ExecuteTaskAsync(parentContext, isDue: false); + } + + #endregion + + #region CronTickerOccurrence - direct path + + [Fact] + public async Task ExecuteTaskAsync_CronTicker_GoesDirectlyToRunContextFunction() + { + var executed = false; + var context = CreateContext( + type: TickerType.CronTickerOccurrence, + ct: (_, _, _) => { executed = true; return Task.CompletedTask; }); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + executed.Should().BeTrue(); + context.Status.Should().Be(TickerStatus.Done); + } + + #endregion + + #region Instrumentation + + [Fact] + public async Task ExecuteTaskAsync_LogsJobEnqueued_OnExecution() + { + var context = CreateContext(ct: (_, _, _) => Task.CompletedTask); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + _instrumentation.Received().LogJobEnqueued( + Arg.Any(), + Arg.Is(context.FunctionName), + Arg.Is(context.TickerId), + Arg.Any()); + } + + [Fact] + public async Task ExecuteTaskAsync_LogsJobCompleted_OnSuccess() + { + var context = CreateContext(ct: (_, _, _) => Task.CompletedTask); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + _instrumentation.Received().LogJobCompleted( + Arg.Is(context.TickerId), + Arg.Is(context.FunctionName), + Arg.Any(), + Arg.Is(true)); + } + + [Fact] + public async Task ExecuteTaskAsync_LogsJobFailed_OnFailure() + { + var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("fail")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + _instrumentation.Received().LogJobFailed( + Arg.Is(context.TickerId), + Arg.Is(context.FunctionName), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteTaskAsync_LogsJobCancelled_OnCancellation() + { + var context = CreateContext(ct: (_, _, _) => throw new TaskCanceledException()); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + _instrumentation.Received().LogJobCancelled( + Arg.Is(context.TickerId), + Arg.Is(context.FunctionName), + Arg.Any()); + } + + [Fact] + public async Task ExecuteTaskAsync_LogsJobSkipped_OnTerminateExecution() + { + var context = CreateContext(ct: (_, _, _) => + throw new TerminateExecutionException("skipped reason")); + + await _handler.ExecuteTaskAsync(context, isDue: false); + + _instrumentation.Received().LogJobSkipped( + Arg.Is(context.TickerId), + Arg.Is(context.FunctionName), + Arg.Any()); + } + + #endregion + + #region Helpers + + private static InternalFunctionContext CreateContext( + TickerFunctionDelegate? ct = null, + TickerType type = TickerType.CronTickerOccurrence) + { + return new InternalFunctionContext + { + TickerId = Guid.NewGuid(), + FunctionName = "TestFunction", + Type = type, + ExecutionTime = DateTime.UtcNow, + RetryIntervals = [], + Retries = 0, + RetryCount = 0, + Status = TickerStatus.Idle, + CachedDelegate = ct, + TimeTickerChildren = [] + }; + } + + #endregion +} diff --git a/tests/TickerQ.Tests/TickerHelperTests.cs b/tests/TickerQ.Tests/TickerHelperTests.cs new file mode 100644 index 00000000..a6e06aee --- /dev/null +++ b/tests/TickerQ.Tests/TickerHelperTests.cs @@ -0,0 +1,238 @@ +using System.Text; +using System.Text.Json; +using FluentAssertions; +using TickerQ.Utilities; + +namespace TickerQ.Tests; + +public class TickerHelperTests : IDisposable +{ + // Store original state so we can restore after each test + private readonly bool _originalGZipEnabled; + private readonly JsonSerializerOptions _originalOptions; + + public TickerHelperTests() + { + _originalGZipEnabled = TickerHelper.UseGZipCompression; + _originalOptions = TickerHelper.RequestJsonSerializerOptions; + } + + public void Dispose() + { + TickerHelper.UseGZipCompression = _originalGZipEnabled; + TickerHelper.RequestJsonSerializerOptions = _originalOptions; + } + + #region CreateTickerRequest - No Compression + + [Fact] + public void CreateTickerRequest_WithoutCompression_SerializesObject() + { + TickerHelper.UseGZipCompression = false; + var data = new TestPayload { Name = "test", Value = 42 }; + + var bytes = TickerHelper.CreateTickerRequest(data); + + var json = Encoding.UTF8.GetString(bytes); + json.Should().Contain("test"); + json.Should().Contain("42"); + } + + [Fact] + public void CreateTickerRequest_WithoutCompression_ByteArray_ReturnsAsIs() + { + TickerHelper.UseGZipCompression = false; + var original = new byte[] { 1, 2, 3, 4, 5 }; + + var result = TickerHelper.CreateTickerRequest(original); + + result.Should().BeEquivalentTo(original); + } + + [Fact] + public void CreateTickerRequest_WithoutCompression_String_SerializesToJson() + { + TickerHelper.UseGZipCompression = false; + + var bytes = TickerHelper.CreateTickerRequest("hello"); + + var result = Encoding.UTF8.GetString(bytes); + result.Should().Contain("hello"); + } + + #endregion + + #region CreateTickerRequest - With GZip Compression + + [Fact] + public void CreateTickerRequest_WithCompression_ProducesGZipBytes() + { + TickerHelper.UseGZipCompression = true; + var data = new TestPayload { Name = "compressed", Value = 99 }; + + var bytes = TickerHelper.CreateTickerRequest(data); + + // GZip signature is appended at end: [0x1f, 0x8b, 0x08, 0x00] + bytes.Length.Should().BeGreaterThan(4); + bytes[^4].Should().Be(0x1f); + bytes[^3].Should().Be(0x8b); + bytes[^2].Should().Be(0x08); + bytes[^1].Should().Be(0x00); + } + + [Fact] + public void CreateTickerRequest_WithCompression_AlreadyCompressed_ReturnsSameBytes() + { + TickerHelper.UseGZipCompression = true; + // Create compressed data first + var original = TickerHelper.CreateTickerRequest(new TestPayload { Name = "test", Value = 1 }); + + // Pass the already-compressed bytes back in + var result = TickerHelper.CreateTickerRequest(original); + + result.Should().BeEquivalentTo(original); + } + + #endregion + + #region ReadTickerRequest - No Compression + + [Fact] + public void ReadTickerRequest_WithoutCompression_DeserializesObject() + { + TickerHelper.UseGZipCompression = false; + var data = new TestPayload { Name = "read_test", Value = 7 }; + var bytes = TickerHelper.CreateTickerRequest(data); + + var result = TickerHelper.ReadTickerRequest(bytes); + + result.Name.Should().Be("read_test"); + result.Value.Should().Be(7); + } + + [Fact] + public void ReadTickerRequestAsString_WithoutCompression_ReturnsJsonString() + { + TickerHelper.UseGZipCompression = false; + var json = """{"Name":"hello","Value":42}"""; + var bytes = Encoding.UTF8.GetBytes(json); + + var result = TickerHelper.ReadTickerRequestAsString(bytes); + + result.Should().Be(json); + } + + #endregion + + #region ReadTickerRequest - With GZip Compression + + [Fact] + public void ReadTickerRequest_WithCompression_DeserializesCompressedObject() + { + TickerHelper.UseGZipCompression = true; + var data = new TestPayload { Name = "gzip_test", Value = 100 }; + var bytes = TickerHelper.CreateTickerRequest(data); + + var result = TickerHelper.ReadTickerRequest(bytes); + + result.Name.Should().Be("gzip_test"); + result.Value.Should().Be(100); + } + + [Fact] + public void ReadTickerRequestAsString_WithCompression_ThrowsForNonGzipBytes() + { + TickerHelper.UseGZipCompression = true; + var plainBytes = Encoding.UTF8.GetBytes("not compressed"); + + var act = () => TickerHelper.ReadTickerRequestAsString(plainBytes); + + act.Should().Throw().WithMessage("*not GZip compressed*"); + } + + #endregion + + #region Round-Trip Tests + + [Fact] + public void RoundTrip_WithoutCompression_PreservesData() + { + TickerHelper.UseGZipCompression = false; + var original = new TestPayload { Name = "roundtrip", Value = 123 }; + + var bytes = TickerHelper.CreateTickerRequest(original); + var result = TickerHelper.ReadTickerRequest(bytes); + + result.Name.Should().Be(original.Name); + result.Value.Should().Be(original.Value); + } + + [Fact] + public void RoundTrip_WithCompression_PreservesData() + { + TickerHelper.UseGZipCompression = true; + var original = new TestPayload { Name = "gzip_roundtrip", Value = 456 }; + + var bytes = TickerHelper.CreateTickerRequest(original); + var result = TickerHelper.ReadTickerRequest(bytes); + + result.Name.Should().Be(original.Name); + result.Value.Should().Be(original.Value); + } + + [Fact] + public void RoundTrip_WithCustomJsonOptions_UsesConfiguredOptions() + { + TickerHelper.UseGZipCompression = false; + TickerHelper.RequestJsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var original = new TestPayload { Name = "camel", Value = 789 }; + var bytes = TickerHelper.CreateTickerRequest(original); + var json = Encoding.UTF8.GetString(bytes); + + json.Should().Contain("\"name\""); + json.Should().Contain("\"value\""); + } + + [Fact] + public void RoundTrip_ComplexObject_PreservesStructure() + { + TickerHelper.UseGZipCompression = false; + var original = new ComplexPayload + { + Id = Guid.NewGuid(), + Items = ["a", "b", "c"], + Nested = new TestPayload { Name = "nested", Value = 10 } + }; + + var bytes = TickerHelper.CreateTickerRequest(original); + var result = TickerHelper.ReadTickerRequest(bytes); + + result.Id.Should().Be(original.Id); + result.Items.Should().BeEquivalentTo(original.Items); + result.Nested.Name.Should().Be("nested"); + result.Nested.Value.Should().Be(10); + } + + #endregion + + #region Test Models + + private class TestPayload + { + public string Name { get; set; } = string.Empty; + public int Value { get; set; } + } + + private class ComplexPayload + { + public Guid Id { get; set; } + public List Items { get; set; } = []; + public TestPayload Nested { get; set; } = new(); + } + + #endregion +} diff --git a/tests/TickerQ.Tests/TickerQDispatcherTests.cs b/tests/TickerQ.Tests/TickerQDispatcherTests.cs new file mode 100644 index 00000000..12a562d8 --- /dev/null +++ b/tests/TickerQ.Tests/TickerQDispatcherTests.cs @@ -0,0 +1,116 @@ +using FluentAssertions; +using NSubstitute; +using TickerQ.Dispatcher; +using TickerQ.Utilities.Enums; +using TickerQ.Utilities.Interfaces; +using TickerQ.Utilities.Models; + +namespace TickerQ.Tests; + +public class TickerQDispatcherTests +{ + private readonly ITickerQTaskScheduler _taskScheduler; + private readonly ITickerExecutionTaskHandler _taskHandler; + private readonly TickerQDispatcher _dispatcher; + + public TickerQDispatcherTests() + { + _taskScheduler = Substitute.For(); + _taskHandler = Substitute.For(); + _dispatcher = new TickerQDispatcher(_taskScheduler, _taskHandler); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenTaskSchedulerIsNull() + { + var act = () => new TickerQDispatcher(null!, _taskHandler); + + act.Should().Throw() + .And.ParamName.Should().Be("taskScheduler"); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenTaskHandlerIsNull() + { + var act = () => new TickerQDispatcher(_taskScheduler, null!); + + act.Should().Throw() + .And.ParamName.Should().Be("taskHandler"); + } + + [Fact] + public void IsEnabled_ReturnsTrue() + { + _dispatcher.IsEnabled.Should().BeTrue(); + } + + [Fact] + public async Task DispatchAsync_DoesNothing_WhenContextsIsNull() + { + await _dispatcher.DispatchAsync(null!); + + await _taskScheduler.DidNotReceive().QueueAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task DispatchAsync_DoesNothing_WhenContextsIsEmpty() + { + await _dispatcher.DispatchAsync([]); + + await _taskScheduler.DidNotReceive().QueueAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task DispatchAsync_QueuesEachContext_InTaskScheduler() + { + var contexts = new[] + { + new InternalFunctionContext + { + TickerId = Guid.NewGuid(), + FunctionName = "Func1", + CachedPriority = TickerTaskPriority.Normal, + TimeTickerChildren = [] + }, + new InternalFunctionContext + { + TickerId = Guid.NewGuid(), + FunctionName = "Func2", + CachedPriority = TickerTaskPriority.High, + TimeTickerChildren = [] + } + }; + + await _dispatcher.DispatchAsync(contexts); + + await _taskScheduler.Received(2).QueueAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task DispatchAsync_UsesContextPriority_WhenQueuing() + { + var context = new InternalFunctionContext + { + TickerId = Guid.NewGuid(), + FunctionName = "Func1", + CachedPriority = TickerTaskPriority.LongRunning, + TimeTickerChildren = [] + }; + + await _dispatcher.DispatchAsync([context]); + + await _taskScheduler.Received(1).QueueAsync( + Arg.Any>(), + Arg.Is(TickerTaskPriority.LongRunning), + Arg.Any()); + } +} diff --git a/tests/TickerQ.Tests/TickerResultTests.cs b/tests/TickerQ.Tests/TickerResultTests.cs new file mode 100644 index 00000000..2897b2e8 --- /dev/null +++ b/tests/TickerQ.Tests/TickerResultTests.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using TickerQ.Utilities.Models; + +namespace TickerQ.Tests; + +public class TickerResultTests +{ + // TickerResult has internal constructors, so we test via internal access (InternalsVisibleTo) + // The test project references TickerQ.Utilities, which has InternalsVisibleTo for the test assembly. + // If not accessible, we can use reflection. + + [Fact] + public void Constructor_WithResult_SetsIsSucceeded() + { + var result = CreateSuccessResult("value"); + + result.IsSucceeded.Should().BeTrue(); + result.Result.Should().Be("value"); + result.Exception.Should().BeNull(); + } + + [Fact] + public void Constructor_WithException_SetsIsSucceededFalse() + { + var ex = new InvalidOperationException("fail"); + var result = CreateFailureResult(ex); + + result.IsSucceeded.Should().BeFalse(); + result.Exception.Should().BeSameAs(ex); + result.Result.Should().BeNull(); + } + + [Fact] + public void Constructor_WithAffectedRows_SetsIsSucceeded() + { + var result = CreateAffectedRowsResult(5); + + result.IsSucceeded.Should().BeTrue(); + result.AffectedRows.Should().Be(5); + } + + [Fact] + public void Constructor_WithResultAndAffectedRows_SetsBoth() + { + var result = CreateResultWithRows("value", 3); + + result.IsSucceeded.Should().BeTrue(); + result.Result.Should().Be("value"); + result.AffectedRows.Should().Be(3); + } + + // Use reflection to create instances since constructors are internal + private static TickerResult CreateSuccessResult(string value) + { + var ctor = typeof(TickerResult) + .GetConstructor( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + [typeof(string)], + null); + return (TickerResult)ctor!.Invoke([value]); + } + + private static TickerResult CreateFailureResult(Exception exception) + { + var ctor = typeof(TickerResult) + .GetConstructor( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + [typeof(Exception)], + null); + return (TickerResult)ctor!.Invoke([exception]); + } + + private static TickerResult CreateAffectedRowsResult(int rows) + { + var ctor = typeof(TickerResult) + .GetConstructor( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + [typeof(int)], + null); + return (TickerResult)ctor!.Invoke([rows]); + } + + private static TickerResult CreateResultWithRows(string value, int rows) + { + var ctor = typeof(TickerResult) + .GetConstructor( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, + [typeof(string), typeof(int)], + null); + return (TickerResult)ctor!.Invoke([value, rows]); + } +} diff --git a/tests/TickerQ.Tests/WorkItemTests.cs b/tests/TickerQ.Tests/WorkItemTests.cs new file mode 100644 index 00000000..b38276c5 --- /dev/null +++ b/tests/TickerQ.Tests/WorkItemTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using TickerQ.TickerQThreadPool; + +namespace TickerQ.Tests; + +public class WorkItemTests +{ + [Fact] + public void Constructor_SetsWorkAndToken() + { + using var cts = new CancellationTokenSource(); + Func work = _ => Task.CompletedTask; + + var item = new WorkItem(work, cts.Token); + + item.Work.Should().BeSameAs(work); + item.UserToken.Should().Be(cts.Token); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenWorkIsNull() + { + var act = () => new WorkItem(null!, CancellationToken.None); + + act.Should().Throw() + .And.ParamName.Should().Be("work"); + } + + [Fact] + public void Constructor_AcceptsCancellationTokenNone() + { + var item = new WorkItem(_ => Task.CompletedTask, CancellationToken.None); + + item.UserToken.Should().Be(CancellationToken.None); + } + + [Fact] + public void Constructor_AcceptsCancelledToken() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var item = new WorkItem(_ => Task.CompletedTask, cts.Token); + + item.UserToken.IsCancellationRequested.Should().BeTrue(); + } + + [Fact] + public async Task Work_CanBeInvoked() + { + var executed = false; + var item = new WorkItem(_ => + { + executed = true; + return Task.CompletedTask; + }, CancellationToken.None); + + await item.Work(CancellationToken.None); + + executed.Should().BeTrue(); + } +}