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:
+403
@@ -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);
|
||||
}
|
||||
}
|
||||
+154
@@ -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}");
|
||||
}
|
||||
}
|
||||
+98
@@ -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>());
|
||||
}
|
||||
}
|
||||
+453
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user