Files
mxaccessgw/src/MxGateway.Worker.Tests/MxAccess/MxAccessComServerTests.cs
T
Joseph Doherty 06030dd1ef Implement MXAccess write commands in the worker
The .proto contract and MxCommandKind already defined Write, Write2,
WriteSecured, and WriteSecured2, but the worker's MxAccessCommandExecutor
had no case for any of them — every write kind fell through to
CreateInvalidRequestReply ("Unsupported MXAccess command kind Write").

Implement all four:

- VariantConverter.ConvertToComValue projects an MxValue into a
  COM-marshalable object (scalars, arrays, null) — the inverse of the
  existing COM-to-MxValue projection.
- IMxAccessServer / MxAccessComServer gain Write/Write2/WriteSecured/
  WriteSecured2, routed to ILMXProxyServer / ILMXProxyServer4.
- MxAccessSession and MxAccessCommandExecutor add the four write paths,
  following the existing ExecuteAdvise pattern; the reply is a plain OK
  reply and the outcome surfaces later as an OnWriteComplete event.

Verified live: a Write now returns PROTOCOL_STATUS_CODE_OK and produces
an OnWriteComplete event where it previously returned InvalidRequest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:45:35 -04:00

160 lines
5.5 KiB
C#

using System;
using System.Collections.Generic;
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Worker-007 regression tests for <see cref="MxAccessComServer"/>. The
/// adapter no longer falls back to late-bound <c>Type.InvokeMember</c>
/// reflection: a COM object must implement either the typed
/// <c>ILMXProxyServer</c> COM interface family (production) or
/// <see cref="IMxAccessServer"/> directly (test fakes).
/// </summary>
public sealed class MxAccessComServerTests
{
/// <summary>
/// A COM object implementing <see cref="IMxAccessServer"/> is routed
/// through the typed interface — no reflection — preserving arguments
/// and return values.
/// </summary>
[Fact]
public void Methods_WithTypedServer_RouteThroughTypedInterface()
{
RecordingMxAccessServer typed = new(registerHandle: 77);
MxAccessComServer adapter = new(typed);
int serverHandle = adapter.Register("client-a");
adapter.Advise(serverHandle, itemHandle: 9);
adapter.Unregister(serverHandle);
Assert.Equal(77, serverHandle);
Assert.Equal("client-a", typed.RegisteredClientName);
Assert.Equal(new[] { "Register:client-a", "Advise:77:9", "Unregister:77" }, typed.Calls);
}
/// <summary>
/// A COM object that implements neither the typed COM interface family
/// nor <see cref="IMxAccessServer"/> fails fast with a clear
/// <see cref="InvalidOperationException"/> instead of a late-bound
/// reflection call.
/// </summary>
[Fact]
public void Methods_WithUntypedObject_ThrowInvalidOperation()
{
MxAccessComServer adapter = new(new object());
InvalidOperationException exception =
Assert.Throws<InvalidOperationException>(() => adapter.Register("client"));
Assert.Contains("does not implement", exception.Message, StringComparison.Ordinal);
Assert.Contains(nameof(IMxAccessServer), exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Exceptions thrown by the typed server propagate unchanged — no
/// <c>TargetInvocationException</c> wrapping (reflection is gone).
/// </summary>
[Fact]
public void Methods_WhenTypedServerThrows_PropagateOriginalException()
{
RecordingMxAccessServer typed = new(registerHandle: 1)
{
ThrowOnRegister = new InvalidOperationException("register failed"),
};
MxAccessComServer adapter = new(typed);
InvalidOperationException exception =
Assert.Throws<InvalidOperationException>(() => adapter.Register("client"));
Assert.Equal("register failed", exception.Message);
}
private sealed class RecordingMxAccessServer : IMxAccessServer
{
private readonly int registerHandle;
private readonly List<string> calls = new();
public RecordingMxAccessServer(int registerHandle)
{
this.registerHandle = registerHandle;
}
public string? RegisteredClientName { get; private set; }
public Exception? ThrowOnRegister { get; set; }
public IReadOnlyList<string> Calls => calls.ToArray();
public int Register(string clientName)
{
calls.Add($"Register:{clientName}");
RegisteredClientName = clientName;
if (ThrowOnRegister is not null)
{
throw ThrowOnRegister;
}
return registerHandle;
}
public void Unregister(int serverHandle)
{
calls.Add($"Unregister:{serverHandle}");
}
public int AddItem(int serverHandle, string itemDefinition)
{
calls.Add($"AddItem:{serverHandle}:{itemDefinition}");
return 0;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
{
calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
return 0;
}
public void RemoveItem(int serverHandle, int itemHandle)
{
calls.Add($"RemoveItem:{serverHandle}:{itemHandle}");
}
public void Advise(int serverHandle, int itemHandle)
{
calls.Add($"Advise:{serverHandle}:{itemHandle}");
}
public void UnAdvise(int serverHandle, int itemHandle)
{
calls.Add($"UnAdvise:{serverHandle}:{itemHandle}");
}
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
}
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}");
}
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}");
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}");
}
public void WriteSecured2(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
calls.Add($"WriteSecured2:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}:{timestamp}");
}
}
}