From 5593aa5fbae239a7f666c0ce9fbafbcbe180cee6 Mon Sep 17 00:00:00 2001 From: HiroshiYoshino Date: Thu, 5 Nov 2026 07:00:49 +0900 Subject: [PATCH] fix(eventing): route InMemory event handlers to the event tenant's database DbContexts capture their tenant and connection at construction, so handlers resolved for a dedicated-database tenant used the shared database. - Add IEventTenantScope.ExecuteAsync (default: wraps Begin) and run the InMemoryEventBus handler loop inside it. - FinbuckleEventTenantScope resolves the full tenant before consumers are built: reuse a matching complete context, else look it up through the registered Finbuckle tenant stores in a short-lived scope (the distributed cache store first, then the EF catalog store, written back to the cache). Missing or inactive tenants fall back to an ID-only context with a warning; global events leave the ambient context unchanged. - WebhookFanoutHandler keeps a complete context the bus already resolved. - Add unit and Testcontainers tests for dedicated-database routing. --- .../IEventTenantScope.cs | 14 + .../Eventing/InMemory/InMemoryEventBus.cs | 4 +- .../Services/FinbuckleEventTenantScope.cs | 76 ++++- .../Services/WebhookFanoutHandler.cs | 10 +- .../Eventing/DedicatedTenantRoutingTests.cs | 308 ++++++++++++++++++ .../FinbuckleEventTenantScopeTests.cs | 14 +- 6 files changed, 412 insertions(+), 14 deletions(-) create mode 100644 src/Tests/Integration.Tests/Tests/Eventing/DedicatedTenantRoutingTests.cs diff --git a/src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs b/src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs index acfe796a1e..484ee6e66e 100644 --- a/src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs +++ b/src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs @@ -24,4 +24,18 @@ public interface IEventTenantScope /// ambient context unchanged (global, non-tenant-scoped events). /// IDisposable Begin(string? tenantId); + + /// + /// Runs inside the tenant scope for . Implementations + /// that resolve tenant metadata asynchronously override this so resolution and the action share one + /// async flow. The default bridges to . + /// + async Task ExecuteAsync(string? tenantId, Func action, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(action); + using (Begin(tenantId)) + { + await action(ct).ConfigureAwait(false); + } + } } diff --git a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs index 24afdae0c6..201a46f0ec 100644 --- a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs +++ b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs @@ -62,7 +62,7 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke // Set tenant context BEFORE resolving handlers — MultiTenantDbContext captures TenantInfo at // construction, so a late tenant NREs the query filter. This is what makes background publishers work. - using (_tenantScope.Begin(@event.TenantId)) + await _tenantScope.ExecuteAsync(@event.TenantId, async _ => { // One failing handler must not starve the others: every handler gets its turn, and the failure // surfaces afterwards so the outbox retries the event. The inbox records each handler that @@ -105,7 +105,7 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke } ThrowIfAnyFailed(failures, @event.Id); - } + }, ct).ConfigureAwait(false); } private static void ThrowIfAnyFailed(List? failures, Guid eventId) diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/FinbuckleEventTenantScope.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/FinbuckleEventTenantScope.cs index 5fb7646395..f5372c2839 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/FinbuckleEventTenantScope.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/FinbuckleEventTenantScope.cs @@ -1,7 +1,12 @@ using Finbuckle.MultiTenant; using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; +using Finbuckle.MultiTenant.Stores; using FSH.Framework.Eventing.Abstractions; using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Data; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace FSH.Modules.Multitenancy.Services; @@ -11,22 +16,26 @@ namespace FSH.Modules.Multitenancy.Services; /// handler DbContexts resolved afterwards capture a real TenantInfo instead of the /// null one a background scope would otherwise carry. /// -/// Mirrors the create-scope-then-set-tenant pattern already used by -/// WebhookDispatchJob / SqlAuditSink, generalized to the event pipeline. -/// Only tenant identity is set (sufficient for the row-level tenant query filter in the -/// shared-database model); per-tenant connection strings are not resolved here. +/// resolves the full tenant (including a dedicated connection string) +/// before the action constructs consumers, in the same async flow. /// public sealed class FinbuckleEventTenantScope : IEventTenantScope { private readonly IMultiTenantContextAccessor _accessor; private readonly IMultiTenantContextSetter _setter; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; public FinbuckleEventTenantScope( IMultiTenantContextAccessor accessor, - IMultiTenantContextSetter setter) + IMultiTenantContextSetter setter, + IServiceScopeFactory scopeFactory, + ILogger logger) { _accessor = accessor; _setter = setter; + _scopeFactory = scopeFactory; + _logger = logger; } public IDisposable Begin(string? tenantId) @@ -44,6 +53,63 @@ public IDisposable Begin(string? tenantId) return new RestoreScope(_setter, previous); } + public async Task ExecuteAsync( + string? tenantId, + Func action, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(action); + + var previous = _accessor.MultiTenantContext; + if (string.IsNullOrWhiteSpace(tenantId) || + (string.Equals(previous.TenantInfo?.Id, tenantId, StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(previous.TenantInfo?.ConnectionString))) + { + await action(ct).ConfigureAwait(false); + return; + } + + try + { + var tenant = await FindTenantAsync(tenantId, ct).ConfigureAwait(false); + if (tenant is null || !tenant.IsActive) + { + _logger.LogWarning("Event tenant {TenantId} is missing or inactive; dispatching with tenant identity only", tenantId); + tenant = new AppTenantInfo(tenantId, tenantId); + } + + _setter.MultiTenantContext = new MultiTenantContext(tenant); + await action(ct).ConfigureAwait(false); + } + finally + { + _setter.MultiTenantContext = previous; + } + } + + private async Task FindTenantAsync(string tenantId, CancellationToken ct) + { + // A single IMultiTenantStore resolve returns only the last registration (the EF store), so enumerate all of them. + await using var scope = _scopeFactory.CreateAsyncScope(); + var stores = scope.ServiceProvider.GetServices>().ToList(); + var cache = stores.OfType>().Single(); + + var tenant = await cache.GetAsync(tenantId).WaitAsync(ct).ConfigureAwait(false); + if (tenant is not null) + { + return tenant; + } + + var efStore = stores.OfType>().Single(); + tenant = await efStore.GetAsync(tenantId).WaitAsync(ct).ConfigureAwait(false); + if (tenant is not null) + { + await cache.AddAsync(tenant).ConfigureAwait(false); + } + + return tenant; + } + private sealed class RestoreScope : IDisposable { private readonly IMultiTenantContextSetter _setter; diff --git a/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs index 46806263cf..62fdc2104e 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs @@ -59,9 +59,13 @@ public async Task HandleAsync(TEvent @event, CancellationToken ct = default) var prev = _tenantContextAccessor.MultiTenantContext; try { - var info = new AppTenantInfo(@event.TenantId, @event.TenantId); - ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = - new MultiTenantContext(info); + // Keep a complete context (e.g. a dedicated-database connection) the bus already resolved. + if (prev.TenantInfo?.Id != @event.TenantId || string.IsNullOrWhiteSpace(prev.TenantInfo.ConnectionString)) + { + var info = new AppTenantInfo(@event.TenantId, @event.TenantId); + ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = + new MultiTenantContext(info); + } var eventType = typeof(TEvent).Name; diff --git a/src/Tests/Integration.Tests/Tests/Eventing/DedicatedTenantRoutingTests.cs b/src/Tests/Integration.Tests/Tests/Eventing/DedicatedTenantRoutingTests.cs new file mode 100644 index 0000000000..de0e572493 --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Eventing/DedicatedTenantRoutingTests.cs @@ -0,0 +1,308 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; +using Finbuckle.MultiTenant.Extensions; +using Finbuckle.MultiTenant.Stores; +using FSH.Framework.Eventing; +using FSH.Framework.Eventing.Abstractions; +using FSH.Framework.Eventing.InMemory; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Eventing.Persistence; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Domain; +using FSH.Modules.Files.Contracts.Events; +using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Services; +using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Domain; +using FSH.Modules.Webhooks.Services; +using Integration.Tests.Infrastructure; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Npgsql; +using System.Collections.Concurrent; + +namespace Integration.Tests.Tests.Eventing; + +[Collection(FshCollectionDefinition.Name)] +public sealed class DedicatedTenantRoutingTests(FshWebApplicationFactory factory) +{ + [Fact] + public async Task DispatchAsync_Should_WriteConsumerAndInboxOnlyToDedicatedDatabase() + { + await using var environment = await RoutingEnvironment.CreateAsync(factory.Services, false); + var integrationEvent = NewEvent(environment.Tenant.Id); + environment.EventIds.Add(integrationEvent.Id); + + await environment.DispatchAsync(integrationEvent); + + DatabaseSnapshot dedicated = await ReadSnapshotAsync(environment.DedicatedConnection, integrationEvent); + DatabaseSnapshot shared = await ReadSnapshotAsync(environment.DefaultConnection, integrationEvent); + dedicated.ConsumerRows.ShouldBe(1); + dedicated.InboxRows.ShouldBe(1); + shared.ConsumerRows.ShouldBe(0); + shared.InboxRows.ShouldBe(0); + } + + [Fact] + public async Task DispatchAsync_Should_LookUpWebhookSubscriptionsInDedicatedDatabase() + { + await using var environment = await RoutingEnvironment.CreateAsync(factory.Services, true); + var integrationEvent = NewEvent(environment.Tenant.Id); + environment.EventIds.Add(integrationEvent.Id); + Guid expected = await environment.SeedSubscriptionAsync(); + + await environment.DispatchAsync(integrationEvent); + + environment.Recorder.Records.ShouldHaveSingleItem().ShouldBe((environment.Tenant.Id, expected)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExecuteAsync_Should_RestorePreviousContext_AfterSuccessOrFailure(bool fail) + { + var services = new ServiceCollection(); + services.AddMultiTenant().WithDistributedCacheStore(TimeSpan.FromMinutes(60)); + services.AddDistributedMemoryCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var accessor = provider.GetRequiredService>(); + var setter = provider.GetRequiredService(); + var previous = new MultiTenantContext(new AppTenantInfo("source", "source", "source-db", "test@example.test")); + setter.MultiTenantContext = previous; + var tenant = new AppTenantInfo("target", "target", "target-db", "test@example.test"); + var store = provider.GetServices>().OfType>().Single(); + (await store.AddAsync(tenant)).ShouldBeTrue(); + var scope = new FinbuckleEventTenantScope(accessor, setter, + provider.GetRequiredService(), NullLogger.Instance); + + async Task DispatchAsync() => await scope.ExecuteAsync("target", async _ => + { + await Task.Yield(); + accessor.MultiTenantContext.TenantInfo.ShouldNotBeNull().Id.ShouldBe(tenant.Id); + accessor.MultiTenantContext.TenantInfo.ConnectionString.ShouldBe(tenant.ConnectionString); + if (fail) throw new InvalidOperationException("handler failed"); + }); + + if (fail) await Should.ThrowAsync(DispatchAsync); + else await DispatchAsync(); + accessor.MultiTenantContext.ShouldBeSameAs(previous); + } + + private static FileFinalizedIntegrationEvent NewEvent(string tenantId) + { + Guid id = Guid.CreateVersion7(); + return new(id, DateTime.UtcNow, tenantId, $"corr-{id:N}", "Files", Guid.CreateVersion7(), "Test", null, + "application/octet-stream", 1, 1); + } + + private static async Task ReadSnapshotAsync(string connectionString, FileFinalizedIntegrationEvent integrationEvent) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(""" + SELECT + (SELECT COUNT(*) FROM catalog."Brands" WHERE "Name" = @marker), + (SELECT COUNT(*) FROM framework."InboxMessages" WHERE "Id" = @event AND "HandlerName" = @handler) + """, connection); + command.Parameters.AddWithValue("marker", $"dispatch-{integrationEvent.Id:N}"); + command.Parameters.AddWithValue("event", integrationEvent.Id); + command.Parameters.AddWithValue("handler", typeof(FileEventMarkerHandler).FullName!); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + return new DatabaseSnapshot(reader.GetInt64(0), reader.GetInt64(1)); + } + + private sealed record DatabaseSnapshot(long ConsumerRows, long InboxRows); + + private sealed class RoutingEnvironment : IAsyncDisposable + { + private readonly string _databaseName; + private bool _databaseCreated; + + private RoutingEnvironment(ServiceProvider provider, string defaultConnection, string dedicatedConnection, + string databaseName, AppTenantInfo tenant, WebhookRecorder recorder) + { + Provider = provider; + DefaultConnection = defaultConnection; + DedicatedConnection = dedicatedConnection; + _databaseName = databaseName; + Tenant = tenant; + Recorder = recorder; + } + + public ServiceProvider Provider { get; } + public string DefaultConnection { get; } + public string DedicatedConnection { get; } + public AppTenantInfo Tenant { get; } + public WebhookRecorder Recorder { get; } + public List EventIds { get; } = []; + public EventingDrainTarget DedicatedTarget => new(Tenant.Id, DedicatedConnection); + + public static async Task CreateAsync(IServiceProvider host, bool webhooks) + { + string defaultConnection = host.GetRequiredService>().Value.ConnectionString; + string suffix = Guid.CreateVersion7().ToString("N"); + string databaseName = $"routing_{suffix}"; + string dedicatedConnection = new NpgsqlConnectionStringBuilder(defaultConnection) { Database = databaseName }.ConnectionString; + var tenant = new AppTenantInfo($"tenant-{suffix}", $"tenant-{suffix}", dedicatedConnection, "routing@example.test"); + var recorder = new WebhookRecorder(); + ServiceProvider provider = CreateProvider(host, defaultConnection, recorder, webhooks); + var environment = new RoutingEnvironment(provider, defaultConnection, dedicatedConnection, databaseName, tenant, recorder); + try + { + await ExecuteDatabaseCommandAsync(defaultConnection, $"CREATE DATABASE {QuoteIdentifier(databaseName)}"); + environment._databaseCreated = true; + using (provider.GetRequiredService().Begin(environment.DedicatedTarget)) + using (var scope = provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + if (webhooks) await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + } + using (var scope = provider.CreateScope()) + { + (await scope.ServiceProvider.GetRequiredService>().AddAsync(tenant)).ShouldBeTrue(); + } + return environment; + } + catch + { + await environment.DisposeAsync(); + throw; + } + } + + public async Task DispatchAsync(IIntegrationEvent integrationEvent) + { + using (Provider.GetRequiredService().Begin(new EventingDrainTarget(MultitenancyConstants.Root.Id, null))) + using (var scope = Provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService().AddAsync(integrationEvent); + await scope.ServiceProvider.GetRequiredService().DispatchAsync(); + } + } + + public async Task SeedSubscriptionAsync() + { + using (Provider.GetRequiredService().Begin(DedicatedTarget)) + using (var scope = Provider.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + var subscription = WebhookSubscription.Create("https://routing.example.test/hook", [nameof(FileFinalizedIntegrationEvent)], "test-hash"); + context.Subscriptions.Add(subscription); + await context.SaveChangesAsync(); + return subscription.Id; + } + } + + public async ValueTask DisposeAsync() + { + try + { + using (var scope = Provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService>().RemoveAsync(Tenant.Id); + } + await using var connection = new NpgsqlConnection(DefaultConnection); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(""" + DELETE FROM catalog."Brands" WHERE "Name" = ANY(@markers); + DELETE FROM framework."InboxMessages" WHERE "Id" = ANY(@ids); + DELETE FROM framework."OutboxMessages" WHERE "Id" = ANY(@ids); + """, connection); + command.Parameters.AddWithValue("markers", EventIds.Select(id => $"dispatch-{id:N}").ToArray()); + command.Parameters.AddWithValue("ids", EventIds.ToArray()); + await command.ExecuteNonQueryAsync(); + } + finally + { + await Provider.DisposeAsync(); + if (_databaseCreated) + await ExecuteDatabaseCommandAsync(DefaultConnection, $"DROP DATABASE {QuoteIdentifier(_databaseName)} WITH (FORCE)"); + } + } + + private static ServiceProvider CreateProvider(IServiceProvider host, string connection, WebhookRecorder recorder, bool webhooks) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["EventingOptions:Provider"] = "InMemory", + ["EventingOptions:UseHostedServiceDispatcher"] = "false" + }).Build(); + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddSingleton(host.GetRequiredService()); + services.AddSingleton(TimeProvider.System); + services.AddLogging(); + services.Configure(options => + { + options.Provider = "POSTGRESQL"; + options.ConnectionString = connection; + options.MigrationsAssembly = "FSH.Starter.Migrations.PostgreSQL"; + }); + services.AddScoped(); + services.AddScoped(); + services.AddDbContext(options => options.UseNpgsql(connection)); + services.AddDistributedMemoryCache(); + services.AddMultiTenant() + .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) + .WithStore>(ServiceLifetime.Scoped); + services.AddEventingCore(configuration); + services.AddHeroDbContext(); + services.Replace(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Singleton()); + services.AddScoped, FileEventMarkerHandler>(); + if (webhooks) + { + services.AddHeroDbContext(); + services.AddSingleton(recorder); + services.AddScoped(typeof(IIntegrationEventHandler<>), typeof(WebhookFanoutHandler<>)); + } + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + } + + private static string QuoteIdentifier(string identifier) + { + using var builder = new NpgsqlCommandBuilder(); + return builder.QuoteIdentifier(identifier); + } + + private static async Task ExecuteDatabaseCommandAsync(string connectionString, string sql) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); +#pragma warning disable CA2100 + await using var command = new NpgsqlCommand(sql, connection); +#pragma warning restore CA2100 + await command.ExecuteNonQueryAsync(); + } + } + + public sealed class FileEventMarkerHandler(CatalogDbContext context) : IIntegrationEventHandler + { + public async Task HandleAsync(FileFinalizedIntegrationEvent integrationEvent, CancellationToken ct = default) + { + context.Brands.Add(Brand.Create($"dispatch-{integrationEvent.Id:N}", null, null)); + await context.SaveChangesAsync(ct); + } + } + + private sealed class WebhookRecorder : IWebhookDispatcher + { + public ConcurrentQueue<(string Tenant, Guid Subscription)> Records { get; } = new(); + + public Task EnqueueAsync(string tenantId, Guid subscriptionId, string eventType, string payloadJson, CancellationToken cancellationToken = default) + { + Records.Enqueue((tenantId, subscriptionId)); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Tests/Multitenancy.Tests/Services/FinbuckleEventTenantScopeTests.cs b/src/Tests/Multitenancy.Tests/Services/FinbuckleEventTenantScopeTests.cs index a01c9aeca1..545b077d03 100644 --- a/src/Tests/Multitenancy.Tests/Services/FinbuckleEventTenantScopeTests.cs +++ b/src/Tests/Multitenancy.Tests/Services/FinbuckleEventTenantScopeTests.cs @@ -2,6 +2,9 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; using Shouldly; using Xunit; @@ -14,7 +17,7 @@ public sealed class FinbuckleEventTenantScopeTests [Fact] public void Begin_Should_SetTenantContext_When_TenantIdProvided() { - var sut = new FinbuckleEventTenantScope(_accessor, _accessor); + var sut = CreateScope(); using (sut.Begin("acme")) { @@ -30,7 +33,7 @@ public void Begin_Should_RestorePreviousContext_When_Disposed() // Seed a pre-existing ambient tenant. ((IMultiTenantContextSetter)_accessor).MultiTenantContext = new MultiTenantContext(new AppTenantInfo("root", "root")); - var sut = new FinbuckleEventTenantScope(_accessor, _accessor); + var sut = CreateScope(); using (sut.Begin("acme")) { @@ -46,7 +49,7 @@ public void Begin_Should_LeaveContextUntouched_When_TenantIdNullOrWhitespace() // Seed a known tenant so we can prove Begin(null/"") does not replace it. ((IMultiTenantContextSetter)_accessor).MultiTenantContext = new MultiTenantContext(new AppTenantInfo("root", "root")); - var sut = new FinbuckleEventTenantScope(_accessor, _accessor); + var sut = CreateScope(); using (sut.Begin(null)) { @@ -59,6 +62,9 @@ public void Begin_Should_LeaveContextUntouched_When_TenantIdNullOrWhitespace() } } + private FinbuckleEventTenantScope CreateScope() + => new(_accessor, _accessor, Substitute.For(), NullLogger.Instance); + #pragma warning disable S2376 // Finbuckle's IMultiTenantContextSetter is a set-only contract. private sealed class StubAccessor : IMultiTenantContextAccessor, IMultiTenantContextSetter { @@ -75,4 +81,4 @@ IMultiTenantContext IMultiTenantContextSetter.MultiTenantContext } } #pragma warning restore S2376 -} +} \ No newline at end of file