test(labels): share InMemory/EF store conformance scenarios (#8122)
* test(labels): share InMemory/EF store conformance scenarios Add one abstract Labels store suite and run it against InMemory and EF Core (SQLite) so tenant isolation, NormalizedName uniqueness, cascade delete, ReplaceAsync, and association lookups stay aligned. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): drop provider-specific uniqueness and paging asserts SaveMany name swaps trip EF BulkUpsert unique indexes, and Memory ToPage counts after Skip/Take. Keep the shared matrix on contracts both InMemory and EF/SQLite honor. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): map conformance matrix to the #8091 lock list Keep one scenario per issue invariant so the InMemory/EF harness stays small and readable: cascade delete, ReplaceAsync, association finds/deletes, ListAsync order/paging, tenant stamp/isolation, and per-tenant NormalizedName uniqueness. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): cover cross-tenant lookup conformance * test(labels): use Path.Join for sqlite fixture * test(labels): lock ID-only ReplaceAsync and SaveMany tenant stamp Vary every field except Id on the removed association so ReplaceAsync cannot match the stored row by payload. Also stamp null-tenant batches through SaveManyAsync on both label and association stores. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): stamp null TenantId under a named ambient tenant Cover SaveAsync and SaveManyAsync for labels and associations when the ambient tenant is tenant-a, so a default-only stamp cannot pass. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): reject tenant-A association leakage under tenant-B Tenant-B FindByVersion and FindByLabelIds now require assoc-b and assoc-star, and fail if assoc-a is visible. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
f3e77fe3e7
commit
fe9217bdfa
15
Elsa.sln
15
Elsa.sln
|
|
@ -525,6 +525,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration"
|
|||
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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Labels.Persistence.ConformanceTests", "test\integration\Elsa.Labels.Persistence.ConformanceTests\Elsa.Labels.Persistence.ConformanceTests.csproj", "{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -2513,6 +2515,18 @@ Global
|
|||
{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
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -2721,6 +2735,7 @@ Global
|
|||
{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}
|
||||
{7C4E1A92-6B8F-4D3A-9E11-2F5A8C0B4D17} = {823D4020-332D-2C13-F261-6F510F11A57E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Include>[Elsa.Labels]*,[Elsa.Persistence.EFCore]*</Include>
|
||||
<Threshold>0</Threshold>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite"/>
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Labels\Elsa.Labels.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Labels.Entities;
|
||||
|
||||
namespace Elsa.Labels.Persistence.ConformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared InMemory / EF Core store-contract assertions for Labels ports.
|
||||
/// </summary>
|
||||
public abstract class LabelStoreConformanceTests
|
||||
{
|
||||
protected abstract Task<LabelStoreScenario> CreateScenarioAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingALabelCascadesItsAssociations()
|
||||
{
|
||||
await using var scenario = await CreateScenarioAsync();
|
||||
await scenario.Labels.SaveAsync(Label("label-a", "A", "tenant-a"));
|
||||
await scenario.Labels.SaveAsync(Label("label-keep", "Keep", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-a1", "label-a", "order", "order:1", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-a2", "label-a", "invoice", "invoice:1", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-keep", "label-keep", "order", "order:1", "tenant-a"));
|
||||
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
{
|
||||
await scenario.Labels.SaveAsync(Label("label-b", "A", "tenant-b"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-b", "label-b", "order", "order:1", "tenant-b"));
|
||||
}
|
||||
|
||||
Assert.True(await scenario.Labels.DeleteAsync("label-a"));
|
||||
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-a"]));
|
||||
Assert.Equal("assoc-keep", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-keep"])).Id);
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
{
|
||||
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-b"));
|
||||
Assert.Equal("assoc-b", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-b"])).Id);
|
||||
}
|
||||
|
||||
await scenario.Labels.SaveAsync(Label("label-x", "X", "tenant-a"));
|
||||
await scenario.Labels.SaveAsync(Label("label-y", "Y", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-x", "label-x", "order", "order:2", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-y", "label-y", "order", "order:2", "tenant-a"));
|
||||
|
||||
Assert.Equal(2, await scenario.Labels.DeleteManyAsync(["label-x", "label-y"]));
|
||||
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-x", "label-y"]));
|
||||
Assert.Equal("assoc-keep", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["label-keep"])).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceAsyncRemovesAndAddsByAssociationId()
|
||||
{
|
||||
await using var scenario = await CreateScenarioAsync();
|
||||
await scenario.Associations.SaveAsync(Association("assoc-red", "red", "order", "order:1", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-blue", "blue", "order", "order:1", "tenant-a"));
|
||||
|
||||
await scenario.Associations.ReplaceAsync(
|
||||
[Association("assoc-red", "wrong-label", "other", "other:9", "tenant-b")],
|
||||
[Association("assoc-green", "green", "order", "order:1", "tenant-a")]);
|
||||
|
||||
var remaining = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"))
|
||||
.Select(x => x.Id)
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
Assert.Equal(["assoc-blue", "assoc-green"], remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssociationFindsAndDeletesHonorWorkflowDefinitionKeys()
|
||||
{
|
||||
await using var scenario = await CreateScenarioAsync();
|
||||
await scenario.Associations.SaveAsync(Association("assoc-v1-red", "red", "order", "order:1", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-v1-blue", "blue", "order", "order:1", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-v2-red", "red", "order", "order:2", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-invoice", "red", "invoice", "invoice:1", "tenant-a"));
|
||||
|
||||
var version1 = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
Assert.Equal(2, version1.Count);
|
||||
Assert.Contains(version1, x => x.Id == "assoc-v1-red");
|
||||
Assert.Contains(version1, x => x.Id == "assoc-v1-blue");
|
||||
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("missing"));
|
||||
|
||||
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync([]));
|
||||
Assert.Empty(await scenario.AssociationQuery.FindByLabelIdsAsync(["unknown"]));
|
||||
var byRed = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).ToList();
|
||||
Assert.Equal(3, byRed.Count);
|
||||
Assert.Contains(byRed, x => x.Id == "assoc-v1-red");
|
||||
Assert.Contains(byRed, x => x.Id == "assoc-v2-red");
|
||||
Assert.Contains(byRed, x => x.Id == "assoc-invoice");
|
||||
|
||||
var byRedAndBlue = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red", "blue"])).ToList();
|
||||
Assert.Equal(4, byRedAndBlue.Count);
|
||||
Assert.Contains(byRedAndBlue, x => x.Id == "assoc-v1-blue");
|
||||
|
||||
Assert.Equal(1, await scenario.Associations.DeleteByWorkflowDefinitionVersionIdAsync("order:2"));
|
||||
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:2"));
|
||||
Assert.NotEmpty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"));
|
||||
|
||||
Assert.Equal(1, await scenario.Associations.DeleteByWorkflowDefinitionIdAsync("invoice"));
|
||||
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("invoice:1"));
|
||||
|
||||
await scenario.Associations.SaveManyAsync(
|
||||
[
|
||||
Association("assoc-pay-1", "red", "payroll", "payroll:1", "tenant-a"),
|
||||
Association("assoc-pay-2", "red", "payroll", "payroll:2", "tenant-a"),
|
||||
Association("assoc-hr-1", "blue", "hr", "hr:1", "tenant-a")
|
||||
]);
|
||||
Assert.Equal(2, await scenario.Associations.DeleteByWorkflowDefinitionVersionIdsAsync(["payroll:1", "payroll:2"]));
|
||||
Assert.Equal(1, await scenario.Associations.DeleteByWorkflowDefinitionIdsAsync(["hr"]));
|
||||
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("payroll:1"));
|
||||
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("hr:1"));
|
||||
Assert.NotEmpty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsyncOrdersByNameAndPages()
|
||||
{
|
||||
await using var scenario = await CreateScenarioAsync();
|
||||
await scenario.Labels.SaveAsync(Label("label-z", "Zebra", "tenant-a"));
|
||||
await scenario.Labels.SaveAsync(Label("label-a", "Apple", "tenant-a"));
|
||||
await scenario.Labels.SaveAsync(Label("label-m", "Mango", "tenant-a"));
|
||||
|
||||
var all = (await scenario.Labels.ListAsync()).Items.Select(x => x.Name).ToList();
|
||||
Assert.Equal(["Apple", "Mango", "Zebra"], all);
|
||||
|
||||
// Page.TotalCount is not a shared contract: Memory ToPage counts after Skip/Take,
|
||||
// EF PaginateAsync counts the unpaged query.
|
||||
var firstPage = await scenario.Labels.ListAsync(PageArgs.FromRange(0, 2));
|
||||
Assert.Equal(["Apple", "Mango"], firstPage.Items.Select(x => x.Name).ToList());
|
||||
|
||||
var secondPage = await scenario.Labels.ListAsync(PageArgs.FromRange(2, 2));
|
||||
Assert.Equal(["Zebra"], secondPage.Items.Select(x => x.Name).ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantStampAndIsolationHonorAmbientTenant()
|
||||
{
|
||||
await using var scenario = await CreateScenarioAsync();
|
||||
await SeedMixedLabelsAsync(scenario);
|
||||
await SeedMixedAssociationsAsync(scenario);
|
||||
|
||||
var listed = (await scenario.Labels.ListAsync()).Items.ToList();
|
||||
Assert.Equal(2, listed.Count);
|
||||
Assert.Contains(listed, x => x.Id == "label-a");
|
||||
Assert.Contains(listed, x => x.Id == "label-star");
|
||||
Assert.DoesNotContain(listed, x => x.Id == "label-b");
|
||||
|
||||
Assert.Null(await scenario.Labels.FindByIdAsync("label-b"));
|
||||
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-a"));
|
||||
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-star"));
|
||||
|
||||
var foundMany = (await scenario.Labels.FindManyByIdAsync(["label-a", "label-b", "label-star"])).ToList();
|
||||
Assert.Equal(2, foundMany.Count);
|
||||
Assert.DoesNotContain(foundMany, x => x.Id == "label-b");
|
||||
|
||||
var byVersion = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
Assert.Equal(2, byVersion.Count);
|
||||
Assert.Contains(byVersion, x => x.Id == "assoc-a");
|
||||
Assert.Contains(byVersion, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(byVersion, x => x.Id == "assoc-b");
|
||||
|
||||
var byLabel = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).ToList();
|
||||
Assert.Equal(2, byLabel.Count);
|
||||
Assert.DoesNotContain(byLabel, x => x.Id == "assoc-b");
|
||||
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
{
|
||||
var tenantBLabels = (await scenario.Labels.ListAsync()).Items.ToList();
|
||||
Assert.Contains(tenantBLabels, x => x.Id == "label-star");
|
||||
Assert.DoesNotContain(tenantBLabels, x => x.Id == "label-a");
|
||||
Assert.Equal("label-star", (await scenario.Labels.FindByIdAsync("label-star"))!.Id);
|
||||
|
||||
var tenantBByVersion = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
Assert.Equal(2, tenantBByVersion.Count);
|
||||
Assert.Contains(tenantBByVersion, x => x.Id == "assoc-b");
|
||||
Assert.Contains(tenantBByVersion, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(tenantBByVersion, x => x.Id == "assoc-a");
|
||||
|
||||
var tenantBByLabel = (await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).ToList();
|
||||
Assert.Equal(2, tenantBByLabel.Count);
|
||||
Assert.Contains(tenantBByLabel, x => x.Id == "assoc-b");
|
||||
Assert.Contains(tenantBByLabel, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(tenantBByLabel, x => x.Id == "assoc-a");
|
||||
}
|
||||
|
||||
Assert.False(await scenario.Labels.DeleteAsync("label-b"));
|
||||
Assert.False(await scenario.Associations.DeleteAsync("assoc-b"));
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
{
|
||||
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-b"));
|
||||
Assert.Contains(await scenario.AssociationQuery.FindByLabelIdsAsync(["red"]), x => x.Id == "assoc-b");
|
||||
}
|
||||
|
||||
await scenario.Associations.ReplaceAsync(
|
||||
[Association("assoc-a", "red", "order", "order:1", "tenant-a"), Association("assoc-b", "red", "order", "order:1", "tenant-b")],
|
||||
[Association("assoc-a2", "red", "order", "order:1", "tenant-a")]);
|
||||
var remainingA = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
Assert.Contains(remainingA, x => x.Id == "assoc-a2");
|
||||
Assert.Contains(remainingA, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(remainingA, x => x.Id == "assoc-a");
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
Assert.Contains(await scenario.AssociationQuery.FindByLabelIdsAsync(["red"]), x => x.Id == "assoc-b");
|
||||
|
||||
var deletedLabels = await scenario.Labels.DeleteManyAsync(["label-a", "label-b", "label-star"]);
|
||||
Assert.Equal(2, deletedLabels);
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
Assert.Equal("label-b", (await scenario.Labels.FindByIdAsync("label-b"))!.Id);
|
||||
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
await scenario.Associations.SaveAsync(Association("assoc-b-order", "blue", "order", "order:1", "tenant-b"));
|
||||
Assert.Equal(2, await scenario.Associations.DeleteByWorkflowDefinitionIdAsync("order"));
|
||||
Assert.Empty(await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1"));
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
{
|
||||
var stillB = (await scenario.Associations.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
Assert.Contains(stillB, x => x.Id == "assoc-b");
|
||||
Assert.Contains(stillB, x => x.Id == "assoc-b-order");
|
||||
}
|
||||
|
||||
using (scenario.UseTenant(Tenant.DefaultTenantId))
|
||||
{
|
||||
var stampedLabel = Label("label-null", "Null", tenantId: null);
|
||||
await scenario.Labels.SaveAsync(stampedLabel);
|
||||
Assert.Equal(Tenant.DefaultTenantId, stampedLabel.TenantId);
|
||||
|
||||
var namedLabel = Label("label-named", "Named", "tenant-a");
|
||||
await scenario.Labels.SaveAsync(namedLabel);
|
||||
Assert.Equal("tenant-a", namedLabel.TenantId);
|
||||
|
||||
var defaultLabels = (await scenario.Labels.ListAsync()).Items.ToList();
|
||||
Assert.Contains(defaultLabels, x => x.Id == "label-null");
|
||||
Assert.DoesNotContain(defaultLabels, x => x.Id == "label-named");
|
||||
|
||||
var stampedAssociation = Association("assoc-null", "red", "order", "order:1", tenantId: null);
|
||||
await scenario.Associations.SaveAsync(stampedAssociation);
|
||||
Assert.Equal(Tenant.DefaultTenantId, stampedAssociation.TenantId);
|
||||
Assert.Equal("assoc-null", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["red"])).Id);
|
||||
|
||||
var batchLabels = new[] { Label("label-batch-null", "BatchNull", tenantId: null) };
|
||||
await scenario.Labels.SaveManyAsync(batchLabels);
|
||||
Assert.Equal(Tenant.DefaultTenantId, batchLabels[0].TenantId);
|
||||
Assert.Equal(Tenant.DefaultTenantId, (await scenario.Labels.FindByIdAsync("label-batch-null"))!.TenantId);
|
||||
|
||||
var batchAssociations = new[] { Association("assoc-batch-null", "blue", "order", "order:1", tenantId: null) };
|
||||
await scenario.Associations.SaveManyAsync(batchAssociations);
|
||||
Assert.Equal(Tenant.DefaultTenantId, batchAssociations[0].TenantId);
|
||||
Assert.Equal("assoc-batch-null", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["blue"])).Id);
|
||||
}
|
||||
|
||||
var agnostic = Label("label-star-2", "Star2", Tenant.AgnosticTenantId);
|
||||
await scenario.Labels.SaveAsync(agnostic);
|
||||
Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
|
||||
|
||||
var stampedNamedLabel = Label("label-named-stamp", "NamedStamp", tenantId: null);
|
||||
await scenario.Labels.SaveAsync(stampedNamedLabel);
|
||||
Assert.Equal("tenant-a", stampedNamedLabel.TenantId);
|
||||
Assert.Equal("tenant-a", (await scenario.Labels.FindByIdAsync("label-named-stamp"))!.TenantId);
|
||||
|
||||
var stampedNamedLabelBatch = new[] { Label("label-named-batch", "NamedBatch", tenantId: null) };
|
||||
await scenario.Labels.SaveManyAsync(stampedNamedLabelBatch);
|
||||
Assert.Equal("tenant-a", stampedNamedLabelBatch[0].TenantId);
|
||||
Assert.Equal("tenant-a", (await scenario.Labels.FindByIdAsync("label-named-batch"))!.TenantId);
|
||||
|
||||
var stampedNamedAssociation = Association("assoc-named-stamp", "orange", "order", "order:1", tenantId: null);
|
||||
await scenario.Associations.SaveAsync(stampedNamedAssociation);
|
||||
Assert.Equal("tenant-a", stampedNamedAssociation.TenantId);
|
||||
Assert.Equal("assoc-named-stamp", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["orange"])).Id);
|
||||
|
||||
var stampedNamedAssociationBatch = new[] { Association("assoc-named-batch", "purple", "order", "order:1", tenantId: null) };
|
||||
await scenario.Associations.SaveManyAsync(stampedNamedAssociationBatch);
|
||||
Assert.Equal("tenant-a", stampedNamedAssociationBatch[0].TenantId);
|
||||
Assert.Equal("assoc-named-batch", Assert.Single(await scenario.AssociationQuery.FindByLabelIdsAsync(["purple"])).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizedNamesAreUniquePerTenant()
|
||||
{
|
||||
await using var scenario = await CreateScenarioAsync();
|
||||
|
||||
await scenario.Labels.SaveAsync(Label("label-1", "Urgent", "tenant-a"));
|
||||
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveAsync(Label("label-2", "urgent", "tenant-a")));
|
||||
Assert.Equal("label-1", Assert.Single((await scenario.Labels.ListAsync()).Items).Id);
|
||||
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
await scenario.Labels.SaveAsync(Label("label-b", "Urgent", "tenant-b"));
|
||||
Assert.Equal("urgent", (await scenario.Labels.FindByIdAsync("label-1"))!.NormalizedName);
|
||||
using (scenario.UseTenant("tenant-b"))
|
||||
Assert.Equal("urgent", (await scenario.Labels.FindByIdAsync("label-b"))!.NormalizedName);
|
||||
|
||||
await scenario.Labels.SaveAsync(Label("label-1", "Critical", "tenant-a"));
|
||||
var updated = await scenario.Labels.FindByIdAsync("label-1");
|
||||
Assert.Equal("Critical", updated!.Name);
|
||||
Assert.Equal("critical", updated.NormalizedName);
|
||||
|
||||
await scenario.Labels.SaveAsync(Label("label-later", "Later", "tenant-a"));
|
||||
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveAsync(Label("label-later", "Critical", "tenant-a")));
|
||||
Assert.Equal("Later", (await scenario.Labels.FindByIdAsync("label-later"))!.Name);
|
||||
|
||||
await scenario.Labels.SaveAsync(Label("label-star", "Critical", Tenant.AgnosticTenantId));
|
||||
Assert.NotNull(await scenario.Labels.FindByIdAsync("label-star"));
|
||||
|
||||
using (scenario.UseTenant(Tenant.DefaultTenantId))
|
||||
{
|
||||
await scenario.Labels.SaveAsync(Label("label-default", "Shared", tenantId: null));
|
||||
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveAsync(Label("label-default-dup", "Shared", tenantId: null)));
|
||||
Assert.Equal(Tenant.DefaultTenantId, (await scenario.Labels.FindByIdAsync("label-default"))!.TenantId);
|
||||
}
|
||||
|
||||
await scenario.AssertUniquenessConflictAsync(() =>
|
||||
scenario.Labels.SaveManyAsync([Label("label-3", "Later", "tenant-a")]));
|
||||
Assert.Null(await scenario.Labels.FindByIdAsync("label-3"));
|
||||
|
||||
await scenario.AssertUniquenessConflictAsync(() => scenario.Labels.SaveManyAsync(
|
||||
[
|
||||
Label("label-batch-1", "Invoice", "tenant-a"),
|
||||
Label("label-batch-2", "invoice", "tenant-a")
|
||||
]));
|
||||
Assert.Null(await scenario.Labels.FindByIdAsync("label-batch-1"));
|
||||
Assert.Null(await scenario.Labels.FindByIdAsync("label-batch-2"));
|
||||
Assert.Equal("Later", (await scenario.Labels.FindByIdAsync("label-later"))!.Name);
|
||||
}
|
||||
|
||||
private static async Task SeedMixedLabelsAsync(LabelStoreScenario scenario)
|
||||
{
|
||||
await scenario.Labels.SaveAsync(Label("label-a", "A", "tenant-a"));
|
||||
await scenario.Labels.SaveAsync(Label("label-b", "B", "tenant-b"));
|
||||
await scenario.Labels.SaveAsync(Label("label-star", "Star", Tenant.AgnosticTenantId));
|
||||
}
|
||||
|
||||
private static async Task SeedMixedAssociationsAsync(LabelStoreScenario scenario)
|
||||
{
|
||||
await scenario.Associations.SaveAsync(Association("assoc-a", "red", "order", "order:1", "tenant-a"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-b", "red", "order", "order:1", "tenant-b"));
|
||||
await scenario.Associations.SaveAsync(Association("assoc-star", "red", "order", "order:1", Tenant.AgnosticTenantId));
|
||||
}
|
||||
|
||||
private static Label Label(string id, string name, string? tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
private static WorkflowDefinitionLabel Association(
|
||||
string id,
|
||||
string labelId,
|
||||
string workflowDefinitionId,
|
||||
string workflowDefinitionVersionId,
|
||||
string? tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LabelId = labelId,
|
||||
WorkflowDefinitionId = workflowDefinitionId,
|
||||
WorkflowDefinitionVersionId = workflowDefinitionVersionId,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class LabelStoreInMemoryConformanceCollection
|
||||
{
|
||||
public const string Name = "LabelStores:InMemory";
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class LabelStoreSqliteConformanceCollection
|
||||
{
|
||||
public const string Name = "LabelStores:EFCore.Sqlite";
|
||||
}
|
||||
|
||||
[Collection(LabelStoreInMemoryConformanceCollection.Name)]
|
||||
public sealed class InMemoryLabelStoreConformanceTests : LabelStoreConformanceTests
|
||||
{
|
||||
protected override Task<LabelStoreScenario> CreateScenarioAsync() => LabelStoreScenario.CreateInMemoryAsync();
|
||||
}
|
||||
|
||||
[Collection(LabelStoreSqliteConformanceCollection.Name)]
|
||||
public sealed class SqliteLabelStoreConformanceTests : LabelStoreConformanceTests
|
||||
{
|
||||
protected override Task<LabelStoreScenario> CreateScenarioAsync() => LabelStoreScenario.CreateSqliteAsync();
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Labels.Contracts;
|
||||
using Elsa.Labels.Entities;
|
||||
using Elsa.Labels.Services;
|
||||
using Elsa.Persistence.EFCore;
|
||||
using Elsa.Persistence.EFCore.EntityHandlers;
|
||||
using Elsa.Persistence.EFCore.Extensions;
|
||||
using Elsa.Persistence.EFCore.Modules.Labels;
|
||||
using Elsa.Persistence.EFCore.Sqlite;
|
||||
using Elsa.Tenants.Options;
|
||||
using Elsa.Testing.Shared.Multitenancy;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Elsa.Labels.Persistence.ConformanceTests;
|
||||
|
||||
/// <summary>
|
||||
/// Holds one InMemory or EF/SQLite pair of Labels stores and the ambient tenant they read.
|
||||
/// </summary>
|
||||
public sealed class LabelStoreScenario(
|
||||
TestTenantAccessor tenantAccessor,
|
||||
ILabelStore labels,
|
||||
IWorkflowDefinitionLabelStore associations,
|
||||
IWorkflowDefinitionLabelQuery associationQuery,
|
||||
Func<Func<Task>, Task> assertUniquenessConflictAsync,
|
||||
Func<ValueTask> disposeAsync) : IAsyncDisposable
|
||||
{
|
||||
public TestTenantAccessor TenantAccessor { get; } = tenantAccessor;
|
||||
public ILabelStore Labels { get; } = labels;
|
||||
public IWorkflowDefinitionLabelStore Associations { get; } = associations;
|
||||
public IWorkflowDefinitionLabelQuery AssociationQuery { get; } = associationQuery;
|
||||
|
||||
public IDisposable UseTenant(string tenantId) =>
|
||||
TenantAccessor.PushContext(tenantId == Tenant.DefaultTenantId
|
||||
? Tenant.Default
|
||||
: new Tenant { Id = tenantId, Name = tenantId });
|
||||
|
||||
public Task AssertUniquenessConflictAsync(Func<Task> operation) => assertUniquenessConflictAsync(operation);
|
||||
|
||||
public ValueTask DisposeAsync() => disposeAsync();
|
||||
|
||||
public static Task<LabelStoreScenario> CreateInMemoryAsync()
|
||||
{
|
||||
var tenantAccessor = new TestTenantAccessor("tenant-a");
|
||||
var labels = new MemoryStore<Label>();
|
||||
var associations = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var labelStore = new InMemoryLabelStore(labels, associations, tenantAccessor);
|
||||
var associationStore = new InMemoryWorkflowDefinitionLabelStore(associations, tenantAccessor);
|
||||
|
||||
return Task.FromResult(new LabelStoreScenario(
|
||||
tenantAccessor,
|
||||
labelStore,
|
||||
associationStore,
|
||||
associationStore,
|
||||
operation => Assert.ThrowsAsync<InvalidOperationException>(operation),
|
||||
() => ValueTask.CompletedTask));
|
||||
}
|
||||
|
||||
public static async Task<LabelStoreScenario> CreateSqliteAsync()
|
||||
{
|
||||
var databasePath = Path.Join(Path.GetTempPath(), $"elsa-labels-conformance-{Guid.NewGuid():N}.db");
|
||||
var tenantAccessor = new TestTenantAccessor("tenant-a");
|
||||
ServiceProvider? services = null;
|
||||
IServiceScope? scope = null;
|
||||
|
||||
try
|
||||
{
|
||||
var migrationsAssembly = typeof(LabelsDbContextFactory).Assembly;
|
||||
services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.AddSingleton<ITenantAccessor>(tenantAccessor)
|
||||
.Configure<TenantsOptions>(options => options.IsEnabled = true)
|
||||
.AddScoped<IEntitySavingHandler, ApplyTenantId>()
|
||||
.AddScoped<IEntityModelCreatingHandler, SetTenantIdFilter>()
|
||||
.AddSqliteEntityModelCreatingHandlers()
|
||||
.AddDbContextFactory<LabelsElsaDbContext>((_, builder) =>
|
||||
builder.UseElsaSqlite(migrationsAssembly, $"Data Source={databasePath};Default Timeout=30"))
|
||||
.Decorate<IDbContextFactory<LabelsElsaDbContext>, TenantAwareDbContextFactory<LabelsElsaDbContext>>()
|
||||
.AddScoped<EntityStore<LabelsElsaDbContext, Label>>()
|
||||
.AddScoped<EntityStore<LabelsElsaDbContext, WorkflowDefinitionLabel>>()
|
||||
.AddScoped<EFCoreLabelStore>()
|
||||
.AddScoped<EFCoreWorkflowDefinitionLabelStore>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
await using (var dbContext = await services.GetRequiredService<IDbContextFactory<LabelsElsaDbContext>>().CreateDbContextAsync())
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
|
||||
scope = services.CreateScope();
|
||||
var scoped = scope.ServiceProvider;
|
||||
var associationStore = scoped.GetRequiredService<EFCoreWorkflowDefinitionLabelStore>();
|
||||
|
||||
return new(
|
||||
tenantAccessor,
|
||||
scoped.GetRequiredService<EFCoreLabelStore>(),
|
||||
associationStore,
|
||||
associationStore,
|
||||
AssertSqliteUniquenessConflictAsync,
|
||||
async () =>
|
||||
{
|
||||
scope.Dispose();
|
||||
await services.DisposeAsync();
|
||||
SqliteConnection.ClearAllPools();
|
||||
File.Delete(databasePath);
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
scope?.Dispose();
|
||||
if (services is not null)
|
||||
await services.DisposeAsync();
|
||||
SqliteConnection.ClearAllPools();
|
||||
File.Delete(databasePath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AssertSqliteUniquenessConflictAsync(Func<Task> 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue