using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// /// Flow Debugger (design 2026-09-05 §5.4): /// matches on ExecutionId OR ParentExecutionId, honours the since bound, orders by /// (OccurredAtUtc, EventId), caps the row count, and is independent of forwarding state. /// public class SqliteAuditWriterReadByExecutionTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db"); private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc); public void Dispose() { if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } } } private SqliteAuditWriter CreateWriter() => new( Options.Create(new SqliteAuditWriterOptions { DatabasePath = _dbPath }), NullLogger.Instance, new FakeNodeIdentityProvider()); private static AuditEvent Row(DateTime at, Guid? exec, Guid? parent, AuditKind kind = AuditKind.ScriptRun) => ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered, occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a"); [Fact] public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded() { var root = Guid.NewGuid(); var child = Guid.NewGuid(); var unrelated = Guid.NewGuid(); var rows = new[] { Row(T0.AddSeconds(3), child, root, AuditKind.AttributeWrite), // parent match Row(T0.AddSeconds(1), root, null), // exec match Row(T0.AddSeconds(2), child, root), // parent match Row(T0.AddSeconds(4), unrelated, null), // no match Row(T0.AddSeconds(-60), root, null), // before since }; await using (var writer = CreateWriter()) { foreach (var r in rows) await writer.WriteAsync(r); var hits = await writer.ReadByExecutionIdsAsync([root], T0, limit: 100); Assert.Equal(3, hits.Count); Assert.Equal(rows[1].EventId, hits[0].EventId); Assert.Equal(rows[2].EventId, hits[1].EventId); Assert.Equal(rows[0].EventId, hits[2].EventId); Assert.Equal(rows[1], hits[0]); // Bounded. var two = await writer.ReadByExecutionIdsAsync([root], T0, limit: 2); Assert.Equal(2, two.Count); // Forwarding state is irrelevant: mark everything Reconciled, still served. await writer.MarkReconciledAsync(rows.Select(r => r.EventId).ToList()); Assert.Equal(3, (await writer.ReadByExecutionIdsAsync([root], T0, limit: 100)).Count); // Empty id set → empty; multiple ids union. Assert.Empty(await writer.ReadByExecutionIdsAsync(Array.Empty(), T0, 10)); Assert.Equal(4, (await writer.ReadByExecutionIdsAsync([root, unrelated], T0, 100)).Count); } } /// /// Outbound-calls plan (2026-09-06, D6): the site pull filters by execution id ONLY — an /// ApiOutbound row (or any other channel) stored under the execution id is served alongside /// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in. /// [Fact] public async Task ServesOutboundRows_NoChannelPredicate() { var root = Guid.NewGuid(); var siteRun = Guid.NewGuid(); var run = Row(T0.AddSeconds(1), siteRun, root); var apiCall = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, occurredAtUtc: T0.AddSeconds(2), target: "Delmia.AckRecipe", executionId: siteRun, parentExecutionId: root, sourceSiteId: "site-a", correlationId: Guid.NewGuid()); var dbWrite = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, occurredAtUtc: T0.AddSeconds(3), target: "MesDb", executionId: siteRun, parentExecutionId: root, sourceSiteId: "site-a"); var notify = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, occurredAtUtc: T0.AddSeconds(4), target: "Planners", executionId: siteRun, parentExecutionId: root, sourceSiteId: "site-a", correlationId: Guid.NewGuid()); await using var writer = CreateWriter(); foreach (var r in new[] { notify, apiCall, run, dbWrite }) await writer.WriteAsync(r); var hits = await writer.ReadByExecutionIdsAsync([siteRun], T0, limit: 100); Assert.Equal( new[] { run.EventId, apiCall.EventId, dbWrite.EventId, notify.EventId }, hits.Select(h => h.EventId).ToArray()); Assert.Equal(apiCall, hits[1]); Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel); } [Fact] public async Task Guards_LimitAndIdCap() { await using var writer = CreateWriter(); await Assert.ThrowsAsync(() => writer.ReadByExecutionIdsAsync([Guid.NewGuid()], T0, 0)); var tooMany = Enumerable.Range(0, SqliteAuditWriter.MaxExecutionIdsPerRead + 1).Select(_ => Guid.NewGuid()).ToList(); await Assert.ThrowsAsync(() => writer.ReadByExecutionIdsAsync(tooMany, T0, 10)); } }