Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/Cache/ResponseCacheE2ETests.cs
T
Joseph Doherty 1a2856526a mbproxy: strip historical phase/wave/plan references from source comments
Comments described the *history* of how the code arrived (phase numbers,
wave IDs, review IDs, dated TODOs) instead of what it does today. That
scaffolding rotted as the codebase evolved. Cleaned 60 source files +
.gitignore; behaviour unchanged (387/387 tests still pass).

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

342 lines
16 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using Mbproxy;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NModbus;
using Serilog;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy.Cache;
/// <summary>
/// End-to-end coverage of the response cache against the pymodbus DL205 simulator.
///
/// <para><b>pymodbus 3.13 simulator quirk.</b> These tests serialise reads in the
/// simulator-backed cases. The cache's behavioural guarantee (a TTL-bounded cache hit
/// returns the cached value without backend traffic) is independent of the simulator's
/// known concurrent-MBAP-frame bug — sequential reads keep the sim in single-PDU mode,
/// which is its known-good envelope.</para>
///
/// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL
/// must result in EXACTLY 1 backend round-trip.</para>
/// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
public sealed class ResponseCacheE2ETests
{
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
public ResponseCacheE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;
// ── Helpers ──────────────────────────────────────────────────────────────
private static int PickFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
int p = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return p;
}
private Dictionary<string, string?> MakeBaseConfig(int proxyPort) => new()
{
["Mbproxy:AdminPort"] = "0",
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
};
private static IHost BuildBcdHost(Dictionary<string, string?> config)
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(config);
builder.Services.AddSerilog(
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
dispose: false);
builder.AddMbproxyOptions();
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
builder.Services.AddSingleton<ProxyWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
if (int.TryParse(config["Mbproxy:AdminPort"], out int admin) && admin > 0)
builder.AddMbproxyAdmin();
return builder.Build();
}
private sealed class AsyncHostDispose : IAsyncDisposable
{
private readonly IHost _host;
public AsyncHostDispose(IHost host) => _host = host;
public async ValueTask DisposeAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try { await _host.StopAsync(cts.Token); } catch { }
_host.Dispose();
}
}
// ── Headline test: 10 reads at 100 ms intervals → exactly 1 backend round-trip ──
/// <summary>
/// The "is the design pivot worth it?" test. Configure a BCD tag with <c>CacheTtlMs =
/// 1000</c>; issue 10 reads at 100 ms intervals through the proxy. The cache HitCount
/// must show 9 (one miss to prime, 9 hits to serve) and the backend trip count must
/// be exactly 1.
/// </summary>
[Fact(Timeout = 5_000)]
public async Task E2E_CacheHit_TenReadsIn1Sec_BackendSeesOneRoundTrip()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
int proxyPort = PickFreePort();
int adminPort = PickFreePort();
var config = MakeBaseConfig(proxyPort);
config["Mbproxy:AdminPort"] = adminPort.ToString();
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "1000";
var host = BuildBcdHost(config);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await host.StartAsync(startCts.Token);
await using var hd = new AsyncHostDispose(host);
await Task.Delay(300, TestContext.Current.CancellationToken);
using (var client = new TcpClient())
{
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
// 10 reads at 100 ms intervals — total elapsed ~900 ms, well within the 1000 ms TTL.
for (int i = 0; i < 10; i++)
{
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
regs[0].ShouldBe((ushort)1234, $"read #{i}: BCD-decoded value must be 1234");
if (i < 9)
await Task.Delay(100, TestContext.Current.CancellationToken);
}
}
using var httpClient = new HttpClient();
var resp = await httpClient.GetStringAsync(
$"http://127.0.0.1:{adminPort}/status.json",
TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(resp);
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
// 9 cache hits (the first read populated the cache; the next 9 returned from cache).
// 1 cache miss (the priming read).
backend.GetProperty("cacheHitCount").GetInt64()
.ShouldBe(9, "10 reads with TTL=1000 ms at 100 ms intervals must produce 9 cache hits");
backend.GetProperty("cacheMissCount").GetInt64()
.ShouldBe(1, "exactly the first read should miss");
// The backend-trip count is observable via coalescedMissCount (every read that
// makes it to the backend increments this counter; cache hits short-circuit).
backend.GetProperty("coalescedMissCount").GetInt64()
.ShouldBe(1, "exactly one read must reach the backend");
}
// ── Regression: cache disabled by default ────────────────────────────────
/// <summary>
/// Mandatory regression. With no cache config anywhere (default deployment shape),
/// behaviour must be byte-identical to the non-cached path: sequential reads through
/// the same client produce one backend round-trip each — no elision.
/// </summary>
[Fact(Timeout = 5_000)]
public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
int proxyPort = PickFreePort();
int adminPort = PickFreePort();
var config = MakeBaseConfig(proxyPort);
config["Mbproxy:AdminPort"] = adminPort.ToString();
// No Cache section, no CacheTtlMs on any tag — non-cached behaviour.
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
var host = BuildBcdHost(config);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await host.StartAsync(startCts.Token);
await using var hd = new AsyncHostDispose(host);
await Task.Delay(300, TestContext.Current.CancellationToken);
using (var client = new TcpClient())
{
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
for (int i = 0; i < 5; i++)
{
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
regs[0].ShouldBe((ushort)1234, $"read #{i} must still BCD-decode correctly");
}
}
using var httpClient = new HttpClient();
var resp = await httpClient.GetStringAsync(
$"http://127.0.0.1:{adminPort}/status.json",
TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(resp);
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
backend.GetProperty("cacheHitCount").GetInt64()
.ShouldBe(0, "no cache config: HitCount must remain at zero");
backend.GetProperty("cacheMissCount").GetInt64()
.ShouldBe(0, "no cache config: MissCount must remain at zero — cache counters are tracked only for cache-eligible reads");
backend.GetProperty("coalescedMissCount").GetInt64()
.ShouldBe(5, "every read must reach the backend as before — Phase-10 behaviour preserved");
}
// ── TTL expiry path ─────────────────────────────────────────────────────
[Fact(Timeout = 5_000)]
public async Task E2E_CacheExpires_AfterTtl_NextReadHitsBackend()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
int proxyPort = PickFreePort();
int adminPort = PickFreePort();
var config = MakeBaseConfig(proxyPort);
config["Mbproxy:AdminPort"] = adminPort.ToString();
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "200";
var host = BuildBcdHost(config);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await host.StartAsync(startCts.Token);
await using var hd = new AsyncHostDispose(host);
await Task.Delay(300, TestContext.Current.CancellationToken);
using (var client = new TcpClient())
{
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
_ = master.ReadHoldingRegisters(1, 1072, 1); // miss, populates cache
_ = master.ReadHoldingRegisters(1, 1072, 1); // hit
await Task.Delay(350, TestContext.Current.CancellationToken); // > TTL
_ = master.ReadHoldingRegisters(1, 1072, 1); // miss again
}
using var httpClient = new HttpClient();
var resp = await httpClient.GetStringAsync(
$"http://127.0.0.1:{adminPort}/status.json",
TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(resp);
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
backend.GetProperty("cacheHitCount").GetInt64()
.ShouldBe(1, "exactly one read should land inside the TTL window");
backend.GetProperty("cacheMissCount").GetInt64()
.ShouldBe(2, "two reads should miss (initial fill and post-expiry refill)");
backend.GetProperty("coalescedMissCount").GetInt64()
.ShouldBe(2, "the two cache misses must each produce a backend round-trip");
}
// ── Write invalidation ───────────────────────────────────────────────────
/// <summary>
/// Uses a register OUTSIDE the simulator's seeded BCD range so subsequent tests'
/// reads of register 1072 are not polluted by this test's write. The simulator's
/// holding-register table is shared across tests in the collection.
/// </summary>
[Fact(Timeout = 5_000)]
public async Task E2E_WriteInvalidatesOverlappingCacheEntries()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
// Use a scratch register (200) from the simulator's allowed-write range so the
// FC06 write does not fault on the simulator side, but a register that no other
// test reads with BCD decoding — its initial value 0 round-trips through the
// BCD codec without surprises, and any post-write state stays contained.
const ushort isolatedRegister = 200;
int proxyPort = PickFreePort();
int adminPort = PickFreePort();
var config = MakeBaseConfig(proxyPort);
config["Mbproxy:AdminPort"] = adminPort.ToString();
config["Mbproxy:BcdTags:Global:0:Address"] = isolatedRegister.ToString();
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";
var host = BuildBcdHost(config);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await host.StartAsync(startCts.Token);
await using var hd = new AsyncHostDispose(host);
await Task.Delay(300, TestContext.Current.CancellationToken);
using (var client = new TcpClient())
{
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // miss → cached
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // hit
master.WriteSingleRegister(1, isolatedRegister, 4321); // invalidates the cached entry
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // must miss again
}
using var httpClient = new HttpClient();
var resp = await httpClient.GetStringAsync(
$"http://127.0.0.1:{adminPort}/status.json",
TestContext.Current.CancellationToken);
using var doc = JsonDocument.Parse(resp);
var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
backend.GetProperty("cacheHitCount").GetInt64()
.ShouldBe(1, "the second read should hit the cache");
backend.GetProperty("cacheMissCount").GetInt64()
.ShouldBe(2, "first read primes the cache; third read misses because the write invalidated the entry");
backend.GetProperty("cacheInvalidations").GetInt64()
.ShouldBe(1, "the FC06 write must invalidate exactly one cache entry");
}
// ── BCD-decoded bytes are cached ─────────────────────────────────────────
[Fact(Timeout = 5_000)]
public async Task E2E_BcdDecodedBytesAreCached_NotRawBcd()
{
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
int proxyPort = PickFreePort();
var config = MakeBaseConfig(proxyPort);
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";
var host = BuildBcdHost(config);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await host.StartAsync(startCts.Token);
await using var hd = new AsyncHostDispose(host);
await Task.Delay(300, TestContext.Current.CancellationToken);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
ushort[] r1 = master.ReadHoldingRegisters(1, 1072, 1);
ushort[] r2 = master.ReadHoldingRegisters(1, 1072, 1);
ushort[] r3 = master.ReadHoldingRegisters(1, 1072, 1);
r1[0].ShouldBe((ushort)1234, "first read must be BCD-decoded");
r2[0].ShouldBe((ushort)1234, "second read (cache hit) must return decoded 1234, not raw BCD 0x1234");
r3[0].ShouldBe((ushort)1234, "third read (cache hit) must return decoded 1234");
}
}