Skip to content

Commit c0e37e3

Browse files
RalfvandenBurgCCRalfvandenBurgcursoragent
authored
Fix/startuptask activate default tenant when empty (#7305)
* fix: activate Tenant.Default when no tenants configured (Option C) Ensures IStartupTask implementations (e.g., PopulateRegistriesStartupTask, RunMigrationsStartupTask) run when multitenancy is enabled but the tenant provider returns an empty list. - In DefaultTenantService: treat empty provider response as [Tenant.Default] in GetTenantsDictionaryAsync (initial load) and RefreshAsync - Logic is internal to tenant service; no explicit call required Co-authored-by: Cursor <cursoragent@cursor.com> * test: add DefaultTenantService tests for empty-provider fallback - ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant - ListAsync_WhenProviderReturnsEmpty_ReturnsDefaultTenant - ActivateTenantsAsync_WhenProviderReturnsTenants_ReturnsThoseTenants - RefreshAsync_WhenProviderChangesFromTenantsToEmpty_KeepsDefaultTenant Co-authored-by: Cursor <cursoragent@cursor.com> * PR feedback disposes serviceprovider also --------- Co-authored-by: Ralf <Ralf@Careconnections.nl> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6bc0cf2 commit c0e37e3

File tree

3 files changed

+153
-2
lines changed

3 files changed

+153
-2
lines changed

src/modules/Elsa.Common/Multitenancy/Contracts/ITenantService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public interface ITenantService
6363
/// <summary>
6464
/// Invokes the <see cref="ITenantsProvider"/> and caches the result.
6565
/// When new tenants are added, lifecycle events are triggered to ensure background tasks are updated.
66+
/// When the provider returns an empty list, <see cref="Tenant.Default"/> is activated so that startup tasks run.
6667
/// </summary>
6768
Task RefreshAsync(CancellationToken cancellationToken = default);
6869
}

