using Microsoft.Extensions.Logging.Abstractions; using System.Net; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment; /// /// Unit tests for using a stub /// . No live HTTP connections are made. /// public class HttpDeploymentConfigFetcherTests { private static HttpDeploymentConfigFetcher CreateFetcher(HttpMessageHandler handler) { var client = new HttpClient(handler); return new HttpDeploymentConfigFetcher(client, NullLogger.Instance); } // ------------------------------------------------------------------------- // Happy path // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_200_ReturnsBody() { const string expectedBody = """{"instanceUniqueName":"test"}"""; var handler = new StubHandler(HttpStatusCode.OK, expectedBody); var fetcher = CreateFetcher(handler); var result = await fetcher.FetchAsync("http://central", "dep-1", "tok-abc", CancellationToken.None); Assert.Equal(expectedBody, result); } [Fact] public async Task FetchAsync_200_RequestUrlIsCorrect() { var handler = new StubHandler(HttpStatusCode.OK, "{}"); var fetcher = CreateFetcher(handler); await fetcher.FetchAsync("http://central", "dep-abc", "tok", CancellationToken.None); Assert.NotNull(handler.LastRequest); Assert.Equal( "http://central/api/internal/deployments/dep-abc/config", handler.LastRequest!.RequestUri!.ToString()); } [Fact] public async Task FetchAsync_200_SetsDeploymentTokenHeader() { var handler = new StubHandler(HttpStatusCode.OK, "{}"); var fetcher = CreateFetcher(handler); await fetcher.FetchAsync("http://central", "dep-1", "my-secret-token", CancellationToken.None); Assert.NotNull(handler.LastRequest); Assert.True(handler.LastRequest!.Headers.Contains("X-Deployment-Token")); Assert.Equal("my-secret-token", handler.LastRequest.Headers.GetValues("X-Deployment-Token").Single()); } [Fact] public async Task FetchAsync_BaseUrlWithTrailingSlash_NoDoubleSlashInUrl() { var handler = new StubHandler(HttpStatusCode.OK, "{}"); var fetcher = CreateFetcher(handler); await fetcher.FetchAsync("http://central/", "dep-1", "tok", CancellationToken.None); Assert.NotNull(handler.LastRequest); var url = handler.LastRequest!.RequestUri!.ToString(); Assert.DoesNotContain("//api", url); Assert.Equal("http://central/api/internal/deployments/dep-1/config", url); } [Fact] public async Task FetchAsync_DeploymentIdNeedingEscaping_EscapesOnceInUrl() { var handler = new StubHandler(HttpStatusCode.OK, "{}"); var fetcher = CreateFetcher(handler); // A deploymentId containing a space and a slash — both require escaping. await fetcher.FetchAsync("http://central", "dep a/b", "tok", CancellationToken.None); Assert.NotNull(handler.LastRequest); // Uri.OriginalString preserves the escaped form as built (no normalization/decoding). var url = handler.LastRequest!.RequestUri!.OriginalString; Assert.Equal("http://central/api/internal/deployments/dep%20a%2Fb/config", url); // Not double-encoded: the percent-sign itself must not be re-escaped to %25. Assert.DoesNotContain("%25", url); } // ------------------------------------------------------------------------- // 404 → IsSuperseded = true // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_404_ThrowsWithIsSupersededTrue() { var handler = new StubHandler(HttpStatusCode.NotFound, "Not found"); var fetcher = CreateFetcher(handler); var ex = await Assert.ThrowsAsync( () => fetcher.FetchAsync("http://central", "dep-gone", "tok", CancellationToken.None)); Assert.True(ex.IsSuperseded); Assert.Contains("dep-gone", ex.Message); } // ------------------------------------------------------------------------- // Other HTTP errors → IsSuperseded = false // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_401_ThrowsWithIsSupersededFalse() { var handler = new StubHandler(HttpStatusCode.Unauthorized, "Unauthorized"); var fetcher = CreateFetcher(handler); var ex = await Assert.ThrowsAsync( () => fetcher.FetchAsync("http://central", "dep-1", "bad-tok", CancellationToken.None)); Assert.False(ex.IsSuperseded); Assert.Contains("401", ex.Message); } [Fact] public async Task FetchAsync_500_ThrowsWithIsSupersededFalse() { var handler = new StubHandler(HttpStatusCode.InternalServerError, "Server error"); var fetcher = CreateFetcher(handler); var ex = await Assert.ThrowsAsync( () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None)); Assert.False(ex.IsSuperseded); Assert.Contains("500", ex.Message); } // ------------------------------------------------------------------------- // Transport failure → DeploymentConfigFetchException (not raw HttpRequestException) // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_TransportException_WrapsAsDeploymentConfigFetchException() { var handler = new ThrowingHandler(new HttpRequestException("Connection refused")); var fetcher = CreateFetcher(handler); var ex = await Assert.ThrowsAsync( () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None)); Assert.False(ex.IsSuperseded); Assert.IsType(ex.InnerException); Assert.Contains("Connection refused", ex.Message); } [Fact] public async Task FetchAsync_HttpClientTimeout_WrapsAsTransportError_NotCancellation() { // HttpClient.Timeout surfaces as TaskCanceledException while the caller's ct is NOT cancelled. var handler = new ThrowingHandler(new TaskCanceledException("simulated HttpClient timeout")); var fetcher = CreateFetcher(handler); var ex = await Assert.ThrowsAsync( () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None)); Assert.False(ex.IsSuperseded); Assert.IsType(ex.InnerException); } // ------------------------------------------------------------------------- // Cancellation propagates as-is // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_CancelledToken_PropagatesOperationCanceledException() { using var cts = new CancellationTokenSource(); cts.Cancel(); var handler = new DelayHandler(); // never returns var fetcher = CreateFetcher(handler); await Assert.ThrowsAnyAsync( () => fetcher.FetchAsync("http://central", "dep-1", "tok", cts.Token)); } // ------------------------------------------------------------------------- // Stub handlers // ------------------------------------------------------------------------- private sealed class StubHandler : HttpMessageHandler { private readonly HttpStatusCode _status; private readonly string _body; public HttpRequestMessage? LastRequest { get; private set; } public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { LastRequest = request; var resp = new HttpResponseMessage(_status) { Content = new StringContent(_body) }; return Task.FromResult(resp); } } private sealed class ThrowingHandler : HttpMessageHandler { private readonly Exception _ex; public ThrowingHandler(Exception ex) => _ex = ex; protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => throw _ex; } private sealed class DelayHandler : HttpMessageHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { await Task.Delay(Timeout.Infinite, ct); return new HttpResponseMessage(HttpStatusCode.OK); } } }