using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// End-to-end round-trip through the DB for the entity /// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId, /// HostName), string-backed DriverHostState conversion, and the two indexes the /// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc). /// [Trait("Category", "SchemaCompliance")] [Collection(nameof(SchemaComplianceCollection))] public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture) { [Fact] public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers() { await using var ctx = NewContext(); // Same HostName + DriverInstanceId across two different server nodes — classic 2-node // redundancy case. Both rows must be insertable because each server node owns its own // runtime view of the shared host. var now = DateTime.UtcNow; ctx.DriverHostStatuses.Add(new DriverHostStatus { NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform", State = DriverHostState.Running, StateChangedUtc = now, LastSeenUtc = now, }); ctx.DriverHostStatuses.Add(new DriverHostStatus { NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform", State = DriverHostState.Stopped, StateChangedUtc = now, LastSeenUtc = now, Detail = "secondary hasn't taken over yet", }); // Same server node + host, different driver instance — second driver doesn't clobber. ctx.DriverHostStatuses.Add(new DriverHostStatus { NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform", State = DriverHostState.Running, StateChangedUtc = now, LastSeenUtc = now, }); await ctx.SaveChangesAsync(); var rows = await ctx.DriverHostStatuses.AsNoTracking() .Where(r => r.HostName == "GRPlatform").ToListAsync(); rows.Count.ShouldBe(3); rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1"); rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet"); rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1"); } [Fact] public async Task Upsert_pattern_for_same_key_updates_in_place() { // The publisher hosted service (follow-up PR) upserts on every transition + // heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update // keyed on the composite PK. If the composite key ever changes, this test breaks // loudly so the publisher gets a synchronized update. await using var ctx = NewContext(); var t0 = DateTime.UtcNow; ctx.DriverHostStatuses.Add(new DriverHostStatus { NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host", State = DriverHostState.Running, StateChangedUtc = t0, LastSeenUtc = t0, }); await ctx.SaveChangesAsync(); var t1 = t0.AddSeconds(30); await using (var ctx2 = NewContext()) { var existing = await ctx2.DriverHostStatuses.SingleAsync(r => r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host"); existing.State = DriverHostState.Faulted; existing.StateChangedUtc = t1; existing.LastSeenUtc = t1; existing.Detail = "transport reset by peer"; await ctx2.SaveChangesAsync(); } await using var ctx3 = NewContext(); var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r => r.NodeId == "upsert-node" && r.HostName == "upsert-host"); final.State.ShouldBe(DriverHostState.Faulted); final.Detail.ShouldBe("transport reset by peer"); // Only one row — a naive "always insert" would have created a duplicate PK and thrown. (await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1); } [Fact] public async Task Enum_persists_as_string_not_int() { // Fluent config sets HasConversion() on State — the DB stores 'Running' / // 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw // string back via ADO; if someone drops the conversion the column will contain '1' // / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table // directly should see readable state names, not enum ordinals. await using var ctx = NewContext(); ctx.DriverHostStatuses.Add(new DriverHostStatus { NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host", State = DriverHostState.Faulted, StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); await using var conn = fixture.OpenConnection(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'"; var rawValue = (string?)await cmd.ExecuteScalarAsync(); rawValue.ShouldBe("Faulted"); } private OtOpcUaConfigDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseSqlServer(fixture.ConnectionString) .Options; return new OtOpcUaConfigDbContext(options); } }