using System.Net; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ZB.MOM.WW.Overview.Observability; using ZB.MOM.WW.Overview.Polling; namespace ZB.MOM.WW.Overview.Tests; /// /// Boots the REAL Program.cs and asks the questions no in-process test can: does every /// endpoint answer without a login, and does a bad registry stop the host rather than produce a /// dashboard full of nothing. /// /// /// The registry is supplied through environment variables rather than /// ConfigureAppConfiguration. ConfigPreflight runs against /// builder.Configuration in the top-level statements, before Build() — which is when /// the factory's configuration callbacks are applied — so a callback-supplied registry would arrive /// too late for the very check these tests are about. Environment variables are read by /// CreateBuilder itself, so they are in place from the first line. /// /// /// Environment variables are process-global, so every test here lives in one class: xunit runs a /// class's tests sequentially, which is what keeps two hosts from fighting over the same keys. /// /// public sealed class BootTests : IDisposable { private readonly List _keys = []; private void Set(string key, string? value) { _keys.Add(key); Environment.SetEnvironmentVariable(key, value); } /// Restores the environment so a later-running test class sees a clean process. public void Dispose() { foreach (var key in _keys) Environment.SetEnvironmentVariable(key, null); } /// /// A registry with one instance pointing at a port nothing listens on. The dashboard must boot /// and serve regardless of whether anything it watches is up — that is the entire premise of /// rendering from cache. /// private void SetValidRegistry() { Set("ASPNETCORE_ENVIRONMENT", "Testing"); Set("Overview__PollIntervalSeconds", "10"); Set("Overview__TimeoutSeconds", "1"); Set("Overview__StaleAfterSeconds", "45"); Set("Overview__Applications__0__Name", "TestApp"); Set("Overview__Applications__0__ManagementLabel", "UI"); Set("Overview__Applications__0__Instances__0__Name", "node-a"); Set("Overview__Applications__0__Instances__0__BaseUrl", "http://127.0.0.1:1"); Set("Overview__Applications__0__Instances__0__HasActiveRole", "false"); } private static WebApplicationFactory Factory() => new(); [Fact] public async Task Page_IsServedAnonymously() { SetValidRegistry(); using var factory = Factory(); // AllowAutoRedirect off so an auth redirect would surface as a 302 here rather than being // silently followed to a login page that then returns 200. using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, }); using var response = await client.GetAsync("/"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("SCADA Overview", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); } [Fact] public async Task Page_RendersEvenThoughNothingItWatchesIsReachable() { // Cache-first, stated as a test: the registry above points at a dead port, and the page // must still paint its full layout instead of waiting on a sweep. SetValidRegistry(); using var factory = Factory(); using var client = factory.CreateClient(); var html = await client.GetStringAsync("/"); Assert.Contains("data-testid=\"instance-card\"", html, StringComparison.Ordinal); Assert.Contains("node-a", html, StringComparison.Ordinal); } [Theory] [InlineData("/health/ready")] [InlineData("/health/active")] [InlineData("/healthz")] [InlineData("/metrics")] public async Task HealthAndMetrics_AreServedAnonymously(string path) { SetValidRegistry(); using var factory = Factory(); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, }); using var response = await client.GetAsync(path); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task Ready_ReportsTheDashboardsOwnChecks() { SetValidRegistry(); using var factory = Factory(); using var client = factory.CreateClient(); var body = await client.GetStringAsync("/health/ready"); Assert.Contains(ObservabilityServiceCollectionExtensions.RegistryLoadedCheckName, body, StringComparison.Ordinal); Assert.Contains(ObservabilityServiceCollectionExtensions.PollCycleCheckName, body, StringComparison.Ordinal); } [Fact] public async Task Metrics_ExportsTheAppsOwnMeter() { // The Meters allowlist in AddZbTelemetry is silent when it is wrong: a missing name drops // the app's whole instrument surface from /metrics with no warning anywhere. Only an // end-to-end scrape catches that. SetValidRegistry(); using var factory = Factory(); using var client = factory.CreateClient(); // Force a sweep so the observable gauge has a probed instance to report. The instance is // unreachable, which is a perfectly good status to publish. var poller = factory.Services.GetServices().OfType().Single(); await poller.SweepAsync(); var body = await client.GetStringAsync("/metrics"); Assert.Contains("overview_instance_status", body, StringComparison.Ordinal); Assert.Contains("node=\"node-a\"", body, StringComparison.Ordinal); } [Fact] public async Task MissingRegistry_FailsToBootAndNamesTheKey() { // appsettings.json ships an EMPTY Applications list precisely so this is the outcome when a // deployment forgets its registry — an empty dashboard that boots would look like a healthy // fleet of nothing. Set("ASPNETCORE_ENVIRONMENT", "Testing"); using var factory = Factory(); var error = await Record.ExceptionAsync(() => factory.CreateClient().GetAsync("/")); Assert.NotNull(error); Assert.Contains("Overview:Applications:0:Name", Flatten(error), StringComparison.Ordinal); } [Fact] public async Task StaleWindowInsideThePollInterval_FailsToBootWithTheValidatorMessage() { // Past preflight and into the validator: a staleness window shorter than the poll interval // would mark every instance Stale between two perfectly healthy polls. SetValidRegistry(); Set("Overview__StaleAfterSeconds", "5"); using var factory = Factory(); var error = await Record.ExceptionAsync(() => factory.CreateClient().GetAsync("/")); Assert.NotNull(error); Assert.Contains("StaleAfterSeconds", Flatten(error), StringComparison.Ordinal); } private static string Flatten(Exception exception) { var text = new System.Text.StringBuilder(); for (var current = exception; current is not null; current = current.InnerException) text.AppendLine(current.Message); return text.ToString(); } }