Files
wwtools/mbproxy/tests/Mbproxy.Tests/Configuration/ReloadValidatorTests.cs
T
Joseph Doherty 56eee3c563 mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the
production-ready single-listener / 1:1-backend transparent Modbus TCP
proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260
fleet. Phase 9 replaces the connection layer with a single backend
socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's
4-concurrent-client cap as an operational ceiling.

Phase 9 additions of note:
- PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap
- InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing
  for Phase 10 read coalescing — do not collapse to a single field)
- Per-request watchdog: surfaces Modbus exception 0x0B to upstream
  on BackendRequestTimeoutMs, defending against lost responses,
  dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed-
  request bug (its ServerRequestHandler.last_pdu state race)
- Status DTO + HTML gain inFlight / maxInFlight / txIdWraps /
  disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md)

Tests: 263 unit + 38 E2E. Multiplexer correctness under truly
concurrent backend traffic is proved against a stub backend in
PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus
3.13's single-PDU framer stays in known-good mode.

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

159 lines
5.9 KiB
C#

using Mbproxy.Configuration;
using Mbproxy.Options;
using Xunit;
namespace Mbproxy.Tests.Configuration;
/// <summary>
/// Unit tests for <see cref="ReloadValidator.Validate"/>.
/// Each test covers one specific failure mode or the happy path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ReloadValidatorTests
{
// ── Helpers ───────────────────────────────────────────────────────────────────────────
private static PlcOptions MakePlc(string name, int listenPort, string host = "127.0.0.1")
=> new() { Name = name, ListenPort = listenPort, Host = host, Port = 502 };
private static MbproxyOptions MakeOptions(
PlcOptions[] plcs,
int adminPort = 8080,
BcdTagListOptions? global = null)
=> new()
{
Plcs = plcs,
AdminPort = adminPort,
BcdTags = global ?? new BcdTagListOptions(),
};
// ── 1. Duplicate PLC name → fails ────────────────────────────────────────────────────
[Fact]
public void Validate_DuplicatePlcName_Fails()
{
var opts = MakeOptions([
MakePlc("PLC-A", 5020),
MakePlc("PLC-A", 5021), // same name
]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("PLC-A") && e.Contains("uplicate"));
}
// ── 2. Duplicate ListenPort → fails ──────────────────────────────────────────────────
[Fact]
public void Validate_DuplicateListenPort_Fails()
{
var opts = MakeOptions([
MakePlc("PLC-A", 5020),
MakePlc("PLC-B", 5020), // same port
]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("5020") && e.Contains("uplicate"));
}
// ── 3. AdminPort collides with a PLC's ListenPort → fails ────────────────────────────
[Fact]
public void Validate_AdminPortCollidesWith_PlcListenPort_Fails()
{
var opts = MakeOptions(
plcs: [MakePlc("PLC-A", 5020)],
adminPort: 5020); // collides with PLC-A
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("AdminPort") && e.Contains("5020"));
}
// ── 4. Per-PLC BCD map build error → fails ────────────────────────────────────────────
[Fact]
public void Validate_PerPlc_BcdMapBuildError_Fails()
{
// A 32-bit tag at address 100 and a 16-bit tag at 101 collide on high register.
var global = new BcdTagListOptions
{
Global =
[
new BcdTagOptions { Address = 100, Width = 32 },
new BcdTagOptions { Address = 101, Width = 16 }, // overlaps 100's high register
],
};
var opts = MakeOptions([MakePlc("PLC-A", 5020)], global: global);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("PLC-A"));
}
// ── 5. Port out of range → fails ─────────────────────────────────────────────────────
[Fact]
public void Validate_PortOutOfRange_Fails()
{
// ListenPort 0 is below the valid [1, 65535] range.
var opts = MakeOptions([MakePlc("PLC-A", 0)]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("0") && e.Contains("range"));
}
// ── 5b. AdminPort out of range → fails ───────────────────────────────────────────────
[Fact]
public void Validate_AdminPortOutOfRange_Fails()
{
var opts = MakeOptions([MakePlc("PLC-A", 5020)], adminPort: 70000);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("70000") && e.Contains("range"));
}
// ── 6. Happy path → passes ───────────────────────────────────────────────────────────
[Fact]
public void Validate_HappyPath_Passes()
{
var global = new BcdTagListOptions
{
Global = [new BcdTagOptions { Address = 1072, Width = 16 }],
};
var opts = MakeOptions(
plcs: [MakePlc("PLC-A", 5020), MakePlc("PLC-B", 5021)],
adminPort: 8080,
global: global);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.True(valid);
Assert.Empty(errors);
}
// ── 7. Empty PLC name → fails ────────────────────────────────────────────────────────
[Fact]
public void Validate_EmptyPlcName_Fails()
{
var opts = MakeOptions([MakePlc("", 5020)]);
bool valid = ReloadValidator.Validate(opts, out var errors);
Assert.False(valid);
Assert.Contains(errors, e => e.Contains("non-empty"));
}
}