using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Queries;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
///
/// Verifies — the cluster-scoped audit view used
/// by the AdminUI ClusterAudit page. The structured AuditWriterActor path stamps NodeId (not
/// ClusterId), so before the Task 2.2 fix those rows were invisible to a cluster filtered only on
/// ClusterId. These tests pin the OR-predicate that joins NodeId back to its cluster.
///
[Trait("Category", "Unit")]
public sealed class ClusterAuditQueryTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
/// Initializes a new instance with a fresh in-memory config database.
public ClusterAuditQueryTests()
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase($"cluster-audit-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
}
/// Disposes the database context.
public void Dispose() => _db.Dispose();
private void SeedNode(string clusterId, string nodeId) =>
_db.ClusterNodes.Add(new ClusterNode
{
NodeId = nodeId,
ClusterId = clusterId,
Host = $"{nodeId}.local",
ApplicationUri = $"urn:{nodeId}",
CreatedBy = "test",
});
private void SeedAudit(string eventType, string? clusterId, string? nodeId, DateTime ts) =>
_db.ConfigAuditLogs.Add(new ConfigAuditLog
{
Principal = "tester",
EventType = eventType,
ClusterId = clusterId,
NodeId = nodeId,
Timestamp = ts,
});
/// Structured rows (ClusterId null, NodeId set) for a node in the cluster are now
/// visible, alongside the SP-path rows that stamp ClusterId directly.
[Fact]
public async Task Surfaces_both_clusterId_rows_and_structured_nodeId_rows()
{
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-B");
SeedNode("OTHER-CLUSTER", "OTHER-A");
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
// SP path: stamps ClusterId.
SeedAudit("Published", clusterId: "LINE3-OPCUA", nodeId: null, ts: t0);
// Structured AuditWriterActor path: stamps NodeId, ClusterId null — these were invisible.
SeedAudit("DraftEdited", clusterId: null, nodeId: "LINE3-OPCUA-A", ts: t0.AddMinutes(1));
SeedAudit("NodeApplied", clusterId: null, nodeId: "LINE3-OPCUA-B", ts: t0.AddMinutes(2));
// Noise that must NOT appear: other cluster's structured row + an orphan NodeId.
SeedAudit("Published", clusterId: null, nodeId: "OTHER-A", ts: t0.AddMinutes(3));
SeedAudit("Published", clusterId: null, nodeId: "UNKNOWN-NODE", ts: t0.AddMinutes(4));
await _db.SaveChangesAsync();
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 200);
rows.Select(r => r.EventType).ShouldBe(
["NodeApplied", "DraftEdited", "Published"], // newest first
ignoreOrder: false);
}
/// An audit row stamped with another cluster's ClusterId never appears.
[Fact]
public async Task Does_not_surface_other_cluster_rows()
{
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
SeedAudit("Published", clusterId: "OTHER-CLUSTER", nodeId: null, ts: t0);
await _db.SaveChangesAsync();
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 200);
rows.ShouldBeEmpty();
}
/// Respects the page-size cap, newest first.
[Fact]
public async Task Caps_to_page_size_newest_first()
{
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
for (var i = 0; i < 5; i++)
SeedAudit("DraftEdited", clusterId: null, nodeId: "LINE3-OPCUA-A", ts: t0.AddMinutes(i));
await _db.SaveChangesAsync();
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 3);
rows.Count.ShouldBe(3);
rows.First().Timestamp.ShouldBe(t0.AddMinutes(4)); // newest
}
}