DriverHostState enum lives in Configuration.Enums/ rather than reusing Core.Abstractions.HostState so the Configuration project stays free of driver-runtime dependencies (it's referenced by both the Admin process and the Server process, so pulling in the driver-abstractions assembly to every Admin build would be unnecessary weight). The server-side publisher hosted service (follow-up PR 34) will translate HostStatusChangedEventArgs.NewState to this enum on every transition. No foreign key to ClusterNode — a Server may start reporting host status before its ClusterNode row exists (first-boot bootstrap), and we'd rather keep the status row than drop it. The Admin-side service that renders the dashboard will left-join on NodeId when presenting. Two indexes declared: IX_DriverHostStatus_Node drives the per-cluster drill-down (Admin UI joins ClusterNode on ClusterId to pick which NodeIds to fetch), IX_DriverHostStatus_LastSeen drives the stale-row query (now - LastSeen > threshold). EF migration AddDriverHostStatus creates the table + PK + both indexes. Model snapshot updated. SchemaComplianceTests expected-tables list extended. DriverHostStatusTests (3 new cases, category SchemaCompliance, uses the shared fixture DB): composite key allows same (host, driver) across different nodes AND same (node, host) across different drivers — both real-world cases the publisher needs to support; upsert-in-place pattern (fetch-by-composite-PK, mutate, save) produces one row not two — the pattern the publisher will use; State enum persists as string not int — reading the DB via ADO.NET returns 'Faulted' not '3'. Configuration.Tests SchemaCompliance suite: 10 pass / 0 fail (7 prior + 3 new). Configuration build clean. No Server or Admin code changes yet — publisher + /hosts page are PR 34. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
5.8 KiB
C#
129 lines
5.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
|
|
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
|
|
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
|
|
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
|
|
/// </summary>
|
|
[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<string>() 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<OtOpcUaConfigDbContext>()
|
|
.UseSqlServer(fixture.ConnectionString)
|
|
.Options;
|
|
return new OtOpcUaConfigDbContext(options);
|
|
}
|
|
}
|