From f3e77fe3e7ed73f8a00f16096f7c8bbaa99c7838 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 13 Sep 2026 18:07:09 +0200 Subject: [PATCH] test(workflows): share Memory/EF store conformance scenarios (#8115) * test(workflows): share Memory/EF store conformance scenarios Add a shared management/runtime store matrix that runs the same uniqueness and tenant-isolation assertions against Memory and EF Core (SQLite). Memory now rejects a second version row for the same (DefinitionId, Version, TenantId) so that DefinitionId+Version uniqueness can fail closed on both paths. Closes #8088 Co-authored-by: Sipke Schoorstra * test(workflows): lock bookmark and log Find semantics Add the remaining #8088 store-contract row: bookmark, activity-execution, and execution-log Find/FindMany filters, paging, and same-Id upsert, run against both Memory and EF/SQLite. Co-authored-by: Sipke Schoorstra * test(workflows): correct execution-log ExcludeActivityType assertion Exclude WriteLine only when another activity type is present, so the shared Find row asserts a real filter match on both providers. Co-authored-by: Sipke Schoorstra * fix(management): reject duplicate definition version keys in SaveMany MemoryWorkflowDefinitionStore.SaveManyAsync now throws when a batch contains distinct Ids that share (DefinitionId, Version, TenantId), matching EF unique-index failure instead of silently dropping rows. Give the label-filter list fixture distinct versions so two red definition rows no longer collide on the default Version=1 key. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- Elsa.sln | 17 + .../Stores/MemoryWorkflowDefinitionStore.cs | 51 +- ...kflows.Persistence.ConformanceTests.csproj | 20 + .../WorkflowStoreConformanceTests.cs | 468 ++++++++++++++++++ .../WorkflowStoreScenario.cs | 211 ++++++++ .../WorkflowDefinitionLabelFilterTests.cs | 4 +- ...yWorkflowDefinitionStoreUniquenessTests.cs | 59 +++ 7 files changed, 827 insertions(+), 3 deletions(-) create mode 100644 test/integration/Elsa.Workflows.Persistence.ConformanceTests/Elsa.Workflows.Persistence.ConformanceTests.csproj create mode 100644 test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreConformanceTests.cs create mode 100644 test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreScenario.cs create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreUniquenessTests.cs diff --git a/Elsa.sln b/Elsa.sln index 1e94d989d..70b5c24e5 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -521,6 +521,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Labels.UnitTests", "te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Alterations.Core.UnitTests", "test\unit\Elsa.Alterations.Core.UnitTests\Elsa.Alterations.Core.UnitTests.csproj", "{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{823D4020-332D-2C13-F261-6F510F11A57E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Workflows.Persistence.ConformanceTests", "test\integration\Elsa.Workflows.Persistence.ConformanceTests\Elsa.Workflows.Persistence.ConformanceTests.csproj", "{E099B352-C3E6-499D-B141-67B209125B67}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -2497,6 +2501,18 @@ Global {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x64.Build.0 = Release|Any CPU {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x86.ActiveCfg = Release|Any CPU {EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x86.Build.0 = Release|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Debug|x64.ActiveCfg = Debug|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Debug|x64.Build.0 = Debug|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Debug|x86.ActiveCfg = Debug|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Debug|x86.Build.0 = Debug|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Release|Any CPU.Build.0 = Release|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Release|x64.ActiveCfg = Release|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Release|x64.Build.0 = Release|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Release|x86.ActiveCfg = Release|Any CPU + {E099B352-C3E6-499D-B141-67B209125B67}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2704,6 +2720,7 @@ Global {0BE4BC01-D02D-4185-A433-E33780584261} = {90031D64-CA0F-46D0-9AF4-8DC023A5FFCD} {ED8D7C78-154D-4124-8C0A-E63785A862E5} = {18453B51-25EB-4317-A4B3-B10518252E92} {EAF427BE-7A07-42D7-A223-70FB1FC91BB2} = {18453B51-25EB-4317-A4B3-B10518252E92} + {E099B352-C3E6-499D-B141-67B209125B67} = {823D4020-332D-2C13-F261-6F510F11A57E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs b/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs index d55d76123..f71efe4d8 100644 --- a/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs +++ b/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs @@ -98,7 +98,10 @@ public class MemoryWorkflowDefinitionStore(MemoryStore store public Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) { lock (store.Sync) + { + EnsureVersionKeyAvailable(definition); store.Save(definition, GetId); + } return Task.CompletedTask; } @@ -107,7 +110,15 @@ public class MemoryWorkflowDefinitionStore(MemoryStore store public Task SaveManyAsync(IEnumerable definitions, CancellationToken cancellationToken = default) { lock (store.Sync) - store.SaveMany(definitions, GetId); + { + var definitionList = definitions.ToList(); + EnsureBatchVersionKeysUnique(definitionList); + + foreach (var definition in definitionList) + EnsureVersionKeyAvailable(definition); + + store.SaveMany(definitionList, GetId); + } return Task.CompletedTask; } @@ -191,5 +202,43 @@ public class MemoryWorkflowDefinitionStore(MemoryStore store private string CurrentTenantId => tenantAccessor?.TenantId ?? Tenant.DefaultTenantId; + /// + /// EF enforces (DefinitionId, Version) globally via + /// IX_WorkflowDefinition_DefinitionId_Version. Memory keeps tenant in the key so + /// same-tenant duplicates fail closed while cross-tenant rows remain distinct until #7539 + /// adds TenantId to that index. + /// + private void EnsureVersionKeyAvailable(WorkflowDefinition definition) + { + var versionKey = GetVersionKey(definition); + var existing = store.Find(x => x.Id != definition.Id && GetVersionKey(x) == versionKey); + + if (existing is not null) + throw CreateVersionKeyConflict(definition); + } + + private static void EnsureBatchVersionKeysUnique(IEnumerable definitions) + { + var seen = new Dictionary(); + + foreach (var definition in definitions) + { + var versionKey = GetVersionKey(definition); + + if (seen.TryGetValue(versionKey, out var existingId) && existingId != definition.Id) + throw CreateVersionKeyConflict(definition); + + seen[versionKey] = definition.Id; + } + } + + private static InvalidOperationException CreateVersionKeyConflict(WorkflowDefinition definition) => + new($"A workflow definition already exists for definition '{definition.DefinitionId}' version {definition.Version} tenant '{definition.TenantId}'."); + + private static DefinitionVersionKey GetVersionKey(WorkflowDefinition definition) => + new(definition.DefinitionId, definition.Version, definition.TenantId); + private string GetId(WorkflowDefinition workflowDefinition) => workflowDefinition.Id; + + private readonly record struct DefinitionVersionKey(string DefinitionId, int Version, string? TenantId); } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.Persistence.ConformanceTests/Elsa.Workflows.Persistence.ConformanceTests.csproj b/test/integration/Elsa.Workflows.Persistence.ConformanceTests/Elsa.Workflows.Persistence.ConformanceTests.csproj new file mode 100644 index 000000000..073b2324a --- /dev/null +++ b/test/integration/Elsa.Workflows.Persistence.ConformanceTests/Elsa.Workflows.Persistence.ConformanceTests.csproj @@ -0,0 +1,20 @@ + + + + [Elsa.Workflows.Management]*,[Elsa.Workflows.Runtime]*,[Elsa.Persistence.EFCore]* + 0 + + + + + + + + + + + + + + + diff --git a/test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreConformanceTests.cs b/test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreConformanceTests.cs new file mode 100644 index 000000000..4a25b6fe5 --- /dev/null +++ b/test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreConformanceTests.cs @@ -0,0 +1,468 @@ +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; + +namespace Elsa.Workflows.Persistence.ConformanceTests; + +/// +/// Shared Memory / EF Core store-contract assertions for workflow management and runtime ports. +/// +public abstract class WorkflowStoreConformanceTests +{ + protected abstract Task CreateScenarioAsync(); + + [Fact] + public async Task TriggerLogicalKeysAreUniqueAndReplaceAsyncSkipsExistingKeys() + { + await using var scenario = await CreateScenarioAsync(); + var existing = Trigger("existing-id", hash: "hash-1"); + + await scenario.Triggers.SaveAsync(existing); + await scenario.AssertUniquenessConflictAsync(() => scenario.Triggers.SaveAsync(Trigger("other-id", hash: "hash-1")).AsTask()); + + var afterConflict = (await scenario.Triggers.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList(); + Assert.Equal("existing-id", Assert.Single(afterConflict).Id); + + await scenario.Triggers.SaveAsync(Trigger("existing-id", hash: "hash-2", workflowDefinitionVersionId: "v2")); + var updated = await scenario.Triggers.FindAsync(new TriggerFilter { Id = "existing-id" }); + Assert.Equal("hash-2", updated!.Hash); + Assert.Equal("v2", updated.WorkflowDefinitionVersionId); + + await scenario.Triggers.ReplaceAsync([], [Trigger("batch-1"), Trigger("batch-2")]); + var afterBatch = (await scenario.Triggers.FindManyAsync(new TriggerFilter { Hash = "hash-1", TenantAgnostic = true })).ToList(); + Assert.Equal("batch-1", Assert.Single(afterBatch).Id); + + await scenario.Triggers.ReplaceAsync([], [Trigger("skipped-id")]); + var afterSkip = (await scenario.Triggers.FindManyAsync(new TriggerFilter { Hash = "hash-1", TenantAgnostic = true })).ToList(); + Assert.Equal("batch-1", Assert.Single(afterSkip).Id); + + await scenario.Triggers.ReplaceAsync(afterSkip, [Trigger("replacement-id", workflowDefinitionVersionId: "v3")]); + var afterReplace = (await scenario.Triggers.FindManyAsync(new TriggerFilter { Hash = "hash-1", TenantAgnostic = true })).ToList(); + var replacement = Assert.Single(afterReplace); + Assert.Equal("replacement-id", replacement.Id); + Assert.Equal("v3", replacement.WorkflowDefinitionVersionId); + + var tenantA = Trigger("id-a", hash: "shared-hash"); + tenantA.TenantId = "tenant-a"; + var tenantB = Trigger("id-b", hash: "shared-hash"); + tenantB.TenantId = "tenant-b"; + await scenario.Triggers.ReplaceAsync([], [tenantA, tenantB]); + var bothTenants = (await scenario.Triggers.FindManyAsync(new TriggerFilter { Hash = "shared-hash", TenantAgnostic = true })).ToList(); + Assert.Equal(2, bothTenants.Count); + Assert.Contains(bothTenants, x => x.Id == "id-a"); + Assert.Contains(bothTenants, x => x.Id == "id-b"); + } + + [Fact] + public async Task TriggerTenantIsolationHonorsAmbientTenantAndTenantAgnostic() + { + await using var scenario = await CreateScenarioAsync(); + await SeedMixedTriggersAsync(scenario); + + var visible = (await scenario.Triggers.FindManyAsync(new TriggerFilter())).ToList(); + Assert.Equal(2, visible.Count); + Assert.Contains(visible, x => x.Id == "id-a"); + Assert.Contains(visible, x => x.Id == "id-star"); + Assert.DoesNotContain(visible, x => x.Id == "id-b"); + + var all = (await scenario.Triggers.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList(); + Assert.Equal(3, all.Count); + Assert.Contains(all, x => x.Id == "id-b"); + + Assert.Null(await scenario.Triggers.FindAsync(new TriggerFilter { Id = "id-b" })); + + var deleted = await scenario.Triggers.DeleteManyAsync(new TriggerFilter()); + var remaining = (await scenario.Triggers.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList(); + Assert.Equal(2, deleted); + Assert.Equal("id-b", Assert.Single(remaining).Id); + + using (scenario.UseTenant(Tenant.DefaultTenantId)) + { + await scenario.Triggers.SaveAsync(Trigger("id-null", hash: "hash-null", tenantId: null)); + await scenario.Triggers.SaveAsync(Trigger("id-named", hash: "hash-named", tenantId: "tenant-a")); + + var defaultVisible = (await scenario.Triggers.FindManyAsync(new TriggerFilter())).ToList(); + Assert.Contains(defaultVisible, x => x.Id == "id-null"); + Assert.DoesNotContain(defaultVisible, x => x.Id == "id-named"); + } + + using (scenario.UseTenant("tenant-a")) + { + var namedVisible = (await scenario.Triggers.FindManyAsync(new TriggerFilter { Hash = "hash-null" })).ToList(); + Assert.Empty(namedVisible); + } + } + + [Fact] + public async Task DefinitionVersionsAreUniquePerDefinitionId() + { + await using var scenario = await CreateScenarioAsync(); + var first = Definition("def-v1", "order", "tenant-a"); + + await scenario.Definitions.SaveAsync(first); + await scenario.AssertUniquenessConflictAsync(() => scenario.Definitions.SaveAsync(Definition("def-v1-dup", "order", "tenant-a"))); + + var afterConflict = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter + { + DefinitionId = "order", + VersionOptions = VersionOptions.SpecificVersion(1), + TenantAgnostic = true + })).ToList(); + Assert.Equal("def-v1", Assert.Single(afterConflict).Id); + + first.Name = "Order Updated"; + await scenario.Definitions.SaveAsync(first); + var updated = await scenario.Definitions.FindAsync(new WorkflowDefinitionFilter { Id = "def-v1" }); + Assert.Equal("Order Updated", updated!.Name); + + await scenario.Definitions.SaveAsync(Definition("def-v2", "order", "tenant-a", version: 2)); + var versions = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "order", TenantAgnostic = true })).ToList(); + Assert.Equal(2, versions.Count); + Assert.Contains(versions, x => x.Id == "def-v1" && x.Version == 1); + Assert.Contains(versions, x => x.Id == "def-v2" && x.Version == 2); + } + + [Fact] + public async Task DefinitionSaveManyRejectsDuplicateVersionKeysInTheBatch() + { + await using var scenario = await CreateScenarioAsync(); + await scenario.Definitions.SaveAsync(Definition("def-kept", "kept", "tenant-a")); + + await scenario.AssertUniquenessConflictAsync(() => scenario.Definitions.SaveManyAsync( + [ + Definition("def-batch-1", "invoice", "tenant-a"), + Definition("def-batch-2", "invoice", "tenant-a") + ])); + + var invoices = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter + { + DefinitionId = "invoice", + TenantAgnostic = true + })).ToList(); + Assert.Empty(invoices); + Assert.NotNull(await scenario.Definitions.FindAsync(new WorkflowDefinitionFilter { Id = "def-kept", TenantAgnostic = true })); + } + + [Fact] + public async Task DefinitionTenantIsolationHonorsAmbientTenantAndTenantAgnostic() + { + await using var scenario = await CreateScenarioAsync(); + await SeedMixedDefinitionsAsync(scenario); + + var visible = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter())).ToList(); + Assert.Equal(2, visible.Count); + Assert.Contains(visible, x => x.Id == "def-a"); + Assert.Contains(visible, x => x.Id == "def-star"); + Assert.DoesNotContain(visible, x => x.Id == "def-b"); + + var all = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList(); + Assert.Equal(3, all.Count); + Assert.Contains(all, x => x.Id == "def-b"); + + Assert.Null(await scenario.Definitions.FindAsync(new WorkflowDefinitionFilter { Id = "def-b" })); + Assert.False(await scenario.Definitions.AnyAsync(new WorkflowDefinitionFilter { Id = "def-b" })); + Assert.Equal(2, await scenario.Definitions.CountDistinctAsync()); + + var deleted = await scenario.Definitions.DeleteAsync(new WorkflowDefinitionFilter()); + var remaining = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList(); + Assert.Equal(2, deleted); + Assert.Equal("def-b", Assert.Single(remaining).Id); + + using (scenario.UseTenant(Tenant.DefaultTenantId)) + { + await scenario.Definitions.SaveAsync(Definition("def-null", "Null", tenantId: null)); + await scenario.Definitions.SaveAsync(Definition("def-named", "Named", "tenant-a")); + + var defaultVisible = (await scenario.Definitions.FindManyAsync(new WorkflowDefinitionFilter())).ToList(); + Assert.Contains(defaultVisible, x => x.Id == "def-null"); + Assert.DoesNotContain(defaultVisible, x => x.Id == "def-named"); + } + } + + [Fact] + public async Task BookmarkTenantIsolationHonorsAmbientTenantAndTenantAgnostic() + { + await using var scenario = await CreateScenarioAsync(); + await SeedMixedBookmarksAsync(scenario); + + var visible = (await scenario.Bookmarks.FindManyAsync(new BookmarkFilter())).ToList(); + Assert.Equal(2, visible.Count); + Assert.Contains(visible, x => x.Id == "bm-a"); + Assert.Contains(visible, x => x.Id == "bm-star"); + Assert.DoesNotContain(visible, x => x.Id == "bm-b"); + + var all = (await scenario.Bookmarks.FindManyAsync(new BookmarkFilter { TenantAgnostic = true })).ToList(); + Assert.Equal(3, all.Count); + Assert.Contains(all, x => x.Id == "bm-b"); + + Assert.Null(await scenario.Bookmarks.FindAsync(new BookmarkFilter { BookmarkId = "bm-b" })); + + var deleted = await scenario.Bookmarks.DeleteAsync(new BookmarkFilter()); + var remaining = (await scenario.Bookmarks.FindManyAsync(new BookmarkFilter { TenantAgnostic = true })).ToList(); + Assert.Equal(2, deleted); + Assert.Equal("bm-b", Assert.Single(remaining).Id); + + using (scenario.UseTenant(Tenant.DefaultTenantId)) + { + await scenario.Bookmarks.SaveAsync(Bookmark("bm-null", tenantId: null)); + await scenario.Bookmarks.SaveAsync(Bookmark("bm-named", tenantId: "tenant-a")); + + var defaultVisible = (await scenario.Bookmarks.FindManyAsync(new BookmarkFilter())).ToList(); + Assert.Contains(defaultVisible, x => x.Id == "bm-null"); + Assert.DoesNotContain(defaultVisible, x => x.Id == "bm-named"); + } + } + + [Fact] + public async Task DeadLetterOriginalQueueItemIdIsUniqueAndAddOrGetIsIdempotent() + { + await using var scenario = await CreateScenarioAsync(); + var first = DeadLetter("dl-1", "queue-1"); + + await scenario.DeadLetters.SaveAsync(first); + await scenario.AssertUniquenessConflictAsync(() => scenario.DeadLetters.SaveAsync(DeadLetter("dl-2", "queue-1"))); + + var afterConflict = (await scenario.DeadLetters.FindManyAsync(new BookmarkQueueDeadLetterFilter { OriginalQueueItemId = "queue-1" })).ToList(); + Assert.Equal("dl-1", Assert.Single(afterConflict).Id); + + var existing = await scenario.DeadLetters.AddOrGetExistingAsync(DeadLetter("dl-3", "queue-1")); + Assert.Equal("dl-1", existing.Id); + + var created = await scenario.DeadLetters.AddOrGetExistingAsync(DeadLetter("dl-4", "queue-2")); + Assert.Equal("dl-4", created.Id); + + var all = (await scenario.DeadLetters.FindManyAsync(new BookmarkQueueDeadLetterFilter())).ToList(); + Assert.Equal(2, all.Count); + Assert.Contains(all, x => x.Id == "dl-1"); + Assert.Contains(all, x => x.Id == "dl-4"); + } + + [Fact] + public async Task BookmarkActivityExecutionAndExecutionLogFindsHonorFiltersAndIdUpsert() + { + await using var scenario = await CreateScenarioAsync(); + + await scenario.Bookmarks.SaveAsync(Bookmark("bm-1", hash: "hash-http", workflowInstanceId: "instance-1")); + await scenario.Bookmarks.SaveAsync(Bookmark("bm-2", hash: "hash-timer", workflowInstanceId: "instance-1")); + await scenario.Bookmarks.SaveAsync(Bookmark("bm-3", hash: "hash-http", workflowInstanceId: "instance-2")); + + Assert.Equal("bm-1", (await scenario.Bookmarks.FindAsync(new BookmarkFilter { BookmarkId = "bm-1" }))!.Id); + Assert.Equal("bm-2", (await scenario.Bookmarks.FindAsync(new BookmarkFilter { Hash = "hash-timer" }))!.Id); + Assert.Null(await scenario.Bookmarks.FindAsync(new BookmarkFilter { BookmarkId = "missing" })); + + var instanceBookmarks = (await scenario.Bookmarks.FindManyAsync(new BookmarkFilter { WorkflowInstanceId = "instance-1" })).ToList(); + Assert.Equal(2, instanceBookmarks.Count); + Assert.Contains(instanceBookmarks, x => x.Id == "bm-1"); + Assert.Contains(instanceBookmarks, x => x.Id == "bm-2"); + + var hashed = (await scenario.Bookmarks.FindManyAsync(new BookmarkFilter { Hash = "hash-http" })).ToList(); + Assert.Equal(2, hashed.Count); + + var firstPage = await scenario.Bookmarks.FindManyAsync(new BookmarkFilter { WorkflowInstanceId = "instance-1" }, PageArgs.FromRange(0, 1)); + Assert.Equal(2, firstPage.TotalCount); + Assert.Single(firstPage.Items); + + await scenario.Bookmarks.SaveAsync(Bookmark("bm-1", hash: "hash-updated", workflowInstanceId: "instance-1")); + Assert.Equal("hash-updated", (await scenario.Bookmarks.FindAsync(new BookmarkFilter { BookmarkId = "bm-1" }))!.Hash); + + await scenario.ActivityExecutions.SaveAsync(ActivityExecution("ae-1", "instance-1", "activity-a", ActivityStatus.Running)); + await scenario.ActivityExecutions.SaveAsync(ActivityExecution("ae-2", "instance-1", "activity-b", ActivityStatus.Completed, completedAt: StartedAt.AddMinutes(1))); + await scenario.ActivityExecutions.SaveAsync(ActivityExecution("ae-3", "instance-2", "activity-a", ActivityStatus.Running)); + + Assert.Equal("ae-1", (await scenario.ActivityExecutions.FindAsync(new ActivityExecutionRecordFilter { Id = "ae-1" }))!.Id); + Assert.Null(await scenario.ActivityExecutions.FindAsync(new ActivityExecutionRecordFilter { Id = "missing" })); + + var instanceActivities = (await scenario.ActivityExecutions.FindManyAsync(new ActivityExecutionRecordFilter { WorkflowInstanceId = "instance-1" })).ToList(); + Assert.Equal(2, instanceActivities.Count); + Assert.Equal(2, await scenario.ActivityExecutions.CountAsync(new ActivityExecutionRecordFilter { WorkflowInstanceId = "instance-1" })); + Assert.Equal("ae-1", Assert.Single(await scenario.ActivityExecutions.FindManyAsync(new ActivityExecutionRecordFilter { ActivityId = "activity-a", WorkflowInstanceId = "instance-1" })).Id); + Assert.Equal("ae-2", Assert.Single(await scenario.ActivityExecutions.FindManyAsync(new ActivityExecutionRecordFilter { Status = ActivityStatus.Completed })).Id); + + await scenario.ActivityExecutions.SaveAsync(ActivityExecution("ae-1", "instance-1", "activity-a", ActivityStatus.Completed, completedAt: StartedAt.AddMinutes(2))); + Assert.Equal(ActivityStatus.Completed, (await scenario.ActivityExecutions.FindAsync(new ActivityExecutionRecordFilter { Id = "ae-1" }))!.Status); + + Assert.Equal(2, await scenario.ActivityExecutions.DeleteManyAsync(new ActivityExecutionRecordFilter { WorkflowInstanceId = "instance-1" })); + Assert.Equal("ae-3", Assert.Single(await scenario.ActivityExecutions.FindManyAsync(new ActivityExecutionRecordFilter())).Id); + + await scenario.ExecutionLogs.SaveAsync(ExecutionLog("el-1", "instance-1", "activity-a", "Started")); + await scenario.ExecutionLogs.SaveAsync(ExecutionLog("el-2", "instance-1", "activity-b", "Completed", activityType: "Elsa.Delay")); + await scenario.ExecutionLogs.SaveAsync(ExecutionLog("el-3", "instance-2", "activity-a", "Started")); + + Assert.Equal("el-1", (await scenario.ExecutionLogs.FindAsync(new WorkflowExecutionLogRecordFilter { Id = "el-1" }))!.Id); + Assert.Null(await scenario.ExecutionLogs.FindAsync(new WorkflowExecutionLogRecordFilter { Id = "missing" })); + + var instanceLogs = await scenario.ExecutionLogs.FindManyAsync(new WorkflowExecutionLogRecordFilter { WorkflowInstanceId = "instance-1" }, PageArgs.FromRange(0, 10)); + Assert.Equal(2, instanceLogs.TotalCount); + Assert.Equal(2, instanceLogs.Items.Count); + + var firstLogPage = await scenario.ExecutionLogs.FindManyAsync(new WorkflowExecutionLogRecordFilter { WorkflowInstanceId = "instance-1" }, PageArgs.FromRange(0, 1)); + Assert.Equal(2, firstLogPage.TotalCount); + Assert.Single(firstLogPage.Items); + + Assert.Equal("el-2", (await scenario.ExecutionLogs.FindAsync(new WorkflowExecutionLogRecordFilter { EventName = "Completed" }))!.Id); + Assert.Equal("el-2", Assert.Single((await scenario.ExecutionLogs.FindManyAsync(new WorkflowExecutionLogRecordFilter { ActivityId = "activity-b" }, PageArgs.All)).Items).Id); + + var excluded = await scenario.ExecutionLogs.FindManyAsync(new WorkflowExecutionLogRecordFilter { WorkflowInstanceId = "instance-1", ExcludeActivityType = "Elsa.WriteLine" }, PageArgs.All); + Assert.Equal("el-2", Assert.Single(excluded.Items).Id); + + await scenario.ExecutionLogs.SaveAsync(ExecutionLog("el-1", "instance-1", "activity-a", "Resumed")); + Assert.Equal("Resumed", (await scenario.ExecutionLogs.FindAsync(new WorkflowExecutionLogRecordFilter { Id = "el-1" }))!.EventName); + + Assert.Equal(2, await scenario.ExecutionLogs.DeleteManyAsync(new WorkflowExecutionLogRecordFilter { WorkflowInstanceId = "instance-1" })); + Assert.Equal("el-3", Assert.Single((await scenario.ExecutionLogs.FindManyAsync(new WorkflowExecutionLogRecordFilter(), PageArgs.All)).Items).Id); + } + + private static async Task SeedMixedTriggersAsync(WorkflowStoreScenario scenario) + { + await scenario.Triggers.SaveAsync(Trigger("id-a", hash: "hash-a", tenantId: "tenant-a")); + await scenario.Triggers.SaveAsync(Trigger("id-b", hash: "hash-b", tenantId: "tenant-b")); + await scenario.Triggers.SaveAsync(Trigger("id-star", hash: "hash-star", tenantId: Tenant.AgnosticTenantId)); + } + + private static async Task SeedMixedDefinitionsAsync(WorkflowStoreScenario scenario) + { + await scenario.Definitions.SaveAsync(Definition("def-a", "A", "tenant-a")); + await scenario.Definitions.SaveAsync(Definition("def-b", "B", "tenant-b")); + await scenario.Definitions.SaveAsync(Definition("def-star", "Star", Tenant.AgnosticTenantId)); + } + + private static async Task SeedMixedBookmarksAsync(WorkflowStoreScenario scenario) + { + await scenario.Bookmarks.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a")); + await scenario.Bookmarks.SaveAsync(Bookmark("bm-b", tenantId: "tenant-b")); + await scenario.Bookmarks.SaveAsync(Bookmark("bm-star", tenantId: Tenant.AgnosticTenantId)); + } + + private static StoredTrigger Trigger( + string id, + string workflowDefinitionId = "workflow-1", + string workflowDefinitionVersionId = "v1", + string activityId = "activity-1", + string? hash = "hash-1", + string? tenantId = "tenant-a") => + new() + { + Id = id, + TenantId = tenantId, + WorkflowDefinitionId = workflowDefinitionId, + WorkflowDefinitionVersionId = workflowDefinitionVersionId, + ActivityId = activityId, + Hash = hash, + Name = "Elsa.HttpEndpoint" + }; + + private static WorkflowDefinition Definition(string id, string name, string? tenantId, string? definitionId = null, int version = 1) => + new() + { + Id = id, + DefinitionId = definitionId ?? name.ToLowerInvariant(), + Name = name, + TenantId = tenantId, + Version = version, + IsLatest = version == 1, + MaterializerName = "Json", + CreatedAt = new DateTimeOffset(2026, 9, 13, 12, 0, 0, TimeSpan.Zero) + }; + + private static readonly DateTimeOffset StartedAt = new(2026, 9, 13, 12, 0, 0, TimeSpan.Zero); + + private static StoredBookmark Bookmark( + string id, + string? tenantId = "tenant-a", + string? hash = null, + string workflowInstanceId = "instance-1") => + new() + { + Id = id, + TenantId = tenantId, + Hash = hash ?? id, + WorkflowInstanceId = workflowInstanceId, + Name = "Elsa.HttpEndpoint", + CreatedAt = StartedAt + }; + + private static ActivityExecutionRecord ActivityExecution( + string id, + string workflowInstanceId, + string activityId, + ActivityStatus status, + DateTimeOffset? completedAt = null) => + new() + { + Id = id, + TenantId = "tenant-a", + WorkflowInstanceId = workflowInstanceId, + ActivityId = activityId, + ActivityNodeId = $"node-{activityId}", + ActivityType = "Elsa.WriteLine", + ActivityTypeVersion = 1, + ActivityName = activityId, + Status = status, + StartedAt = StartedAt, + CompletedAt = completedAt + }; + + private static WorkflowExecutionLogRecord ExecutionLog( + string id, + string workflowInstanceId, + string activityId, + string eventName, + string activityType = "Elsa.WriteLine") => + new() + { + Id = id, + TenantId = "tenant-a", + WorkflowDefinitionId = "workflow-1", + WorkflowDefinitionVersionId = "v1", + WorkflowInstanceId = workflowInstanceId, + WorkflowVersion = 1, + ActivityInstanceId = $"ai-{id}", + ActivityId = activityId, + ActivityType = activityType, + ActivityTypeVersion = 1, + ActivityNodeId = $"node-{activityId}", + Timestamp = StartedAt, + Sequence = 0, + EventName = eventName + }; + + private static BookmarkQueueDeadLetterItem DeadLetter(string id, string originalQueueItemId) => + new() + { + Id = id, + TenantId = "tenant-a", + OriginalQueueItemId = originalQueueItemId, + WorkflowInstanceId = "instance-1", + Reason = "delivery-failed", + OriginalCreatedAt = new DateTimeOffset(2026, 9, 13, 12, 0, 0, TimeSpan.Zero), + DeadLetteredAt = new DateTimeOffset(2026, 9, 13, 12, 5, 0, TimeSpan.Zero) + }; +} + +[CollectionDefinition(Name)] +public sealed class WorkflowStoreInMemoryConformanceCollection +{ + public const string Name = "WorkflowStores:InMemory"; +} + +[CollectionDefinition(Name)] +public sealed class WorkflowStoreSqliteConformanceCollection +{ + public const string Name = "WorkflowStores:EFCore.Sqlite"; +} + +[Collection(WorkflowStoreInMemoryConformanceCollection.Name)] +public sealed class InMemoryWorkflowStoreConformanceTests : WorkflowStoreConformanceTests +{ + protected override Task CreateScenarioAsync() => WorkflowStoreScenario.CreateInMemoryAsync(); +} + +[Collection(WorkflowStoreSqliteConformanceCollection.Name)] +public sealed class SqliteWorkflowStoreConformanceTests : WorkflowStoreConformanceTests +{ + protected override Task CreateScenarioAsync() => WorkflowStoreScenario.CreateSqliteAsync(); +} diff --git a/test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreScenario.cs b/test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreScenario.cs new file mode 100644 index 000000000..e075255be --- /dev/null +++ b/test/integration/Elsa.Workflows.Persistence.ConformanceTests/WorkflowStoreScenario.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using Elsa.Common; +using Elsa.Common.Codecs; +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.Persistence.EFCore; +using Elsa.Persistence.EFCore.EntityHandlers; +using Elsa.Persistence.EFCore.Extensions; +using Elsa.Persistence.EFCore.Modules.Management; +using Elsa.Persistence.EFCore.Modules.Runtime; +using Elsa.Persistence.EFCore.Sqlite; +using Elsa.Tenants.Options; +using Elsa.Testing.Shared.Multitenancy; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Stores; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Stores; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Elsa.Workflows.Persistence.ConformanceTests; + +/// +/// Holds one Memory or EF/SQLite set of management + runtime stores and the ambient tenant they read. +/// +public sealed class WorkflowStoreScenario( + TestTenantAccessor tenantAccessor, + IWorkflowDefinitionStore definitions, + ITriggerStore triggers, + IBookmarkStore bookmarks, + IBookmarkQueueDeadLetterStore deadLetters, + IActivityExecutionStore activityExecutions, + IWorkflowExecutionLogStore executionLogs, + Func, Task> assertUniquenessConflictAsync, + Func disposeAsync) : IAsyncDisposable +{ + public TestTenantAccessor TenantAccessor { get; } = tenantAccessor; + public IWorkflowDefinitionStore Definitions { get; } = definitions; + public ITriggerStore Triggers { get; } = triggers; + public IBookmarkStore Bookmarks { get; } = bookmarks; + public IBookmarkQueueDeadLetterStore DeadLetters { get; } = deadLetters; + public IActivityExecutionStore ActivityExecutions { get; } = activityExecutions; + public IWorkflowExecutionLogStore ExecutionLogs { get; } = executionLogs; + + public IDisposable UseTenant(string tenantId) => + TenantAccessor.PushContext(tenantId == Tenant.DefaultTenantId + ? Tenant.Default + : new Tenant { Id = tenantId, Name = tenantId }); + + public Task AssertUniquenessConflictAsync(Func operation) => assertUniquenessConflictAsync(operation); + + public ValueTask DisposeAsync() => disposeAsync(); + + public static Task CreateInMemoryAsync() + { + var tenantAccessor = new TestTenantAccessor("tenant-a"); + return Task.FromResult(new WorkflowStoreScenario( + tenantAccessor, + new MemoryWorkflowDefinitionStore(new MemoryStore(), tenantAccessor), + new MemoryTriggerStore(new MemoryStore(), tenantAccessor), + new MemoryBookmarkStore(new MemoryStore(), tenantAccessor), + new MemoryBookmarkQueueDeadLetterStore(new MemoryStore()), + new MemoryActivityExecutionStore(new MemoryStore()), + new MemoryWorkflowExecutionLogStore(new MemoryStore()), + operation => Assert.ThrowsAsync(operation), + () => ValueTask.CompletedTask)); + } + + public static async Task CreateSqliteAsync() + { + var managementPath = Path.Combine(Path.GetTempPath(), $"elsa-workflow-management-conformance-{Guid.NewGuid():N}.db"); + var runtimePath = Path.Combine(Path.GetTempPath(), $"elsa-workflow-runtime-conformance-{Guid.NewGuid():N}.db"); + var tenantAccessor = new TestTenantAccessor("tenant-a"); + ServiceProvider? services = null; + IServiceScope? scope = null; + + try + { + var migrationsAssembly = typeof(ManagementDbContextFactory).Assembly; + services = new ServiceCollection() + .AddLogging() + .AddSingleton(tenantAccessor) + .AddSingleton() + .AddSingleton() + .AddSingleton(_ => new CompressionCodecResolver([new None()])) + .Configure(options => options.IsEnabled = true) + .AddScoped() + .AddScoped() + .AddSqliteEntityModelCreatingHandlers() + .AddDbContextFactory((_, builder) => + builder.UseElsaSqlite(migrationsAssembly, $"Data Source={managementPath};Default Timeout=30")) + .AddDbContextFactory((_, builder) => + builder.UseElsaSqlite(migrationsAssembly, $"Data Source={runtimePath};Default Timeout=30")) + .Decorate, TenantAwareDbContextFactory>() + .Decorate, TenantAwareDbContextFactory>() + .AddScoped>() + .AddScoped>() + .AddScoped>() + .AddScoped>() + .AddScoped>() + .AddScoped>() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .BuildServiceProvider(); + + await using (var management = await services.GetRequiredService>().CreateDbContextAsync()) + await management.Database.EnsureCreatedAsync(); + await using (var runtime = await services.GetRequiredService>().CreateDbContextAsync()) + await runtime.Database.EnsureCreatedAsync(); + + scope = services.CreateScope(); + var scoped = scope.ServiceProvider; + + return new( + tenantAccessor, + scoped.GetRequiredService(), + scoped.GetRequiredService(), + scoped.GetRequiredService(), + scoped.GetRequiredService(), + scoped.GetRequiredService(), + scoped.GetRequiredService(), + AssertSqliteUniquenessConflictAsync, + async () => + { + scope.Dispose(); + await services.DisposeAsync(); + SqliteConnection.ClearAllPools(); + File.Delete(managementPath); + File.Delete(runtimePath); + }); + } + catch + { + scope?.Dispose(); + if (services is not null) + await services.DisposeAsync(); + SqliteConnection.ClearAllPools(); + File.Delete(managementPath); + File.Delete(runtimePath); + throw; + } + } + + private static async Task AssertSqliteUniquenessConflictAsync(Func operation) + { + var exception = await Record.ExceptionAsync(operation); + var sqliteException = exception switch + { + DbUpdateException { InnerException: SqliteException inner } => inner, + SqliteException direct => direct, + _ => throw new Xunit.Sdk.XunitException($"Expected a SQLite uniqueness violation, received {exception?.GetType().FullName ?? "no exception"}.") + }; + + Assert.Equal(19, sqliteException.SqliteErrorCode); + Assert.Contains("UNIQUE constraint failed", sqliteException.Message, StringComparison.Ordinal); + } + + private sealed class ConformancePayloadSerializer : IPayloadSerializer + { + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public string Serialize(object payload) => JsonSerializer.Serialize(payload, Options); + public JsonElement SerializeToElement(object payload) => JsonSerializer.SerializeToElement(payload, Options); + public object Deserialize(string serializedData) => JsonSerializer.Deserialize(serializedData, Options)!; + public object Deserialize(string serializedData, Type type) => JsonSerializer.Deserialize(serializedData, type, Options)!; + public object Deserialize(JsonElement serializedData) => serializedData.Deserialize(Options)!; + public T Deserialize(string serializedData) => JsonSerializer.Deserialize(serializedData, Options)!; + public T Deserialize(JsonElement serializedData) => serializedData.Deserialize(Options)!; + public JsonSerializerOptions GetOptions() => Options; + } + + private sealed class ConformanceSafeSerializer : ISafeSerializer + { + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public ValueTask SerializeAsync(object? value, CancellationToken cancellationToken = default) => + ValueTask.FromResult(Serialize(value)); + + public ValueTask SerializeToElementAsync(object? value, CancellationToken cancellationToken = default) => + new(SerializeToElement(value)); + + public ValueTask DeserializeAsync(string json, CancellationToken cancellationToken = default) => + new(Deserialize(json)); + + public ValueTask DeserializeAsync(JsonElement element, CancellationToken cancellationToken = default) => + new(Deserialize(element)); + + public string Serialize(object? value) => JsonSerializer.Serialize(value, Options); + public JsonElement SerializeToElement(object? value) => JsonSerializer.SerializeToElement(value, Options); + public T Deserialize(string json) => JsonSerializer.Deserialize(json, Options)!; + public T Deserialize(JsonElement element) => element.Deserialize(Options)!; + public JsonSerializerOptions GetOptions() => Options; + } +} diff --git a/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs b/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs index ef4046e24..6f372fd74 100644 --- a/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs +++ b/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs @@ -164,8 +164,8 @@ public class WorkflowDefinitionLabelFilterTests var workflowDefinitionStore = new MemoryWorkflowDefinitionStore(memoryStore); await workflowDefinitionStore.SaveManyAsync( [ - new WorkflowDefinition { Id = "red-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json" }, - new WorkflowDefinition { Id = "red-unlabeled-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json" }, + new WorkflowDefinition { Id = "red-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json", Version = 1 }, + new WorkflowDefinition { Id = "red-unlabeled-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json", Version = 2 }, new WorkflowDefinition { Id = "red-second-version", DefinitionId = "red-second", Name = "Red second", MaterializerName = "Json" }, new WorkflowDefinition { Id = "blue-version", DefinitionId = "blue", Name = "Blue", MaterializerName = "Json" }, new WorkflowDefinition { Id = "plain-version", DefinitionId = "plain", Name = "Plain", MaterializerName = "Json" } diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreUniquenessTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreUniquenessTests.cs new file mode 100644 index 000000000..cbb690aa0 --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreUniquenessTests.cs @@ -0,0 +1,59 @@ +using Elsa.Common.Services; +using Elsa.Testing.Shared.Multitenancy; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Stores; + +namespace Elsa.Workflows.Management.UnitTests.Stores; + +/// +/// Memory must reject colliding (DefinitionId, Version, TenantId) keys the same way EF +/// rejects IX_WorkflowDefinition_DefinitionId_Version on Save/SaveMany. +/// +public class MemoryWorkflowDefinitionStoreUniquenessTests +{ + [Fact(DisplayName = "SaveManyAsync rejects two different Ids that share a version key")] + public async Task SaveManyAsync_WhenBatchRepeatsVersionKey_ThrowsAndLeavesStoreUnchanged() + { + var store = CreateStore(); + + var exception = await Assert.ThrowsAsync(() => + store.SaveManyAsync( + [ + Definition("def-1", "order"), + Definition("def-2", "order") + ])); + + Assert.Contains("already exists", exception.Message); + Assert.Empty((await store.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList()); + } + + [Fact(DisplayName = "SaveManyAsync rejects a batch whose version key is already present under another Id")] + public async Task SaveManyAsync_WhenVersionKeyExistsUnderAnotherId_ThrowsAndLeavesExisting() + { + var store = CreateStore(); + await store.SaveAsync(Definition("def-1", "order")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveManyAsync([Definition("def-2", "order")])); + + Assert.Contains("already exists", exception.Message); + var stored = (await store.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList(); + Assert.Equal("def-1", Assert.Single(stored).Id); + } + + private static MemoryWorkflowDefinitionStore CreateStore() => + new(new MemoryStore(), new TestTenantAccessor("tenant-a")); + + private static WorkflowDefinition Definition(string id, string definitionId) => + new() + { + Id = id, + DefinitionId = definitionId, + Name = definitionId, + TenantId = "tenant-a", + Version = 1, + IsLatest = true, + MaterializerName = "Json" + }; +}