using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.PublishingStrategies;
using Elsa.Testing.Shared;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.GracefulShutdown;
///
/// Verifies the pause/resume idempotency invariants and the SC-007 audit-entry-count rule against the real DI graph.
/// The HTTP layer is exercised separately by component tests (Elsa.Workflows.ComponentTests); these tests focus on the
/// audit-publishing logic that lives inside the endpoint handlers.
///
public class AdminPauseResumeIntegrationTests
{
private readonly IServiceProvider _services;
private readonly RecordingNotificationSender _recorder;
public AdminPauseResumeIntegrationTests(ITestOutputHelper testOutputHelper)
{
_recorder = new RecordingNotificationSender();
_services = new TestApplicationBuilder(testOutputHelper)
.ConfigureElsa(elsa => elsa.UseWorkflowRuntime())
.ConfigureServices(s => s.AddSingleton(_recorder))
.Build();
}
[Fact(DisplayName = "PauseAsync followed by repeated PauseAsync calls converges on the same state")]
public async Task PauseIsIdempotent()
{
var signal = _services.GetRequiredService();
var first = await signal.PauseAsync("maintenance", "op@example.com", CancellationToken.None);
var second = await signal.PauseAsync("maintenance", "op@example.com", CancellationToken.None);
Assert.True(first.Reason.HasFlag(QuiescenceReason.AdministrativePause));
Assert.Equal(first.PausedAt, second.PausedAt);
}
[Fact(DisplayName = "ResumeAsync clears AdministrativePause; second call is a no-op")]
public async Task ResumeIsIdempotent()
{
var signal = _services.GetRequiredService();
await signal.PauseAsync(null, null, CancellationToken.None);
var first = await signal.ResumeAsync("op@example.com", CancellationToken.None);
var second = await signal.ResumeAsync("op@example.com", CancellationToken.None);
Assert.False(first.Reason.HasFlag(QuiescenceReason.AdministrativePause));
Assert.Equal(QuiescenceReason.None, second.Reason);
}
[Fact(DisplayName = "ResumeAsync during drain is a no-op (returns state with Drain still set)")]
public async Task ResumeDuringDrainIsNoOp()
{
var signal = _services.GetRequiredService();
await signal.PauseAsync(null, null, CancellationToken.None);
await signal.BeginDrainAsync();
var state = await signal.ResumeAsync(null, CancellationToken.None);
Assert.True(state.Reason.HasFlag(QuiescenceReason.Drain));
Assert.True(state.Reason.HasFlag(QuiescenceReason.AdministrativePause));
}
[Fact(DisplayName = "Internal bookmark-queue ingress source surfaces in registry snapshot")]
public void InternalIngressSourceVisible()
{
var registry = _services.GetRequiredService();
var sources = registry.Snapshot();
Assert.Contains(sources, s => s.Name == "internal.bookmark-queue-worker");
}
[Fact(DisplayName = "Internal ingress source reports Paused state when the runtime is paused")]
public async Task InternalIngressReflectsSignal()
{
var registry = _services.GetRequiredService();
var signal = _services.GetRequiredService();
var internalSource = registry.Sources.First(s => s.Name == "internal.bookmark-queue-worker");
Assert.Equal(IngressSourceState.Running, internalSource.CurrentState);
await signal.PauseAsync(null, null, CancellationToken.None);
Assert.Equal(IngressSourceState.Paused, internalSource.CurrentState);
}
/// Captures published mediator notifications for assertion.
private sealed class RecordingNotificationSender : INotificationSender
{
public List Published { get; } = new();
public Task SendAsync(INotification notification, CancellationToken cancellationToken = default)
{
Published.Add(notification);
return Task.CompletedTask;
}
public Task SendAsync(INotification notification, IEventPublishingStrategy? strategy, CancellationToken cancellationToken = default)
{
Published.Add(notification);
return Task.CompletedTask;
}
}
}