using System; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using MxGateway.Contracts.Proto; using MxGateway.Worker.MxAccess; using MxGateway.Worker.Sta; namespace MxGateway.Worker.Tests.MxAccess; /// /// Tests for . /// public sealed class MxAccessStaSessionTests { /// /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. /// [Fact] public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread() { FakeMxAccessComObjectFactory factory = new(); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, eventSink); WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234); Assert.Equal(1234, ready.WorkerProcessId); Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid); Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid); Assert.NotNull(ready.ReadyTimestamp); Assert.Equal(runtime.StaThreadId, factory.CreateThreadId); Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId); Assert.Equal(ApartmentState.STA, factory.CreateApartmentState); Assert.Same(factory.CreatedObject, eventSink.AttachedObject); Assert.Equal("session-1", eventSink.SessionId); } /// /// Verifies that StartAsync maps creation exceptions with HResult when the factory fails. /// [Fact] public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult() { const int hresult = unchecked((int)0x80040154); FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult)); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, eventSink); MxAccessCreationException exception = await Assert.ThrowsAsync( () => session.StartAsync(workerProcessId: 1234)); Assert.Equal(hresult, exception.CapturedHResult); Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId); Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid); Assert.Null(eventSink.AttachedObject); } /// /// Verifies that Dispose detaches the event sink on the STA thread. /// [Fact] public async Task Dispose_DetachesEventSinkOnStaThread() { FakeMxAccessComObjectFactory factory = new(); FakeMxAccessEventSink eventSink = new(); using StaRuntime runtime = CreateRuntime(); MxAccessStaSession session = new(runtime, factory, eventSink); await session.StartAsync(workerProcessId: 1234); session.Dispose(); Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId); } private static StaRuntime CreateRuntime() { return new StaRuntime( new NoopComApartmentInitializer(), new StaMessagePump(), TimeSpan.FromMilliseconds(25)); } /// /// Fake MXAccess COM object factory for testing. /// private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory { private readonly Exception? exception; /// /// Initializes a fake factory that optionally throws an exception. /// /// Exception to throw when Create is called; null to succeed. public FakeMxAccessComObjectFactory(Exception? exception = null) { this.exception = exception; } /// /// Gets the COM object created by this factory. /// public object CreatedObject { get; } = new(); /// /// Gets the managed thread ID when Create was called. /// public int? CreateThreadId { get; private set; } /// /// Gets the apartment state when Create was called. /// public ApartmentState? CreateApartmentState { get; private set; } /// /// Creates the COM object or throws the configured exception. /// public object Create() { CreateThreadId = Thread.CurrentThread.ManagedThreadId; CreateApartmentState = Thread.CurrentThread.GetApartmentState(); if (exception is not null) { throw exception; } return CreatedObject; } } /// /// Fake MXAccess event sink for testing. /// private sealed class FakeMxAccessEventSink : IMxAccessEventSink { /// /// Gets the attached MXAccess COM object. /// public object? AttachedObject { get; private set; } /// /// Gets the managed thread ID when Attach was called. /// public int? AttachThreadId { get; private set; } /// /// Gets the managed thread ID when Detach was called. /// public int? DetachThreadId { get; private set; } /// /// Gets the session identifier. /// public string? SessionId { get; private set; } /// /// Attaches the MXAccess COM object and records thread context. /// /// MXAccess COM object to attach. /// Identifier of the session. public void Attach( object mxAccessComObject, string sessionId) { AttachedObject = mxAccessComObject; AttachThreadId = Thread.CurrentThread.ManagedThreadId; SessionId = sessionId; } /// /// Detaches the MXAccess COM object and records thread context. /// public void Detach() { DetachThreadId = Thread.CurrentThread.ManagedThreadId; AttachedObject = null; } } /// /// Noop STA COM apartment initializer for testing. /// private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer { /// /// Initializes the COM apartment (no-op). /// public void Initialize() { } /// /// Uninitializes the COM apartment (no-op). /// public void Uninitialize() { } } }