v2-mxgw follow-ups: production reads, secret resolution, perf knobs
Lands the five concrete code-level follow-ups identified after Phase 7.1: #1 GalaxyDriver.ReadAsync now works in production. Previously threw NotSupportedException when no test reader was injected. New path subscribes through the existing SubscriptionRegistry + EventPump, waits for the first OnDataChange per item handle (gw pushes the initial value after SubscribeBulk), then unsubscribes. Tags the gw rejects up front, or that don't publish before the caller's CT fires, return Bad-status snapshots in input order so callers still get one snapshot per requested reference. #2 ResolveApiKey() routes Gateway.ApiKeySecretRef through three forms: env:NAME, file:PATH, or literal-string fallback. A future DPAPI arm slots in here without touching the call site. #3 GatewayGalaxySubscriber actually honors bufferedUpdateIntervalMs now (was being silently dropped). Calls SetBufferedUpdateInterval via the gw's MxCommandKind.SetBufferedUpdateInterval before SubscribeBulk when the requested interval differs from the cached last-applied value. Soft-fails on a non-Ok protocol status (the SubscribeBulk still succeeds at gw cadence). #4 GalaxyMxAccessOptions.EventPumpChannelCapacity surfaces the bounded- channel size through DriverConfig JSON, defaulting to 50_000. #5 Stale doc-comments in HostStatusAggregator and GatewayGalaxySubscriber describing follow-ups that already shipped. Tests: +6 (read subscribe-once happy path + rejected-tag fallback; five resolver scenarios). Total Galaxy driver tests now 180/180 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up #2 — pins the three resolution forms supported by
|
||||
/// <see cref="GalaxyDriver.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||
/// and the literal-string fallback. A future DPAPI arm slots in here without
|
||||
/// touching the call site.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverApiKeyResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Literal_string_is_returned_unchanged()
|
||||
{
|
||||
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Env_prefix_resolves_to_environment_variable()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||
try
|
||||
{
|
||||
GalaxyDriver.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxyDriver.ResolveApiKey($"env:{name}"));
|
||||
ex.Message.ShouldContain(name);
|
||||
ex.Message.ShouldContain("unset");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_prefix_resolves_to_trimmed_file_contents()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, " key-from-file \n");
|
||||
try
|
||||
{
|
||||
GalaxyDriver.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_prefix_missing_path_throws()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxyDriver.ResolveApiKey($"file:{path}"));
|
||||
ex.Message.ShouldContain(path);
|
||||
ex.Message.ShouldContain("doesn't exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_prefix_empty_file_throws()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, " \n ");
|
||||
try
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxyDriver.ResolveApiKey($"file:{path}"));
|
||||
ex.Message.ShouldContain("empty");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,13 +66,17 @@ public sealed class GalaxyDriverReadTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_NoReader_Throws_PointingAtPR44()
|
||||
public async Task ReadAsync_NoSeams_AndNoProductionRuntime_Throws()
|
||||
{
|
||||
// Construction without seams + without InitializeAsync gives a driver where
|
||||
// _dataReader and _subscriber are both null. The follow-up read path can't
|
||||
// synthesise a Read without one, so it surfaces a NotSupportedException
|
||||
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
||||
var driver = new GalaxyDriver("g", Opts());
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.ReadAsync(["x"], CancellationToken.None));
|
||||
ex.Message.ShouldContain("PR 4.4");
|
||||
ex.Message.ShouldContain("production runtime not built");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,6 +88,48 @@ public sealed class GalaxyDriverReadTests
|
||||
driver.ReadAsync(["x"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_SubscribeOncePath_ResolvesFromFirstOnDataChange()
|
||||
{
|
||||
// Follow-up #1: when no test reader is injected but a subscriber IS, the driver
|
||||
// synthesises a Read by subscribing, waiting for the first OnDataChange event
|
||||
// per item handle (gw pushes initial value), then unsubscribing.
|
||||
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var readTask = driver.ReadAsync(["Tank.Level"], CancellationToken.None);
|
||||
// Push the "initial value" event the gw would emit immediately after SubscribeBulk.
|
||||
await Task.Delay(50); // give SubscribeBulk a beat to register + handler to attach
|
||||
var itemHandle = subscriber.Map["Tank.Level"];
|
||||
await subscriber.EmitOnDataChangeAsync(itemHandle, 42.0);
|
||||
|
||||
var result = await readTask;
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].Value.ShouldBe(42.0);
|
||||
// Cleanup unsubscribed the live handle.
|
||||
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_SubscribeOncePath_RejectedTagSurfacesAsBadStatus()
|
||||
{
|
||||
// gw rejects "Bad" at SubscribeBulk; the read path completes that slot with a
|
||||
// Bad-status snapshot rather than waiting forever for an event that won't come.
|
||||
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber { Decide = tag => tag != "Bad" };
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var readTask = driver.ReadAsync(["Good", "Bad"], CancellationToken.None);
|
||||
await Task.Delay(50);
|
||||
await subscriber.EmitOnDataChangeAsync(subscriber.Map["Good"], 1.0);
|
||||
|
||||
var result = await readTask;
|
||||
result.Count.ShouldBe(2);
|
||||
result[0].Value.ShouldBe(1.0);
|
||||
result[1].StatusCode.ShouldBe(0x80000000u); // Bad
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_PreservesReaderStatusCodes()
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class GalaxyDriverSubscribeTests
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions());
|
||||
|
||||
private sealed class FakeSubscriber : IGalaxySubscriber
|
||||
internal sealed class FakeSubscriber : IGalaxySubscriber
|
||||
{
|
||||
private int _nextHandle = 1;
|
||||
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
|
||||
|
||||
Reference in New Issue
Block a user