feat: wire ExternalSystem, Database, and Notify APIs into script runtime
IServiceProvider now flows through the actor chain (DeploymentManagerActor → InstanceActor → ScriptActor → ScriptExecutionActor) so scripts can resolve IExternalSystemClient, IDatabaseGateway, and INotificationDeliveryService from DI. ScriptGlobals exposes ExternalSystem, Database, Notify, and Scripts as top-level properties so scripts can use them without the Instance. prefix.
This commit is contained in:
429
tests/ScadaLink.Host.Tests/CompositionRootTests.cs
Normal file
429
tests/ScadaLink.Host.Tests/CompositionRootTests.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.ClusterInfrastructure;
|
||||
using ScadaLink.Communication;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.DataConnectionLayer;
|
||||
using ScadaLink.DeploymentManager;
|
||||
using ScadaLink.ExternalSystemGateway;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using ScadaLink.Host;
|
||||
using ScadaLink.Host.Actors;
|
||||
using ScadaLink.InboundAPI;
|
||||
using ScadaLink.ManagementService;
|
||||
using ScadaLink.NotificationService;
|
||||
using ScadaLink.Security;
|
||||
using ScadaLink.SiteEventLogging;
|
||||
using ScadaLink.SiteRuntime;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Repositories;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using ScadaLink.StoreAndForward;
|
||||
using ScadaLink.TemplateEngine;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Removes AkkaHostedService from running as a hosted service while keeping it
|
||||
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
|
||||
/// </summary>
|
||||
internal static class AkkaHostedServiceRemover
|
||||
{
|
||||
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
|
||||
{
|
||||
// Pattern used in Program.cs:
|
||||
// services.AddSingleton<AkkaHostedService>(); // index N
|
||||
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
|
||||
// We keep the singleton so DI resolution works, but remove the IHostedService
|
||||
// factory so StartAsync is never called.
|
||||
int akkaIndex = -1;
|
||||
for (int i = 0; i < services.Count; i++)
|
||||
{
|
||||
if (services[i].ServiceType == typeof(AkkaHostedService))
|
||||
{
|
||||
akkaIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (akkaIndex < 0) return;
|
||||
|
||||
// The IHostedService factory is the next registration after the singleton
|
||||
for (int i = akkaIndex + 1; i < services.Count; i++)
|
||||
{
|
||||
if (services[i].ServiceType == typeof(IHostedService)
|
||||
&& services[i].ImplementationFactory != null)
|
||||
{
|
||||
services.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies every expected DI service resolves from the Central composition root.
|
||||
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
|
||||
/// </summary>
|
||||
public class CentralCompositionRootTests : IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly string? _previousEnv;
|
||||
|
||||
public CentralCompositionRootTests()
|
||||
{
|
||||
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||
["ScadaLink:Database:SkipMigrations"] = "true",
|
||||
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||
["ScadaLink:Security:LdapServer"] = "localhost",
|
||||
["ScadaLink:Security:LdapPort"] = "3893",
|
||||
["ScadaLink:Security:LdapUseTls"] = "false",
|
||||
["ScadaLink:Security:AllowInsecureLdap"] = "true",
|
||||
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace SQL Server with in-memory database
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaLinkDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"CompositionRootTests_{Guid.NewGuid()}"));
|
||||
|
||||
// Keep AkkaHostedService in DI (other services depend on it)
|
||||
// but prevent it from starting by removing only its IHostedService registration.
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger host build
|
||||
_ = _factory.Server;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||
}
|
||||
|
||||
// --- Singletons ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralSingletonServices))]
|
||||
public void Central_ResolveSingleton(Type serviceType)
|
||||
{
|
||||
var service = _factory.Services.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralSingletonServices => new[]
|
||||
{
|
||||
new object[] { typeof(CommunicationService) },
|
||||
new object[] { typeof(ISiteHealthCollector) },
|
||||
new object[] { typeof(CentralHealthAggregator) },
|
||||
new object[] { typeof(ICentralHealthAggregator) },
|
||||
new object[] { typeof(OperationLockManager) },
|
||||
new object[] { typeof(OAuth2TokenService) },
|
||||
new object[] { typeof(InboundScriptExecutor) },
|
||||
};
|
||||
|
||||
// --- Scoped services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralScopedServices))]
|
||||
public void Central_ResolveScoped(Type serviceType)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralScopedServices => new[]
|
||||
{
|
||||
// TemplateEngine
|
||||
new object[] { typeof(TemplateService) },
|
||||
new object[] { typeof(SharedScriptService) },
|
||||
new object[] { typeof(InstanceService) },
|
||||
new object[] { typeof(SiteService) },
|
||||
new object[] { typeof(AreaService) },
|
||||
new object[] { typeof(TemplateDeletionService) },
|
||||
// DeploymentManager
|
||||
new object[] { typeof(IFlatteningPipeline) },
|
||||
new object[] { typeof(DeploymentService) },
|
||||
new object[] { typeof(ArtifactDeploymentService) },
|
||||
// Security
|
||||
new object[] { typeof(LdapAuthService) },
|
||||
new object[] { typeof(JwtTokenService) },
|
||||
new object[] { typeof(RoleMapper) },
|
||||
// InboundAPI
|
||||
new object[] { typeof(ApiKeyValidator) },
|
||||
new object[] { typeof(RouteHelper) },
|
||||
// ExternalSystemGateway
|
||||
new object[] { typeof(ExternalSystemClient) },
|
||||
new object[] { typeof(IExternalSystemClient) },
|
||||
new object[] { typeof(DatabaseGateway) },
|
||||
new object[] { typeof(IDatabaseGateway) },
|
||||
// NotificationService
|
||||
new object[] { typeof(NotificationDeliveryService) },
|
||||
new object[] { typeof(INotificationDeliveryService) },
|
||||
// ConfigurationDatabase repositories
|
||||
new object[] { typeof(ScadaLinkDbContext) },
|
||||
new object[] { typeof(ISecurityRepository) },
|
||||
new object[] { typeof(ICentralUiRepository) },
|
||||
new object[] { typeof(ITemplateEngineRepository) },
|
||||
new object[] { typeof(IDeploymentManagerRepository) },
|
||||
new object[] { typeof(ISiteRepository) },
|
||||
new object[] { typeof(IExternalSystemRepository) },
|
||||
new object[] { typeof(INotificationRepository) },
|
||||
new object[] { typeof(IInboundApiRepository) },
|
||||
new object[] { typeof(IAuditService) },
|
||||
new object[] { typeof(IInstanceLocator) },
|
||||
// CentralUI
|
||||
new object[] { typeof(AuthenticationStateProvider) },
|
||||
};
|
||||
|
||||
// --- Transient services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralTransientServices))]
|
||||
public void Central_ResolveTransient(Type serviceType)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralTransientServices => new[]
|
||||
{
|
||||
new object[] { typeof(FlatteningService) },
|
||||
new object[] { typeof(DiffService) },
|
||||
new object[] { typeof(RevisionHashService) },
|
||||
new object[] { typeof(ScriptCompiler) },
|
||||
new object[] { typeof(SemanticValidator) },
|
||||
new object[] { typeof(ValidationService) },
|
||||
};
|
||||
|
||||
// --- Options ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CentralOptions))]
|
||||
public void Central_ResolveOptions(Type optionsType)
|
||||
{
|
||||
var service = _factory.Services.GetService(optionsType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CentralOptions => new[]
|
||||
{
|
||||
new object[] { typeof(IOptions<NodeOptions>) },
|
||||
new object[] { typeof(IOptions<ClusterOptions>) },
|
||||
new object[] { typeof(IOptions<DatabaseOptions>) },
|
||||
new object[] { typeof(IOptions<CommunicationOptions>) },
|
||||
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
||||
new object[] { typeof(IOptions<NotificationOptions>) },
|
||||
new object[] { typeof(IOptions<LoggingOptions>) },
|
||||
new object[] { typeof(IOptions<SecurityOptions>) },
|
||||
new object[] { typeof(IOptions<InboundApiOptions>) },
|
||||
new object[] { typeof(IOptions<ManagementServiceOptions>) },
|
||||
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
|
||||
};
|
||||
|
||||
// --- Hosted services ---
|
||||
|
||||
[Fact]
|
||||
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
|
||||
{
|
||||
var hostedServices = _factory.Services.GetServices<IHostedService>();
|
||||
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies every expected DI service resolves from the Site composition root.
|
||||
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
|
||||
/// matches the real Program.cs registration.
|
||||
/// </summary>
|
||||
public class SiteCompositionRootTests : IDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private readonly string _tempDbPath;
|
||||
|
||||
public SiteCompositionRootTests()
|
||||
{
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db");
|
||||
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||
builder.ConfigureAppConfiguration(config =>
|
||||
{
|
||||
config.Sources.Clear();
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||
|
||||
// Keep AkkaHostedService in DI (other services depend on it)
|
||||
// but prevent it from starting by removing only its IHostedService registration.
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||
});
|
||||
|
||||
_host = builder.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.Dispose();
|
||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// --- Singletons ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteSingletonServices))]
|
||||
public void Site_ResolveSingleton(Type serviceType)
|
||||
{
|
||||
var service = _host.Services.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteSingletonServices => new[]
|
||||
{
|
||||
new object[] { typeof(CommunicationService) },
|
||||
new object[] { typeof(ISiteHealthCollector) },
|
||||
new object[] { typeof(SiteStorageService) },
|
||||
new object[] { typeof(ScriptCompilationService) },
|
||||
new object[] { typeof(SharedScriptLibrary) },
|
||||
new object[] { typeof(IDataConnectionFactory) },
|
||||
new object[] { typeof(StoreAndForwardStorage) },
|
||||
new object[] { typeof(StoreAndForwardService) },
|
||||
new object[] { typeof(ReplicationService) },
|
||||
new object[] { typeof(ISiteEventLogger) },
|
||||
new object[] { typeof(IEventLogQueryService) },
|
||||
new object[] { typeof(OAuth2TokenService) },
|
||||
new object[] { typeof(ISiteIdentityProvider) },
|
||||
new object[] { typeof(IHealthReportTransport) },
|
||||
};
|
||||
|
||||
// --- Scoped services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteScopedServices))]
|
||||
public void Site_ResolveScoped(Type serviceType)
|
||||
{
|
||||
using var scope = _host.Services.CreateScope();
|
||||
var service = scope.ServiceProvider.GetService(serviceType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteScopedServices => new[]
|
||||
{
|
||||
new object[] { typeof(IExternalSystemRepository) },
|
||||
new object[] { typeof(INotificationRepository) },
|
||||
new object[] { typeof(ExternalSystemClient) },
|
||||
new object[] { typeof(IExternalSystemClient) },
|
||||
new object[] { typeof(DatabaseGateway) },
|
||||
new object[] { typeof(IDatabaseGateway) },
|
||||
new object[] { typeof(NotificationDeliveryService) },
|
||||
new object[] { typeof(INotificationDeliveryService) },
|
||||
};
|
||||
|
||||
// --- Implementation type assertions ---
|
||||
|
||||
[Fact]
|
||||
public void Site_ExternalSystemRepository_IsSiteImplementation()
|
||||
{
|
||||
using var scope = _host.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
|
||||
Assert.IsType<SiteExternalSystemRepository>(repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_NotificationRepository_IsSiteImplementation()
|
||||
{
|
||||
using var scope = _host.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||
Assert.IsType<SiteNotificationRepository>(repo);
|
||||
}
|
||||
|
||||
// --- Options ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteOptions))]
|
||||
public void Site_ResolveOptions(Type optionsType)
|
||||
{
|
||||
var service = _host.Services.GetService(optionsType);
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteOptions => new[]
|
||||
{
|
||||
new object[] { typeof(IOptions<NodeOptions>) },
|
||||
new object[] { typeof(IOptions<ClusterOptions>) },
|
||||
new object[] { typeof(IOptions<DatabaseOptions>) },
|
||||
new object[] { typeof(IOptions<CommunicationOptions>) },
|
||||
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
||||
new object[] { typeof(IOptions<NotificationOptions>) },
|
||||
new object[] { typeof(IOptions<LoggingOptions>) },
|
||||
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
|
||||
new object[] { typeof(IOptions<DataConnectionOptions>) },
|
||||
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
|
||||
new object[] { typeof(IOptions<SiteEventLogOptions>) },
|
||||
};
|
||||
|
||||
// --- Hosted services ---
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SiteHostedServices))]
|
||||
public void Site_HostedServiceRegistered(Type expectedType)
|
||||
{
|
||||
var hostedServices = _host.Services.GetServices<IHostedService>();
|
||||
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> SiteHostedServices => new[]
|
||||
{
|
||||
new object[] { typeof(HealthReportSender) },
|
||||
new object[] { typeof(SiteStorageInitializer) },
|
||||
new object[] { typeof(EventLogPurgeService) },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user