using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Junction.Domain.Models; using Junction.Domain.Protocols; using Junction.Protocols.MTConnect; using Xunit; namespace Junction.Tests.Unit { public sealed class MtconnectDriverTests { private static readonly Guid MachineId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); private const string AgentUrl = "http://mtconnect-agent.test:5000"; private static string LoadCurrentFixture() { var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "mtconnect", "current.xml"); return File.ReadAllText(path); } /// Stub handler: canned response or thrown exception, per configuration. private sealed class StubHandler : HttpMessageHandler { private readonly Func> _responder; public Uri? LastRequestUri { get; private set; } public StubHandler(HttpStatusCode status, string body) { _responder = (_, __) => Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body), }); } public StubHandler(Func> responder) { _responder = responder; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequestUri = request.RequestUri; return _responder(request, cancellationToken); } } private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30); // Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver. private static MtconnectDriver DriverWith(HttpMessageHandler handler, TimeSpan? requestTimeout = null) => new MtconnectDriver( MachineId, AgentUrl, new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }, requestTimeout ?? GenerousTimeout); // ---- ReadCurrentAsync: happy path ---- [Fact] public async Task ReadCurrentAsync_200WithValidXml_ReturnsOkSnapshotStampedWithMachineId() { var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture()); var driver = DriverWith(handler); var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.True(result.IsSuccess); Assert.False(result.WasCancelled); Assert.NotNull(result.Value); Assert.Equal(MachineId, result.Value.MachineId); Assert.True(result.Value.Items.Count > 0); Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState); // Driver hits the agent's /current endpoint. Assert.NotNull(handler.LastRequestUri); Assert.EndsWith("/current", handler.LastRequestUri!.AbsoluteUri); } [Fact] public void ProtocolId_IsMtconnect() { var driver = DriverWith(new StubHandler(HttpStatusCode.OK, "")); Assert.Equal("mtconnect", driver.ProtocolId); } // ---- ReadCurrentAsync: HTTP failure statuses ---- [Theory] [InlineData(HttpStatusCode.NotFound)] [InlineData(HttpStatusCode.InternalServerError)] public async Task ReadCurrentAsync_NonSuccessStatus_ReturnsFailNoThrow(HttpStatusCode status) { var handler = new StubHandler(status, "irrelevant"); var driver = DriverWith(handler); var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.False(result.IsSuccess); Assert.False(result.WasCancelled); Assert.NotEmpty(result.Errors); } // ---- ReadCurrentAsync: network down ---- [Fact] public async Task ReadCurrentAsync_HttpRequestException_ReturnsFailNoThrow() { var handler = new StubHandler((_, __) => throw new HttpRequestException("connection refused")); var driver = DriverWith(handler); var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.False(result.IsSuccess); Assert.False(result.WasCancelled); Assert.NotEmpty(result.Errors); } // ---- ReadCurrentAsync: cancellation ---- [Fact] public async Task ReadCurrentAsync_CancelledBeforeCall_ReturnsCancelled() { var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture()); var driver = DriverWith(handler); using var cts = new CancellationTokenSource(); cts.Cancel(); var result = await driver.ReadCurrentAsync(cts.Token); Assert.False(result.IsSuccess); Assert.True(result.WasCancelled); } [Fact] public async Task ReadCurrentAsync_CancelledDuringSend_ReturnsCancelledNoThrow() { var handler = new StubHandler(async (_, ct) => { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); return new HttpResponseMessage(HttpStatusCode.OK); }); var driver = DriverWith(handler); using var cts = new CancellationTokenSource(); var task = driver.ReadCurrentAsync(cts.Token); cts.Cancel(); var result = await task; Assert.False(result.IsSuccess); Assert.True(result.WasCancelled); } // ---- ReadCurrentAsync: per-request timeout (linked CTS) ---- [Fact] public async Task ReadCurrentAsync_RequestExceedsPerRequestTimeout_ReturnsFailCurrentTimeoutNotCancelled() { // Handler stalls until its token trips; only the driver's linked CTS (CancelAfter) can trip it. var handler = new StubHandler(async (_, ct) => { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); return new HttpResponseMessage(HttpStatusCode.OK); }); var driver = DriverWith(handler, TimeSpan.FromMilliseconds(50)); // Caller token never cancelled: any cancellation here is the per-request timeout. var result = await driver.ReadCurrentAsync(CancellationToken.None); Assert.False(result.IsSuccess); Assert.False(result.WasCancelled); Assert.NotEmpty(result.Errors); Assert.Contains(result.Errors, e => e.Code == "CURRENT_TIMEOUT"); } [Fact] public async Task ReadCurrentAsync_CallerCancelBeatsTimeout_ReturnsCancelled() { var handler = new StubHandler(async (_, ct) => { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); return new HttpResponseMessage(HttpStatusCode.OK); }); // Generous per-request timeout so the caller cancel wins the race, not the timeout. var driver = DriverWith(handler, GenerousTimeout); using var cts = new CancellationTokenSource(); var task = driver.ReadCurrentAsync(cts.Token); cts.Cancel(); var result = await task; Assert.False(result.IsSuccess); Assert.True(result.WasCancelled); } // ---- Factory: config validation ---- private static Machine MachineWithConfig(IReadOnlyDictionary? config) => new Machine(MachineId, "VMC-01", "mtconnect", config, TimeSpan.FromSeconds(5)); [Fact] public void Factory_ProtocolId_IsMtconnect() { Assert.Equal("mtconnect", new MtconnectDriverFactory().ProtocolId); } [Fact] public void Factory_MissingAgentUrl_ReturnsFail() { var factory = new MtconnectDriverFactory(); var machine = MachineWithConfig(new Dictionary()); var result = factory.Create(machine); Assert.False(result.IsSuccess); Assert.NotEmpty(result.Errors); } [Fact] public void Factory_EmptyAgentUrl_ReturnsFail() { var factory = new MtconnectDriverFactory(); var machine = MachineWithConfig(new Dictionary { ["AgentUrl"] = " " }); var result = factory.Create(machine); Assert.False(result.IsSuccess); } [Fact] public void Factory_ValidAgentUrl_ReturnsOkDriverWithMtconnectProtocolId() { var factory = new MtconnectDriverFactory(); var machine = MachineWithConfig(new Dictionary { ["AgentUrl"] = AgentUrl }); var result = factory.Create(machine); Assert.True(result.IsSuccess); Assert.NotNull(result.Value); Assert.Equal("mtconnect", result.Value.ProtocolId); } [Fact] public void Factory_AgentUrlKeyIsCaseInsensitive() { var factory = new MtconnectDriverFactory(); var machine = MachineWithConfig(new Dictionary { ["agenturl"] = AgentUrl }); var result = factory.Create(machine); Assert.True(result.IsSuccess); } [Fact] public void Factory_InvalidTimeout_ReturnsFail() { var factory = new MtconnectDriverFactory(); var machine = MachineWithConfig(new Dictionary { ["AgentUrl"] = AgentUrl, ["TimeoutSeconds"] = "not-a-number", }); var result = factory.Create(machine); Assert.False(result.IsSuccess); } } }