diff --git a/README.md b/README.md
index 4f1eac3..c91af20 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
[](https://www.nuget.org/packages/Trax.Scheduler/)
[](LICENSE)
+[](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));
+ }
+}