fix: preserve single latest draft when saving a new identity (#7918)

Co-authored-by: Shivamkmr8 <shivam.k@surya-fintech.com>
This commit is contained in:
Shivam Kumar 2026-09-08 04:33:30 +05:30 committed by GitHub
parent 83af7309f2
commit f50d6e984e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 393 additions and 11 deletions

View file

@ -221,25 +221,37 @@ public class WorkflowDefinitionPublisher(
{
DefinitionId = definitionId
};
var lastVersion = await workflowDefinitionStore.FindLastVersionAsync(filter, cancellationToken);
var highestVersion = await workflowDefinitionStore.FindLastVersionAsync(filter, cancellationToken);
var latestVersion = await workflowDefinitionStore.FindAsync(new()
{
DefinitionId = definitionId,
VersionOptions = VersionOptions.Latest
}, cancellationToken);
draft.Version = draft.Id == lastVersion?.Id ? lastVersion.Version : lastVersion?.Version + 1 ?? 1;
draft.Version = draft.Id == latestVersion?.Id
? latestVersion.Version
: draft.Id == highestVersion?.Id
? highestVersion.Version
: highestVersion?.Version + 1 ?? 1;
draft.IsLatest = true;
draft = Initialize(draft);
await mediator.SendAsync(new WorkflowDefinitionDraftSaving(draft), cancellationToken);
await workflowDefinitionStore.SaveAsync(draft, cancellationToken);
if (latestVersion != null && latestVersion.Id != draft.Id)
{
latestVersion.IsLatest = false;
await workflowDefinitionStore.SaveManyAsync([latestVersion, draft], cancellationToken);
}
else
{
await workflowDefinitionStore.SaveAsync(draft, cancellationToken);
}
await mediator.SendAsync(new WorkflowDefinitionDraftSaved(draft), cancellationToken);
if (lastVersion is null)
if (highestVersion is null)
await mediator.SendAsync(new WorkflowDefinitionCreated(definition), cancellationToken);
if (lastVersion is { IsPublished: true, IsLatest: true })
{
lastVersion.IsLatest = false;
await workflowDefinitionStore.SaveAsync(lastVersion, cancellationToken);
}
return draft;
}

View file

@ -1,7 +1,14 @@
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Testing.Shared;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Notifications;
using Elsa.Workflows.Management.Services;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionVersioning;
@ -15,12 +22,251 @@ public class Tests
_services = new TestApplicationBuilder(testOutputHelper).Build();
}
[Fact]
public async Task SaveDraftAsync_ShouldClearLatestFlagFromPreviousUnpublishedDraft()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
var existingDraft = new WorkflowDefinition
{
Id = "v1",
DefinitionId = definitionId,
Version = 1,
IsPublished = false,
IsLatest = true
};
var newDraft = new WorkflowDefinition
{
Id = "v2",
DefinitionId = definitionId
};
await store.SaveAsync(existingDraft);
var savedDraft = await publisher.SaveDraftAsync(newDraft);
var definitions = (await store.FindManyAsync(
new WorkflowDefinitionFilter
{
DefinitionId = definitionId
}
)).ToDictionary(x => x.Id);
Assert.False(definitions["v1"].IsLatest);
Assert.True(definitions["v2"].IsLatest);
Assert.Single(definitions.Values, x => x.IsLatest);
Assert.Equal(2, definitions["v2"].Version);
Assert.Equal(2, savedDraft.Version);
}
[Fact]
public async Task SaveDraftAsync_ShouldKeepExistingDraftLatestWhenResaved()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
var existingDraft = new WorkflowDefinition
{
Id = "draft-1",
DefinitionId = definitionId,
Version = 1,
IsPublished = false,
IsLatest = true
};
await store.SaveAsync(existingDraft);
var savedDraft = await publisher.SaveDraftAsync(existingDraft);
var definitions = (await store.FindManyAsync(
new WorkflowDefinitionFilter
{
DefinitionId = definitionId
}
)).ToList();
var persistedDraft = Assert.Single(definitions);
Assert.Equal("draft-1", persistedDraft.Id);
Assert.Equal(1, persistedDraft.Version);
Assert.True(persistedDraft.IsLatest);
Assert.Equal(1, savedDraft.Version);
Assert.True(savedDraft.IsLatest);
}
[Fact]
public async Task SaveDraftAsync_ShouldDemoteActualLatestWhenHighestVersionIsNotLatest()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
await store.SaveManyAsync([
CreateDefinition("v1", definitionId, 1, true),
CreateDefinition("v2", definitionId, 2, false)
]);
var savedDraft = await publisher.SaveDraftAsync(CreateDefinition("v3", definitionId, 0, false));
var definitions = (await store.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = definitionId })).ToList();
Assert.False(definitions.Single(x => x.Id == "v1").IsLatest);
Assert.False(definitions.Single(x => x.Id == "v2").IsLatest);
Assert.True(definitions.Single(x => x.Id == "v3").IsLatest);
Assert.Equal(3, savedDraft.Version);
Assert.Equal(3, definitions.Single(x => x.Id == "v3").Version);
Assert.Single(definitions, x => x.IsLatest);
}
[Fact]
public async Task SaveDraftAsync_ShouldPreserveActualLatestVersionWhenHigherVersionIsNotLatest()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
var latestDraft = CreateDefinition("v1", definitionId, 1, true);
await store.SaveManyAsync([
latestDraft,
CreateDefinition("v2", definitionId, 2, false)
]);
var savedDraft = await publisher.SaveDraftAsync(latestDraft);
var definitions = (await store.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = definitionId })).ToList();
Assert.Equal(2, definitions.Count);
Assert.Equal(1, savedDraft.Version);
Assert.Equal(1, definitions.Single(x => x.Id == "v1").Version);
Assert.True(definitions.Single(x => x.Id == "v1").IsLatest);
Assert.False(definitions.Single(x => x.Id == "v2").IsLatest);
Assert.Single(definitions, x => x.IsLatest);
}
[Fact]
public async Task SaveDraftAsync_ShouldPreserveHighestVersionWhenPromotingNonLatestDraft()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
await store.SaveManyAsync([
CreateDefinition("v1", definitionId, 1, true),
CreateDefinition("v2", definitionId, 2, false)
]);
var highestDraft = await publisher.GetDraftAsync(definitionId, VersionOptions.SpecificVersion(2));
var savedDraft = await publisher.SaveDraftAsync(highestDraft!);
var definitions = (await store.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = definitionId })).ToList();
Assert.Equal(2, definitions.Count);
Assert.Equal("v2", savedDraft.Id);
Assert.Equal(2, savedDraft.Version);
Assert.False(definitions.Single(x => x.Id == "v1").IsLatest);
Assert.True(definitions.Single(x => x.Id == "v2").IsLatest);
Assert.Single(definitions, x => x.IsLatest);
}
[Fact]
public async Task SaveDraftAsync_ShouldPreservePublishedStateWhenCreatingDraftAfterPublishedLatest()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
var publishedDefinition = CreateDefinition("v1", definitionId, 1, true);
publishedDefinition.IsPublished = true;
await store.SaveAsync(publishedDefinition);
var savedDraft = await publisher.SaveDraftAsync(CreateDefinition("v2", definitionId, 0, false));
var definitions = (await store.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = definitionId })).ToList();
var persistedPublishedDefinition = definitions.Single(x => x.Id == "v1");
Assert.True(persistedPublishedDefinition.IsPublished);
Assert.False(persistedPublishedDefinition.IsLatest);
Assert.Equal(1, persistedPublishedDefinition.Version);
Assert.False(savedDraft.IsPublished);
Assert.True(savedDraft.IsLatest);
Assert.Equal(2, savedDraft.Version);
Assert.Single(definitions, x => x.IsLatest);
}
[Theory]
[InlineData(BatchFailurePoint.Replacement)]
[InlineData(BatchFailurePoint.PreviousLatest)]
public async Task SaveDraftAsync_WhenReplacementBatchFails_ShouldPreserveSinglePreviousLatest(
BatchFailurePoint failurePoint
)
{
const string definitionId = "test-definition";
var persisted = new Dictionary<string, WorkflowDefinition>
{
["v1"] = CreateDefinition("v1", definitionId, 1, true)
};
var store = CreateFailingStore(persisted, failurePoint);
var publisher = ActivatorUtilities.CreateInstance<WorkflowDefinitionPublisher>(
_services,
store
);
var replacement = CreateDefinition("v2", definitionId, 0, false);
await Assert.ThrowsAsync<TestPersistenceException>(
() => publisher.SaveDraftAsync(replacement)
);
var previous = Assert.Single(persisted.Values);
Assert.Equal("v1", previous.Id);
Assert.True(previous.IsLatest);
Assert.Single(persisted.Values, x => x.IsLatest);
}
[Fact]
public async Task SaveDraftAsync_WhenDraftSavedNotificationFails_ShouldKeepCommittedLatestStateConsistent()
{
const string definitionId = "test-definition";
var services = new TestApplicationBuilder(
_services.GetRequiredService<ITestOutputHelper>()
)
.ConfigureServices(serviceCollection =>
serviceCollection.AddNotificationHandler<
FailingDraftSavedHandler,
WorkflowDefinitionDraftSaved
>()
)
.Build();
var store = services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = services.GetRequiredService<IWorkflowDefinitionPublisher>();
await store.SaveAsync(CreateDefinition("v1", definitionId, 1, true));
await Assert.ThrowsAsync<TestNotificationException>(
() => publisher.SaveDraftAsync(CreateDefinition("v2", definitionId, 0, false))
);
var definitions = (await store.FindManyAsync(
new WorkflowDefinitionFilter
{
DefinitionId = definitionId
}
)).ToList();
Assert.Equal(2, definitions.Count);
Assert.False(definitions.Single(x => x.Id == "v1").IsLatest);
Assert.True(definitions.Single(x => x.Id == "v2").IsLatest);
Assert.Single(definitions, x => x.IsLatest);
}
[Fact]
public async Task RevertVersionAsync_ShouldAllocateVersionAfterHighestStoredVersion()
{
const string definitionId = "test-definition";
var store = _services.GetRequiredService<IWorkflowDefinitionStore>();
var publisher = _services.GetRequiredService<IWorkflowDefinitionPublisher>();
var definitions = new WorkflowDefinition[]
{
new()
@ -55,4 +301,128 @@ public class Tests
Assert.Equal(4, revertedDefinition.Version);
}
}
private static IWorkflowDefinitionStore CreateFailingStore(
IDictionary<string, WorkflowDefinition> persisted,
BatchFailurePoint failurePoint
)
{
var store = Substitute.For<IWorkflowDefinitionStore>();
store.FindLastVersionAsync(
Arg.Any<WorkflowDefinitionFilter>(),
Arg.Any<CancellationToken>()
)
.Returns(_ =>
Task.FromResult(
persisted.Values
.OrderByDescending(x => x.Version)
.Select(Clone)
.FirstOrDefault()
)
);
store.FindAsync(
Arg.Is<WorkflowDefinitionFilter>(x => x.VersionOptions!.Value.IsLatest),
Arg.Any<CancellationToken>()
)
.Returns(_ =>
Task.FromResult(
persisted.Values
.Where(x => x.IsLatest)
.Select(Clone)
.FirstOrDefault()
)
);
store.SaveManyAsync(
Arg.Any<IEnumerable<WorkflowDefinition>>(),
Arg.Any<CancellationToken>()
)
.Returns(callInfo =>
{
var definitions = callInfo.Arg<IEnumerable<WorkflowDefinition>>().ToList();
var snapshot = persisted.ToDictionary(
x => x.Key,
x => Clone(x.Value)
);
try
{
foreach (var definition in definitions)
{
if (
failurePoint == BatchFailurePoint.PreviousLatest
&& definition.Id == "v1"
)
{
throw new TestPersistenceException();
}
if (
failurePoint == BatchFailurePoint.Replacement
&& definition.Id == "v2"
)
{
throw new TestPersistenceException();
}
persisted[definition.Id] = Clone(definition);
}
}
catch
{
persisted.Clear();
foreach (var item in snapshot)
{
persisted[item.Key] = item.Value;
}
throw;
}
return Task.CompletedTask;
});
return store;
}
private static WorkflowDefinition CreateDefinition(
string id,
string definitionId,
int version,
bool isLatest
) =>
new()
{
Id = id,
DefinitionId = definitionId,
Version = version,
IsLatest = isLatest,
IsPublished = false
};
private static WorkflowDefinition Clone(WorkflowDefinition definition) =>
definition.ShallowClone();
public enum BatchFailurePoint
{
Replacement,
PreviousLatest
}
private class FailingDraftSavedHandler
: INotificationHandler<WorkflowDefinitionDraftSaved>
{
public Task HandleAsync(
WorkflowDefinitionDraftSaved notification,
CancellationToken cancellationToken
) => throw new TestNotificationException();
}
private class TestPersistenceException : Exception;
private class TestNotificationException : Exception;
}