using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Junction.Domain; using Junction.Domain.Models; using Junction.Domain.Protocols; using Junction.Persistence; using Junction.Protocols.MTConnect; using Xunit; using Xunit.Abstractions; using Xunit.Sdk; namespace Junction.Tests.Integration { /// /// Docker-gated end-to-end integration tests: real -> /// live ladder99 MTConnect agent (docker mock at http://localhost:5000) -> snapshot /// round-trip through the real SQLite repository. /// /// These tests assume the mock is ALREADY running (they do NOT auto-start docker): /// docker compose -f mock/docker-compose.yml up -d. When the agent is not /// reachable (no docker in CI, mock down) each test SKIPS (never fails), so a CI /// without docker stays green. /// /// xUnit v2 2.9.3 has no Assert.Skip (v3-only), so a self-contained skippable-fact /// discoverer ( + ) provides a /// genuine runtime SKIP result — no extra NuGet package, no csproj change. /// Filter with dotnet test --filter Category=Docker. /// [Trait("Category", "Docker")] public sealed class MtconnectEndToEndTests { private const string AgentUrl = "http://localhost:5000"; /// /// Probes the agent's /probe endpoint with a short timeout. Returns true only when the /// agent answers with a success status. Never throws — any failure means "not reachable". /// private static bool IsAgentReachable() { try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; using var response = http.GetAsync(AgentUrl + "/probe").GetAwaiter().GetResult(); return response.IsSuccessStatusCode; } catch { return false; } } /// Skips the current test (does not fail) when the docker MTConnect mock is down. private static void SkipIfAgentUnavailable() { if (!IsAgentReachable()) { throw new SkipTestException( "MTConnect docker mock not reachable at " + AgentUrl + "/probe. Start it with: docker compose -f mock/docker-compose.yml up -d"); } } private static Machine NewMtconnectMachine() => new Machine( Guid.NewGuid(), "IT Mock Mill", "mtconnect", new Dictionary { ["AgentUrl"] = AgentUrl }, TimeSpan.FromSeconds(1)); [DockerFact] public async Task Driver_ReadsCurrent_FromLiveAgent_VersionAgnostic() { SkipIfAgentUnavailable(); var machine = NewMtconnectMachine(); var factory = new MtconnectDriverFactory(); Result created = factory.Create(machine); Assert.True(created.IsSuccess, Describe(created)); IProtocolDriver driver = created.Value; try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); Result read = await driver.ReadCurrentAsync(cts.Token); Assert.True(read.IsSuccess, Describe(read)); var snapshot = read.Value; Assert.Equal(machine.Id, snapshot.MachineId); Assert.True(snapshot.Items.Count > 0, "expected at least one datum from the live agent"); // Agent reports AVAILABLE -> Connected. Real MTConnect 2.0 output must parse. Assert.Equal(ConnectionState.Connected, snapshot.ConnectionState); // A meaningful datum with a non-empty value must be present. The parser is // version-agnostic, so match on the datum Name (parsed from the agent's real feed). var meaningful = snapshot.Items.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i.Value) && (Contains(i.Name, "Position") || Contains(i.Name, "Execution") || Contains(i.Name, "Availability"))); Assert.True( meaningful != null, "expected a Position/Execution/Availability datum with a non-empty value; got: " + string.Join(", ", snapshot.Items.Take(20).Select(i => i.Name + "=" + i.Value))); } finally { (driver as IDisposable)?.Dispose(); } } [DockerFact] public async Task Driver_To_Repository_RoundTrip_PersistsSnapshot() { SkipIfAgentUnavailable(); var machine = NewMtconnectMachine(); var dbPath = Path.Combine( Path.GetTempPath(), "junction_it_" + Guid.NewGuid().ToString("N") + ".db"); var connectionFactory = new SqliteConnectionFactory("Data Source=" + dbPath); try { Result schema = SqliteSchema.EnsureCreated(connectionFactory); Assert.True(schema.IsSuccess, Describe(schema)); var repo = new SqliteMachineRepository(connectionFactory); Result upsert = await repo.UpsertAsync(machine, CancellationToken.None); Assert.True(upsert.IsSuccess, Describe(upsert)); // Take a live snapshot via the real driver. var driverFactory = new MtconnectDriverFactory(); Result created = driverFactory.Create(machine); Assert.True(created.IsSuccess, Describe(created)); IProtocolDriver driver = created.Value; MachineSnapshot snapshot; try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); Result read = await driver.ReadCurrentAsync(cts.Token); Assert.True(read.IsSuccess, Describe(read)); snapshot = read.Value; } finally { (driver as IDisposable)?.Dispose(); } Assert.True(snapshot.Items.Count > 0, "live snapshot must carry datums to persist"); // Persist and read back the latest snapshot. Result save = await repo.SaveSnapshotAsync(snapshot, CancellationToken.None); Assert.True(save.IsSuccess, Describe(save)); Result loadedResult = await repo.GetLatestSnapshotAsync(machine.Id, CancellationToken.None); Assert.True(loadedResult.IsSuccess, Describe(loadedResult)); var loaded = loadedResult.Value; Assert.Equal(machine.Id, loaded.MachineId); Assert.Equal(snapshot.ConnectionState, loaded.ConnectionState); Assert.Equal(snapshot.Items.Count, loaded.Items.Count); // Every persisted datum is retrievable with its value intact (last datum). var expected = snapshot.Items[0]; var actual = loaded.TryGetItem(expected.Id); Assert.NotNull(actual); Assert.Equal(expected.Value, actual!.Value); Assert.Equal(expected.Name, actual.Name); Assert.Equal(expected.Category, actual.Category); } finally { TryDelete(dbPath); } } private static bool Contains(string haystack, string needle) => haystack != null && haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch { // best-effort cleanup } } private static string Describe(Result result) => result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString())); private static string Describe(Result result) => result.IsSuccess ? "" : string.Join("; ", result.Errors.Select(e => e.ToString())); } // --------------------------------------------------------------------------------------- // Self-contained skippable-fact support for xUnit v2 (no Assert.Skip, no extra package). // A test throws SkipTestException at runtime; the custom test case rewrites the resulting // TestFailed message into a TestSkipped message, so the runner reports a genuine SKIP. // --------------------------------------------------------------------------------------- /// Thrown to skip a test at runtime (dynamic skip for xUnit v2). public sealed class SkipTestException : Exception { public SkipTestException(string reason) : base(reason) { } } /// A whose tests may skip at runtime via . [XunitTestCaseDiscoverer("Junction.Tests.Integration.DockerFactDiscoverer", "Junction.Tests")] [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public sealed class DockerFactAttribute : FactAttribute { } /// Discovers -decorated methods as skippable test cases. public sealed class DockerFactDiscoverer : IXunitTestCaseDiscoverer { private static readonly string[] SkippingExceptionNames = { typeof(SkipTestException).FullName! }; private readonly IMessageSink _diagnosticMessageSink; public DockerFactDiscoverer(IMessageSink diagnosticMessageSink) { _diagnosticMessageSink = diagnosticMessageSink; } public IEnumerable Discover( ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) { yield return new SkippableFactTestCase( SkippingExceptionNames, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod); } } /// Test case that converts a designated exception into a skip result. public sealed class SkippableFactTestCase : XunitTestCase { private string[] _skippingExceptionNames = Array.Empty(); [EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] public SkippableFactTestCase() { } public SkippableFactTestCase( string[] skippingExceptionNames, IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod, object[]? testMethodArguments = null) : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) { _skippingExceptionNames = skippingExceptionNames; } public override void Serialize(IXunitSerializationInfo data) { base.Serialize(data); data.AddValue(nameof(_skippingExceptionNames), _skippingExceptionNames); } public override void Deserialize(IXunitSerializationInfo data) { base.Deserialize(data); _skippingExceptionNames = data.GetValue(nameof(_skippingExceptionNames)); } public override async Task RunAsync( IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) { var interceptor = new SkippableTestMessageBus(messageBus, _skippingExceptionNames); var result = await base.RunAsync( diagnosticMessageSink, interceptor, constructorArguments, aggregator, cancellationTokenSource); result.Failed -= interceptor.SkippedCount; result.Skipped += interceptor.SkippedCount; return result; } } /// Rewrites carrying a skipping exception into . public sealed class SkippableTestMessageBus : IMessageBus { private readonly IMessageBus _inner; private readonly string[] _skippingExceptionNames; public SkippableTestMessageBus(IMessageBus inner, string[] skippingExceptionNames) { _inner = inner; _skippingExceptionNames = skippingExceptionNames; } public int SkippedCount { get; private set; } public bool QueueMessage(IMessageSinkMessage message) { if (message is ITestFailed failed) { var exceptionType = failed.ExceptionTypes.FirstOrDefault(); if (exceptionType != null && _skippingExceptionNames.Contains(exceptionType)) { SkippedCount++; var reason = failed.Messages != null && failed.Messages.Length > 0 ? failed.Messages[0] : "skipped"; return _inner.QueueMessage(new TestSkipped(failed.Test, reason)); } } return _inner.QueueMessage(message); } public void Dispose() => _inner.Dispose(); } }