elsa-core/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnErrorResponseMappingTests.cs
Sipke Schoorstra 0ac7184226
fix(bpmn): make document PUT If-Match and save a compare-and-swap (#8092)
* fix(bpmn): make document PUT If-Match and save a compare-and-swap

The document PUT checked If-Match, reloaded metadata, then saved through
the importer as separate steps. Two writers could both pass If-Match, and
a metadata-only save in that window was silently reverted.

Add IWorkflowDefinitionStore.TryUpdateLatestAsync — load, match, apply,
save as one critical section (memory) or ExecuteUpdate against the loaded
snapshot (EF). ImportDocumentAsync reads metadata inside that swap.
A lost race throws the same 412 the stale If-Match already returns.

Mongo/Dapper/ES stores need the same method before the endpoint is
concurrency-safe on those providers.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): resolve document-service DI and interleave test compile

Drop the cache-manager constructor dependency (only registered when
definition caching is on) and evict via DraftSaving/DraftSaved instead.
Update the export-availability stub construction and the CAS interleave
fixture to parse edited XML through the reader.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): close Greptile P1s on document PUT CAS

Require IsLatest in the EF ExecuteUpdate WHERE so a published-to-draft
loser is Conflict instead of a unique-key failure. Lock Memory CAS on
the shared MemoryStore so scoped wrappers cannot stale-overwrite.
Dispatch WorkflowDefinitionDraftSaving before the CAS persist so a
rejecting handler fails the request before commit.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): reuse published draft identity across DraftSaving and CAS

Allocate the published-to-draft id, version and created-at once before
WorkflowDefinitionDraftSaving. The compare-and-swap still rebuilds from
the just-loaded row so metadata is not frozen from the outer Find, then
reuses that announced identity and keeps handler-added custom properties.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(management): run Memory CAS lock holder off the test thread

The shared-lock test blocked inside TryUpdateLatestAsync on the test
thread, so it never reached the release signal and hung.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(efcore): drop the SQLite CAS harness that cannot match ExecuteUpdate

The in-memory SQLite fixture could not satisfy the store's DateTimeOffset
ORDER BY plus Data snapshot WHERE, so the winner CAS returned Conflict
before the IsLatest loser path ran. Memory already covers that contract.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): persist the prepared DraftSaving draft on document PUT CAS

Prepare the draft, dispatch WorkflowDefinitionDraftSaving so handlers can
mutate or reject, then TryUpdateLatestAsync with If-Match plus the loaded
snapshot and update: _ => draft. A metadata change in the window is 412
instead of overwriting the other write. DraftSaved still fires after CAS.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* docs(bpmn): align document PUT remarks with prepared-draft CAS

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 01:40:43 -07:00

128 lines
6.8 KiB
C#

