From 4423b22518baefcba7a621b116255f08d0615586 Mon Sep 17 00:00:00 2001 From: Jan-Willem de Bruyn Date: Thu, 21 May 2026 08:28:25 +0200 Subject: [PATCH 1/2] Update Dapper bookmark store Apply Filter to also account for Id filters. --- .../Modules/Runtime/Stores/DapperBookmarkStore.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs index c0083bee..9442a41f 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs @@ -53,6 +53,8 @@ public async ValueTask DeleteAsync(BookmarkFilter filter, CancellationToke private void ApplyFilter(ParameterizedQuery query, BookmarkFilter filter) { query + .Is(nameof(StoredBookmarkRecord.Id), filter.BookmarkId) + .In(nameof(StoredBookmarkRecord.Id), filter.BookmarkIds) .Is(nameof(StoredBookmarkRecord.Hash), filter.Hash) .In(nameof(StoredBookmarkRecord.Hash), filter.Hashes) .Is(nameof(StoredBookmarkRecord.WorkflowInstanceId), filter.WorkflowInstanceId) From 32615a08afd61affbb3810e987d306d65aadd206 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 6 Sep 2026 07:43:21 +0200 Subject: [PATCH 2/2] Cover Dapper bookmark ID filters with SQLite regressions --- .../Elsa.Persistence.Dapper.csproj | 4 + .../DapperBookmarkStoreTests.cs | 181 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperBookmarkStoreTests.cs diff --git a/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj b/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj index 2fce2723..35cb6d43 100644 --- a/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj +++ b/src/modules/persistence/Elsa.Persistence.Dapper/Elsa.Persistence.Dapper.csproj @@ -23,6 +23,10 @@ + + + + diff --git a/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperBookmarkStoreTests.cs b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperBookmarkStoreTests.cs new file mode 100644 index 00000000..04c7f13e --- /dev/null +++ b/test/modules/persistence/Elsa.Persistence.Dapper.UnitTests/DapperBookmarkStoreTests.cs @@ -0,0 +1,181 @@ +using Dapper; +using Elsa.Common.Models; +using Elsa.Common.Multitenancy; +using Elsa.Persistence.Dapper.Modules.Runtime.Records; +using Elsa.Persistence.Dapper.Modules.Runtime.Stores; +using Elsa.Persistence.Dapper.Services; +using Elsa.Workflows; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Microsoft.Data.Sqlite; +using NSubstitute; + +namespace Elsa.Persistence.Dapper.UnitTests; + +public sealed class DapperBookmarkStoreTests : IDisposable +{ + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"elsa-dapper-bookmarks-{Guid.NewGuid():N}.db"); + private readonly DapperBookmarkStore _store; + private readonly TestTenantAccessor _tenantAccessor = new(); + + public DapperBookmarkStoreTests() + { + var connectionString = new SqliteConnectionStringBuilder { DataSource = _databasePath, Pooling = false }.ToString(); + var connectionProvider = new SqliteDbConnectionProvider(connectionString); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + connection.Execute(""" + create table Bookmarks ( + Id text not null primary key, + TenantId text null, + ActivityTypeName text not null, + Hash text not null, + WorkflowInstanceId text not null, + CorrelationId text null, + ActivityInstanceId text null, + SerializedPayload text null, + SerializedMetadata text null, + CreatedAt text not null + ); + insert into Bookmarks (Id, TenantId, ActivityTypeName, Hash, WorkflowInstanceId, ActivityInstanceId, CreatedAt) + values ('a1', 'tenant-a', 'Test', 'hash-a1', 'workflow-a', 'activity-a1', '2026-01-01T00:00:00+00:00'); + insert into Bookmarks (Id, TenantId, ActivityTypeName, Hash, WorkflowInstanceId, ActivityInstanceId, CreatedAt) + values ('a2', 'tenant-a', 'Test', 'hash-a2', 'workflow-other', 'activity-a2', '2026-01-02T00:00:00+00:00'); + insert into Bookmarks (Id, TenantId, ActivityTypeName, Hash, WorkflowInstanceId, ActivityInstanceId, CreatedAt) + values ('a3', 'tenant-a', 'Test', 'hash-a3', 'workflow-a', 'activity-a3', '2026-01-03T00:00:00+00:00'); + insert into Bookmarks (Id, TenantId, ActivityTypeName, Hash, WorkflowInstanceId, ActivityInstanceId, CreatedAt) + values ('b1', 'tenant-b', 'Test', 'hash-b1', 'workflow-b', 'activity-b1', '2026-01-04T00:00:00+00:00'); + insert into Bookmarks (Id, TenantId, ActivityTypeName, Hash, WorkflowInstanceId, ActivityInstanceId, CreatedAt) + values ('b2', 'tenant-b', 'Test', 'hash-b2', 'workflow-b', 'activity-b2', '2026-01-05T00:00:00+00:00'); + """); + + var store = new Store(connectionProvider, _tenantAccessor, "Bookmarks"); + _store = new DapperBookmarkStore(store, Substitute.For()); + } + + [Fact] + public async Task FindAsync_WithBookmarkId_ReturnsOnlyRequestedBookmark() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var bookmark = await _store.FindAsync(new BookmarkFilter { BookmarkId = "a2" }); + + Assert.NotNull(bookmark); + Assert.Equal("a2", bookmark.Id); + } + + [Fact] + public async Task FindManyAsync_WithBookmarkIds_ReturnsOnlyRequestedIdsForCurrentTenant() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var bookmarks = await _store.FindManyAsync(new BookmarkFilter { BookmarkIds = ["a1", "b1"] }); + + Assert.Equal(["a1"], bookmarks.Select(x => x.Id)); + } + + [Fact] + public async Task FindManyAsync_WithBookmarkIds_ComposesWithOtherFilters() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var bookmarks = await _store.FindManyAsync(new BookmarkFilter + { + BookmarkIds = ["a1", "a2"], + WorkflowInstanceId = "workflow-a" + }); + + Assert.Equal(["a1"], bookmarks.Select(x => x.Id)); + } + + [Fact] + public async Task FindManyAsync_WithUnknownOrEmptyBookmarkIds_ReturnsNoRows() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var unknown = await _store.FindManyAsync(new BookmarkFilter { BookmarkIds = ["missing"] }); + var empty = await _store.FindManyAsync(new BookmarkFilter { BookmarkIds = [] }); + + Assert.Empty(unknown); + Assert.Empty(empty); + } + + [Fact] + public async Task FindManyAsync_WithoutBookmarkIds_PreservesUnfilteredBehavior() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var bookmarks = await _store.FindManyAsync(new BookmarkFilter()); + + Assert.Equal(["a1", "a2", "a3"], bookmarks.Select(x => x.Id).Order()); + } + + [Fact] + public async Task FindManyAsync_WithBookmarkIds_PaginatesMatchingRowsAndReportsTotalCount() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var filter = new BookmarkFilter { BookmarkIds = ["a1", "a2"] }; + + var firstPage = await _store.FindManyAsync(filter, PageArgs.FromRange(0, 1)); + var secondPage = await _store.FindManyAsync(filter, PageArgs.FromRange(1, 1)); + + Assert.Equal(["a1"], firstPage.Items.Select(x => x.Id)); + Assert.Equal(2, firstPage.TotalCount); + Assert.Equal(["a2"], secondPage.Items.Select(x => x.Id)); + Assert.Equal(2, secondPage.TotalCount); + } + + [Fact] + public async Task DeleteAsync_WithBookmarkIds_DeletesOnlyRequestedRows() + { + using (var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" })) + { + var deleted = await _store.DeleteAsync(new BookmarkFilter { BookmarkIds = ["a1", "b1"] }); + + Assert.Equal(1, deleted); + } + + using var remainingTenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + var remainingTenantBookmarks = await _store.FindManyAsync(new BookmarkFilter { TenantAgnostic = true }); + + Assert.Equal(["a2", "a3", "b1", "b2"], remainingTenantBookmarks.Select(x => x.Id).Order()); + } + + [Fact] + public async Task FindManyAsync_WithTenantAgnosticBookmarkIds_ReturnsMatchingRowsAcrossTenants() + { + using var tenantScope = _tenantAccessor.PushContext(new Tenant { Id = "tenant-a" }); + + var bookmarks = await _store.FindManyAsync(new BookmarkFilter + { + BookmarkIds = ["a1", "b1"], + TenantAgnostic = true + }); + + Assert.Equal(["a1", "b1"], bookmarks.Select(x => x.Id).Order()); + } + + public void Dispose() + { + File.Delete(_databasePath); + } + + private sealed class TestTenantAccessor : ITenantAccessor + { + public string TenantId => Tenant?.Id ?? Tenant.DefaultTenantId; + public Tenant? Tenant { get; private set; } + + public IDisposable PushContext(Tenant? tenant) + { + var previousTenant = Tenant; + Tenant = tenant; + return new Restore(() => Tenant = previousTenant); + } + + private sealed class Restore(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + } +}