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();
}
}