* 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>
128 lines
6.8 KiB
C#
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]);
|
|
}
|
|
}
|