elsa-core/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Scenarios/Interchange/BpmnInterchangeDocumentServiceTests.cs
Sipke Schoorstra fe83d2b385
test(bpmn): event subprocesses, dormant and listener-backed (#7963)
* test(bpmn): event subprocesses, dormant and listener-backed (#7933)

Both flavours, and the three responsibilities the host has for them. The
production diff is empty: the applier and binder need no event-subprocess
code path, which is what #7909 measured and what this pins.

A dormant catcher -- error or escalation -- rides the FaultSignal seam and the
escalation signal path, and reaches the host as an ordinary StartWork for its
body. A listener-backed one gets a second StartWork for its listenerBindingRef
at scope start and a CancelWorkSubtree for it when the scope completes. Both
already apply like any other command.

The host responsibilities, each with the failure it would otherwise hide:

- The listener is armed at scope start and observable in the scope's own ledger
  before anything fires, not inferred from a fire that worked.
- A completing scope retires a still-armed listener. Pinned twice: at the root,
  where Elsa's own container completion would cancel the child regardless and
  only the scope's ledger tells the two apart, and inside a subprocess the
  workflow outlives, where a listener left behind is one something could still
  resume into.
- The start-element hint reaches the body and nothing else inherits it. The
  body's only start event is event-defined, so a body that never received the
  hint faults bpmn.start.none-available rather than starting somewhere
  plausible; the ordinary subprocess inside it faults bpmn.start.unresolved-hint
  if the hint travels where it must not. The scope's invocation correlation is
  read back after the body has run work of its own, because the dictionary is
  fixed for the scope's lifetime and the hint is read from it.
- A non-interrupting listener fired twice re-arms onto the slot the first fire
  vacated, holding one live record and one bookmark at a time. This is the case
  the completed-work-removed-before-the-interpreter-is-asked ordering exists
  for, and it is now observable; BpmnHostInvariantTests points at it.

The library's declaration rules are pinned as refusals rather than gaps: a body
with more than one start event, a second error-triggered event subprocess in a
scope, and a non-interrupting error event subprocess are each refused when the
scope builds its graph, before any work starts. The last is additionally
dropped at import, with the rest of the document reading as written -- the
dropped body carries an undeclared serviceTask, so an import that still
succeeds is what proves the drop took its bindings with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): pin the catch-all escalation event subprocess refusal

Two error-triggered event subprocesses per scope was pinned but its sibling
rule -- at most one code-less catch-all escalation event subprocess -- had
no test. Add TwoCatchAllEscalationEventSubprocesses_AreRefused, mirroring
the error refusal test, and let Escalation() build a code-less definition.

Also record in BpmnCommandApplier why CancelSubtreeAsync's explicit
subtree cancellation is redundant on the scope-completion path (Elsa's own
container-completion behaviour already covers it) while the ledger removal
above it is not, so a future reader does not "simplify" the ledger removal
away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:02:51 +02:00

162 lines
9.6 KiB
C#

using System.Xml.Linq;
using Bpmn.Interchange;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Models;
using Xunit.Abstractions;
namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Interchange;
/// <summary>
/// The round trip the Analyze, Import and Export endpoints exist for: a Camunda-authored <c>.bpmn</c> file survives
/// import and export with its foreign extension elements, foreign attributes and BPMN DI waypoints intact.
/// </summary>
public class BpmnInterchangeDocumentServiceTests(ITestOutputHelper testOutputHelper) : BpmnInterchangeTestBase(testOutputHelper)
{
private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
private static readonly XNamespace Elsa = "https://elsaworkflows.io/schemas/bpmn/v1";
private static readonly XNamespace Dc = "http://www.omg.org/spec/DD/20100524/DC";
private static readonly XNamespace Di = "http://www.omg.org/spec/DD/20100524/DI";
private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
[Fact(DisplayName = "Analyze and Import report the same findings for the same document")]
public async Task Analyze_AndImport_ReportTheSameFindings()
{
var xml = ReadAsset("camunda-order-process.bpmn");
var analysis = DocumentService.Analyze(xml);
var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.Equal(analysis.ProcessIds, imported.Analysis.ProcessIds);
Assert.Equal(analysis.Issues.Select(issue => issue.Message), imported.Analysis.Issues.Select(issue => issue.Message));
}
[Fact(DisplayName = "Importing a Camunda file binds the unbound task and persists a workflow definition")]
public async Task Import_BindsAndPersists()
{
var xml = ReadAsset("camunda-order-process.bpmn");
var result = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.True(result.ImportResult.Succeeded, string.Join("; ", result.ImportResult.ValidationErrors.Select(error => error.Message)));
Assert.NotEmpty(result.ImportResult.WorkflowDefinition.DefinitionId);
Assert.Contains(result.Analysis.Issues, issue => issue.ElementId == "NotifyWarehouse" && issue.Severity == BpmnImportIssueSeverity.Info);
}
[Fact(DisplayName = "A non-interrupting error event subprocess is dropped at import, and the rest of the document still imports")]
public async Task Import_DropsANonInterruptingErrorEventSubprocess()
{
// A refusal, not a bug, and not a failed import: error events are always interrupting per BPMN, so the reader
// reports the whole <subProcess> as Dropped and reads the rest of the document as written.
//
// The import succeeding is what proves the drop was total. The dropped body declares an undeclared
// <serviceTask>, and an unbound task the binder can see is refused outright -- so a drop that reported the
// element but left its bindings behind would surface here as a BpmnBindingException naming 'HandleError',
// not as a quietly half-imported process.
var xml = ReadAsset("non-interrupting-error-event-subprocess.bpmn");
var analysis = DocumentService.Analyze(xml);
var dropped = Assert.Single(analysis.Issues, candidate => candidate.Severity == BpmnImportIssueSeverity.Dropped);
Assert.Equal("OnError", dropped.ElementId);
Assert.Contains("non-interrupting error event subprocess", dropped.Message);
// The reader's own findings about the dropped body's elements survive the drop, at Info: the body is read
// before the rule that drops the element around it is applied. Pinned so a future reader meets it as the
// library's behaviour rather than as evidence the drop was partial -- what proves it was total is the import.
Assert.Contains(analysis.Issues, candidate => candidate.ElementId == "HandleError" && candidate.Severity == BpmnImportIssueSeverity.Info);
var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.True(imported.ImportResult.Succeeded, string.Join("; ", imported.ImportResult.ValidationErrors.Select(error => error.Message)));
}
[Fact(DisplayName = "Exporting an imported definition retains foreign extension elements, foreign attributes and waypoints byte-identically")]
public async Task Export_RetainsForeignContentAndWaypoints()
{
var sourceXml = ReadAsset("camunda-order-process.bpmn");
var imported = await DocumentService.ImportAsync(sourceXml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.True(imported.ImportResult.Succeeded);
var definitionId = imported.ImportResult.WorkflowDefinition.DefinitionId;
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var stored = await DefinitionStore.FindAsync(filter);
Assert.NotNull(stored);
Assert.True(stored!.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceXmlCustomPropertyKey, out var storedXml));
Assert.Equal(sourceXml, storedXml);
var exportedBytes = DocumentService.Export(storedXml!);
var exportedXml = System.Text.Encoding.UTF8.GetString(exportedBytes);
var sourceDocument = XDocument.Parse(sourceXml);
var exportedDocument = XDocument.Parse(exportedXml);
// Whole-file byte identity is explicitly not what the library guarantees: attribute order, whitespace and
// namespace prefixes are documented cuts. What survives is asserted piece by piece instead.
Assert.NotEqual(sourceXml, exportedXml);
AssertForeignAttributesRetained(sourceDocument, exportedDocument);
AssertForeignExtensionElementsRetained(sourceDocument, exportedDocument);
AssertWaypointsRetained(sourceDocument, exportedDocument);
}
private static void AssertForeignAttributesRetained(XDocument source, XDocument exported)
{
var sourceProcess = source.Descendants(Bpmn + "process").Single();
var exportedProcess = exported.Descendants(Bpmn + "process").Single();
Assert.Equal(sourceProcess.Attribute(Camunda + "versionTag")?.Value, exportedProcess.Attribute(Camunda + "versionTag")?.Value);
var sourceTask = source.Descendants(Bpmn + "serviceTask").Single();
var exportedTask = exported.Descendants(Bpmn + "serviceTask").Single();
Assert.Equal("true", sourceTask.Attribute(Camunda + "asyncBefore")?.Value);
Assert.Equal(sourceTask.Attribute(Camunda + "asyncBefore")?.Value, exportedTask.Attribute(Camunda + "asyncBefore")?.Value);
}
private static void AssertForeignExtensionElementsRetained(XDocument source, XDocument exported)
{
var sourceProperty = source.Descendants(Camunda + "property").Single();
var exportedProperty = exported.Descendants(Camunda + "property").Single();
Assert.Equal("owner", sourceProperty.Attribute("name")?.Value);
Assert.Equal(sourceProperty.Attribute("name")?.Value, exportedProperty.Attribute("name")?.Value);
Assert.Equal(sourceProperty.Attribute("value")?.Value, exportedProperty.Attribute("value")?.Value);
var sourceInputParameter = source.Descendants(Camunda + "inputParameter").Single();
var exportedInputParameter = exported.Descendants(Camunda + "inputParameter").Single();
Assert.Equal(sourceInputParameter.Value, exportedInputParameter.Value);
// The elsa: activity binding is foreign to Bpmn.Interchange itself (its own vendor namespace defaults to
// vw:), so it round-trips as opaque retained content exactly like the camunda: extensions above.
var sourceBinding = source.Descendants(Elsa + "activityBinding").Single();
var exportedBinding = exported.Descendants(Elsa + "activityBinding").Single();
Assert.Equal("Elsa.WriteLine", sourceBinding.Attribute("activityType")?.Value);
Assert.Equal(sourceBinding.Attribute("activityType")?.Value, exportedBinding.Attribute("activityType")?.Value);
Assert.Equal(sourceBinding.Descendants(Elsa + "input").Single().Value, exportedBinding.Descendants(Elsa + "input").Single().Value);
var sourceDocumentation = source.Descendants(Bpmn + "documentation").Single();
var exportedDocumentation = exported.Descendants(Bpmn + "documentation").Single();
Assert.Equal(sourceDocumentation.Value, exportedDocumentation.Value);
}
private static void AssertWaypointsRetained(XDocument source, XDocument exported)
{
var sourceWaypoints = source.Descendants(Di + "waypoint").Select(WaypointOf).ToList();
var exportedWaypoints = exported.Descendants(Di + "waypoint").Select(WaypointOf).ToList();
Assert.Equal(4, sourceWaypoints.Count);
Assert.Equal(sourceWaypoints, exportedWaypoints);
var sourceBounds = source.Descendants(Dc + "Bounds").Select(BoundsOf).ToList();
var exportedBounds = exported.Descendants(Dc + "Bounds").Select(BoundsOf).ToList();
Assert.Equal(sourceBounds, exportedBounds);
}
private static (string X, string Y) WaypointOf(XElement element) => (element.Attribute("x")!.Value, element.Attribute("y")!.Value);
private static (string X, string Y, string Width, string Height) BoundsOf(XElement element) =>
(element.Attribute("x")!.Value, element.Attribute("y")!.Value, element.Attribute("width")!.Value, element.Attribute("height")!.Value);
}