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; /// /// End-to-end coverage of the response cache against the pymodbus DL205 simulator. /// /// pymodbus 3.13 simulator quirk. 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. /// /// The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL /// must result in EXACTLY 1 backend round-trip. /// [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 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 config) { var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(config); builder.Services.AddSerilog( new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false); builder.AddMbproxyOptions(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); 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 ── /// /// The "is the design pivot worth it?" test. Configure a BCD tag with CacheTtlMs = /// 1000; 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. /// [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 ──────────────────────────────── /// /// 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. /// [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 ─────────────────────────────────────────────────── /// /// 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. /// [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"); } }