using Bpmn.Model;
using Bpmn.Semantics;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Bpmn.Interchange.Services;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.UnitTests;
/// <summary>
/// The BPMN endpoints' exception-to-response mapping (<see cref="BpmnImportErrorResponses"/>,
/// <see cref="BpmnExportErrorResponses"/>) turns a thrown exception into a stable <see cref="BpmnErrorCodes"/> code
/// and, for the capability refusal, structured data — exercised here at the mapping itself, the same way
/// <see cref="BpmnInterchangeDocumentServiceCapabilityTests"/> exercises capability refusal directly, rather than
/// through an HTTP round trip: <c>Bpmn.*</c> 0.2.0 declares every capability this deployment's runtime needs, so a
/// document a real import refuses on capability grounds cannot be produced through the public API today. The same is
/// true of <see cref="BpmnDefinitionNotFoundException"/> (only reachable through a race the endpoints' own
/// existence checks close off) and <see cref="BpmnExportUnavailableReason.SourceVersionUnknown"/> (only reachable
/// through custom properties edited outside <c>ImportAsync</c>). See
/// <c>Elsa.Bpmn.Interchange.IntegrationTests.Endpoints.BpmnInterchangeEndpointTests</c> for the codes reachable
/// through a real HTTP request.
/// </summary>
public class BpmnErrorResponseMappingTests
{
[Fact(DisplayName = "A capability refusal is coded bpmn.import.capability-unsupported, carrying the missing capability names and driving element ids as data")]
public void CapabilityResponseFor_CarriesTheCodeAndTheStructuredData()
{
var definition = MultiInstanceDefinition("main", "each");
var exception = Assert.Throws<BpmnCapabilityException>(() =>
BpmnInterchangeDocumentService.EnsureCapabilitiesSatisfied(definition, [], BpmnHostCapabilities.None));
var response = BpmnImportErrorResponses.CapabilityResponseFor(exception);
Assert.Equal(BpmnErrorCodes.ImportCapabilityUnsupported, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
var message = Assert.Single(response.Errors["generalErrors"]);
Assert.Contains("IterationScopes", message);
Assert.Contains("each", message);
dynamic data = response.Data!;
Assert.Equal(new[] { "IterationScopes" }, (IReadOnlyList<string>)data.Capabilities);
Assert.Equal(new[] { "each" }, (IReadOnlyList<string>)data.ElementIds);
}
[Fact(DisplayName = "A binding refusal is coded bpmn.import.binding-invalid, with no structured data, keeping the exception's own message")]
public void BindingInvalidResponseFor_CarriesTheCodeAndTheOriginalMessage()
{
var exception = new BpmnBindingException("BPMN element 'task-1' declares no binding.");
var response = BpmnImportErrorResponses.BindingInvalidResponseFor(exception);
Assert.Equal(BpmnErrorCodes.ImportBindingInvalid, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
[Fact(DisplayName = "A duplicate-element-id refusal is coded bpmn.import.duplicate-element-id, carrying the duplicated ids as data")]
public void DuplicateElementIdResponseFor_CarriesTheCodeAndTheStructuredData()
{
var exception = new BpmnDuplicateElementIdException("The document declares the same element id more than once: Outer.", ["Outer"]);
var response = BpmnImportErrorResponses.DuplicateElementIdResponseFor(exception);
Assert.Equal(BpmnErrorCodes.ImportDuplicateElementId, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
dynamic data = response.Data!;
Assert.Equal(new[] { "Outer" }, (IReadOnlyList<string>)data.ElementIds);
}
[Fact(DisplayName = "A definition-not-found refusal is coded bpmn.document.not-found")]
public void NotFoundResponseFor_CarriesTheCode()
{
var exception = new BpmnDefinitionNotFoundException("Workflow definition 'def-1' does not exist, so its BPMN document cannot be edited.");
var response = BpmnImportErrorResponses.NotFoundResponseFor(exception);
Assert.Equal(BpmnErrorCodes.DocumentNotFound, response.Code);
Assert.Equal(StatusCodes.Status404NotFound, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
[Fact(DisplayName = "A lost compare-and-swap is coded bpmn.document.precondition-failed, the same 412 If-Match already uses")]
public void PreconditionFailedResponseFor_CarriesTheCode()
{
var exception = new BpmnDocumentPreconditionFailedException(
"The workflow definition has been written since the ETag in If-Match was issued. GET the document again, reapply the edit, and PUT it with the new ETag.");
var response = BpmnImportErrorResponses.PreconditionFailedResponseFor(exception);
Assert.Equal(BpmnErrorCodes.DocumentPreconditionFailed, response.Code);
Assert.Equal(StatusCodes.Status412PreconditionFailed, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
[Theory(DisplayName = "Each BpmnExportUnavailableReason maps to its own code")]
[InlineData(BpmnExportUnavailableReason.NotImported, BpmnErrorCodes.ExportNotImported)]
[InlineData(BpmnExportUnavailableReason.SourceVersionUnknown, BpmnErrorCodes.ExportSourceVersionUnknown)]
[InlineData(BpmnExportUnavailableReason.SourceStale, BpmnErrorCodes.ExportSourceStale)]
public void ResponseFor_MapsEachReasonToItsOwnCode(BpmnExportUnavailableReason reason, string expectedCode)
{
var exception = new BpmnExportUnavailableException("Workflow definition 'def-1' cannot be exported.", reason);
var response = BpmnExportErrorResponses.ResponseFor(exception);
Assert.Equal(expectedCode, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
private static BpmnProcessDefinition MultiInstanceDefinition(string processId, string elementId)
{
var element = new BpmnElement(
elementId,
BpmnElementTypes.ServiceTask,
bindingRef: $"node-{elementId}",
loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 3));
return new BpmnProcessDefinition(processId, Elements: [element]);
}
}