diff --git a/README.md b/README.md index 4f1eac3..c91af20 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![NuGet Version](https://img.shields.io/nuget/v/Trax.Scheduler)](https://www.nuget.org/packages/Trax.Scheduler/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![codecov](https://codecov.io/gh/TraxSharp/Trax.Scheduler/branch/main/graph/badge.svg)](https://codecov.io/gh/TraxSharp/Trax.Scheduler) Timetable management for [Trax](https://www.nuget.org/packages/Trax.Effect/) trains — recurring schedules, automatic retries, dead-letter handling, and dependent departures. diff --git a/tests/Trax.Scheduler.Tests.Integration/Fixtures/SchedulerE2EFixture.cs b/tests/Trax.Scheduler.Tests.Integration/Fixtures/SchedulerE2EFixture.cs new file mode 100644 index 0000000..880bb13 --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/Fixtures/SchedulerE2EFixture.cs @@ -0,0 +1,217 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Effect.Data.InMemory.Extensions; +using Trax.Effect.Data.Postgres.Extensions; +using Trax.Effect.Data.Services.DataContext; +using Trax.Effect.Data.Services.IDataContextFactory; +using Trax.Effect.Extensions; +using Trax.Effect.Provider.Json.Extensions; +using Trax.Effect.Provider.Parameter.Extensions; +using Trax.Mediator.Extensions; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Extensions; +using Trax.Scheduler.Services.TraxScheduler; +using Trax.Scheduler.Trains.DeadLetterCleanup; +using Trax.Scheduler.Trains.JobDispatcher; +using Trax.Scheduler.Trains.JobRunner; +using Trax.Scheduler.Trains.ManifestManager; + +namespace Trax.Scheduler.Tests.Integration.Fixtures; + +/// +/// Per-test scaffold that boots a full Trax scheduler with Postgres + the test-train assembly, +/// lets the test customise the SchedulerConfigurationBuilder, materialises the queued +/// PendingManifests via SchedulerStartupService's seeding path, and exposes the orchestration +/// trains (ManifestManager, JobDispatcher) so tests can drive the polling cycle directly. +/// +/// +/// Distinct from , which uses a single shared ServiceProvider +/// with a fixed scheduler config (no custom Schedule / Include / ScheduleMany). The E2E +/// fixture builds a fresh provider per test so each test can declare its own scheduling +/// graph and assert on the resulting Manifest / WorkQueue / Metadata rows. +/// +public sealed class SchedulerE2EFixture : IAsyncDisposable +{ + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + + public IDataContext DataContext { get; } + public ITraxScheduler Scheduler { get; } + public SchedulerConfiguration Configuration { get; } + + private SchedulerE2EFixture( + ServiceProvider provider, + IServiceScope scope, + IDataContext dataContext, + ITraxScheduler scheduler, + SchedulerConfiguration configuration + ) + { + _provider = provider; + _scope = scope; + DataContext = dataContext; + Scheduler = scheduler; + Configuration = configuration; + } + + /// + /// Builds a fresh ServiceProvider, applies , cleans + /// the database, and returns a fixture wired up for the test. Disposing the fixture tears + /// down the provider. + /// + public static async Task CreateAsync( + Action configureScheduler + ) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + var connectionString = configuration.GetRequiredSection("Configuration")[ + "DatabaseConnectionString" + ]!; + + // Each E2E test stands up its own ServiceProvider with its own Npgsql connection pool. + // Pin the pool to a single connection that immediately returns to the pool — without + // this, parallel fixtures briefly hold dozens of connections and trip Postgres's + // max_connections cap (the validation tests in this suite see "53300: too many clients"). + if (!connectionString.Contains("Maximum Pool Size", StringComparison.OrdinalIgnoreCase)) + connectionString += + ";Pooling=true;Maximum Pool Size=1;Minimum Pool Size=0;Connection Idle Lifetime=1;Connection Pruning Interval=1"; + + var provider = new ServiceCollection() + .AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)) + .AddTrax(trax => + trax.AddEffects(effects => + effects.UsePostgres(connectionString).AddJson().SaveTrainParameters() + ) + .AddMediator(typeof(AssemblyMarker).Assembly, typeof(JobRunnerTrain).Assembly) + .AddScheduler(scheduler => + { + scheduler.UseInMemoryWorkers(); + configureScheduler(scheduler); + return scheduler; + }) + ) + .AddScoped(sp => + { + var factory = sp.GetRequiredService(); + return (IDataContext)factory.Create(); + }) + .BuildServiceProvider(); + + var scope = provider.CreateScope(); + var dataContext = scope.ServiceProvider.GetRequiredService(); + + await TestSetup.CleanupDatabase(dataContext); + + return new SchedulerE2EFixture( + provider, + scope, + dataContext, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService() + ); + } + + /// + /// Invokes every queued PendingManifest.ScheduleFunc against the live scheduler. + /// Mirrors what SchedulerStartupService.SeedPendingManifests does at host startup, + /// but in the test thread so failures surface as exceptions rather than logged warnings. + /// + public async Task MaterializePendingManifestsAsync(CancellationToken ct = default) + { + foreach (var pending in Configuration.PendingManifests.ToList()) + await pending.ScheduleFunc(Scheduler, ct); + } + + /// + /// Runs the ManifestManager train end-to-end: loads manifests, processes timeouts, + /// determines which jobs to queue, and writes WorkQueue entries. Use after + /// to exercise the queueing pipeline. + /// + public Task RunManifestManagerAsync(CancellationToken ct = default) + { + var train = _scope.ServiceProvider.GetRequiredService(); + return train.Run(LanguageExt.Unit.Default, ct); + } + + /// + /// Runs the JobDispatcher train end-to-end: loads queued work, applies capacity limits, + /// and dispatches jobs to the registered run executor. + /// + public Task RunJobDispatcherAsync(CancellationToken ct = default) + { + var train = _scope.ServiceProvider.GetRequiredService(); + return train.Run(LanguageExt.Unit.Default, ct); + } + + /// + /// Runs the DeadLetterCleanup train end-to-end: deletes resolved dead letters older than + /// the configured retention period. + /// + public Task RunDeadLetterCleanupAsync(CancellationToken ct = default) + { + var train = _scope.ServiceProvider.GetRequiredService(); + return train.Run(new DeadLetterCleanupRequest(), ct); + } + + /// + /// Builds a fresh provider with the InMemory data provider so the test exercises + /// InMemoryManifestManagerTrain + InMemoryDispatchJobsJunction (the path used by tests + /// and small samples that don't need Postgres). Each call gets its own EF Core + /// InMemory database so no cleanup is needed. + /// + public static SchedulerE2EFixture CreateInMemory( + Action configureScheduler + ) + { + var provider = new ServiceCollection() + .AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)) + .AddTrax(trax => + trax.AddEffects(effects => effects.UseInMemory().AddJson().SaveTrainParameters()) + .AddMediator(typeof(AssemblyMarker).Assembly, typeof(JobRunnerTrain).Assembly) + .AddScheduler(scheduler => + { + configureScheduler(scheduler); + return scheduler; + }) + ) + .AddScoped(sp => + { + var factory = sp.GetRequiredService(); + return (IDataContext)factory.Create(); + }) + .BuildServiceProvider(); + + var scope = provider.CreateScope(); + var dataContext = scope.ServiceProvider.GetRequiredService(); + + return new SchedulerE2EFixture( + provider, + scope, + dataContext, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService() + ); + } + + public async ValueTask DisposeAsync() + { + if (DataContext is IAsyncDisposable async) + await async.DisposeAsync(); + else if (DataContext is IDisposable sync) + sync.Dispose(); + + _scope.Dispose(); + await _provider.DisposeAsync(); + + // Aggressively clear Npgsql connection pools so the next test in the suite + // (which builds its own ServiceProvider with its own pool) doesn't hit the + // server-side max_connections cap on the shared trax_scheduler_tests database. + Npgsql.NpgsqlConnection.ClearAllPools(); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/InMemoryDispatchTests.cs b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/InMemoryDispatchTests.cs new file mode 100644 index 0000000..68241ac --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/InMemoryDispatchTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Trax.Scheduler.Tests.Integration.Fakes.Trains; +using Trax.Scheduler.Tests.Integration.Fixtures; +using Every = Trax.Scheduler.Services.Scheduling.Every; + +namespace Trax.Scheduler.Tests.Integration.IntegrationTests; + +/// +/// Drives the InMemory polling pipeline (InMemoryManifestManagerTrain → +/// InMemoryDispatchJobsJunction → InMemoryJobSubmitter). The Postgres pipeline tests +/// don't reach this code path because UsePostgres registers the standard +/// ManifestManagerTrain + JobDispatcherTrain instead. +/// +[TestFixture] +public class InMemoryDispatchTests +{ + private const string TrainNameFilter = "SchedulerTestTrain"; + + [Test] + public async Task ManifestManager_InMemory_DispatchesJobsInline() + { + await using var fx = SchedulerE2EFixture.CreateInMemory(s => + s.Schedule( + "inmem-dispatch", + new SchedulerTestInput { Value = "hi" }, + Every.Minutes(1) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + // InMemoryDispatchJobsJunction creates Metadata records inline for the test train + // (the orchestration trains create their own Metadata too — filter by train name). + var metadatas = await fx + .DataContext.Metadatas.AsNoTracking() + .Where(m => m.Name.Contains(TrainNameFilter)) + .ToListAsync(); + metadatas.Should().NotBeEmpty(); + } + + [Test] + public async Task ManifestManager_InMemory_DisabledManifest_NoMetadataCreated() + { + await using var fx = SchedulerE2EFixture.CreateInMemory(s => + s.Schedule( + "inmem-disabled", + new SchedulerTestInput(), + Every.Minutes(1), + opts => opts.Enabled(false) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + var metadatas = await fx + .DataContext.Metadatas.AsNoTracking() + .Where(m => m.Name.Contains(TrainNameFilter)) + .ToListAsync(); + metadatas.Should().BeEmpty(); + } + + [Test] + public async Task ManifestManager_InMemory_NoManifests_NoOp() + { + await using var fx = SchedulerE2EFixture.CreateInMemory(s => { }); + + await fx.RunManifestManagerAsync(); + + var testMetadatas = await fx + .DataContext.Metadatas.AsNoTracking() + .Where(m => m.Name.Contains(TrainNameFilter)) + .ToListAsync(); + testMetadatas.Should().BeEmpty(); + } + + [Test] + public async Task ManifestManager_InMemory_MultipleManifests_DispatchesEach() + { + await using var fx = SchedulerE2EFixture.CreateInMemory(s => + s.Schedule("inmem-a", new SchedulerTestInput(), Every.Minutes(1)) + .Include("inmem-b", new SchedulerTestInput()) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + var metadatas = await fx + .DataContext.Metadatas.AsNoTracking() + .Where(m => m.Name.Contains(TrainNameFilter)) + .ToListAsync(); + // Only the root manifest fires on first cycle; dependent fires after parent succeeds. + metadatas.Should().HaveCountGreaterThan(0); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerBuilderBatchOverloadsTests.cs b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerBuilderBatchOverloadsTests.cs new file mode 100644 index 0000000..686c3ff --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerBuilderBatchOverloadsTests.cs @@ -0,0 +1,339 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Trax.Effect.Models.Manifest; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Tests.Integration.Fakes.Trains; +using Trax.Scheduler.Tests.Integration.Fixtures; +using Every = Trax.Scheduler.Services.Scheduling.Every; +using Schedule = Trax.Scheduler.Services.Scheduling.Schedule; + +namespace Trax.Scheduler.Tests.Integration.IntegrationTests; + +/// +/// E2E coverage of the explicit (TTrain, TInput, TOutput, TSource) BatchScheduling overloads — +/// ScheduleMany, ScheduleMany name-based, IncludeMany (with and without per-item dependsOn). +/// The InferredScheduling tests already cover the TTrain-only variants. +/// +[TestFixture] +public class SchedulerBuilderBatchOverloadsTests +{ + [Test] + public async Task ScheduleMany_ExplicitTypes_MaterialisesEachItem() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleMany( + new[] { "x", "y", "z" }, + src => ($"explicit-{src}", new SchedulerTestInput { Value = src }), + Every.Minutes(5) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("explicit-")) + .ToListAsync(); + manifests.Should().HaveCount(3); + } + + [Test] + public async Task ScheduleMany_ExplicitTypes_NameBased_AppliesPrefix() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleMany( + "ingest", + new[] { "users", "orders" }, + src => (src, new SchedulerTestInput()), + Every.Minutes(5) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("ingest-")) + .ToListAsync(); + manifests + .Select(m => m.ExternalId) + .Should() + .BeEquivalentTo("ingest-users", "ingest-orders"); + } + + [Test] + public async Task IncludeMany_ExplicitTypes_AfterSchedule_QueuesDependents() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "exp-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .IncludeMany( + new[] { "a", "b" }, + src => ($"exp-dep-{src}", new SchedulerTestInput { Value = src }) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("exp-")) + .ToListAsync(); + + var root = manifests.First(m => m.ExternalId == "exp-root"); + var deps = manifests.Where(m => m.ExternalId.StartsWith("exp-dep-")).ToList(); + deps.Should().HaveCount(2); + deps.Should().AllSatisfy(d => d.DependsOnManifestId.Should().Be(root.Id)); + } + + [Test] + public async Task IncludeMany_ExplicitTypes_PerItemDependsOnFunc_RespectsExplicitParent() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "func-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .IncludeMany( + new[] { "a", "b" }, + src => ($"func-{src}", new SchedulerTestInput()), + dependsOn: src => "func-root" + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("func-")) + .ToListAsync(); + var root = manifests.First(m => m.ExternalId == "func-root"); + var deps = manifests.Where(m => m.ExternalId != "func-root").ToList(); + deps.Should().AllSatisfy(d => d.DependsOnManifestId.Should().Be(root.Id)); + } + + [Test] + public async Task IncludeMany_ExplicitTypes_NameBased_AppliesGroupAndPrefix() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "name-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .IncludeMany( + "process", + new[] { "a", "b" }, + src => (src, new SchedulerTestInput()) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Include(m => m.ManifestGroup) + .Where(m => m.ExternalId.StartsWith("process-")) + .ToListAsync(); + + manifests.Select(m => m.ExternalId).Should().BeEquivalentTo("process-a", "process-b"); + manifests.Should().AllSatisfy(m => m.ManifestGroup.Name.Should().Be("process")); + } + + [Test] + public async Task ThenIncludeMany_ExplicitTypes_PerItemDependsOn_QueuesDependents() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "ti-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .Include( + "ti-mid", + new SchedulerTestInput() + ) + .ThenIncludeMany( + new[] { "x", "y" }, + src => ($"ti-leaf-{src}", new SchedulerTestInput()), + dependsOn: src => "ti-mid" + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("ti-")) + .ToListAsync(); + var mid = manifests.First(m => m.ExternalId == "ti-mid"); + var leafs = manifests.Where(m => m.ExternalId.StartsWith("ti-leaf-")).ToList(); + leafs.Should().HaveCount(2); + leafs.Should().AllSatisfy(l => l.DependsOnManifestId.Should().Be(mid.Id)); + } + + [Test] + public async Task ThenIncludeMany_Inferred_RequiresDependsOnOnEachItem() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("tim-root", new SchedulerTestInput(), Every.Minutes(5)) + .Include("tim-mid", new SchedulerTestInput()) + .ThenIncludeMany( + new[] + { + new ManifestItem("tim-leaf-a", new SchedulerTestInput()) + { + DependsOn = "tim-mid", + }, + new ManifestItem("tim-leaf-b", new SchedulerTestInput()) + { + DependsOn = "tim-mid", + }, + } + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("tim-")) + .ToListAsync(); + manifests.Should().HaveCount(4); + } + + [Test] + public void ThenIncludeMany_Inferred_MissingDependsOn_Throws() + { + Action act = () => + SchedulerE2EFixture.CreateInMemory(s => + s.Schedule( + "missing-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .ThenIncludeMany( + new[] { new ManifestItem("orphan", new SchedulerTestInput()) } + ) + ); + + act.Should().Throw().WithMessage("*DependsOn*"); + } + + [Test] + public async Task IncludeMany_Inferred_NameBased_AppliesPrefixAndGroup() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("im-root", new SchedulerTestInput(), Every.Minutes(5)) + .IncludeMany( + "process", + new[] + { + new ManifestItem("a", new SchedulerTestInput()), + new ManifestItem("b", new SchedulerTestInput()), + } + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Include(m => m.ManifestGroup) + .Where(m => m.ExternalId.StartsWith("process-")) + .ToListAsync(); + manifests.Should().HaveCount(2); + manifests.Should().AllSatisfy(m => m.ManifestGroup.Name.Should().Be("process")); + } + + [Test] + public async Task ThenIncludeMany_NameBased_AppliesPrefixAndDependsOn() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "tn-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .Include( + "tn-mid", + new SchedulerTestInput() + ) + .ThenIncludeMany( + "leaves", + new[] { "a", "b" }, + src => (src, new SchedulerTestInput()), + dependsOn: src => "tn-mid" + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Include(m => m.ManifestGroup) + .Where(m => m.ExternalId.StartsWith("leaves-")) + .ToListAsync(); + + manifests.Should().HaveCount(2); + manifests.Select(m => m.ExternalId).Should().BeEquivalentTo("leaves-a", "leaves-b"); + manifests.Should().AllSatisfy(m => m.ManifestGroup.Name.Should().Be("leaves")); + } + + [Test] + public async Task IncludeMany_NameBasedWithExplicitDependsOn_AppliesPrefixAndParent() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "in-root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .IncludeMany( + "deps", + new[] { "a", "b" }, + src => (src, new SchedulerTestInput()), + dependsOn: src => "in-root" + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Include(m => m.ManifestGroup) + .Where(m => m.ExternalId.StartsWith("deps-")) + .ToListAsync(); + + manifests.Should().HaveCount(2); + manifests.Should().AllSatisfy(m => m.ManifestGroup.Name.Should().Be("deps")); + } + + [Test] + public async Task ScheduleMany_ConfigureEach_AppliesPerItemManifestOptions() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleMany( + new[] { 1, 2, 3 }, + i => ($"each-{i}", new SchedulerTestInput()), + Every.Minutes(5), + configureEach: (i, manifestOpts) => manifestOpts.Priority = i + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("each-")) + .OrderBy(m => m.ExternalId) + .ToListAsync(); + manifests.Select(m => m.Priority).Should().Equal(1, 2, 3); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerBuilderMaterializationTests.cs b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerBuilderMaterializationTests.cs new file mode 100644 index 0000000..bc248e6 --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerBuilderMaterializationTests.cs @@ -0,0 +1,288 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Trax.Effect.Enums; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Tests.Integration.Fakes.Trains; +using Trax.Scheduler.Tests.Integration.Fixtures; +using Every = Trax.Scheduler.Services.Scheduling.Every; +using Schedule = Trax.Scheduler.Services.Scheduling.Schedule; + +namespace Trax.Scheduler.Tests.Integration.IntegrationTests; + +/// +/// End-to-end coverage of the SchedulerConfigurationBuilder ScheduleFunc lambdas — the bits +/// that don't run from unit tests because they live inside async closures captured at +/// configuration time and only execute when SchedulerStartupService materialises the queued +/// PendingManifests at host startup. +/// +/// Each test builds its own scheduler config, materialises it via the fixture, and asserts +/// on the resulting Manifest / ManifestGroup rows in Postgres. +/// +[TestFixture] +public class SchedulerBuilderMaterializationTests +{ + [Test] + public async Task Schedule_Inferred_MaterialisesIntoManifestRow() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "ext-schedule", + new SchedulerTestInput { Value = "hi" }, + Every.Minutes(5) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .FirstOrDefaultAsync(m => m.ExternalId == "ext-schedule"); + manifest.Should().NotBeNull(); + manifest!.IsEnabled.Should().BeTrue(); + manifest.ScheduleType.Should().Be(ScheduleType.Interval); + manifest.IntervalSeconds.Should().Be(300); + } + + [Test] + public async Task Schedule_Explicit_MaterialisesIntoManifestRow() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "ext-explicit", + new SchedulerTestInput { Value = "hi" }, + Every.Minutes(10) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .FirstOrDefaultAsync(m => m.ExternalId == "ext-explicit"); + manifest.Should().NotBeNull(); + manifest!.IntervalSeconds.Should().Be(600); + } + + [Test] + public async Task ScheduleOnce_Inferred_MaterialisesAsOnceManifestWithScheduledAt() + { + var before = DateTime.UtcNow; + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleOnce( + "ext-once", + new SchedulerTestInput(), + TimeSpan.FromMinutes(5) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .FirstOrDefaultAsync(m => m.ExternalId == "ext-once"); + manifest.Should().NotBeNull(); + manifest!.ScheduleType.Should().Be(ScheduleType.Once); + manifest.ScheduledAt.Should().NotBeNull(); + manifest.ScheduledAt!.Value.Should().BeAfter(before); + } + + [Test] + public async Task Include_Inferred_LinksDependentToRoot() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .Include("child", new SchedulerTestInput()) + ); + + await fx.MaterializePendingManifestsAsync(); + + var root = await fx + .DataContext.Manifests.AsNoTracking() + .FirstAsync(m => m.ExternalId == "root"); + var child = await fx + .DataContext.Manifests.AsNoTracking() + .FirstAsync(m => m.ExternalId == "child"); + child.DependsOnManifestId.Should().Be(root.Id); + } + + [Test] + public async Task ThenInclude_Inferred_LinksDependentToPriorManifest() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .ThenInclude("child", new SchedulerTestInput()) + .ThenInclude("grandchild", new SchedulerTestInput()) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => new[] { "root", "child", "grandchild" }.Contains(m.ExternalId)) + .ToListAsync(); + manifests.Should().HaveCount(3); + var root = manifests.First(m => m.ExternalId == "root"); + var child = manifests.First(m => m.ExternalId == "child"); + var grand = manifests.First(m => m.ExternalId == "grandchild"); + child.DependsOnManifestId.Should().Be(root.Id); + grand.DependsOnManifestId.Should().Be(child.Id); + } + + [Test] + public async Task ScheduleMany_Inferred_MaterialisesEachItem() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleMany( + new[] + { + new ManifestItem("batch-a", new SchedulerTestInput { Value = "a" }), + new ManifestItem("batch-b", new SchedulerTestInput { Value = "b" }), + new ManifestItem("batch-c", new SchedulerTestInput { Value = "c" }), + }, + Every.Minutes(5) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("batch-")) + .ToListAsync(); + manifests.Should().HaveCount(3); + manifests + .Select(m => m.ExternalId) + .Should() + .BeEquivalentTo("batch-a", "batch-b", "batch-c"); + } + + [Test] + public async Task ScheduleMany_NameBased_AppliesPrefixToExternalIds() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleMany( + "sync", + new[] + { + new ManifestItem("users", new SchedulerTestInput()), + new ManifestItem("orders", new SchedulerTestInput()), + }, + Every.Minutes(5) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("sync-")) + .ToListAsync(); + manifests.Select(m => m.ExternalId).Should().BeEquivalentTo("sync-users", "sync-orders"); + } + + [Test] + public async Task IncludeMany_Inferred_AllItemsDependOnRootByDefault() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .IncludeMany( + new[] + { + new ManifestItem("dep-a", new SchedulerTestInput()), + new ManifestItem("dep-b", new SchedulerTestInput()), + } + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var root = await fx + .DataContext.Manifests.AsNoTracking() + .FirstAsync(m => m.ExternalId == "root"); + var deps = await fx + .DataContext.Manifests.AsNoTracking() + .Where(m => m.ExternalId.StartsWith("dep-")) + .ToListAsync(); + + deps.Should().HaveCount(2); + deps.Should().AllSatisfy(d => d.DependsOnManifestId.Should().Be(root.Id)); + } + + [Test] + public async Task IncludeMany_PerItemDependsOn_RespectsExplicitParent() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .IncludeMany( + new[] + { + new ManifestItem("child-a", new SchedulerTestInput()) + { + DependsOn = "root", + }, + new ManifestItem("child-b", new SchedulerTestInput()) + { + DependsOn = "root", + }, + } + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifests = await fx.DataContext.Manifests.AsNoTracking().ToListAsync(); + var root = manifests.First(m => m.ExternalId == "root"); + var ca = manifests.First(m => m.ExternalId == "child-a"); + var cb = manifests.First(m => m.ExternalId == "child-b"); + + ca.DependsOnManifestId.Should().Be(root.Id); + cb.DependsOnManifestId.Should().Be(root.Id); + } + + [Test] + public async Task Schedule_WithGroupOptions_MaterialisesGroupSettings() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "ext-grouped", + new SchedulerTestInput(), + Every.Minutes(5), + opts => opts.Group("my-group", g => g.MaxActiveJobs(7).Priority(3)) + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .Include(m => m.ManifestGroup) + .FirstAsync(m => m.ExternalId == "ext-grouped"); + + manifest.ManifestGroup.Name.Should().Be("my-group"); + manifest.ManifestGroup.MaxActiveJobs.Should().Be(7); + manifest.ManifestGroup.Priority.Should().Be(3); + } + + [Test] + public async Task Schedule_WithCronExpression_StoresCronManifest() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "ext-cron", + new SchedulerTestInput(), + Schedule.FromCron("0 3 * * *") + ) + ); + + await fx.MaterializePendingManifestsAsync(); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .FirstAsync(m => m.ExternalId == "ext-cron"); + + manifest.ScheduleType.Should().Be(ScheduleType.Cron); + manifest.CronExpression.Should().Be("0 3 * * *"); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerDeadLetterTests.cs b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerDeadLetterTests.cs new file mode 100644 index 0000000..c3e0612 --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerDeadLetterTests.cs @@ -0,0 +1,227 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Trax.Effect.Enums; +using Trax.Effect.Models.DeadLetter; +using Trax.Effect.Models.DeadLetter.DTOs; +using Trax.Scheduler.Tests.Integration.Fakes.Trains; +using Trax.Scheduler.Tests.Integration.Fixtures; +using Every = Trax.Scheduler.Services.Scheduling.Every; + +namespace Trax.Scheduler.Tests.Integration.IntegrationTests; + +/// +/// E2E coverage of TraxScheduler dead-letter operations: RequeueDeadLetterAsync, +/// AcknowledgeDeadLetterAsync, the batch and "all" variants. Each test seeds a manifest + +/// dead letter directly into Postgres, then exercises the scheduler API and asserts on the +/// resulting Status / WorkQueue rows. +/// +[TestFixture] +public class SchedulerDeadLetterTests +{ + private static async Task SeedDeadLetterAsync( + SchedulerE2EFixture fx, + string externalId + ) + { + var manifest = await fx + .DataContext.Manifests.Include(m => m.ManifestGroup) + .FirstAsync(m => m.ExternalId == externalId); + var dl = DeadLetter.Create( + new CreateDeadLetter + { + Manifest = manifest, + Reason = "test", + RetryCount = 3, + } + ); + await fx.DataContext.Track(dl); + await fx.DataContext.SaveChanges(default); + fx.DataContext.Reset(); + return dl; + } + + private static async Task CreateWithManifestAsync(string externalId) + { + var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule(externalId, new SchedulerTestInput(), Every.Minutes(5)) + ); + await fx.MaterializePendingManifestsAsync(); + return fx; + } + + [Test] + public async Task RequeueDeadLetterAsync_ValidId_RequeuesAndMarksRequeued() + { + await using var fx = await CreateWithManifestAsync("dl-1"); + var dl = await SeedDeadLetterAsync(fx, "dl-1"); + + var result = await fx.Scheduler.RequeueDeadLetterAsync(dl.Id); + + result.Success.Should().BeTrue(); + result.WorkQueueId.Should().NotBeNull(); + + var reloaded = await fx + .DataContext.DeadLetters.AsNoTracking() + .FirstAsync(d => d.Id == dl.Id); + reloaded.Status.Should().Be(DeadLetterStatus.Retried); + reloaded.ResolutionNote.Should().Contain("Re-queued"); + } + + [Test] + public async Task RequeueDeadLetterAsync_MissingId_ReturnsFailure() + { + await using var fx = await CreateWithManifestAsync("dl-2"); + + var result = await fx.Scheduler.RequeueDeadLetterAsync(999_999); + + result.Success.Should().BeFalse(); + result.WorkQueueId.Should().BeNull(); + } + + [Test] + public async Task AcknowledgeDeadLetterAsync_ValidId_MarksAcknowledged() + { + await using var fx = await CreateWithManifestAsync("dl-3"); + var dl = await SeedDeadLetterAsync(fx, "dl-3"); + + var result = await fx.Scheduler.AcknowledgeDeadLetterAsync(dl.Id, "intentional"); + + result.Success.Should().BeTrue(); + var reloaded = await fx + .DataContext.DeadLetters.AsNoTracking() + .FirstAsync(d => d.Id == dl.Id); + reloaded.Status.Should().Be(DeadLetterStatus.Acknowledged); + reloaded.ResolutionNote.Should().Be("intentional"); + } + + [Test] + public async Task AcknowledgeDeadLetterAsync_MissingId_ReturnsFailure() + { + await using var fx = await CreateWithManifestAsync("dl-4"); + + var result = await fx.Scheduler.AcknowledgeDeadLetterAsync(999_999, "n/a"); + + result.Success.Should().BeFalse(); + } + + [Test] + public async Task RequeueDeadLettersAsync_BatchAcrossManifests_RequeuesEach() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("dl-5a", new SchedulerTestInput(), Every.Minutes(5)) + .Include("dl-5b", new SchedulerTestInput()) + ); + await fx.MaterializePendingManifestsAsync(); + var d1 = await SeedDeadLetterAsync(fx, "dl-5a"); + var d2 = await SeedDeadLetterAsync(fx, "dl-5b"); + + var result = await fx.Scheduler.RequeueDeadLettersAsync(new[] { d1.Id, d2.Id }); + + result.Count.Should().Be(2); + var reloaded = await fx + .DataContext.DeadLetters.AsNoTracking() + .Where(d => d.Id == d1.Id || d.Id == d2.Id) + .ToListAsync(); + reloaded.Should().AllSatisfy(d => d.Status.Should().Be(DeadLetterStatus.Retried)); + } + + [Test] + public async Task AcknowledgeDeadLettersAsync_BatchOfIds_AcknowledgesEach() + { + await using var fx = await CreateWithManifestAsync("dl-6"); + var d1 = await SeedDeadLetterAsync(fx, "dl-6"); + var d2 = await SeedDeadLetterAsync(fx, "dl-6"); + + var result = await fx.Scheduler.AcknowledgeDeadLettersAsync( + new[] { d1.Id, d2.Id }, + "batch-ack" + ); + + result.Count.Should().Be(2); + var reloaded = await fx + .DataContext.DeadLetters.AsNoTracking() + .Where(d => d.Id == d1.Id || d.Id == d2.Id) + .ToListAsync(); + reloaded.Should().AllSatisfy(d => d.Status.Should().Be(DeadLetterStatus.Acknowledged)); + } + + [Test] + public async Task RequeueAllDeadLettersAsync_RequeuesEveryAwaitingIntervention() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("dl-7a", new SchedulerTestInput(), Every.Minutes(5)) + .Include("dl-7b", new SchedulerTestInput()) + .Include("dl-7c", new SchedulerTestInput()) + ); + await fx.MaterializePendingManifestsAsync(); + await SeedDeadLetterAsync(fx, "dl-7a"); + await SeedDeadLetterAsync(fx, "dl-7b"); + await SeedDeadLetterAsync(fx, "dl-7c"); + + var result = await fx.Scheduler.RequeueAllDeadLettersAsync(); + + result.Count.Should().Be(3); + } + + [Test] + public async Task DeadLetterCleanup_ResolvedAndExpired_AreDeleted() + { + await using var fx = await CreateWithManifestAsync("dl-cleanup"); + var dl = await SeedDeadLetterAsync(fx, "dl-cleanup"); + + // Acknowledge so it has a ResolvedAt timestamp + await fx.Scheduler.AcknowledgeDeadLetterAsync(dl.Id, "old"); + + // Backdate ResolvedAt so it falls outside the retention window (default 30 days) + await fx + .DataContext.DeadLetters.Where(d => d.Id == dl.Id) + .ExecuteUpdateAsync(s => + s.SetProperty(d => d.ResolvedAt, DateTime.UtcNow.AddDays(-90)) + ); + + await fx.RunDeadLetterCleanupAsync(); + + var exists = await fx.DataContext.DeadLetters.AsNoTracking().AnyAsync(d => d.Id == dl.Id); + exists.Should().BeFalse(); + } + + [Test] + public async Task DeadLetterCleanup_AwaitingIntervention_NotDeleted() + { + await using var fx = await CreateWithManifestAsync("dl-keep"); + var dl = await SeedDeadLetterAsync(fx, "dl-keep"); + + await fx.RunDeadLetterCleanupAsync(); + + var exists = await fx.DataContext.DeadLetters.AsNoTracking().AnyAsync(d => d.Id == dl.Id); + exists.Should().BeTrue(); + } + + [Test] + public async Task DeadLetterCleanup_RecentlyResolved_NotDeleted() + { + await using var fx = await CreateWithManifestAsync("dl-recent"); + var dl = await SeedDeadLetterAsync(fx, "dl-recent"); + + await fx.Scheduler.AcknowledgeDeadLetterAsync(dl.Id, "recent"); + + await fx.RunDeadLetterCleanupAsync(); + + var exists = await fx.DataContext.DeadLetters.AsNoTracking().AnyAsync(d => d.Id == dl.Id); + exists.Should().BeTrue(); + } + + [Test] + public async Task AcknowledgeAllDeadLettersAsync_AcknowledgesEveryAwaitingIntervention() + { + await using var fx = await CreateWithManifestAsync("dl-8"); + await SeedDeadLetterAsync(fx, "dl-8"); + await SeedDeadLetterAsync(fx, "dl-8"); + + var result = await fx.Scheduler.AcknowledgeAllDeadLettersAsync("clearing"); + + result.Count.Should().Be(2); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerPollingCycleTests.cs b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerPollingCycleTests.cs new file mode 100644 index 0000000..ad2cc85 --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/IntegrationTests/SchedulerPollingCycleTests.cs @@ -0,0 +1,245 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Trax.Effect.Enums; +using Trax.Scheduler.Tests.Integration.Fakes.Trains; +using Trax.Scheduler.Tests.Integration.Fixtures; +using Every = Trax.Scheduler.Services.Scheduling.Every; +using Schedule = Trax.Scheduler.Services.Scheduling.Schedule; + +namespace Trax.Scheduler.Tests.Integration.IntegrationTests; + +/// +/// Drives the full polling cycle (materialise manifests → ManifestManager → JobDispatcher) +/// against Postgres so the EnqueueJobsJunction / dispatcher junctions / TraxScheduler trigger +/// paths actually execute. The unit-level tests can't reach these because they live inside +/// junction async state machines that only fire when the orchestration trains run. +/// +[TestFixture] +public class SchedulerPollingCycleTests +{ + [Test] + public async Task ManifestManager_AfterScheduling_QueuesWorkForDueManifest() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "due-now", + new SchedulerTestInput { Value = "x" }, + Every.Minutes(1) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + var workQueue = await fx.DataContext.WorkQueues.AsNoTracking().ToListAsync(); + workQueue.Should().NotBeEmpty(); + workQueue.Should().Contain(w => w.Status == WorkQueueStatus.Queued); + } + + [Test] + public async Task ManifestManager_DisabledManifest_DoesNotQueueWork() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "disabled", + new SchedulerTestInput(), + Every.Minutes(1), + opts => opts.Enabled(false) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + var workQueue = await fx.DataContext.WorkQueues.AsNoTracking().ToListAsync(); + workQueue.Should().BeEmpty(); + } + + [Test] + public async Task ManifestManager_OnceManifestNotYetDue_DoesNotQueue() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.ScheduleOnce( + "future-once", + new SchedulerTestInput(), + TimeSpan.FromHours(1) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + var workQueue = await fx.DataContext.WorkQueues.AsNoTracking().ToListAsync(); + workQueue.Should().BeEmpty(); + } + + [Test] + public async Task ManifestManager_DependentNotFiredBeforeParentSucceeds() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("parent", new SchedulerTestInput(), Every.Minutes(1)) + .ThenInclude("dependent", new SchedulerTestInput()) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + + var workQueue = await fx + .DataContext.WorkQueues.AsNoTracking() + .Include(w => w.Manifest) + .ToListAsync(); + // Parent is due, but dependent waits for parent to have a LastSuccessfulRun + workQueue.Select(w => w.Manifest!.ExternalId).Should().NotContain("dependent"); + } + + [Test] + public async Task ManifestManager_RunTwice_NoDuplicateWorkQueueEntries() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("once-only", new SchedulerTestInput(), Every.Minutes(1)) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.RunManifestManagerAsync(); + var afterFirst = await fx.DataContext.WorkQueues.AsNoTracking().CountAsync(); + await fx.RunManifestManagerAsync(); + var afterSecond = await fx.DataContext.WorkQueues.AsNoTracking().CountAsync(); + + // Manifest manager should not requeue an entry that's already pending dispatch. + afterSecond.Should().Be(afterFirst); + } + + [Test] + public async Task JobDispatcher_AfterManifestManager_DispatchesQueuedWork() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "dispatch-me", + new SchedulerTestInput { Value = "x" }, + Every.Minutes(1) + ) + ); + await fx.MaterializePendingManifestsAsync(); + await fx.RunManifestManagerAsync(); + + await fx.RunJobDispatcherAsync(); + + var workQueue = await fx.DataContext.WorkQueues.AsNoTracking().ToListAsync(); + workQueue.Should().NotBeEmpty(); + workQueue + .Should() + .Contain(w => + w.Status == WorkQueueStatus.Dispatched || w.Status == WorkQueueStatus.Cancelled + ); + } + + [Test] + public async Task TriggerAsync_QueuesWorkOutsideOfPollingCycle() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "manual-trigger", + new SchedulerTestInput(), + // Schedule at 1 hour intervals so polling won't queue it + Every.Hours(1) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.Scheduler.TriggerAsync("manual-trigger"); + + var workQueue = await fx + .DataContext.WorkQueues.AsNoTracking() + .Include(w => w.Manifest) + .ToListAsync(); + workQueue.Should().Contain(w => w.Manifest!.ExternalId == "manual-trigger"); + } + + [Test] + public async Task DisableAsync_FlipsManifestEnabledFalse() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "to-disable", + new SchedulerTestInput(), + Every.Minutes(5) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.Scheduler.DisableAsync("to-disable"); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .FirstAsync(m => m.ExternalId == "to-disable"); + manifest.IsEnabled.Should().BeFalse(); + } + + [Test] + public async Task EnableAsync_FlipsManifestEnabledTrue() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "to-enable", + new SchedulerTestInput(), + Every.Minutes(5), + opts => opts.Enabled(false) + ) + ); + await fx.MaterializePendingManifestsAsync(); + + await fx.Scheduler.EnableAsync("to-enable"); + + var manifest = await fx + .DataContext.Manifests.AsNoTracking() + .FirstAsync(m => m.ExternalId == "to-enable"); + manifest.IsEnabled.Should().BeTrue(); + } + + [Test] + public async Task TriggerGroupAsync_QueuesEveryEnabledManifestInGroup() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule( + "g-a", + new SchedulerTestInput(), + Every.Hours(1), + opts => opts.Group("trigger-group") + ) + .Include( + "g-b", + new SchedulerTestInput(), + opts => opts.Group("trigger-group") + ) + ); + await fx.MaterializePendingManifestsAsync(); + + var group = await fx + .DataContext.ManifestGroups.AsNoTracking() + .FirstAsync(g => g.Name == "trigger-group"); + var triggered = await fx.Scheduler.TriggerGroupAsync(group.Id); + + triggered.Should().BeGreaterThan(0); + var workQueue = await fx + .DataContext.WorkQueues.AsNoTracking() + .Include(w => w.Manifest) + .ToListAsync(); + workQueue.Should().Contain(w => w.Manifest!.ExternalId == "g-a"); + } + + [Test] + public async Task CancelAsync_CancelsPendingWorkAndUpdatesMetadata() + { + await using var fx = await SchedulerE2EFixture.CreateAsync(s => + s.Schedule("to-cancel", new SchedulerTestInput(), Every.Minutes(1)) + ); + await fx.MaterializePendingManifestsAsync(); + await fx.RunManifestManagerAsync(); + + var cancelled = await fx.Scheduler.CancelAsync("to-cancel"); + + cancelled.Should().BeGreaterThanOrEqualTo(0); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/HttpRunExecutorTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/HttpRunExecutorTests.cs new file mode 100644 index 0000000..406290d --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/HttpRunExecutorTests.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Trax.Core.Exceptions; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Services.Http; +using Trax.Scheduler.Services.RunExecutor; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +[TestFixture] +public class HttpRunExecutorTests +{ + private static HttpRunExecutor BuildExecutor(StubHandler handler, RemoteRunOptions? opts = null) + { + opts ??= new RemoteRunOptions + { + BaseUrl = "https://example.invalid/", + Timeout = TimeSpan.FromSeconds(5), + Retry = new HttpRetryOptions { MaxRetries = 0 }, + }; + var client = new HttpClient(handler) { BaseAddress = new Uri(opts.BaseUrl) }; + return new HttpRunExecutor(client, opts, NullLogger.Instance); + } + + [Test] + public async Task ExecuteAsync_HappyPath_ReturnsRunTrainResult() + { + var handler = new StubHandler( + (req, ct) => + { + var resp = new RemoteRunResponse( + MetadataId: 42, + ExternalId: "ext-42", + OutputJson: """{"value":"ok"}""", + OutputType: typeof(SimpleOutput).FullName, + IsError: false + ); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, resp)); + } + ); + var executor = BuildExecutor(handler); + + var result = await executor.ExecuteAsync( + "Trax.X.MyTrain", + new SimpleInput { Value = "hi" }, + typeof(SimpleOutput) + ); + + result.MetadataId.Should().Be(42); + result.ExternalId.Should().Be("ext-42"); + result.Output.Should().BeOfType(); + ((SimpleOutput)result.Output!).Value.Should().Be("ok"); + } + + [Test] + public async Task ExecuteAsync_NullOutputJson_ReturnsResultWithNullOutput() + { + var handler = new StubHandler( + (req, ct) => + { + var resp = new RemoteRunResponse( + MetadataId: 1, + ExternalId: "ext-1", + OutputJson: null, + OutputType: null, + IsError: false + ); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, resp)); + } + ); + var executor = BuildExecutor(handler); + + var result = await executor.ExecuteAsync( + "Trax.X.MyTrain", + new SimpleInput(), + typeof(SimpleOutput) + ); + + result.Output.Should().BeNull(); + } + + [Test] + public async Task ExecuteAsync_NonSuccessHttp_ThrowsTrainExceptionWithBody() + { + var handler = new StubHandler( + (req, ct) => + Task.FromResult( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("internal boom"), + } + ) + ); + var executor = BuildExecutor(handler); + + Func act = () => + executor.ExecuteAsync("Trax.X.MyTrain", new SimpleInput(), typeof(SimpleOutput)); + + await act.Should() + .ThrowAsync() + .WithMessage("*HTTP 500*") + .WithMessage("*internal boom*"); + } + + [Test] + public async Task ExecuteAsync_NonSuccessEmptyBody_FallsBackToReasonPhrase() + { + var handler = new StubHandler( + (req, ct) => + Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent(""), + ReasonPhrase = "Bad Gateway", + } + ) + ); + var executor = BuildExecutor(handler); + + Func act = () => + executor.ExecuteAsync("Trax.X.MyTrain", new SimpleInput(), typeof(SimpleOutput)); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task ExecuteAsync_ErrorResponse_ThrowsExceptionWithStructuredJson() + { + var handler = new StubHandler( + (req, ct) => + { + var resp = new RemoteRunResponse( + MetadataId: 0, + ExternalId: null, + OutputJson: null, + OutputType: null, + IsError: true, + ExceptionType: "InvalidOperationException", + FailureJunction: "MyJunction", + ErrorMessage: "boom" + ); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, resp)); + } + ); + var executor = BuildExecutor(handler); + + Func act = () => + executor.ExecuteAsync("Trax.X.MyTrain", new SimpleInput(), typeof(SimpleOutput)); + + var ex = await act.Should().ThrowAsync(); + // The structured exception data is JSON-serialized into the message + ex.Which.Message.Should().Contain("InvalidOperationException"); + ex.Which.Message.Should().Contain("MyJunction"); + } + + [Test] + public async Task ExecuteAsync_ErrorResponseWithoutStructuredFields_ThrowsPlainTrainException() + { + var handler = new StubHandler( + (req, ct) => + { + var resp = new RemoteRunResponse( + MetadataId: 0, + ExternalId: null, + OutputJson: null, + OutputType: null, + IsError: true, + ErrorMessage: "the failure" + ); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, resp)); + } + ); + var executor = BuildExecutor(handler); + + Func act = () => + executor.ExecuteAsync("Trax.X.MyTrain", new SimpleInput(), typeof(SimpleOutput)); + + await act.Should().ThrowAsync().WithMessage("*the failure*"); + } + + [Test] + public async Task ExecuteAsync_NullJsonResponse_ThrowsTrainException() + { + var handler = new StubHandler( + (req, ct) => + Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + } + ) + ); + var executor = BuildExecutor(handler); + + Func act = () => + executor.ExecuteAsync("Trax.X.MyTrain", new SimpleInput(), typeof(SimpleOutput)); + + await act.Should().ThrowAsync().WithMessage("*null response*"); + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode code, T payload) => + new(code) { Content = JsonContent.Create(payload) }; + + private record SimpleInput + { + public string Value { get; init; } = ""; + } + + private record SimpleOutput + { + public string Value { get; init; } = ""; + } + + private class StubHandler : HttpMessageHandler + { + private readonly Func< + HttpRequestMessage, + CancellationToken, + Task + > _handler; + + public StubHandler( + Func> handler + ) => _handler = handler; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => _handler(request, cancellationToken); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/ScheduleOptionsTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/ScheduleOptionsTests.cs new file mode 100644 index 0000000..8a2a537 --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/ScheduleOptionsTests.cs @@ -0,0 +1,125 @@ +using FluentAssertions; +using NUnit.Framework; +using Trax.Effect.Enums; +using Trax.Effect.Models.Manifest; +using Trax.Scheduler.Configuration; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +[TestFixture] +public class ScheduleOptionsTests +{ + [Test] + public void Priority_StoresValue() + { + var opts = new ScheduleOptions().Priority(7); + opts.ToManifestOptions().Priority.Should().Be(7); + } + + [Test] + public void Enabled_FalseFlagsManifestDisabled() + { + var opts = new ScheduleOptions().Enabled(false); + opts.ToManifestOptions().IsEnabled.Should().BeFalse(); + } + + [Test] + public void MaxRetries_StoresValue() + { + var opts = new ScheduleOptions().MaxRetries(7); + opts.ToManifestOptions().MaxRetries.Should().Be(7); + } + + [Test] + public void Timeout_StoresValue() + { + var opts = new ScheduleOptions().Timeout(TimeSpan.FromMinutes(2)); + opts.ToManifestOptions().Timeout.Should().Be(TimeSpan.FromMinutes(2)); + } + + [Test] + public void Dormant_FlagsAsDormant() + { + var opts = new ScheduleOptions().Dormant(); + opts.ToManifestOptions().IsDormant.Should().BeTrue(); + } + + [Test] + public void OnMisfire_StoresPolicy() + { + var opts = new ScheduleOptions().OnMisfire(MisfirePolicy.FireOnceNow); + opts.ToManifestOptions().MisfirePolicy.Should().Be(MisfirePolicy.FireOnceNow); + } + + [Test] + public void MisfireThreshold_StoresValue() + { + var opts = new ScheduleOptions().MisfireThreshold(TimeSpan.FromMinutes(15)); + opts.ToManifestOptions().MisfireThreshold.Should().Be(TimeSpan.FromMinutes(15)); + } + + [Test] + public void Exclude_AppendsExclusion() + { + var opts = new ScheduleOptions() + .Exclude(Exclude.DaysOfWeek(DayOfWeek.Sunday)) + .Exclude(Exclude.TimeWindow(new TimeOnly(2, 0), new TimeOnly(4, 0))); + + opts.ToManifestOptions().Exclusions.Should().HaveCount(2); + } + + [Test] + public void Variance_StoresValue() + { + var opts = new ScheduleOptions().Variance(TimeSpan.FromSeconds(30)); + opts.ToManifestOptions().Variance.Should().Be(TimeSpan.FromSeconds(30)); + } + + [Test] + public void Group_String_AppliesGroupId() + { + var opts = new ScheduleOptions().Group("g1"); + opts._groupId.Should().Be("g1"); + } + + [Test] + public void Group_StringWithConfigure_BuildsGroupOptions() + { + var opts = new ScheduleOptions().Group("g1", g => g.MaxActiveJobs(5)); + opts._groupId.Should().Be("g1"); + opts._groupOptions.Should().NotBeNull(); + } + + [Test] + public void Group_ConfigureOnly_BuildsGroupOptionsWithoutGroupId() + { + var opts = new ScheduleOptions().Group(g => g.MaxActiveJobs(5)); + opts._groupOptions.Should().NotBeNull(); + opts._groupId.Should().BeNull(); + } + + [Test] + public void PrunePrefix_StoresPrefix() + { + var opts = new ScheduleOptions().PrunePrefix("my-prefix"); + opts._prunePrefix.Should().Be("my-prefix"); + } + + [Test] + public void ChainedFluentSetters_AllAppliedToManifestOptions() + { + var manifestOpts = new ScheduleOptions() + .Priority(5) + .Enabled(true) + .MaxRetries(2) + .Timeout(TimeSpan.FromSeconds(30)) + .Variance(TimeSpan.FromSeconds(5)) + .ToManifestOptions(); + + manifestOpts.Priority.Should().Be(5); + manifestOpts.IsEnabled.Should().BeTrue(); + manifestOpts.MaxRetries.Should().Be(2); + manifestOpts.Timeout.Should().Be(TimeSpan.FromSeconds(30)); + manifestOpts.Variance.Should().Be(TimeSpan.FromSeconds(5)); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulerBuilderSchedulingTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulerBuilderSchedulingTests.cs new file mode 100644 index 0000000..e41f13d --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulerBuilderSchedulingTests.cs @@ -0,0 +1,382 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Trax.Effect.Configuration.TraxBuilder; +using Trax.Effect.Data.InMemory.Extensions; +using Trax.Effect.Extensions; +using Trax.Effect.Models.Manifest; +using Trax.Mediator.Extensions; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Extensions; +using Trax.Scheduler.Services.Scheduling; +using Trax.Scheduler.Tests.Integration.Fakes.Trains; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +/// +/// Direct tests for SchedulerConfigurationBuilder Schedule / ScheduleOnce / ThenInclude / +/// Include / ScheduleMany surface that build PendingManifest entries. Verifies the queue +/// shape, dependency edges, and the ThenInclude/Include validation guards. +/// +[TestFixture] +public class SchedulerBuilderSchedulingTests +{ + private SchedulerConfiguration ResolveConfiguration( + Action configure + ) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTrax(trax => + trax.AddEffects(effects => effects.UseInMemory()) + .AddMediator(typeof(AssemblyMarker).Assembly) + .AddScheduler(scheduler => + { + scheduler.UseInMemoryWorkers(); + configure(scheduler); + return scheduler; + }) + ); + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + #region Schedule + + [Test] + public void Schedule_Explicit3TypeArgs_QueuesPendingManifest() + { + var config = ResolveConfiguration(s => + s.Schedule( + "ext-1", + new SchedulerTestInput(), + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().HaveCount(1); + config.PendingManifests[0].ExternalId.Should().Be("ext-1"); + } + + [Test] + public void Schedule_WithOptions_AppliesGroupId() + { + var config = ResolveConfiguration(s => + s.Schedule( + "ext-grouped", + new SchedulerTestInput(), + Every.Minutes(5), + opts => opts.Group("my-group", g => g.MaxActiveJobs(3)) + ) + ); + + config.PendingManifests.Should().ContainSingle(m => m.ExternalId == "ext-grouped"); + } + + #endregion + + #region ScheduleOnce + + [Test] + public void ScheduleOnce_Explicit3TypeArgs_QueuesPendingManifest() + { + var config = ResolveConfiguration(s => + s.ScheduleOnce( + "once-1", + new SchedulerTestInput(), + TimeSpan.FromMinutes(1) + ) + ); + + config.PendingManifests.Should().ContainSingle(m => m.ExternalId == "once-1"); + } + + [Test] + public void ScheduleOnce_Inferred_QueuesPendingManifest() + { + var config = ResolveConfiguration(s => + s.ScheduleOnce( + "once-inferred", + new SchedulerTestInput(), + TimeSpan.FromMinutes(1) + ) + ); + + config.PendingManifests.Should().ContainSingle(m => m.ExternalId == "once-inferred"); + } + + #endregion + + #region ThenInclude / Include + + [Test] + public void ThenInclude_AfterSchedule_AddsDependentAfterRoot() + { + var config = ResolveConfiguration(s => + s.Schedule( + "root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .ThenInclude( + "child", + new SchedulerTestInput() + ) + ); + + config.PendingManifests.Select(m => m.ExternalId).Should().Equal("root", "child"); + } + + [Test] + public void ThenInclude_WithoutPriorSchedule_Throws() + { + Action act = () => + ResolveConfiguration(s => + s.ThenInclude( + "orphan", + new SchedulerTestInput() + ) + ); + + act.Should().Throw().WithMessage("*ThenInclude*"); + } + + [Test] + public void Include_AfterSchedule_BranchesFromRoot() + { + var config = ResolveConfiguration(s => + s.Schedule( + "root", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .Include( + "branchA", + new SchedulerTestInput() + ) + .Include( + "branchB", + new SchedulerTestInput() + ) + ); + + config + .PendingManifests.Select(m => m.ExternalId) + .Should() + .Equal("root", "branchA", "branchB"); + } + + [Test] + public void Include_WithoutPriorSchedule_Throws() + { + Action act = () => + ResolveConfiguration(s => + s.Include( + "orphan", + new SchedulerTestInput() + ) + ); + + act.Should().Throw().WithMessage("*Include*"); + } + + [Test] + public void IncludeThenInclude_Combination_BuildsBranchedDag() + { + var config = ResolveConfiguration(s => + s.Schedule( + "A", + new SchedulerTestInput(), + Every.Minutes(5) + ) + .Include( + "B", + new SchedulerTestInput() + ) + .Include( + "C", + new SchedulerTestInput() + ) + .ThenInclude( + "D", + new SchedulerTestInput() + ) + ); + + config.PendingManifests.Should().HaveCount(4); + } + + #endregion + + #region ScheduleMany + + [Test] + public void ScheduleMany_3TypeArgsWithSourceFunc_QueuesSingleBatchManifest() + { + var config = ResolveConfiguration(s => + s.ScheduleMany( + new[] { "a", "b", "c" }, + source => ($"item-{source}", new SchedulerTestInput()), + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().HaveCount(1); + config.PendingManifests[0].ExpectedExternalIds.Should().HaveCount(3); + } + + [Test] + public void ScheduleMany_NameBasedConvention_BuildsExternalIds() + { + var config = ResolveConfiguration(s => + s.ScheduleMany( + "sync", + new[] { "users", "orders" }, + source => (source, new SchedulerTestInput()), + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().HaveCount(1); + config.PendingManifests[0].ExpectedExternalIds.Should().HaveCount(2); + config + .PendingManifests[0] + .ExpectedExternalIds.Should() + .Contain(id => id.StartsWith("sync-")); + } + + [Test] + public void ScheduleMany_EmptySource_QueuesEmptyManifest() + { + var config = ResolveConfiguration(s => + s.ScheduleMany( + Array.Empty(), + source => (source, new SchedulerTestInput()), + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().HaveCount(1); + config.PendingManifests[0].ExpectedExternalIds.Should().BeEmpty(); + } + + #endregion + + #region Inferred (TTrain only) overloads + + [Test] + public void Schedule_Inferred_QueuesPendingManifest() + { + var config = ResolveConfiguration(s => + s.Schedule( + "ext-inferred", + new SchedulerTestInput(), + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().ContainSingle(m => m.ExternalId == "ext-inferred"); + } + + [Test] + public void Include_Inferred_AddsDependent() + { + var config = ResolveConfiguration(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .Include("branch", new SchedulerTestInput()) + ); + + config.PendingManifests.Should().HaveCount(2); + config.PendingManifests.Select(m => m.ExternalId).Should().Equal("root", "branch"); + } + + [Test] + public void ThenInclude_Inferred_AddsDependent() + { + var config = ResolveConfiguration(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .ThenInclude("child", new SchedulerTestInput()) + ); + + config.PendingManifests.Select(m => m.ExternalId).Should().Equal("root", "child"); + } + + [Test] + public void Include_Inferred_WithoutSchedule_Throws() + { + Action act = () => + ResolveConfiguration(s => + s.Include("orphan", new SchedulerTestInput()) + ); + + act.Should().Throw().WithMessage("*Include*"); + } + + [Test] + public void ThenInclude_Inferred_WithoutSchedule_Throws() + { + Action act = () => + ResolveConfiguration(s => + s.ThenInclude("orphan", new SchedulerTestInput()) + ); + + act.Should().Throw().WithMessage("*ThenInclude*"); + } + + [Test] + public void ScheduleMany_Inferred_ManifestItems_QueuesBatch() + { + var config = ResolveConfiguration(s => + s.ScheduleMany( + new[] + { + new ManifestItem("a", new SchedulerTestInput()), + new ManifestItem("b", new SchedulerTestInput()), + }, + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().HaveCount(1); + config.PendingManifests[0].ExpectedExternalIds.Should().Equal("a", "b"); + } + + [Test] + public void ScheduleMany_NameBased_AppliesPrefix() + { + var config = ResolveConfiguration(s => + s.ScheduleMany( + "sync", + new[] + { + new ManifestItem("users", new SchedulerTestInput()), + new ManifestItem("orders", new SchedulerTestInput()), + }, + Every.Minutes(5) + ) + ); + + config.PendingManifests.Should().HaveCount(1); + config.PendingManifests[0].ExpectedExternalIds.Should().Equal("sync-users", "sync-orders"); + } + + [Test] + public void IncludeMany_Inferred_AfterSchedule_QueuesDependents() + { + var config = ResolveConfiguration(s => + s.Schedule("root", new SchedulerTestInput(), Every.Minutes(5)) + .IncludeMany( + new[] + { + new ManifestItem("a", new SchedulerTestInput()), + new ManifestItem("b", new SchedulerTestInput()), + } + ) + ); + + config.PendingManifests.Should().HaveCount(2); + } + + #endregion +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulerSettingsExtraTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulerSettingsExtraTests.cs new file mode 100644 index 0000000..785bfed --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulerSettingsExtraTests.cs @@ -0,0 +1,145 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Trax.Effect.Configuration.TraxBuilder; +using Trax.Effect.Data.InMemory.Extensions; +using Trax.Effect.Enums; +using Trax.Effect.Extensions; +using Trax.Mediator.Extensions; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Extensions; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +/// +/// Coverage for the SchedulerConfigurationBuilder Settings methods that the existing +/// SchedulerConfigurationBuilderSettingsTests doesn't reach: MaxConcurrentDispatch, +/// MaxDispatchAttempts, MaxWorkQueueEntriesPerCycle, StalePendingTimeout, +/// StaleInProgressTimeout, DefaultMisfirePolicy. +/// +[TestFixture] +public class SchedulerSettingsExtraTests +{ + private static SchedulerConfiguration ResolveConfiguration( + Action configure + ) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTrax(trax => + trax.AddEffects(effects => effects.UseInMemory()) + .AddMediator(typeof(AssemblyMarker).Assembly) + .AddScheduler(scheduler => + { + scheduler.UseInMemoryWorkers(); + configure(scheduler); + return scheduler; + }) + ); + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + [Test] + public void MaxConcurrentDispatch_PositiveValue_StoresValue() + { + var config = ResolveConfiguration(b => b.MaxConcurrentDispatch(7)); + config.MaxConcurrentDispatch.Should().Be(7); + } + + [Test] + public void MaxConcurrentDispatch_ZeroOrNegative_FloorsAtOne() + { + var config = ResolveConfiguration(b => b.MaxConcurrentDispatch(0)); + config.MaxConcurrentDispatch.Should().Be(1); + } + + [Test] + public void MaxDispatchAttempts_PositiveValue_StoresValue() + { + var config = ResolveConfiguration(b => b.MaxDispatchAttempts(9)); + config.MaxDispatchAttempts.Should().Be(9); + } + + [Test] + public void MaxDispatchAttempts_Negative_FloorsAtZero() + { + var config = ResolveConfiguration(b => b.MaxDispatchAttempts(-3)); + config.MaxDispatchAttempts.Should().Be(0); + } + + [Test] + public void MaxWorkQueueEntriesPerCycle_PositiveValue_StoresValue() + { + var config = ResolveConfiguration(b => b.MaxWorkQueueEntriesPerCycle(150)); + config.MaxWorkQueueEntriesPerCycle.Should().Be(150); + } + + [Test] + public void MaxWorkQueueEntriesPerCycle_Null_DisablesLimit() + { + var config = ResolveConfiguration(b => b.MaxWorkQueueEntriesPerCycle(null)); + config.MaxWorkQueueEntriesPerCycle.Should().BeNull(); + } + + [Test] + public void MaxWorkQueueEntriesPerCycle_Zero_FloorsAtOne() + { + var config = ResolveConfiguration(b => b.MaxWorkQueueEntriesPerCycle(0)); + config.MaxWorkQueueEntriesPerCycle.Should().Be(1); + } + + [Test] + public void StalePendingTimeout_AppliesValue() + { + var config = ResolveConfiguration(b => b.StalePendingTimeout(TimeSpan.FromMinutes(45))); + config.StalePendingTimeout.Should().Be(TimeSpan.FromMinutes(45)); + } + + [Test] + public void StaleInProgressTimeout_AppliesValue() + { + var config = ResolveConfiguration(b => b.StaleInProgressTimeout(TimeSpan.FromMinutes(120))); + config.StaleInProgressTimeout.Should().Be(TimeSpan.FromMinutes(120)); + } + + [Test] + public void DefaultMisfirePolicy_AppliesValue() + { + var config = ResolveConfiguration(b => b.DefaultMisfirePolicy(MisfirePolicy.DoNothing)); + config.DefaultMisfirePolicy.Should().Be(MisfirePolicy.DoNothing); + } + + [Test] + public void UseRemoteRun_RegistersRemoteRunOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTrax(trax => + trax.AddEffects(effects => effects.UseInMemory()) + .AddMediator(typeof(AssemblyMarker).Assembly) + .AddScheduler(scheduler => + { + scheduler + .UseInMemoryWorkers() + .UseRemoteRun(opts => opts.BaseUrl = "https://run.example.com/"); + return scheduler; + }) + ); + using var provider = services.BuildServiceProvider(); + + var opts = provider.GetService(); + opts.Should().NotBeNull(); + opts!.BaseUrl.Should().Be("https://run.example.com/"); + } + + [Test] + public void PruneOrphanedManifests_FlagSetsConfiguration() + { + var enabledConfig = ResolveConfiguration(b => b.PruneOrphanedManifests()); + var disabledConfig = ResolveConfiguration(b => b.PruneOrphanedManifests(false)); + + enabledConfig.PruneOrphanedManifests.Should().BeTrue(); + disabledConfig.PruneOrphanedManifests.Should().BeFalse(); + } +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulingHelpersExtraTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulingHelpersExtraTests.cs new file mode 100644 index 0000000..5bb45cc --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/SchedulingHelpersExtraTests.cs @@ -0,0 +1,352 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Trax.Effect.Enums; +using Trax.Effect.Models.Manifest; +using Trax.Effect.Models.Manifest.DTOs; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Trains.ManifestManager.Utilities; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +/// +/// Coverage for SchedulingHelpers code paths the cron-focused suite doesn't reach: +/// the OnDemand/Dependent default arms, malformed cron handling, the IsTimeForCron / +/// IsTimeForInterval public helpers, and ComputeNextScheduledRun across schedule types. +/// +[TestFixture] +public class SchedulingHelpersExtraTests +{ + private static SchedulerConfiguration NewConfig() => new(); + + private static Manifest NewManifest() + { + var m = Manifest.Create(new CreateManifest { Name = typeof(SomeTrain) }); + m.IsEnabled = true; + return m; + } + + private class SomeTrain { } + + [Test] + public void ShouldRunNow_OnDemandSchedule_ReturnsFalse() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.OnDemand; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeFalse(); + } + + [Test] + public void ShouldRunNow_DependentSchedule_ReturnsFalse() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Dependent; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeFalse(); + } + + [Test] + public void ShouldRunNow_CronWithMissingExpression_ReturnsFalse() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = null; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeFalse(); + } + + [Test] + public void ShouldRunNow_CronWithMalformedExpression_DoesNotThrow() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = "totally not cron syntax @@@"; + + Action act = () => + SchedulingHelpers.ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance); + + act.Should().NotThrow(); + } + + [Test] + public void ShouldRunNow_IntervalWithMissingSeconds_ReturnsFalse() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = null; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeFalse(); + } + + [Test] + public void ShouldRunNow_IntervalWithZeroSeconds_ReturnsFalse() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = 0; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeFalse(); + } + + [Test] + public void ShouldRunNow_OnceAlreadyRan_ReturnsFalse() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Once; + m.ScheduledAt = DateTime.UtcNow.AddMinutes(-1); + m.LastSuccessfulRun = DateTime.UtcNow.AddMinutes(-1); + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeFalse(); + } + + [Test] + public void ShouldRunNow_CronOverdueWithinThreshold_Fires() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = "0 * * * *"; + m.LastSuccessfulRun = DateTime.UtcNow.AddHours(-2); + m.MisfireThresholdSeconds = 7200; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeTrue(); + } + + [Test] + public void ShouldRunNow_CronOverdueBeyondThreshold_FireOnceNow_StillFires() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = "0 * * * *"; + m.LastSuccessfulRun = DateTime.UtcNow.AddDays(-7); + m.MisfirePolicy = MisfirePolicy.FireOnceNow; + m.MisfireThresholdSeconds = 1; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeTrue(); + } + + [Test] + public void ShouldRunNow_CronDoNothingPolicy_ExercisesEvaluateBoundary() + { + // The DoNothing policy with overdue cron triggers EvaluateCronBoundary, the path + // unit-tested here. Outcome depends on wall-clock proximity to a cron tick — what + // we assert is that the call completes without throwing. + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = "*/30 * * * *"; + m.LastSuccessfulRun = DateTime.UtcNow.AddDays(-2); + m.MisfirePolicy = MisfirePolicy.DoNothing; + m.MisfireThresholdSeconds = 1; + + Action act = () => + SchedulingHelpers.ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance); + + act.Should().NotThrow(); + } + + [Test] + public void ShouldRunNow_IntervalDoNothingPolicy_ExercisesEvaluateBoundary() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = 60; + m.LastSuccessfulRun = DateTime.UtcNow.AddHours(-3); + m.MisfirePolicy = MisfirePolicy.DoNothing; + m.MisfireThresholdSeconds = 1; + + Action act = () => + SchedulingHelpers.ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance); + + act.Should().NotThrow(); + } + + [Test] + public void ShouldRunNow_IntervalOverdueFireOnceNow_StillFires() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = 60; + m.LastSuccessfulRun = DateTime.UtcNow.AddDays(-1); + m.MisfirePolicy = MisfirePolicy.FireOnceNow; + m.MisfireThresholdSeconds = 1; + + SchedulingHelpers + .ShouldRunNow(m, DateTime.UtcNow, NewConfig(), NullLogger.Instance) + .Should() + .BeTrue(); + } + + #region IsTimeForCron + + [Test] + public void IsTimeForCron_NeverRun_ReturnsTrue() + { + SchedulingHelpers.IsTimeForCron(null, "* * * * *", DateTime.UtcNow).Should().BeTrue(); + } + + [Test] + public void IsTimeForCron_RanRecently_ReturnsFalse() + { + SchedulingHelpers + .IsTimeForCron(DateTime.UtcNow.AddSeconds(-1), "0 0 * * *", DateTime.UtcNow) + .Should() + .BeFalse(); + } + + [Test] + public void IsTimeForCron_OverdueByLong_ReturnsTrue() + { + SchedulingHelpers + .IsTimeForCron(DateTime.UtcNow.AddDays(-2), "0 0 * * *", DateTime.UtcNow) + .Should() + .BeTrue(); + } + + #endregion + + #region IsTimeForInterval + + [Test] + public void IsTimeForInterval_NeverRun_ReturnsTrue() + { + SchedulingHelpers.IsTimeForInterval(null, 60, DateTime.UtcNow).Should().BeTrue(); + } + + [Test] + public void IsTimeForInterval_NotYetDue_ReturnsFalse() + { + SchedulingHelpers + .IsTimeForInterval(DateTime.UtcNow.AddSeconds(-30), 60, DateTime.UtcNow) + .Should() + .BeFalse(); + } + + [Test] + public void IsTimeForInterval_PastDue_ReturnsTrue() + { + SchedulingHelpers + .IsTimeForInterval(DateTime.UtcNow.AddMinutes(-5), 60, DateTime.UtcNow) + .Should() + .BeTrue(); + } + + #endregion + + #region ComputeNextScheduledRun (via reflection — internal) + + [Test] + public void ComputeNextScheduledRun_NoVariance_ReturnsNull() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = 60; + m.VarianceSeconds = null; + m.LastSuccessfulRun = DateTime.UtcNow; + + var result = Invoke(m); + result.Should().BeNull(); + } + + [Test] + public void ComputeNextScheduledRun_NoLastRun_ReturnsNull() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = 60; + m.VarianceSeconds = 10; + m.LastSuccessfulRun = null; + + var result = Invoke(m); + result.Should().BeNull(); + } + + [Test] + public void ComputeNextScheduledRun_Interval_AppliesVariance() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Interval; + m.IntervalSeconds = 60; + m.VarianceSeconds = 10; + m.LastSuccessfulRun = DateTime.UtcNow; + + var result = Invoke(m); + result.Should().NotBeNull(); + // base = last + 60s, variance ∈ [0, 10s] + result!.Value.Should().BeOnOrAfter(m.LastSuccessfulRun.Value.AddSeconds(60)); + result.Value.Should().BeOnOrBefore(m.LastSuccessfulRun.Value.AddSeconds(70)); + } + + [Test] + public void ComputeNextScheduledRun_Cron_AppliesVariance() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = "0 * * * *"; + m.VarianceSeconds = 30; + m.LastSuccessfulRun = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + var result = Invoke(m); + result.Should().NotBeNull(); + result!.Value.Hour.Should().Be(13); + } + + [Test] + public void ComputeNextScheduledRun_OnceSchedule_ReturnsNull() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Once; + m.VarianceSeconds = 10; + m.LastSuccessfulRun = DateTime.UtcNow; + + var result = Invoke(m); + result.Should().BeNull(); + } + + [Test] + public void ComputeNextScheduledRun_CronWithInvalidExpression_ReturnsNull() + { + var m = NewManifest(); + m.ScheduleType = ScheduleType.Cron; + m.CronExpression = "garbage"; + m.VarianceSeconds = 10; + m.LastSuccessfulRun = DateTime.UtcNow; + + var result = Invoke(m); + result.Should().BeNull(); + } + + private static DateTime? Invoke(Manifest m) + { + var method = typeof(SchedulingHelpers).GetMethod( + "ComputeNextScheduledRun", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + )!; + return (DateTime?)method.Invoke(null, new object[] { m }); + } + + #endregion +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/TraxRequestHandlerErrorTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/TraxRequestHandlerErrorTests.cs new file mode 100644 index 0000000..b359cbf --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/TraxRequestHandlerErrorTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using FluentAssertions; +using NUnit.Framework; +using Trax.Core.Exceptions; +using Trax.Scheduler.Services.RequestHandler; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +[TestFixture] +public class TraxRequestHandlerErrorTests +{ + [Test] + public void BuildErrorResponse_NonStructuredMessage_FallsBackToRawDetails() + { + var ex = new InvalidOperationException("plain failure"); + + var resp = TraxRequestHandler.BuildErrorResponse(ex); + + resp.IsError.Should().BeTrue(); + resp.MetadataId.Should().Be(0); + resp.ErrorMessage.Should().Be("plain failure"); + resp.ExceptionType.Should().Be(nameof(InvalidOperationException)); + } + + [Test] + public void BuildErrorResponse_StructuredJsonMessage_ExtractsTrainExceptionFields() + { + var data = new TrainExceptionData + { + TrainName = "Trax.X.MyTrain", + TrainExternalId = "ext", + Type = "ApplicationException", + Junction = "MyJunction", + Message = "the inner reason", + }; + var ex = new TrainException(JsonSerializer.Serialize(data)); + + var resp = TraxRequestHandler.BuildErrorResponse(ex); + + resp.IsError.Should().BeTrue(); + resp.ErrorMessage.Should().Be("the inner reason"); + resp.ExceptionType.Should().Be("ApplicationException"); + resp.FailureJunction.Should().Be("MyJunction"); + } + + [Test] + public void BuildErrorResponse_NullMessage_DoesNotThrow() + { + var ex = new Exception(); + + var resp = TraxRequestHandler.BuildErrorResponse(ex); + + resp.IsError.Should().BeTrue(); + } + + [Test] + public void BuildErrorResponse_GarbageJsonMessage_FallsBackToPlain() + { + var ex = new Exception("{not really json"); + + var resp = TraxRequestHandler.BuildErrorResponse(ex); + + resp.IsError.Should().BeTrue(); + resp.ErrorMessage.Should().Be("{not really json"); + resp.ExceptionType.Should().Be(nameof(Exception)); + } +}