refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,403 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined-
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
/// as the M1 + M2 repository tests so the actor exercises real
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
/// database. The transaction commits or rolls back inside one
/// <see cref="DbContext.Database"/>.
/// </summary>
public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-d2-cached-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static (AuditEvent audit, SiteCall siteCall) NewEntry(
string siteId,
TrackedOperationId? trackedOperationId = null,
Guid? eventId = null,
string status = "Submitted",
AuditStatus auditStatus = AuditStatus.Submitted)
{
var trackedId = trackedOperationId ?? TrackedOperationId.New();
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var audit = new AuditEvent
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
Status = auditStatus,
SourceSiteId = siteId,
CorrelationId = trackedId.Value,
};
var siteCall = new SiteCall
{
TrackedOperationId = trackedId,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = siteId,
Status = status,
RetryCount = 0,
CreatedAtUtc = now,
UpdatedAtUtc = now,
IngestedAtUtc = now, // overwritten by the actor
};
return (audit, siteCall);
}
/// <summary>
/// Builds a minimal DI container around the per-test MSSQL fixture's
/// connection string — DbContext + the two repositories the dual-write
/// handler resolves. Mirrors AddConfigurationDatabase without the
/// DataProtection wiring (we never write secret columns in these tests).
/// </summary>
private IServiceProvider BuildServiceProvider(
Func<ScadaBridgeDbContext, ISiteCallAuditRepository>? siteCallRepoFactory = null)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
if (siteCallRepoFactory is null)
{
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
}
else
{
services.AddScoped(sp =>
siteCallRepoFactory(sp.GetRequiredService<ScadaBridgeDbContext>()));
}
return services.BuildServiceProvider();
}
private IActorRef CreateActor(IServiceProvider serviceProvider) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
serviceProvider,
NullLogger<AuditLogIngestActor>.Instance)));
[SkippableFact]
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(audit.EventId, reply.AcceptedEventIds[0]);
// Verify rows landed in both tables.
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.NotNull(auditRow);
Assert.NotNull(auditRow!.IngestedAtUtc);
var siteCallRow = await read.Set<SiteCall>()
.SingleOrDefaultAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.NotNull(siteCallRow);
Assert.Equal(siteCall.Status, siteCallRow!.Status);
}
[SkippableFact]
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var eventId = Guid.NewGuid();
var (audit, siteCall) = NewEntry(siteId, trackedId, eventId);
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// First write
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// Second write — same EventId and TrackedOperationId, same status. Both
// the audit insert (idempotent) and the SiteCalls upsert (monotonic
// same-rank → no-op) should silently do nothing while still acking.
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
await using var read = CreateReadContext();
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
var siteCallCount = await read.Set<SiteCall>()
.CountAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal(1, siteCallCount);
}
[SkippableFact]
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted).
var (auditSubmit, siteCallSubmit) = NewEntry(
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(auditSubmit, siteCallSubmit) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// 2nd packet — Attempted with retry count 1 (audit EventId #2,
// SiteCalls Status=Attempted — monotonic upsert wins). Same
// TrackedOperationId throughout.
var (auditAttempt, siteCallAttempt) = NewEntry(
siteId, trackedId, status: "Attempted", auditStatus: AuditStatus.Attempted);
var advanced = siteCallAttempt with { RetryCount = 1, UpdatedAtUtc = siteCallAttempt.UpdatedAtUtc.AddMinutes(1) };
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(auditAttempt, advanced) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(auditAttempt.EventId, reply.AcceptedEventIds[0]);
// Both audit rows exist.
await using var read = CreateReadContext();
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
// SiteCalls row advanced to Attempted with retry count 1.
var siteCallRow = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Attempted", siteCallRow.Status);
Assert.Equal(1, siteCallRow.RetryCount);
}
[SkippableFact]
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
// Wrap the SiteCalls repo so UpsertAsync always throws — the dual-write
// transaction must roll back the AuditLog INSERT done in the same
// transaction, leaving no orphan row.
var sp = BuildServiceProvider(
ctx => new ThrowingSiteCallRepo(new SiteCallAuditRepository(ctx)));
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Empty(reply.AcceptedEventIds);
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.Null(auditRow);
var siteCallRow = await read.Set<SiteCall>()
.SingleOrDefaultAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.Null(siteCallRow);
}
[SkippableFact]
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var entries = Enumerable.Range(0, 5).Select(_ =>
{
var (audit, siteCall) = NewEntry(siteId);
return new CachedTelemetryEntry(audit, siteCall);
}).ToList();
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
actor.Tell(new IngestCachedTelemetryCommand(entries), TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Equal(5, reply.AcceptedEventIds.Count);
Assert.True(entries.Select(e => e.Audit.EventId).ToHashSet()
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
await using var read = CreateReadContext();
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.SourceSiteId == siteId);
Assert.Equal(5, auditCount);
var siteCallCount = await read.Set<SiteCall>().CountAsync(s => s.SourceSite == siteId);
Assert.Equal(5, siteCallCount);
}
[SkippableFact]
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit1, siteCall1) = NewEntry(siteId);
var (audit2, siteCall2) = NewEntry(siteId);
var (audit3, siteCall3) = NewEntry(siteId);
var poisonTrackedId = siteCall2.TrackedOperationId;
// Throw only for the middle entry's TrackedOperationId — entries on
// either side must commit their own transactions independently.
var sp = BuildServiceProvider(
ctx => new PoisonOnIdSiteCallRepo(new SiteCallAuditRepository(ctx), poisonTrackedId));
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[]
{
new CachedTelemetryEntry(audit1, siteCall1),
new CachedTelemetryEntry(audit2, siteCall2),
new CachedTelemetryEntry(audit3, siteCall3),
}),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// Two entries committed; poison entry rolled back.
Assert.Equal(2, reply.AcceptedEventIds.Count);
Assert.Contains(audit1.EventId, reply.AcceptedEventIds);
Assert.Contains(audit3.EventId, reply.AcceptedEventIds);
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
await using var read = CreateReadContext();
var auditRows = await read.Set<AuditEvent>().Where(e => e.SourceSiteId == siteId).ToListAsync();
Assert.Equal(2, auditRows.Count);
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
var siteCallRows = await read.Set<SiteCall>().Where(s => s.SourceSite == siteId).ToListAsync();
Assert.Equal(2, siteCallRows.Count);
Assert.DoesNotContain(siteCallRows, r => r.TrackedOperationId == poisonTrackedId);
}
/// <summary>
/// Test double — throws unconditionally from <see cref="UpsertAsync"/> so
/// the dual-write transaction is forced to roll back. Lets the AuditLog
/// row insert succeed in-transaction; the rollback must remove it.
/// </summary>
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated SiteCalls upsert failure");
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
}
/// <summary>
/// Test double — throws only when the supplied poison TrackedOperationId
/// is the one being upserted. Demonstrates per-entry transaction isolation:
/// one entry's failed transaction must not abort the batch's other entries.
/// </summary>
private sealed class PoisonOnIdSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId;
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall.TrackedOperationId == _poisonId)
{
throw new InvalidOperationException("simulated SiteCalls upsert failure for poison id");
}
return _inner.UpsertAsync(siteCall, ct);
}
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
}
}
@@ -0,0 +1,235 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>. Uses the same
/// <see cref="MsSqlMigrationFixture"/> as the M1 repository tests so the actor
/// exercises real <see cref="AuditLogRepository.InsertIfNotExistsAsync"/>
/// against a partitioned MSSQL schema (the only way to verify the
/// IngestedAtUtc stamp + duplicate-key idempotency end to end).
/// </summary>
public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogIngestActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-d2-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static AuditEvent NewEvent(string siteId, Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
private IActorRef CreateActor(IAuditLogRepository repository) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repository,
NullLogger<AuditLogIngestActor>.Instance)));
[SkippableFact]
public async Task Receive_BatchOf5_Calls_Repo_5Times_Acks_All_5()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var events = Enumerable.Range(0, 5).Select(_ => NewEvent(siteId)).ToList();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var actor = CreateActor(repo);
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
Assert.Equal(5, reply.AcceptedEventIds.Count);
Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(reply.AcceptedEventIds.ToHashSet()));
// Verify rows landed in MSSQL.
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(5, rows.Count);
}
[SkippableFact]
public async Task Receive_BatchWith_AlreadyExistingEvent_AcksAll_NoDoubleInsert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var pre = NewEvent(siteId);
// Pre-insert one event directly via the repo so the actor sees it
// already present when it processes the batch.
await using (var seedContext = CreateContext())
{
var seedRepo = new AuditLogRepository(seedContext);
await seedRepo.InsertIfNotExistsAsync(pre);
}
// Build the batch including the pre-existing event plus 2 new ones.
var fresh1 = NewEvent(siteId);
var fresh2 = NewEvent(siteId);
var batch = new List<AuditEvent> { pre, fresh1, fresh2 };
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var actor = CreateActor(repo);
actor.Tell(new IngestAuditEventsCommand(batch), TestActor);
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
// All 3 acked under idempotent first-write-wins.
Assert.Equal(3, reply.AcceptedEventIds.Count);
// Verify no double-insert.
await using var readContext = CreateContext();
var count = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.CountAsync();
Assert.Equal(3, count);
}
[SkippableFact]
public async Task Receive_Sets_IngestedAtUtc_Before_Insert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var events = Enumerable.Range(0, 3).Select(_ => NewEvent(siteId)).ToList();
var before = DateTime.UtcNow.AddSeconds(-1);
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var actor = CreateActor(repo);
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
var after = DateTime.UtcNow.AddSeconds(1);
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, rows.Count);
Assert.All(rows, r =>
{
Assert.NotNull(r.IngestedAtUtc);
Assert.InRange(r.IngestedAtUtc!.Value, before, after);
});
}
[SkippableFact]
public async Task Receive_RepoThrowsForOneEvent_Other4StillPersisted()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var events = Enumerable.Range(0, 5).Select(_ => NewEvent(siteId)).ToList();
var poisonId = events[2].EventId;
// Wrapper repo that throws only when the poison EventId is being
// inserted. The four neighbours must still land in MSSQL.
await using var context = CreateContext();
var realRepo = new AuditLogRepository(context);
var wrappedRepo = new ThrowingRepository(realRepo, poisonId);
var actor = CreateActor(wrappedRepo);
actor.Tell(new IngestAuditEventsCommand(events), TestActor);
var reply = ExpectMsg<IngestAuditEventsReply>(TimeSpan.FromSeconds(10));
// The actor catches the throw per-row, so 4 ids are accepted and 1 is
// left out.
Assert.Equal(4, reply.AcceptedEventIds.Count);
Assert.DoesNotContain(poisonId, reply.AcceptedEventIds);
await using var readContext = CreateContext();
var rows = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(4, rows.Count);
Assert.DoesNotContain(rows, r => r.EventId == poisonId);
}
/// <summary>
/// Tiny test double that delegates to a real repository but throws on a
/// specified EventId. Used to verify per-row failure isolation: one bad
/// row must not cause the rest of the batch to be lost.
/// </summary>
private sealed class ThrowingRepository : IAuditLogRepository
{
private readonly IAuditLogRepository _inner;
private readonly Guid _poisonId;
public ThrowingRepository(IAuditLogRepository inner, Guid poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt.EventId == _poisonId)
{
throw new InvalidOperationException("simulated repo failure for poison row");
}
return _inner.InsertIfNotExistsAsync(evt, ct);
}
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
_inner.SwitchOutPartitionAsync(monthBoundary, ct);
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
}
}
@@ -0,0 +1,154 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
/// the real EF/MSSQL implementation is exercised by the
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c>. This file is purely
/// about the hosted service's policy decisions (start/stop, exception
/// containment).
/// </summary>
public class AuditLogPartitionMaintenanceServiceTests
{
/// <summary>
/// Recording stub — counts EnsureLookaheadAsync invocations and lets the
/// test inject an exception per invocation to drive the catch-all path.
/// </summary>
private sealed class RecordingMaintenance : IPartitionMaintenance
{
public int EnsureCallCount;
public Exception? ThrowOnce;
public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
{
Interlocked.Increment(ref EnsureCallCount);
if (ThrowOnce is { } ex)
{
ThrowOnce = null;
throw ex;
}
return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
}
public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
}
/// <summary>
/// Captures logged exceptions so the catch-all assertion can prove
/// the exception was actually logged (not silently swallowed) and was
/// the exact instance the stub threw.
/// </summary>
private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
{
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, exception, formatter(state, exception)));
}
}
private static IServiceProvider BuildProvider(IPartitionMaintenance maintenance)
{
var services = new ServiceCollection();
// IPartitionMaintenance is registered as scoped by AddConfigurationDatabase;
// we mirror that here so the hosted service's CreateAsyncScope +
// GetRequiredService resolves the stub the test injected.
services.AddScoped(_ => maintenance);
return services.BuildServiceProvider();
}
[Fact]
public async Task StartStop_NoExceptions()
{
// Long interval so only the eager startup tick fires inside the test
// window — keeps assertions deterministic without relying on
// multiple cadence loops.
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = 1,
});
var maintenance = new RecordingMaintenance();
var sp = BuildProvider(maintenance);
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
NullLogger<AuditLogPartitionMaintenanceService>.Instance);
await svc.StartAsync(CancellationToken.None);
// Spin briefly until the startup tick has fired — the loop's first
// SafeMaintainAsync runs on a background Task.Run continuation, so
// we can't synchronously rely on its completion.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (Volatile.Read(ref maintenance.EnsureCallCount) < 1 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
}
[Fact]
public async Task SafeMaintain_ExceptionLogged_NotPropagated()
{
var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
{
IntervalSeconds = 60,
LookaheadMonths = 1,
});
// The injected exception fires on the FIRST EnsureLookaheadAsync call
// (the startup tick) — the hosted service must contain it and
// continue running.
var boom = new InvalidOperationException("simulated maintenance failure");
var maintenance = new RecordingMaintenance { ThrowOnce = boom };
var sp = BuildProvider(maintenance);
var logger = new CapturingLogger();
var svc = new AuditLogPartitionMaintenanceService(
sp.GetRequiredService<IServiceScopeFactory>(),
opts,
logger);
// StartAsync must not throw even though the very first tick will fail.
await svc.StartAsync(CancellationToken.None);
// Wait for the error to surface in the logger.
var deadline = DateTime.UtcNow.AddSeconds(3);
while (!logger.Entries.Any(e => e.Exception == boom) && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
await svc.StopAsync(CancellationToken.None);
svc.Dispose();
var errorEntry = Assert.Single(logger.Entries, e => e.Exception == boom);
Assert.Equal(LogLevel.Error, errorEntry.Level);
Assert.Equal(1, maintenance.EnsureCallCount);
}
}
@@ -0,0 +1,387 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle C (#23 M6-T4) tests for <see cref="AuditLogPurgeActor"/>. The fast,
/// schedule-only tests substitute a recording stub for
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
/// + event-publish machinery can be exercised without an MSSQL container.
/// The end-to-end "real partition gets switched out" assertion lives in the
/// repository tests (Bundle C of M6-T4); this actor file is purely about the
/// actor's policy decisions.
/// </summary>
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// In-memory recording stub. Captures every
/// <see cref="GetPartitionBoundariesOlderThanAsync"/> + every
/// <see cref="SwitchOutPartitionAsync"/> so tests can assert which boundaries
/// the actor chose to purge and how many ticks it issued. Also lets a
/// specific boundary be configured to throw so the continue-on-error path
/// is exercisable.
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
public List<DateTime> ThresholdQueries { get; } = new();
public List<DateTime> SwitchedBoundaries { get; } = new();
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
public DateTime? ThrowOnBoundary { get; set; }
public Exception? BoundaryException { get; set; }
// The actor enumerator returns whichever list is configured here.
// Mutating this between ticks lets tests simulate "no longer
// eligible" boundaries on the second tick.
public List<DateTime> Boundaries { get; set; } = new();
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
Task.CompletedTask;
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
if (ThrowOnBoundary.HasValue && monthBoundary == ThrowOnBoundary.Value)
{
throw BoundaryException ?? new InvalidOperationException("simulated switch failure");
}
SwitchedBoundaries.Add(monthBoundary);
return Task.FromResult(RowsPerBoundary(monthBoundary));
}
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default)
{
ThresholdQueries.Add(threshold);
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
}
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
{
var services = new ServiceCollection();
// Mirror AddConfigurationDatabase: IAuditLogRepository is scoped, so
// the actor opens a fresh scope per tick and resolves there.
services.AddScoped(_ => repo);
return services.BuildServiceProvider();
}
private IActorRef CreateActor(
IAuditLogRepository repo,
AuditLogPurgeOptions purgeOptions,
AuditLogOptions? auditOptions = null)
{
var sp = BuildScopedProvider(repo);
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions ?? new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
}
private static AuditLogPurgeOptions FastTickOptions(TimeSpan? interval = null) => new()
{
IntervalHours = 24,
IntervalOverride = interval ?? TimeSpan.FromMilliseconds(100),
};
/// <summary>
/// Subscribe a probe to the EventStream so the test can observe
/// <see cref="AuditLogPurgedEvent"/> publications synchronously.
/// </summary>
private Akka.TestKit.TestProbe SubscribePurged()
{
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
return probe;
}
// ---------------------------------------------------------------------
// 1. Tick_Fires_OnDailyInterval
// ---------------------------------------------------------------------
[Fact]
public void Tick_Fires_OnDailyInterval()
{
var repo = new RecordingRepo();
CreateActor(repo, FastTickOptions());
// The first scheduled tick fires after the configured interval. We
// assert the visible side effect (the enumerator was called) rather
// than racing on internal state.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1,
$"expected >= 1 enumerator call, got {repo.ThresholdQueries.Count}"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 2. Tick_OldPartitions_SwitchedOut
// ---------------------------------------------------------------------
[Fact]
public void Tick_OldPartitions_SwitchedOut()
{
var repo = new RecordingRepo
{
Boundaries = new List<DateTime>
{
new(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc),
new(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc),
},
RowsPerBoundary = _ => 42L,
};
CreateActor(repo, FastTickOptions());
AwaitAssert(
() =>
{
Assert.Contains(new DateTime(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc), repo.SwitchedBoundaries);
Assert.Contains(new DateTime(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc), repo.SwitchedBoundaries);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 3. Tick_NewerPartitions_Untouched
// ---------------------------------------------------------------------
[Fact]
public void Tick_NewerPartitions_Untouched()
{
// The actor's contract: it only touches whatever the enumerator
// returns. The enumerator (in production) filters out non-eligible
// boundaries; here we simulate that by handing back an empty list
// and asserting the actor switched nothing despite the tick firing.
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
CreateActor(repo, FastTickOptions());
// Wait for at least one tick (visible via the enumerator call) then
// assert no switch happened.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
Assert.Empty(repo.SwitchedBoundaries);
}
// ---------------------------------------------------------------------
// 4. Tick_PublishesPurgedEvent_WithRowCount
// ---------------------------------------------------------------------
[Fact]
public void Tick_PublishesPurgedEvent_WithRowCount()
{
var boundary = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { boundary },
RowsPerBoundary = _ => 1234L,
};
var probe = SubscribePurged();
CreateActor(repo, FastTickOptions());
var msg = probe.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal(boundary, msg.MonthBoundary);
Assert.Equal(1234L, msg.RowsDeleted);
Assert.True(msg.DurationMs >= 0,
$"DurationMs should be non-negative; was {msg.DurationMs}");
}
// ---------------------------------------------------------------------
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
// ---------------------------------------------------------------------
[Fact]
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
{
var poisonBoundary = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc);
var goodBoundary = new DateTime(2025, 8, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { poisonBoundary, goodBoundary },
ThrowOnBoundary = poisonBoundary,
BoundaryException = new InvalidOperationException("simulated switch failure for poison boundary"),
};
CreateActor(repo, FastTickOptions());
AwaitAssert(
() =>
{
// The good boundary was still switched even though the poison
// boundary threw.
Assert.Contains(goodBoundary, repo.SwitchedBoundaries);
Assert.DoesNotContain(poisonBoundary, repo.SwitchedBoundaries);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
// ---------------------------------------------------------------------
[SkippableFact]
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Today is ~2026-05-20 per the test environment. With RetentionDays =
// 60 the actor computes threshold ≈ 2026-03-21:
// * Jan partition (MAX = Jan 15) → older than threshold → PURGED
// * Apr partition (MAX = Apr 15) → newer than threshold → KEPT
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var janEvt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
var aprEvt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
await using (var seedContext = CreateMsSqlContext())
{
var seedRepo = new AuditLogRepository(seedContext);
await seedRepo.InsertIfNotExistsAsync(janEvt);
await seedRepo.InsertIfNotExistsAsync(aprEvt);
}
// Wire the actor's DI scope to the real repository against the
// fixture's MSSQL database. The actor opens a fresh scope per tick,
// so register the context as scoped (mirroring the production
// AddConfigurationDatabase wiring).
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
var sp = services.BuildServiceProvider();
var auditOptions = new AuditLogOptions { RetentionDays = 60 };
var purgeOptions = new AuditLogPurgeOptions
{
IntervalHours = 24,
IntervalOverride = TimeSpan.FromMilliseconds(100),
};
var probe = SubscribePurged();
Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions),
NullLogger<AuditLogPurgeActor>.Instance)));
// The probe receives one AuditLogPurgedEvent per partition the actor
// purges per tick — other test runs that share the fixture DB may
// also leave behind eligible partitions, but this test creates its
// own fixture DB so the Jan-2026 partition is the only eligible one.
// Use FishForMessage to filter just in case, with a generous timeout
// because the real drop-and-rebuild dance against MSSQL routinely
// takes a couple of seconds on a busy dev container.
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary,
max: TimeSpan.FromSeconds(30));
Assert.True(matched.RowsDeleted >= 1,
$"Expected RowsDeleted >= 1 for the Jan-2026 partition; got {matched.RowsDeleted}.");
// Settle: allow any in-flight tick to commit before reading.
await Task.Delay(TimeSpan.FromMilliseconds(500));
await using var verifyContext = CreateMsSqlContext();
var rows = await verifyContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.DoesNotContain(rows, r => r.EventId == janEvt.EventId);
Assert.Contains(rows, r => r.EventId == aprEvt.EventId);
}
private ScadaBridgeDbContext CreateMsSqlContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
// ---------------------------------------------------------------------
// 7. Threshold_UsesAuditLogOptionsRetentionDays
// ---------------------------------------------------------------------
[Fact]
public void Threshold_UsesAuditLogOptionsRetentionDays()
{
// The actor computes the threshold from AuditLogOptions.RetentionDays;
// assert the enumerator received a threshold whose value is in the
// expected window (today - retentionDays) rather than DateTime.MinValue
// or some other accidental default. We use a non-default retention
// (30 days) so the assertion isn't satisfied by the 365 default.
var repo = new RecordingRepo();
CreateActor(
repo,
FastTickOptions(),
auditOptions: new AuditLogOptions { RetentionDays = 30 });
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
var threshold = repo.ThresholdQueries[0];
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
// 1-minute slack covers test-thread scheduling jitter between the
// tick firing and the assertion running.
Assert.True(
Math.Abs((threshold - expected).TotalMinutes) < 1.0,
$"threshold {threshold:o} should be within 1 minute of {expected:o}");
}
}
@@ -0,0 +1,98 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T9) coverage for the central-side payload-filter redactor
/// failure bridge. M5 wired the SITE bridge
/// (<c>HealthMetricsAuditRedactionFailureCounter</c>) that pushes increments
/// into the site health report; M6 mirrors that with
/// <see cref="CentralAuditRedactionFailureCounter"/> so the same payload
/// filter — when it runs on the central writer paths — surfaces failures on
/// the central <see cref="AuditCentralHealthSnapshot"/>.
/// </summary>
public class CentralAuditRedactionFailureCounterTests : TestKit
{
[Fact]
public void Increment_Routes_To_Snapshot()
{
var snapshot = new AuditCentralHealthSnapshot();
var counter = new CentralAuditRedactionFailureCounter(snapshot);
counter.Increment();
counter.Increment();
counter.Increment();
Assert.Equal(3, snapshot.AuditRedactionFailure);
}
[Fact]
public void Construction_With_Null_Snapshot_Throws()
{
Assert.Throws<ArgumentNullException>(
() => new CentralAuditRedactionFailureCounter(null!));
}
[Fact]
public void AddAuditLogCentralMaintenance_Replaces_IAuditRedactionFailureCounter_With_CentralImpl()
{
// AddAuditLog registers NoOp; AddAuditLogCentralMaintenance is the
// override path. The replaced binding MUST resolve to the central
// bridge — a site host that wires AddAuditLogHealthMetricsBridge
// instead would resolve to the site bridge (covered in
// AddAuditLogTests).
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
// AuditCentralHealthSnapshot no longer takes a tracker dependency —
// the tracker is constructed later by the Akka bootstrap because its
// ctor needs an ActorSystem (not a DI-resolvable singleton). The
// snapshot itself composes purely from primitives.
services.AddAuditLog(config);
services.AddAuditLogCentralMaintenance(config);
using var provider = services.BuildServiceProvider();
var counter = provider.GetRequiredService<IAuditRedactionFailureCounter>();
Assert.IsType<CentralAuditRedactionFailureCounter>(counter);
}
[Fact]
public void AddAuditLog_Default_IAuditRedactionFailureCounter_Is_NoOp()
{
// Sanity check: without AddAuditLogCentralMaintenance the default
// remains the NoOp from M5 — the central bridge only takes effect
// when the central-only registration runs.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config);
using var provider = services.BuildServiceProvider();
var counter = provider.GetRequiredService<IAuditRedactionFailureCounter>();
Assert.IsType<NoOpAuditRedactionFailureCounter>(counter);
}
}
@@ -0,0 +1,170 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T8) regression coverage for the central-side audit-write
/// failure counter. <see cref="CentralAuditWriter"/> and
/// <see cref="AuditLogIngestActor"/> both swallow repository throws (audit
/// must NEVER abort the user-facing action, alog.md §13) but bump the
/// <see cref="ICentralAuditWriteFailureCounter"/> so the central health
/// surface (<see cref="AuditCentralHealthSnapshot"/>) can flag a sustained
/// outage.
/// </summary>
public class CentralAuditWriteFailuresTests : TestKit
{
private static AuditEvent NewEvent() => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
};
/// <summary>
/// Repository stub that always throws on insert — exercises the failure
/// path in both <see cref="CentralAuditWriter"/> and
/// <see cref="AuditLogIngestActor"/>.
/// </summary>
private sealed class ThrowingRepo : IAuditLogRepository
{
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated repo failure");
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
Task.FromResult(0L);
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>
/// In-memory <see cref="ICentralAuditWriteFailureCounter"/> recording
/// every <see cref="Increment"/> call so tests can assert on the count.
/// </summary>
private sealed class RecordingFailureCounter : ICentralAuditWriteFailureCounter
{
private int _count;
public int Count => Volatile.Read(ref _count);
public void Increment() => Interlocked.Increment(ref _count);
}
[Fact]
public async Task Forced_Failure_Increments_Counter()
{
// Direct test: build the writer with a throwing scope and verify the
// injected counter is bumped on the swallowed insert exception.
var counter = new RecordingFailureCounter();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository, ThrowingRepo>();
var sp = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
sp,
NullLogger<CentralAuditWriter>.Instance,
filter: null,
failureCounter: counter);
// WriteAsync swallows the exception and increments the counter.
await writer.WriteAsync(NewEvent());
Assert.Equal(1, counter.Count);
}
[Fact]
public async Task AuditLogIngestActor_Failure_Increments_Counter()
{
// The actor's production ctor resolves both IAuditLogRepository AND
// ICentralAuditWriteFailureCounter from the scope per-message; we
// register both and verify the per-row catch bumps the counter for
// every row in the batch.
var counter = new RecordingFailureCounter();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository, ThrowingRepo>();
// Counter is a singleton — the actor's per-message scope still
// resolves the same instance via the scope's parent provider.
services.AddSingleton<ICentralAuditWriteFailureCounter>(counter);
var sp = services.BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
sp, NullLogger<AuditLogIngestActor>.Instance)));
var batch = new[] { NewEvent(), NewEvent(), NewEvent() };
var reply = await actor.Ask<IngestAuditEventsReply>(
new IngestAuditEventsCommand(batch), TimeSpan.FromSeconds(5));
// Every row threw → none accepted, counter bumped once per row.
Assert.Empty(reply.AcceptedEventIds);
Assert.Equal(batch.Length, counter.Count);
}
[Fact]
public void Snapshot_Aggregates_Counters_And_StalledState()
{
// AuditCentralHealthSnapshot implements both writer surfaces; bumping
// through the writer interfaces is reflected on the read surface, and
// the per-site stalled state is fed in via ApplyStalled — production
// wires that to a SiteAuditTelemetryStalledTracker, but the snapshot
// is testable in isolation against the same Apply surface.
var snapshot = new AuditCentralHealthSnapshot();
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
Assert.Equal(0, snapshot.AuditRedactionFailure);
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
((ICentralAuditWriteFailureCounter)snapshot).Increment();
((ICentralAuditWriteFailureCounter)snapshot).Increment();
((ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot).Increment();
// Wire the tracker so an EventStream publish reaches the snapshot.
// The tracker pushes into the snapshot's ApplyStalled when given
// the snapshot in its ctor; the tracker also keeps its own latch,
// but the snapshot read surface is what the central UI reads.
using var tracker = new SiteAuditTelemetryStalledTracker(Sys, snapshot);
Sys.EventStream.Publish(new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
AwaitAssert(() =>
{
var stalledMap = snapshot.SiteAuditTelemetryStalled;
Assert.True(stalledMap.TryGetValue("siteA", out var s) && s,
"expected siteA to be stalled in snapshot");
},
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(20));
Assert.Equal(2, snapshot.CentralAuditWriteFailures);
Assert.Equal(1, snapshot.AuditRedactionFailure);
}
[Fact]
public void Snapshot_Empty_OnConstruction()
{
// Sanity: the snapshot's three properties start at their zero values
// before any writer or stalled-event publication.
var snapshot = new AuditCentralHealthSnapshot();
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
Assert.Equal(0, snapshot.AuditRedactionFailure);
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
}
}
@@ -0,0 +1,213 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
/// The writer is a thin wrapper around
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
/// <see cref="AuditEvent.IngestedAtUtc"/>, resolves the (scoped) repository
/// from a fresh DI scope per call, and swallows any thrown exception —
/// audit-write failures NEVER abort the user-facing action (alog.md §13).
/// </summary>
public class CentralAuditWriterTests
{
private static AuditEvent NewEvent(Guid? eventId = null) => new()
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifyDeliver,
Status = AuditStatus.Attempted,
CorrelationId = Guid.NewGuid(),
Target = "ops-team",
};
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
}
[Fact]
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
{
var (writer, repo) = BuildWriter();
var evt = NewEvent();
await writer.WriteAsync(evt);
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.EventId == evt.EventId),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
{
var (writer, repo) = BuildWriter();
var before = DateTime.UtcNow;
await writer.WriteAsync(NewEvent());
var after = DateTime.UtcNow;
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e =>
e.IngestedAtUtc != null &&
e.IngestedAtUtc >= before &&
e.IngestedAtUtc <= after),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("db down"));
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
// Must not throw — audit failure NEVER aborts the user-facing action.
await writer.WriteAsync(NewEvent());
}
[Fact]
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
{
// Counting factory: every scope opening should resolve a new repo
// (scoped lifetime). We assert at least two distinct instances
// across two WriteAsync calls.
var instances = new List<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ =>
{
var r = Substitute.For<IAuditLogRepository>();
instances.Add(r);
return r;
});
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
await writer.WriteAsync(NewEvent());
await writer.WriteAsync(NewEvent());
Assert.Equal(2, instances.Count);
Assert.NotSame(instances[0], instances[1]);
}
[Fact]
public void Constructor_NullServices_Throws()
{
Assert.Throws<ArgumentNullException>(
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
}
[Fact]
public void Constructor_NullLogger_Throws()
{
var services = new ServiceCollection().BuildServiceProvider();
Assert.Throws<ArgumentNullException>(
() => new CentralAuditWriter(services, null!));
}
// ----- SourceNode stamping (Task 12) ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity)
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
provider,
NullLogger<CentralAuditWriter>.Instance,
filter: null,
failureCounter: null,
nodeIdentity: nodeIdentity);
return (writer, repo);
}
[Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == "central-a"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
var evt = NewEvent() with { SourceNode = "central-b" };
await writer.WriteAsync(evt);
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == "central-b"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{
var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider(nodeName: null));
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
{
// Locks the back-compat contract for the optional `nodeIdentity = null`
// ctor parameter: when no provider is wired (e.g. legacy M4 test
// composition roots), the writer must not stamp — caller value passes
// through unmodified. Distinct code path from
// "provider supplied, returns null", which the test above covers.
var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);
await writer.WriteAsync(NewEvent() with { SourceNode = "node-z" });
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == "node-z"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
{
// Same back-compat contract for the null-caller-null-provider case.
var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);
await writer.WriteAsync(NewEvent());
await repo.Received(1).InsertIfNotExistsAsync(
Arg.Is<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,453 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle B (M6-T3) tests for <see cref="SiteAuditReconciliationActor"/>. Most
/// tests substitute the <see cref="IAuditLogRepository"/> with an in-memory
/// recording stub so the actor's tick / cursor / stalled state machinery can
/// be exercised in milliseconds without an MSSQL container. The duplicate /
/// idempotency assertion uses the real <see cref="AuditLogRepository"/> against
/// the <see cref="MsSqlMigrationFixture"/> so we verify InsertIfNotExistsAsync
/// actually swallows duplicate-key collisions (the M2 Bundle A race-fix the
/// reconciliation puller depends on).
/// </summary>
public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public SiteAuditReconciliationActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static AuditEvent NewEvent(
string siteId,
DateTime? occurredAt = null,
Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
private static SiteAuditReconciliationOptions FastTickOptions(
int batchSize = 256,
int stalledAfter = 2) =>
new()
{
// 100 ms tick keeps each test under a second. AwaitAssert covers
// schedule jitter so a 100 ms tick has up to ~3 s to fire.
ReconciliationIntervalSeconds = 300,
ReconciliationIntervalOverride = TimeSpan.FromMilliseconds(100),
BatchSize = batchSize,
StalledAfterNonDrainingCycles = stalledAfter,
};
/// <summary>
/// In-memory recording stub used for non-MSSQL tests. Captures every
/// <see cref="InsertIfNotExistsAsync"/> call AND deduplicates on
/// <see cref="AuditEvent.EventId"/> so duplicate-handling assertions don't
/// need a real database for the simple cases.
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
public List<AuditEvent> Inserted { get; } = new();
private readonly HashSet<Guid> _seen = new();
public int InsertCallCount { get; private set; }
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
InsertCallCount++;
if (_seen.Add(evt.EventId))
{
Inserted.Add(evt);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Inserted);
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
Task.FromResult(0L);
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>
/// In-memory enumerator returning a static list of sites.
/// </summary>
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
/// <summary>
/// Scripted pull client — returns the next queued response for the site
/// on each call, looping the last entry if the queue is exhausted. Also
/// records every invocation so tests can assert call counts + arguments.
/// </summary>
private sealed class ScriptedPullClient : IPullAuditEventsClient
{
public List<(string SiteId, DateTime SinceUtc, int BatchSize)> Calls { get; } = new();
private readonly Dictionary<string, Queue<PullAuditEventsResponse>> _scripted = new();
private readonly Dictionary<string, Exception> _throwOnSite = new();
public ScriptedPullClient Script(string siteId, params PullAuditEventsResponse[] responses)
{
_scripted[siteId] = new Queue<PullAuditEventsResponse>(responses);
return this;
}
public ScriptedPullClient ThrowFor(string siteId, Exception ex)
{
_throwOnSite[siteId] = ex;
return this;
}
public Task<PullAuditEventsResponse> PullAsync(
string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct)
{
Calls.Add((siteId, sinceUtc, batchSize));
if (_throwOnSite.TryGetValue(siteId, out var ex))
{
throw ex;
}
if (_scripted.TryGetValue(siteId, out var queue) && queue.Count > 0)
{
return Task.FromResult(queue.Dequeue());
}
return Task.FromResult(
new PullAuditEventsResponse(Array.Empty<AuditEvent>(), MoreAvailable: false));
}
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
{
var services = new ServiceCollection();
// The actor opens a scope per tick and resolves IAuditLogRepository
// from that scope; registering as scoped mirrors how
// AddConfigurationDatabase wires the real repository.
services.AddScoped(_ => repo);
return services.BuildServiceProvider();
}
private IActorRef CreateActor(
ISiteEnumerator sites,
IPullAuditEventsClient client,
IAuditLogRepository repo,
SiteAuditReconciliationOptions options)
{
var sp = BuildScopedProvider(repo);
return Sys.ActorOf(Props.Create(() => new SiteAuditReconciliationActor(
sites,
client,
sp,
Options.Create(options),
NullLogger<SiteAuditReconciliationActor>.Instance)));
}
/// <summary>
/// Subscribes to the EventStream and collects every
/// <see cref="SiteAuditTelemetryStalledChanged"/> publication into a list
/// the test can assert on. Uses a probe actor so the stream's
/// fire-and-forget delivery is observable from the test thread.
/// </summary>
private (Akka.TestKit.TestProbe Probe, List<SiteAuditTelemetryStalledChanged> Captured) SubscribeStalled()
{
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(SiteAuditTelemetryStalledChanged));
var captured = new List<SiteAuditTelemetryStalledChanged>();
return (probe, captured);
}
// ---------------------------------------------------------------------
// 1. Timer_Fires_OnConfiguredInterval
// ---------------------------------------------------------------------
[Fact]
public void Timer_Fires_OnConfiguredInterval()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
var client = new ScriptedPullClient();
var repo = new RecordingRepo();
var opts = FastTickOptions();
CreateActor(sites, client, repo, opts);
// The first scheduled tick fires after `ReconciliationIntervalSeconds`,
// which is 0 for the test — Akka's scheduler still respects the
// ScheduleTellRepeatedlyCancelable contract that issues a Tell on the
// scheduler thread, so we await visible side effects (a PullAsync call)
// rather than racing on internal state.
AwaitAssert(
() => Assert.True(client.Calls.Count >= 1, $"expected >= 1 pull call, got {client.Calls.Count}"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 2. Tick_PullsFromEachKnownSite
// ---------------------------------------------------------------------
[Fact]
public void Tick_PullsFromEachKnownSite()
{
var sites = new StaticEnumerator(
new SiteEntry("siteA", "http://siteA:8083"),
new SiteEntry("siteB", "http://siteB:8083"));
var client = new ScriptedPullClient();
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(() =>
{
Assert.Contains(client.Calls, c => c.SiteId == "siteA");
Assert.Contains(client.Calls, c => c.SiteId == "siteB");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 3. Tick_IngestEvents_ViaInsertIfNotExistsAsync
// ---------------------------------------------------------------------
[Fact]
public void Tick_IngestEvents_ViaInsertIfNotExistsAsync()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
var e1 = NewEvent("siteA");
var e2 = NewEvent("siteA");
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(new[] { e1, e2 }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(() => Assert.Equal(2, repo.InsertCallCount),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
Assert.Contains(repo.Inserted, e => e.EventId == e1.EventId);
Assert.Contains(repo.Inserted, e => e.EventId == e2.EventId);
}
// ---------------------------------------------------------------------
// 4. Tick_Duplicates_NotDoubleInserted (real MSSQL idempotency)
// ---------------------------------------------------------------------
private ScadaBridgeDbContext CreateContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
[SkippableFact]
public async Task Tick_Duplicates_NotDoubleInserted()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = "bundle-b-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var pre = NewEvent(siteId);
// Seed the row directly so the actor sees it already present when the
// pull returns it.
await using (var seedContext = CreateContext())
{
await new AuditLogRepository(seedContext).InsertIfNotExistsAsync(pre);
}
// Stack one new and the pre-existing row in the pull response. The
// second-pull script returns empty so the actor settles.
var fresh = NewEvent(siteId);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://x:8083"));
var client = new ScriptedPullClient().Script(siteId,
new PullAuditEventsResponse(new[] { pre, fresh }, MoreAvailable: false));
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
CreateActor(sites, client, repo, FastTickOptions());
// Wait for the actor to ingest both rows.
await Task.Delay(TimeSpan.FromSeconds(1));
AwaitAssert(() => Assert.True(client.Calls.Count >= 1),
duration: TimeSpan.FromSeconds(3));
// Even though the pull returned 2 events, only 1 fresh row should
// exist in MSSQL alongside the pre-existing one — InsertIfNotExistsAsync
// is first-write-wins on EventId.
await using var read = CreateContext();
var rows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, rows.Count);
Assert.Contains(rows, r => r.EventId == pre.EventId);
Assert.Contains(rows, r => r.EventId == fresh.EventId);
}
// ---------------------------------------------------------------------
// 5. Cursor_Advances_ToMaxOccurredAtUtc
// ---------------------------------------------------------------------
[Fact]
public void Cursor_Advances_ToMaxOccurredAtUtc()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
var e1 = NewEvent("siteA", t1);
var e2 = NewEvent("siteA", t2);
var e3 = NewEvent("siteA", t3);
// First pull returns three events with t1, t2, t3. Subsequent pulls
// return empty — but the test asserts the SECOND pull's since argument
// is t3 (the max OccurredAtUtc from the first pull).
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(new[] { e1, e2, e3 }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
// Wait until we have at least two pulls — the second one must use t3
// as its `since` argument because that was the max OccurredAtUtc in
// the first response.
AwaitAssert(() => Assert.True(client.Calls.Count >= 2,
$"need at least 2 pulls to assert cursor advancement, got {client.Calls.Count}"),
duration: TimeSpan.FromSeconds(5),
interval: TimeSpan.FromMilliseconds(50));
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t3, client.Calls[1].SinceUtc);
}
// ---------------------------------------------------------------------
// 6. Tick_OneSiteThrows_OtherSitesStillProcessed
// ---------------------------------------------------------------------
[Fact]
public void Tick_OneSiteThrows_OtherSitesStillProcessed()
{
var sites = new StaticEnumerator(
new SiteEntry("siteA", "http://siteA:8083"),
new SiteEntry("siteB", "http://siteB:8083"));
var bEvent = NewEvent("siteB");
var client = new ScriptedPullClient()
.ThrowFor("siteA", new InvalidOperationException("simulated transport failure"))
.Script("siteB",
new PullAuditEventsResponse(new[] { bEvent }, MoreAvailable: false));
var repo = new RecordingRepo();
CreateActor(sites, client, repo, FastTickOptions());
AwaitAssert(() =>
{
Assert.Contains(client.Calls, c => c.SiteId == "siteA");
Assert.Contains(repo.Inserted, e => e.EventId == bEvent.EventId);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 7. StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue
// ---------------------------------------------------------------------
[Fact]
public void StalledDetection_TwoConsecutiveNonDrainingCycles_PublishesStalledTrue()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
// Two scripted responses that each return events AND MoreAvailable=true
// — the second pull triggers the stalled transition.
var batch1 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var batch2 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(batch1, MoreAvailable: true),
new PullAuditEventsResponse(batch2, MoreAvailable: true));
var repo = new RecordingRepo();
var (probe, _) = SubscribeStalled();
CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2));
// Expect Stalled=true after the second non-draining tick. The probe
// waits with its own timeout (a few seconds gives the 0 s repeat
// interval ample slack).
var msg = probe.ExpectMsg<SiteAuditTelemetryStalledChanged>(TimeSpan.FromSeconds(5));
Assert.Equal("siteA", msg.SiteId);
Assert.True(msg.Stalled);
}
// ---------------------------------------------------------------------
// 8. StalledDetection_DrainingCycle_PublishesStalledFalse
// ---------------------------------------------------------------------
[Fact]
public void StalledDetection_DrainingCycle_PublishesStalledFalse()
{
var sites = new StaticEnumerator(new SiteEntry("siteA", "http://siteA:8083"));
// Two non-draining responses get the actor into Stalled=true, then a
// draining response (events but MoreAvailable=false) flips it back.
var batch1 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var batch2 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var batch3 = Enumerable.Range(0, 3).Select(_ => NewEvent("siteA")).ToArray();
var client = new ScriptedPullClient().Script("siteA",
new PullAuditEventsResponse(batch1, MoreAvailable: true),
new PullAuditEventsResponse(batch2, MoreAvailable: true),
new PullAuditEventsResponse(batch3, MoreAvailable: false));
var repo = new RecordingRepo();
var (probe, _) = SubscribeStalled();
CreateActor(sites, client, repo, FastTickOptions(stalledAfter: 2));
// First publication is the stalled=true transition; second is the
// back-to-draining flip. The actor publishes ONLY on transitions so we
// expect exactly these two messages in order.
var first = probe.ExpectMsg<SiteAuditTelemetryStalledChanged>(TimeSpan.FromSeconds(5));
Assert.True(first.Stalled);
var second = probe.ExpectMsg<SiteAuditTelemetryStalledChanged>(TimeSpan.FromSeconds(5));
Assert.False(second.Stalled);
Assert.Equal("siteA", second.SiteId);
}
}
@@ -0,0 +1,116 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Bundle E (M6-T7) tests for <see cref="SiteAuditTelemetryStalledTracker"/>.
/// The tracker subscribes to the actor system's EventStream for
/// <see cref="SiteAuditTelemetryStalledChanged"/> publications and maintains a
/// per-site latch the central health surface can read. Since reconciliation is
/// central-driven, the "stalled" state semantically belongs to central — not
/// to the per-site <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/>
/// payload (which the site itself emits). The tracker therefore lives as a
/// central singleton, not on the site health collector.
/// </summary>
public class SiteAuditTelemetryStalledTrackerTests : TestKit
{
/// <summary>
/// Helper: publishes a stalled-changed event on the actor system's
/// EventStream and waits a moment for the tracker's subscribe callback to
/// run. AwaitAssert avoids racing on the stream's async fan-out.
/// </summary>
private void PublishAndWait(SiteAuditTelemetryStalledTracker tracker, SiteAuditTelemetryStalledChanged evt)
{
Sys.EventStream.Publish(evt);
AwaitAssert(
() =>
{
var snapshot = tracker.Snapshot();
Assert.True(snapshot.TryGetValue(evt.SiteId, out var stalled),
$"tracker did not record event for {evt.SiteId}");
Assert.Equal(evt.Stalled, stalled);
},
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(20));
}
[Fact]
public void Initial_Snapshot_IsEmpty()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
var snapshot = tracker.Snapshot();
Assert.Empty(snapshot);
}
[Fact]
public void StalledTrue_Event_TrackerReports_Stalled()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
var snapshot = tracker.Snapshot();
Assert.True(snapshot["siteA"]);
}
[Fact]
public void StalledFalse_Event_TrackerReports_NotStalled()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
// First flip the site into stalled so the false transition has a
// prior value to overwrite — mirrors how the reconciliation actor
// only publishes false after a true.
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: false));
var snapshot = tracker.Snapshot();
Assert.False(snapshot["siteA"]);
}
[Fact]
public void Multiple_Sites_Tracked_Independently()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteB", Stalled: false));
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteC", Stalled: true));
var snapshot = tracker.Snapshot();
Assert.Equal(3, snapshot.Count);
Assert.True(snapshot["siteA"]);
Assert.False(snapshot["siteB"]);
Assert.True(snapshot["siteC"]);
}
[Fact]
public void Constructor_With_Null_ActorSystem_Throws()
{
Assert.Throws<ArgumentNullException>(
() => new SiteAuditTelemetryStalledTracker((ActorSystem)null!));
}
[Fact]
public void Dispose_Unsubscribes_From_EventStream()
{
var tracker = new SiteAuditTelemetryStalledTracker(Sys);
PublishAndWait(tracker, new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
tracker.Dispose();
// After dispose any further events are ignored — the snapshot
// reflects the last known state at dispose time.
Sys.EventStream.Publish(new SiteAuditTelemetryStalledChanged("siteA", Stalled: false));
// Give the stream a moment in case the unsubscribe is racey; the
// assertion is that siteA stays at true.
Thread.Sleep(50);
Assert.True(tracker.Snapshot()["siteA"]);
}
}