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:
Joseph Doherty
2026-03-16 18:59:07 -04:00
parent 22e1eba58a
commit 8c2091dc0a
72 changed files with 1289 additions and 194 deletions

View 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;
}
}

View 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.");
}
}
}
}
}
}

View File

@@ -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>

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.Host.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}