Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionTests.cs
T
Joseph Doherty 30faee0d0e feat(audit): replay/pull include outbound API, DB and notification rows
GetTraceEventsAsync widens its channel filter to ScriptTrace ∪ ApiOutbound ∪
DbOutbound ∪ Notification ∪ (ApiInbound & InboundRequest) (plan D6); SecuredWrite,
Cluster and InboundAuthFailure rows in the same tree stay excluded. The site
SqliteAuditWriter.ReadByExecutionIdsAsync already had no channel predicate — pinned
by a test. Interface doc updated to match.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 01:47:05 -04:00

115 lines
5.8 KiB
C#

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;
/// <summary>
/// Flow Debugger (design 2026-09-05 §5.4): <see cref="SqliteAuditWriter.ReadByExecutionIdsAsync"/>
/// matches on ExecutionId OR ParentExecutionId, honours the since bound, orders by
/// (OccurredAtUtc, EventId), caps the row count, and is independent of forwarding state.
/// </summary>
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<SqliteAuditWriter>.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<Guid>(), T0, 10));
Assert.Equal(4, (await writer.ReadByExecutionIdsAsync([root, unrelated], T0, 100)).Count);
}
}
/// <summary>
/// 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.
/// </summary>
[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<ArgumentOutOfRangeException>(() => writer.ReadByExecutionIdsAsync([Guid.NewGuid()], T0, 0));
var tooMany = Enumerable.Range(0, SqliteAuditWriter.MaxExecutionIdsPerRead + 1).Select(_ => Guid.NewGuid()).ToList();
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => writer.ReadByExecutionIdsAsync(tooMany, T0, 10));
}
}