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 } }