using Microsoft.AspNetCore.Builder; 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.Communication.Grpc; using ScadaLink.SiteRuntime; using ScadaLink.SiteRuntime.Persistence; using ScadaLink.SiteRuntime.Repositories; using ScadaLink.SiteRuntime.Scripts; using ScadaLink.SiteRuntime.Streaming; using ScadaLink.StoreAndForward; using ScadaLink.TemplateEngine; using ScadaLink.TemplateEngine.Flattening; using ScadaLink.TemplateEngine.Services; using ScadaLink.TemplateEngine.Validation; namespace ScadaLink.Host.Tests; /// /// Removes AkkaHostedService from running as a hosted service while keeping it /// resolvable in DI (other services like AkkaHealthReportTransport depend on it). /// internal static class AkkaHostedServiceRemover { internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services) { // Pattern used in Program.cs: // services.AddSingleton(); // index N // services.AddHostedService(sp => sp.GetRequiredService()); // 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; } } } } /// /// Verifies every expected DI service resolves from the Central composition root. /// Uses WebApplicationFactory to exercise the real Program.cs pipeline. /// public class CentralCompositionRootTests : IDisposable { private readonly WebApplicationFactory _factory; private readonly string? _previousEnv; public CentralCompositionRootTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["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) || 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(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 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 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 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 CentralOptions => new[] { new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, }; // --- Hosted services --- [Fact] public void Central_CentralHealthAggregator_RegisteredAsHostedService() { var hostedServices = _factory.Services.GetServices(); Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator)); } } /// /// 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 (WebApplicationBuilder + gRPC). /// public class SiteCompositionRootTests : IDisposable { private readonly WebApplication _host; private readonly string _tempDbPath; public SiteCompositionRootTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db"); var builder = WebApplication.CreateBuilder(); builder.Configuration.Sources.Clear(); builder.Configuration.AddInMemoryCollection(new Dictionary { ["ScadaLink:Node:Role"] = "Site", ["ScadaLink:Node:NodeHostname"] = "test-site", ["ScadaLink:Node:SiteId"] = "TestSite", ["ScadaLink:Node:RemotingPort"] = "0", ["ScadaLink:Node:GrpcPort"] = "0", ["ScadaLink:Database:SiteDbPath"] = _tempDbPath, }); // gRPC server registration (mirrors Program.cs site section) builder.Services.AddGrpc(); builder.Services.AddSingleton(); SiteServiceRegistration.Configure(builder.Services, builder.Configuration); // Keep AkkaHostedService in DI (other services depend on it) // but prevent it from starting by removing only its IHostedService registration. AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services); _host = builder.Build(); } public void Dispose() { (_host as IDisposable)?.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 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(SiteStreamManager) }, new object[] { typeof(ISiteStreamSubscriber) }, new object[] { typeof(SiteStreamGrpcServer) }, 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 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(); Assert.IsType(repo); } [Fact] public void Site_NotificationRepository_IsSiteImplementation() { using var scope = _host.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); Assert.IsType(repo); } // --- Options --- [Theory] [MemberData(nameof(SiteOptions))] public void Site_ResolveOptions(Type optionsType) { var service = _host.Services.GetService(optionsType); Assert.NotNull(service); } public static IEnumerable SiteOptions => new[] { new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, new object[] { typeof(IOptions) }, }; // --- Hosted services --- [Theory] [MemberData(nameof(SiteHostedServices))] public void Site_HostedServiceRegistered(Type expectedType) { var hostedServices = _host.Services.GetServices(); Assert.Contains(hostedServices, s => s.GetType() == expectedType); } public static IEnumerable SiteHostedServices => new[] { new object[] { typeof(HealthReportSender) }, new object[] { typeof(SiteStorageInitializer) }, new object[] { typeof(EventLogPurgeService) }, }; }