src/modules/Elsa.Common/Multitenancy/Implementations/DefaultTenantService.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default)
7777
var tenantsProvider = scope.ServiceProvider.GetRequiredService<ITenantsProvider>();
7878
var currentTenants = await GetTenantsDictionaryAsync(cancellationToken);
7979
var currentTenantIds = currentTenants.Keys;
80-
var newTenants = (await tenantsProvider.ListAsync(cancellationToken)).ToDictionary(x => x.Id.EmptyIfNull());
80+
var tenantsFromProvider = (await tenantsProvider.ListAsync(cancellationToken)).ToList();
81+
var newTenants = tenantsFromProvider.Count == 0
82+
? new Dictionary<string, Tenant> { [Tenant.DefaultTenantId] = Tenant.Default }
83+
: tenantsFromProvider.ToDictionary(x => x.Id.EmptyIfNull());
8184
var newTenantIds = newTenants.Keys;
8285
var removedTenantIds = currentTenantIds.Except(newTenantIds).ToArray();
8386
var addedTenantIds = newTenantIds.Except(currentTenantIds).ToArray();
@@ -112,7 +115,9 @@ private async Task<IDictionary<string, Tenant>> GetTenantsDictionaryAsync(Cancel
112115
_tenantsDictionary = new Dictionary<string, Tenant>();
113116
_tenantScopesDictionary = new Dictionary<Tenant, TenantScope>();
114117
var tenantsProvider = _serviceScope.ServiceProvider.GetRequiredService<ITenantsProvider>();
115-
var tenants = await tenantsProvider.ListAsync(cancellationToken);
118+
var tenants = (await tenantsProvider.ListAsync(cancellationToken)).ToList();
119+
if (tenants.Count == 0)
120+
tenants = [Tenant.Default];
116121

117122
foreach (var tenant in tenants)
118123
await RegisterTenantAsync(tenant, cancellationToken);
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using Elsa.Common.Multitenancy;
2+
using Elsa.Common.Multitenancy.EventHandlers;
3+
using Elsa.Common.RecurringTasks;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using NSubstitute;
6+
7+
namespace Elsa.Common.UnitTests.Multitenancy;
8+
9+
/// <summary>
10+
/// Tests for <see cref="DefaultTenantService"/>, including the fallback to activate
11+
/// <see cref="Tenant.Default"/> when the tenant provider returns an empty list.
12+
/// </summary>
13+
public class DefaultTenantServiceTests
14+
{
15+
[Fact]
16+
public async Task ActivateTenantsAsync_WhenProviderReturnsEmpty_ActivatesDefaultTenant()
17+
{
18+
// Arrange - provider returns no tenants
19+
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty<Tenant>());
20+
21+
try
22+
{
23+
// Act
24+
await tenantService.ActivateTenantsAsync();
25+
26+
// Assert
27+
var tenants = (await tenantService.ListAsync()).ToList();
28+
Assert.Single(tenants);
29+
Assert.Same(Tenant.Default, tenants[0]);
30+
Assert.Equal(Tenant.DefaultTenantId, tenants[0].Id);
31+
}
32+
finally
33+
{
34+
if (tenantService is IAsyncDisposable disposable)
35+
await disposable.DisposeAsync();
36+
await serviceProvider.DisposeAsync();
37+
}
38+
}
39+
40+
[Fact]
41+
public async Task ListAsync_WhenProviderReturnsEmpty_ReturnsDefaultTenant()
42+
{
43+
// Arrange - ListAsync triggers initialization when provider returns empty
44+
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(Array.Empty<Tenant>());
45+
46+
try
47+
{
48+
// Act
49+
var tenants = (await tenantService.ListAsync()).ToList();
50+
51+
// Assert
52+
Assert.Single(tenants);
53+
Assert.Same(Tenant.Default, tenants[0]);
54+
}
55+
finally
56+
{
57+
if (tenantService is IAsyncDisposable disposable)
58+
await disposable.DisposeAsync();
59+
await serviceProvider.DisposeAsync();
60+
}
61+
}
62+
63+
[Fact]
64+
public async Task ActivateTenantsAsync_WhenProviderReturnsTenants_ReturnsThoseTenants()
65+
{
66+
// Arrange - provider returns specific tenants
67+
var tenant1 = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
68+
var tenant2 = new Tenant { Id = "tenant-2", Name = "Tenant 2" };
69+
var (tenantService, serviceProvider) = await CreateTenantServiceAsync([tenant1, tenant2]);
70+
71+
try
72+
{
73+
// Act
74+
await tenantService.ActivateTenantsAsync();
75+
76+
// Assert - should not use Tenant.Default fallback
77+
var tenants = (await tenantService.ListAsync()).ToList();
78+
Assert.Equal(2, tenants.Count);
79+
Assert.Contains(tenants, t => t.Id == "tenant-1");
80+
Assert.Contains(tenants, t => t.Id == "tenant-2");
81+
}
82+
finally
83+
{
84+
if (tenantService is IAsyncDisposable disposable)
85+
await disposable.DisposeAsync();
86+
await serviceProvider.DisposeAsync();
87+
}
88+
}
89+
90+
[Fact]
91+
public async Task RefreshAsync_WhenProviderChangesFromTenantsToEmpty_KeepsDefaultTenant()
92+
{
93+
// Arrange - start with tenants, then provider returns empty (simulating config change)
94+
var tenant1 = new Tenant { Id = "tenant-1", Name = "Tenant 1" };
95+
var providerReturns = new List<Tenant> { tenant1 };
96+
var (tenantService, serviceProvider) = await CreateTenantServiceAsync(providerReturns, () => providerReturns);
97+
98+
try
99+
{
100+
await tenantService.ActivateTenantsAsync();
101+
Assert.Single(await tenantService.ListAsync());
102+
103+
// Simulate provider now returning empty (e.g., config removed all tenants)
104+
providerReturns.Clear();
105+
106+
// Act
107+
await tenantService.RefreshAsync();
108+
109+
// Assert - should fall back to Tenant.Default instead of having zero tenants
110+
var tenants = (await tenantService.ListAsync()).ToList();
111+
Assert.Single(tenants);
112+
Assert.Same(Tenant.Default, tenants[0]);
113+
}
114+
finally
115+
{
116+
if (tenantService is IAsyncDisposable disposable)
117+
await disposable.DisposeAsync();
118+
await serviceProvider.DisposeAsync();
119+
}
120+
}
121+
122+
private static Task<(ITenantService TenantService, ServiceProvider ServiceProvider)> CreateTenantServiceAsync(IEnumerable<Tenant> tenants, Func<List<Tenant>>? tenantsFactory = null)
123+
{
124+
var tenantList = tenants.ToList();
125+
var getTenants = tenantsFactory ?? (() => tenantList);
126+
127+
var tenantsProvider = Substitute.For<ITenantsProvider>();
128+
tenantsProvider.ListAsync(Arg.Any<CancellationToken>()).Returns(_ => getTenants());
129+
130+
var services = new ServiceCollection();
131+
services.AddSingleton(_ => tenantsProvider);
132+
services.AddSingleton<ITenantScopeFactory, DefaultTenantScopeFactory>();
133+
services.AddSingleton<ITenantAccessor, DefaultTenantAccessor>();
134+
services.AddSingleton<ITenantActivatedEvent>(Substitute.For<ITenantActivatedEvent>());
135+
services.AddSingleton<ITenantDeactivatedEvent>(Substitute.For<ITenantDeactivatedEvent>());
136+
services.AddSingleton<ITenantDeletedEvent>(Substitute.For<ITenantDeletedEvent>());
137+
services.AddSingleton<TenantEventsManager>();
138+
services.AddSingleton<RecurringTaskScheduleManager>();
139+
services.AddSingleton<ITenantService, DefaultTenantService>();
140+
services.AddLogging();
141+
142+
var serviceProvider = services.BuildServiceProvider();
143+
return Task.FromResult((serviceProvider.GetRequiredService<ITenantService>(), serviceProvider));
144+
}
145+
}

0 commit comments

Comments
 (0)