Phase 0 WP-0.10–0.12: Host skeleton, options classes, sample configs, and execution framework
- WP-0.10: Role-based Host startup (Central=WebApplication, Site=generic Host), 15 component AddXxx() extension methods, MapCentralUI/MapInboundAPI stubs - WP-0.11: 12 per-component options classes with config binding - WP-0.12: Sample appsettings for central and site topologies - Add execution procedure and checklist template to generate_plans.md - Add phase-0-checklist.md for execution tracking - Resolve all 21 open questions from plan generation - Update IDataConnection with batch ops and IAsyncDisposable 57 tests pass, zero warnings.
This commit is contained in:
200
tests/ScadaLink.Host.Tests/HostStartupTests.cs
Normal file
200
tests/ScadaLink.Host.Tests/HostStartupTests.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ScadaLink.ClusterInfrastructure;
|
||||
using ScadaLink.Communication;
|
||||
using ScadaLink.DataConnectionLayer;
|
||||
using ScadaLink.ExternalSystemGateway;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using ScadaLink.Host;
|
||||
using ScadaLink.NotificationService;
|
||||
using ScadaLink.SiteEventLogging;
|
||||
using ScadaLink.SiteRuntime;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
public class HostStartupTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
{
|
||||
try { d.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralRole_StartsWithoutError()
|
||||
{
|
||||
// WebApplicationFactory replays Program.Main, which reads config from files.
|
||||
// Set the environment to Central so appsettings.Central.json is loaded,
|
||||
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
// Creating the server exercises the full DI container build and startup pipeline
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
// If we get here without exception, the central host started successfully
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteRole_StartsWithoutError()
|
||||
{
|
||||
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"] = "8082",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
// Shared components
|
||||
services.AddClusterInfrastructure();
|
||||
services.AddCommunication();
|
||||
services.AddHealthMonitoring();
|
||||
services.AddExternalSystemGateway();
|
||||
services.AddNotificationService();
|
||||
|
||||
// Site-only components
|
||||
services.AddSiteRuntime();
|
||||
services.AddDataConnectionLayer();
|
||||
services.AddStoreAndForward();
|
||||
services.AddSiteEventLogging();
|
||||
|
||||
// Options binding (mirrors Program.cs site path)
|
||||
services.Configure<NodeOptions>(context.Configuration.GetSection("ScadaLink:Node"));
|
||||
services.Configure<ClusterOptions>(context.Configuration.GetSection("ScadaLink:Cluster"));
|
||||
services.Configure<DatabaseOptions>(context.Configuration.GetSection("ScadaLink:Database"));
|
||||
services.Configure<CommunicationOptions>(context.Configuration.GetSection("ScadaLink:Communication"));
|
||||
services.Configure<HealthMonitoringOptions>(context.Configuration.GetSection("ScadaLink:HealthMonitoring"));
|
||||
services.Configure<NotificationOptions>(context.Configuration.GetSection("ScadaLink:Notification"));
|
||||
services.Configure<LoggingOptions>(context.Configuration.GetSection("ScadaLink:Logging"));
|
||||
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
|
||||
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
|
||||
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
_disposables.Add(host);
|
||||
|
||||
// Build succeeds = DI container is valid and all services resolve
|
||||
Assert.NotNull(host);
|
||||
Assert.NotNull(host.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteRole_DoesNotConfigureKestrel()
|
||||
{
|
||||
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"] = "8082",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddClusterInfrastructure();
|
||||
services.AddCommunication();
|
||||
services.AddHealthMonitoring();
|
||||
services.AddExternalSystemGateway();
|
||||
services.AddNotificationService();
|
||||
services.AddSiteRuntime();
|
||||
services.AddDataConnectionLayer();
|
||||
services.AddStoreAndForward();
|
||||
services.AddSiteEventLogging();
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
// Verify no Kestrel server or web host is registered.
|
||||
// Host.CreateDefaultBuilder does not add Kestrel, so there should be no IServer.
|
||||
var serverType = Type.GetType(
|
||||
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
|
||||
|
||||
if (serverType != null)
|
||||
{
|
||||
var server = host.Services.GetService(serverType);
|
||||
Assert.Null(server);
|
||||
}
|
||||
|
||||
// Additionally verify no HTTP URLs are configured
|
||||
var config = host.Services.GetRequiredService<IConfiguration>();
|
||||
var urls = config["urls"] ?? config["ASPNETCORE_URLS"];
|
||||
Assert.Null(urls);
|
||||
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostProject_DoesNotUseConditionalCompilation()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.TopDirectoryOnly);
|
||||
Assert.NotEmpty(sourceFiles);
|
||||
|
||||
foreach (var file in sourceFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
|
||||
Assert.DoesNotContain("#if", content);
|
||||
Assert.DoesNotContain("#ifdef", content);
|
||||
Assert.DoesNotContain("#ifndef", content);
|
||||
Assert.DoesNotContain("#elif", content);
|
||||
Assert.DoesNotContain("#else", content);
|
||||
Assert.DoesNotContain("#endif", content);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindHostProjectDirectory()
|
||||
{
|
||||
// Walk up from the test assembly location to find the src directory
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ScadaLink.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
65
tests/ScadaLink.Host.Tests/OptionsTests.cs
Normal file
65
tests/ScadaLink.Host.Tests/OptionsTests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
public class OptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify no component library (excluding Host) uses IConfiguration or accepts it
|
||||
/// in its AddXxx() extension method. Component libraries should only depend on
|
||||
/// DI abstractions and Options pattern, not on Configuration directly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComponentLibraries_DoNotAcceptIConfigurationInAddMethods()
|
||||
{
|
||||
// All component assemblies (excluding Host itself and Commons)
|
||||
var componentAssemblies = new[]
|
||||
{
|
||||
typeof(ClusterInfrastructure.ServiceCollectionExtensions).Assembly,
|
||||
typeof(Communication.ServiceCollectionExtensions).Assembly,
|
||||
typeof(HealthMonitoring.ServiceCollectionExtensions).Assembly,
|
||||
typeof(ExternalSystemGateway.ServiceCollectionExtensions).Assembly,
|
||||
typeof(NotificationService.ServiceCollectionExtensions).Assembly,
|
||||
typeof(TemplateEngine.ServiceCollectionExtensions).Assembly,
|
||||
typeof(DeploymentManager.ServiceCollectionExtensions).Assembly,
|
||||
typeof(Security.ServiceCollectionExtensions).Assembly,
|
||||
typeof(ConfigurationDatabase.ServiceCollectionExtensions).Assembly,
|
||||
typeof(SiteRuntime.ServiceCollectionExtensions).Assembly,
|
||||
typeof(DataConnectionLayer.ServiceCollectionExtensions).Assembly,
|
||||
typeof(StoreAndForward.ServiceCollectionExtensions).Assembly,
|
||||
typeof(SiteEventLogging.ServiceCollectionExtensions).Assembly,
|
||||
typeof(CentralUI.ServiceCollectionExtensions).Assembly,
|
||||
typeof(InboundAPI.ServiceCollectionExtensions).Assembly,
|
||||
};
|
||||
|
||||
foreach (var assembly in componentAssemblies)
|
||||
{
|
||||
// Check that the assembly does not reference Microsoft.Extensions.Configuration
|
||||
var configRef = assembly.GetReferencedAssemblies()
|
||||
.FirstOrDefault(a => a.Name == "Microsoft.Extensions.Configuration.Abstractions");
|
||||
|
||||
// Find all public static extension methods named Add*
|
||||
var extensionClasses = assembly.GetExportedTypes()
|
||||
.Where(t => t.IsClass && t.IsAbstract && t.IsSealed); // static classes
|
||||
|
||||
foreach (var cls in extensionClasses)
|
||||
{
|
||||
var addMethods = cls.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(m => m.Name.StartsWith("Add") || m.Name.StartsWith("Map"));
|
||||
|
||||
foreach (var method in addMethods)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
foreach (var param in parameters)
|
||||
{
|
||||
Assert.False(
|
||||
typeof(IConfiguration).IsAssignableFrom(param.ParameterType),
|
||||
$"{assembly.GetName().Name}: {cls.Name}.{method.Name} accepts IConfiguration parameter '{param.Name}'. " +
|
||||
"Component libraries should use the Options pattern, not IConfiguration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -8,8 +8,13 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
@@ -23,4 +28,4 @@
|
||||
<ProjectReference Include="../../src/ScadaLink.Host/ScadaLink.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user