using Elsa.Common.DistributedHosting;
using Elsa.Common.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Management;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime.Entities;
using Medallion.Threading;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Elsa.Workflows.Runtime.UnitTests.Services;
///
/// Characterises how turns the payloads of an into stored triggers.
/// The invariant under test: every stored row's equals the hash a publisher computes from the row's own
/// and , where that name is the one the trigger associated with that payload.
///
public class TriggerIndexerTests
{
private const string EventStimulusName = "Test.Event";
private const string TimerStimulusName = "Test.Timer";
private readonly IStimulusHasher _hasher;
private readonly TriggerIndexer _indexer;
public TriggerIndexerTests()
{
var typeRegistry = SerializationTypeRegistry.CreateDefault();
var serviceProvider = Substitute.For();
var activityRegistry = Substitute.For();
var identityGenerator = Substitute.For();
activityRegistry.Find(Arg.Any(), Arg.Any()).Returns(new ActivityDescriptor());
identityGenerator.GenerateId().Returns(_ => Guid.NewGuid().ToString("N"));
_hasher = new StimulusHasher(new Hasher(typeRegistry));
_indexer = new(
new ActivityVisitor([], serviceProvider),
Substitute.For(),
Substitute.For(),
identityGenerator,
Substitute.For(),
activityRegistry,
Substitute.For(),
serviceProvider,
_hasher,
Substitute.For(),
typeRegistry,
Microsoft.Extensions.Options.Options.Create(new DistributedLockingOptions()),
NullLogger.Instance);
}
[Fact(DisplayName = "A payload without a name of its own is indexed under the activity type name")]
public async Task GetTriggersAsync_PayloadWithoutName_UsesActivityTypeName()
{
var payload = new TestStimulus("a");
var triggers = await IndexAsync(_ => [payload]);
var trigger = Assert.Single(triggers);
Assert.Equal(ActivityTypeNameHelper.GenerateTypeName(), trigger.Name);
AssertMatchable(trigger, ActivityTypeNameHelper.GenerateTypeName(), payload);
}
[Fact(DisplayName = "An assigned trigger name applies to every payload the trigger returns")]
public async Task GetTriggersAsync_AssignedTriggerName_AppliesToEveryPayload()
{
var first = new TestStimulus("a");
var second = new TestStimulus("b");
var triggers = await IndexAsync(context =>
{
context.TriggerName = EventStimulusName;
return [first, second];
});
Assert.Collection(triggers,
trigger => AssertMatchable(trigger, EventStimulusName, first),
trigger => AssertMatchable(trigger, EventStimulusName, second));
}
[Fact(DisplayName = "The last assignment of the trigger name wins for payloads that do not carry a name")]
public async Task GetTriggersAsync_TriggerNameAssignedTwice_LastWriteWinsForUnnamedPayloads()
{
var first = new TestStimulus("a");
var second = new TestStimulus("b");
var triggers = await IndexAsync(context =>
{
context.TriggerName = EventStimulusName;
context.TriggerName = TimerStimulusName;
return [first, second];
});
// Pinned deliberately: the shared field keeps its existing semantics. Payloads that need their own name must say so.
Assert.Collection(triggers,
trigger => AssertMatchable(trigger, TimerStimulusName, first),
trigger => AssertMatchable(trigger, TimerStimulusName, second));
}
[Fact(DisplayName = "Each named payload is indexed under its own name, and each is matchable")]
public async Task GetTriggersAsync_NamedPayloads_AreEachIndexedUnderTheirOwnName()
{
var eventStimulus = new TestStimulus("order-received");
var timerStimulus = new TestStimulus("every-hour");
var triggers = await IndexAsync(context =>
{
// Mirrors the stimulus extension methods, which each assign the shared name as a side effect.
context.TriggerName = EventStimulusName;
context.TriggerName = TimerStimulusName;
return
[
new NamedTriggerPayload(EventStimulusName, eventStimulus),
new NamedTriggerPayload(TimerStimulusName, timerStimulus)
];
});
Assert.Collection(triggers,
trigger => AssertMatchable(trigger, EventStimulusName, eventStimulus),
trigger => AssertMatchable(trigger, TimerStimulusName, timerStimulus));
}
[Fact(DisplayName = "Named and unnamed payloads can be mixed on a single trigger")]
public async Task GetTriggersAsync_MixedPayloads_NameEachPayloadIndependently()
{
var named = new TestStimulus("named");
var unnamed = new TestStimulus("unnamed");
var triggers = await IndexAsync(context =>
{
context.TriggerName = TimerStimulusName;
return [new NamedTriggerPayload(EventStimulusName, named), unnamed];
});
Assert.Collection(triggers,
trigger => AssertMatchable(trigger, EventStimulusName, named),
trigger => AssertMatchable(trigger, TimerStimulusName, unnamed));
}
[Fact(DisplayName = "A named payload is stored unwrapped")]
public async Task GetTriggersAsync_NamedPayload_StoresTheInnerPayload()
{
var payload = new TestStimulus("a");
var triggers = await IndexAsync(_ => [new NamedTriggerPayload(EventStimulusName, payload)]);
// Storing the wrapper would produce a self-consistent but unmatchable row, and would hand the wrapper to payload validators.
var trigger = Assert.Single(triggers);
Assert.Same(payload, trigger.Payload);
Assert.IsType(trigger.Payload);
}
[Fact(DisplayName = "A trigger returning no payloads still produces a single placeholder row")]
public async Task GetTriggersAsync_NoPayloads_ProducesPlaceholderRow()
{
var triggers = await IndexAsync(_ => []);
var trigger = Assert.Single(triggers);
Assert.Null(trigger.Payload);
Assert.Equal(ActivityTypeNameHelper.GenerateTypeName(), trigger.Name);
Assert.Equal(_hasher.Hash(trigger.Name!, null), trigger.Hash);
}
[Fact(DisplayName = "A trigger that throws produces a single placeholder row")]
public async Task GetTriggersAsync_ThrowingTrigger_ProducesPlaceholderRow()
{
var triggers = await IndexAsync(_ => throw new InvalidOperationException("Cannot resolve payloads."));
var trigger = Assert.Single(triggers);
Assert.Null(trigger.Payload);
Assert.Equal(ActivityTypeNameHelper.GenerateTypeName(), trigger.Name);
}
[Theory(DisplayName = "A named payload requires a stimulus name")]
[InlineData("")]
[InlineData(" ")]
public void NamedTriggerPayload_BlankName_Throws(string name)
{
Assert.Throws(() => new NamedTriggerPayload(name, new TestStimulus("a")));
}
[Fact(DisplayName = "A named payload refuses to wrap another named payload")]
public void NamedTriggerPayload_NestedPayload_Throws()
{
var inner = new NamedTriggerPayload(EventStimulusName, new TestStimulus("a"));
Assert.Throws(() => new NamedTriggerPayload(TimerStimulusName, inner));
}
private async Task> IndexAsync(Func> payloadsFactory)
{
var trigger = new TestTrigger
{
PayloadsFactory = payloadsFactory
};
trigger.SetCanStartWorkflow(true);
var workflow = new Workflow(trigger)
{
Identity = new("definition-1", 1, "definition-1:1")
};
return (await _indexer.GetTriggersAsync(workflow, default)).ToList();
}
///
/// Asserts that the row carries the expected name and payload, and that its hash is the one a publisher of that same stimulus name computes.
/// Also asserts that the hash is not the one that would result from labelling the payload with the other stimulus name in play.
///
private void AssertMatchable(StoredTrigger trigger, string expectedName, object expectedPayload)
{
var otherName = expectedName == EventStimulusName ? TimerStimulusName : EventStimulusName;
Assert.Equal(expectedName, trigger.Name);
Assert.Same(expectedPayload, trigger.Payload);
Assert.Equal(_hasher.Hash(expectedName, expectedPayload), trigger.Hash);
Assert.NotEqual(_hasher.Hash(otherName, expectedPayload), trigger.Hash);
}
}
public record TestStimulus(string Value);
public class TestTrigger : Trigger
{
public Func> PayloadsFactory { get; set; } = _ => [];
protected override IEnumerable