< Summary

Information
Class: Elsa.Bpmn.Interchange.Handlers.Notifications.ValidateBpmnProcessBindings
Assembly: Elsa.Bpmn.Interchange
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Handlers/Notifications/ValidateBpmnProcessBindings.cs
Line coverage
100%
Covered lines: 37
Uncovered lines: 0
Coverable lines: 37
Total lines: 113
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
HandleAsync()100%88100%
IsUnboundTaskCandidate(...)100%44100%
FindBoundActivity(...)100%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Handlers/Notifications/ValidateBpmnProcessBindings.cs

#LineLine coverage
 1using Bpmn.Interchange;
 2using Bpmn.Model;
 3using Elsa.Bpmn.Activities;
 4using Elsa.Mediator.Contracts;
 5using Elsa.Workflows;
 6using Elsa.Workflows.Activities;
 7using Elsa.Workflows.Management.Models;
 8using Elsa.Workflows.Management.Notifications;
 9
 10namespace Elsa.Bpmn.Interchange.Handlers.Notifications;
 11
 12/// <summary>
 13/// The second net for <see cref="BpmnWorkBinding.UnboundTask"/>. <see cref="Binding.BpmnWorkBinder"/> already
 14/// refuses an unbound task at <em>import</em>, but a definition can be edited after import — through Elsa's own
 15/// workflow designer, not through the BPMN document — and an edit can remove the activity a task was bound to,
 16/// or leave the binding pointing at an activity type that has since been uninstalled. Neither edit touches the
 17/// BPMN document this scope still carries, so neither is visible to the bind-time refusal; this handler is what
 18/// catches both before the definition publishes. The two are reported with different messages: an id with no
 19/// bound activity at all is "not bound", while an id resolved through <see cref="IActivitySerializer"/> to a
 20/// <see cref="NotFoundActivity"/> - the same placeholder <c>BpmnActivityBindingFormat</c> already refuses at bind
 21/// time for the identical reason - is "bound to an activity type that is no longer installed"; an operator needs
 22/// to know which.
 23/// </summary>
 24/// <remarks>
 25/// <para>
 26/// <b>Where the truth lives.</b> This inspects the materialized activity graph — each <see cref="BpmnProcess"/>'s
 27/// own <see cref="BpmnProcess.WorkBindings"/> and <see cref="Container.Activities"/> — not the BPMN source text a
 28/// document was imported from. The two can disagree, and only one of them is what a published workflow would
 29/// actually run: <see cref="BpmnProcess.Process"/> is an inert snapshot of the document as it read at import time,
 30/// so removing a bound activity from the graph after import leaves that snapshot, and any <c>elsa:activityBinding</c>
 31/// extension on it, exactly as it was. Checking the snapshot instead of the graph would make this gate pass on a
 32/// definition it exists to catch. Re-reading the document's stored source XML (kept only for BPMN export, and
 33/// already documented there as one a post-import edit can leave stale or remove outright) has the identical defect
 34/// for the identical reason: neither the snapshot nor the source text moves when a graph edit removes a binding, so
 35/// only the graph itself can answer whether the definition being published still has one. The graph is reached
 36/// through <see cref="IWorkflowGraphBuilder"/>, the same service <c>ValidateOutputConverters</c> in
 37/// <c>Elsa.Workflows.Management</c> uses for the same reason: it, not a hand-rolled walk of <c>Container.Activities</c>
 38/// is what correctly reaches an activity nested through any composition shape a workflow can use, including a
 39/// <c>BpmnProcess</c> composed into a <c>Flowchart</c> (D11).
 40/// </para>
 41/// <para>
 42/// <b>Which elements this looks at.</b> Of every BPMN element kind that carries a <see cref="BpmnElement.BindingRef"/>,
 43/// only the task family — <see cref="BpmnElementTypes.Task"/>, <see cref="BpmnElementTypes.UserTask"/>,
 44/// <see cref="BpmnElementTypes.ServiceTask"/>, <see cref="BpmnElementTypes.ScriptTask"/>,
 45/// <see cref="BpmnElementTypes.ManualTask"/>, <see cref="BpmnElementTypes.BusinessRuleTask"/>,
 46/// <see cref="BpmnElementTypes.SendTask"/> and <see cref="BpmnElementTypes.ReceiveTask"/> — can be an
 47/// <see cref="BpmnWorkBinding.UnboundTask"/>; every other bindable kind (a call activity, a nested subprocess, a
 48/// message/signal/timer catch or throw, a message/signal/timer boundary) binds to an Elsa activity automatically and
 49/// takes no <c>elsa:activityBinding</c> declaration, so <see cref="Binding.BpmnWorkBinder"/> never refuses those for
 50/// want of one. A send or receive task that resolved a BPMN <c>message</c> is excluded the same way
 51/// <c>Bpmn.Interchange</c>'s own reader tells the two apart: it carries
 52/// <see cref="BpmnXmlReader.MessageNamePropertyKey"/> in its <see cref="BpmnElement.Properties"/>, which only that
 53/// resolution ever sets.
 54/// </para>
 55/// </remarks>
 6556public class ValidateBpmnProcessBindings(IWorkflowGraphBuilder workflowGraphBuilder) : INotificationHandler<WorkflowDefi
 57{
 158    private static readonly IReadOnlySet<string> UnboundTaskElementTypes = new HashSet<string>(StringComparer.Ordinal)
 159    {
 160        BpmnElementTypes.Task,
 161        BpmnElementTypes.UserTask,
 162        BpmnElementTypes.ServiceTask,
 163        BpmnElementTypes.ScriptTask,
 164        BpmnElementTypes.ManualTask,
 165        BpmnElementTypes.BusinessRuleTask,
 166        BpmnElementTypes.SendTask,
 167        BpmnElementTypes.ReceiveTask
 168    };
 69
 70    /// <inheritdoc />
 71    public async Task HandleAsync(WorkflowDefinitionValidating notification, CancellationToken cancellationToken)
 72    {
 673        var graph = await workflowGraphBuilder.BuildAsync(notification.Workflow, cancellationToken);
 74
 2475        foreach (var scope in graph.Nodes
 1676                     .Select(node => node.Activity)
 677                     .OfType<BpmnProcess>()
 1278                     .Where(process => process.Process is not null))
 79        {
 2480            foreach (var element in scope.Process!.Elements.Where(IsUnboundTaskCandidate))
 81            {
 682                var boundActivity = FindBoundActivity(scope, element.BindingRef!);
 83
 684                if (boundActivity is null)
 85                {
 386                    notification.ValidationErrors.Add(new(
 387                        $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{scope.Process.ProcessI
 388                        + "BPMN does not say what this task does; declare an activity binding for it, or remove the elem
 389                        scope.Id));
 390                    continue;
 91                }
 92
 393                if (boundActivity is NotFoundActivity notFound)
 94                {
 195                    notification.ValidationErrors.Add(new(
 196                        $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{scope.Process.ProcessI
 197                        + $"type that is no longer installed ('{notFound.MissingTypeName}'). Rebind the element to an in
 198                        scope.Id));
 99                }
 100            }
 101        }
 6102    }
 103
 104    private static bool IsUnboundTaskCandidate(BpmnElement element) =>
 10105        element.BindingRef is not null
 10106        && UnboundTaskElementTypes.Contains(element.ElementType)
 10107        && !element.Properties.ContainsKey(BpmnXmlReader.MessageNamePropertyKey);
 108
 109    private static IActivity? FindBoundActivity(BpmnProcess scope, string bindingRef) =>
 6110        scope.WorkBindings.TryGetValue(bindingRef, out var activityId)
 3111            ? scope.Activities.FirstOrDefault(activity => string.Equals(activity.Id, activityId, StringComparison.Ordina
 6112            : null;
 113}