513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
692 lines
31 KiB
C#
692 lines
31 KiB
C#
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.Audit;
|
||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||
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>
|
||
/// 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; this actor file is purely about the
|
||
/// actor's policy decisions.
|
||
/// </summary>
|
||
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||
{
|
||
private readonly MsSqlMigrationFixture _fixture;
|
||
|
||
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
|
||
/// <param name="fixture">The shared MSSQL migration fixture.</param>
|
||
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
|
||
{
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
|
||
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
|
||
|
||
/// <summary>Gets the thresholds passed to each partition-boundary query.</summary>
|
||
public List<DateTime> ThresholdQueries { get; } = new();
|
||
/// <summary>Gets the partition boundaries that were switched out, in call order.</summary>
|
||
public List<DateTime> SwitchedBoundaries { get; } = new();
|
||
/// <summary>Gets or sets the function that returns the row count reported for a switched boundary.</summary>
|
||
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
|
||
/// <summary>Gets or sets the boundary whose switch-out should throw, if any.</summary>
|
||
public DateTime? ThrowOnBoundary { get; set; }
|
||
/// <summary>Gets or sets the exception thrown for <see cref="ThrowOnBoundary"/>; a default failure is used when null.</summary>
|
||
public Exception? BoundaryException { get; set; }
|
||
|
||
// Captures the maintenance command timeout the actor threads through to the
|
||
// switch-out + per-channel purge, so tests can assert the options value reaches the repo.
|
||
/// <summary>Gets the command timeouts passed to each partition switch-out call.</summary>
|
||
public List<TimeSpan?> SwitchTimeouts { get; } = new();
|
||
/// <summary>Gets the command timeouts passed to each per-channel purge call.</summary>
|
||
public List<TimeSpan?> ChannelTimeouts { get; } = new();
|
||
|
||
// Records every per-channel purge call as
|
||
// (channel, threshold, batchSize) so tests can assert which channels the
|
||
// actor chose to purge and with what window.
|
||
/// <summary>Gets every per-channel purge call as a channel, threshold, batch size and row ceiling.</summary>
|
||
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
|
||
/// <summary>Gets or sets the function that returns the row count reported for a purged channel.</summary>
|
||
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
||
|
||
// The actor enumerator returns whichever list is configured here.
|
||
// Mutating this between ticks lets tests simulate "no longer
|
||
// eligible" boundaries on the second tick.
|
||
/// <summary>Gets or sets the partition boundaries returned by the next threshold query.</summary>
|
||
public List<DateTime> Boundaries { get; set; } = new();
|
||
|
||
/// <inheritdoc />
|
||
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
|
||
Task.CompletedTask;
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
|
||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||
|
||
/// <inheritdoc />
|
||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
||
{
|
||
SwitchTimeouts.Add(commandTimeout);
|
||
if (ThrowOnBoundary.HasValue && monthBoundary == ThrowOnBoundary.Value)
|
||
{
|
||
throw BoundaryException ?? new InvalidOperationException("simulated switch failure");
|
||
}
|
||
SwitchedBoundaries.Add(monthBoundary);
|
||
return Task.FromResult(RowsPerBoundary(monthBoundary));
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||
DateTime threshold, CancellationToken ct = default)
|
||
{
|
||
ThresholdQueries.Add(threshold);
|
||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<long> PurgeChannelOlderThanAsync(
|
||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
||
{
|
||
ChannelPurges.Add((channel, threshold, batchSize, maxRows));
|
||
ChannelTimeouts.Add(commandTimeout);
|
||
return Task.FromResult(RowsPerChannel(channel));
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<long> BackfillSourceNodeAsync(
|
||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||
Task.FromResult(0L);
|
||
|
||
/// <inheritdoc />
|
||
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));
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
|
||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||
|
||
/// <inheritdoc />
|
||
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)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stops a purge actor and blocks until it is actually dead.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Deferred-work register row 95, the same leak shape row 82 fixed in
|
||
/// <c>PartitionPurgeTests</c> / <c>NotifyDispatcherAuditTrailTests</c>. Every actor this
|
||
/// class creates ticks every <c>100 ms</c>
|
||
/// (<see cref="AuditLogPurgeOptions.IntervalOverride"/>) and was only ever stopped
|
||
/// implicitly by <see cref="TestKit"/>'s <c>Dispose</c> → <c>Shutdown</c> — which is
|
||
/// bounded by a timeout and does NOT fail when that timeout expires. So on a loaded
|
||
/// machine an actor kept ticking while LATER test classes ran: the stub-repo tests burn a
|
||
/// DI scope and a thread every 100 ms for the rest of the assembly run, and
|
||
/// <c>EndToEnd_RealPartition_…</c>'s actor keeps switching partitions out of the shared
|
||
/// <see cref="MsSqlMigrationFixture"/> database that other classes in this assembly seed
|
||
/// against.
|
||
/// </para>
|
||
/// <para>
|
||
/// Stopping the actor inside the test that created it removes the dependency on
|
||
/// ActorSystem-shutdown timing entirely.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void StopPurgeActor(IActorRef actor)
|
||
{
|
||
Watch(actor);
|
||
Sys.Stop(actor);
|
||
// A stop is processed after the in-flight tick completes, so this also guarantees no
|
||
// partition switch is still running against the fixture DB.
|
||
ExpectTerminated(actor, TimeSpan.FromSeconds(30));
|
||
Unwatch(actor);
|
||
}
|
||
|
||
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
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the purge actor runs a purge pass on each daily interval tick.</summary>
|
||
[Fact]
|
||
public void Tick_Fires_OnDailyInterval()
|
||
{
|
||
var repo = new RecordingRepo();
|
||
var actor = 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));
|
||
|
||
StopPurgeActor(actor);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 2. Tick_OldPartitions_SwitchedOut
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a tick switches out partitions older than the retention threshold.</summary>
|
||
[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,
|
||
};
|
||
|
||
var actor = 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));
|
||
|
||
StopPurgeActor(actor);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 3. Tick_NewerPartitions_Untouched
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a tick leaves partitions newer than the retention threshold untouched.</summary>
|
||
[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>() };
|
||
|
||
var actor = 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));
|
||
|
||
// Stop before the negative assertion: a still-ticking actor is exactly what would
|
||
// make it read a moving target (register row 95).
|
||
StopPurgeActor(actor);
|
||
|
||
Assert.Empty(repo.SwitchedBoundaries);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 4. Tick_PublishesPurgedEvent_WithRowCount
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a tick publishes a purged event carrying the number of rows removed.</summary>
|
||
[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();
|
||
var actor = CreateActor(repo, FastTickOptions());
|
||
|
||
var msg = probe.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5));
|
||
StopPurgeActor(actor);
|
||
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)
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a failed switch-out does not prevent the remaining partitions from being processed.</summary>
|
||
[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"),
|
||
};
|
||
|
||
var actor = 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));
|
||
|
||
StopPurgeActor(actor);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a real partition is switched out end to end, its rows are removed and a purged event is published.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[SkippableFact]
|
||
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
// Seeds two rows within the defined pf_AuditLog_Month partition range (Jan 2026 –
|
||
// Dec 2027). RetentionDays is computed dynamically so the purge threshold always
|
||
// anchors near 2026-01-20, keeping the test date-independent:
|
||
// old row = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → partition PURGED
|
||
// kept row = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → partition KEPT
|
||
//
|
||
// Using a fixed thresholdAnchor rather than "N months ago" avoids the problem
|
||
// of relative seeds landing before 2026-01-01 (the catch-all partition that
|
||
// GetPartitionBoundariesOlderThanAsync never returns).
|
||
var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc);
|
||
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
||
|
||
var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
|
||
var keptOccurred = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc);
|
||
|
||
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||
var oldEvt = ScadaBridgeAuditEventFactory.Create(
|
||
eventId: Guid.NewGuid(),
|
||
occurredAtUtc: oldOccurred,
|
||
channel: AuditChannel.ApiOutbound,
|
||
kind: AuditKind.ApiCall,
|
||
status: AuditStatus.Delivered,
|
||
sourceSiteId: siteId);
|
||
var keptEvt = ScadaBridgeAuditEventFactory.Create(
|
||
eventId: Guid.NewGuid(),
|
||
occurredAtUtc: keptOccurred,
|
||
channel: AuditChannel.ApiOutbound,
|
||
kind: AuditKind.ApiCall,
|
||
status: AuditStatus.Delivered,
|
||
sourceSiteId: siteId);
|
||
|
||
await using (var seedContext = CreateMsSqlContext())
|
||
{
|
||
var seedRepo = new AuditLogRepository(seedContext);
|
||
await seedRepo.InsertIfNotExistsAsync(oldEvt);
|
||
await seedRepo.InsertIfNotExistsAsync(keptEvt);
|
||
}
|
||
|
||
// 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 = retentionDays };
|
||
var purgeOptions = new AuditLogPurgeOptions
|
||
{
|
||
IntervalHours = 24,
|
||
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
||
};
|
||
|
||
var probe = SubscribePurged();
|
||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||
sp,
|
||
Options.Create(purgeOptions),
|
||
Options.Create(auditOptions),
|
||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||
|
||
// Fish for the January 2026 partition boundary — the only eligible one in this
|
||
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
|
||
// against MSSQL which 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));
|
||
|
||
// This is the one actor in the class that mutates the SHARED fixture database, so it
|
||
// must not outlive the test and start switching partitions out from under the next
|
||
// class's seed rows (register row 95).
|
||
StopPurgeActor(actor);
|
||
|
||
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<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.DoesNotContain(rows, r => r.EventId == oldEvt.EventId);
|
||
Assert.Contains(rows, r => r.EventId == keptEvt.EventId);
|
||
}
|
||
|
||
private ScadaBridgeDbContext CreateMsSqlContext() =>
|
||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||
.UseSqlServer(_fixture.ConnectionString).Options);
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 7. Threshold_UsesAuditLogOptionsRetentionDays
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the purge threshold is derived from the configured audit log retention days.</summary>
|
||
[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();
|
||
var actor = CreateActor(
|
||
repo,
|
||
FastTickOptions(),
|
||
auditOptions: new AuditLogOptions { RetentionDays = 30 });
|
||
|
||
AwaitAssert(
|
||
() => Assert.True(repo.ThresholdQueries.Count >= 1),
|
||
duration: TimeSpan.FromSeconds(3),
|
||
interval: TimeSpan.FromMilliseconds(50));
|
||
|
||
StopPurgeActor(actor);
|
||
|
||
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}");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a per-channel retention override shorter than the global window triggers a channel purge.</summary>
|
||
[Fact]
|
||
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
|
||
{
|
||
// ApiOutbound has a 30-day override under a 365-day global window — strictly
|
||
// shorter, so the actor must run a per-channel purge with a threshold of
|
||
// ~today-30d and the configured batch size.
|
||
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
||
var purgeOptions = FastTickOptions();
|
||
purgeOptions.ChannelPurgeBatchSizeConfigured = 1234;
|
||
purgeOptions.MaxPurgedRowsPerChannelPerTick = 4321;
|
||
|
||
// Build the options OUTSIDE the Props expression tree — a collection/dictionary
|
||
// initializer is not legal inside an expression-tree lambda (CS8074).
|
||
var auditOptions = Options.Create(new AuditLogOptions
|
||
{
|
||
RetentionDays = 365,
|
||
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiOutbound"] = 30 },
|
||
});
|
||
var purgeOptionsWrapped = Options.Create(purgeOptions);
|
||
|
||
var sp = BuildScopedProvider(repo);
|
||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||
sp,
|
||
purgeOptionsWrapped,
|
||
auditOptions,
|
||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||
|
||
AwaitAssert(
|
||
() => Assert.Contains(repo.ChannelPurges, p => p.Channel == "ApiOutbound"),
|
||
duration: TimeSpan.FromSeconds(3),
|
||
interval: TimeSpan.FromMilliseconds(50));
|
||
|
||
StopPurgeActor(actor);
|
||
|
||
var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound");
|
||
Assert.Equal(1234, purge.BatchSize);
|
||
Assert.Equal(4321, purge.MaxRows);
|
||
|
||
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
|
||
Assert.True(
|
||
Math.Abs((purge.Threshold - expected).TotalMinutes) < 1.0,
|
||
$"channel threshold {purge.Threshold:o} should be within 1 minute of {expected:o}");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a per-channel retention override equal to or longer than the global window does not trigger a channel purge.</summary>
|
||
[Fact]
|
||
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
|
||
{
|
||
// DbOutbound = 365 (== global) and Notification = 400 (> global, validator would
|
||
// normally reject this but the actor must defensively skip it too). Neither is
|
||
// SHORTER than the global window, so the actor must NOT issue a channel purge —
|
||
// the global partition switch-out already governs those rows.
|
||
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
||
|
||
// Build the options OUTSIDE the Props expression tree (CS8074).
|
||
var auditOptions = Options.Create(new AuditLogOptions
|
||
{
|
||
RetentionDays = 365,
|
||
PerChannelRetentionDays = new Dictionary<string, int>
|
||
{
|
||
["DbOutbound"] = 365,
|
||
["Notification"] = 400,
|
||
},
|
||
});
|
||
var purgeOptions = Options.Create(FastTickOptions());
|
||
|
||
var sp = BuildScopedProvider(repo);
|
||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||
sp,
|
||
purgeOptions,
|
||
auditOptions,
|
||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||
|
||
// Wait for at least one tick (visible via the enumerator call), then assert no
|
||
// channel purge was issued.
|
||
AwaitAssert(
|
||
() => Assert.True(repo.ThresholdQueries.Count >= 1),
|
||
duration: TimeSpan.FromSeconds(3),
|
||
interval: TimeSpan.FromMilliseconds(50));
|
||
|
||
// Stop before the negative assertion: a still-ticking actor is exactly what would
|
||
// make it read a moving target (register row 95).
|
||
StopPurgeActor(actor);
|
||
|
||
Assert.Empty(repo.ChannelPurges);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a purge tick passes the configured maintenance command timeout to the repository.</summary>
|
||
[Fact]
|
||
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository()
|
||
{
|
||
// The actor must thread AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout through to
|
||
// BOTH the partition switch-out and each per-channel purge, so the ~30s ADO.NET default
|
||
// cannot self-lock the drop-and-rebuild dance (arch-review 04, S2).
|
||
var boundary = new DateTime(2025, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||
var repo = new RecordingRepo
|
||
{
|
||
Boundaries = new List<DateTime> { boundary },
|
||
RowsPerBoundary = _ => 7L,
|
||
};
|
||
|
||
var purgeOptions = FastTickOptions();
|
||
purgeOptions.MaintenanceCommandTimeoutMinutes = 45;
|
||
|
||
// A per-channel override shorter than the global window so the channel purge also fires.
|
||
var auditOptions = Options.Create(new AuditLogOptions
|
||
{
|
||
RetentionDays = 365,
|
||
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiOutbound"] = 30 },
|
||
});
|
||
|
||
var sp = BuildScopedProvider(repo);
|
||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||
sp,
|
||
Options.Create(purgeOptions),
|
||
auditOptions,
|
||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||
|
||
var expected = TimeSpan.FromMinutes(45);
|
||
AwaitAssert(
|
||
() =>
|
||
{
|
||
Assert.Contains(expected, repo.SwitchTimeouts);
|
||
Assert.Contains(expected, repo.ChannelTimeouts);
|
||
},
|
||
duration: TimeSpan.FromSeconds(3),
|
||
interval: TimeSpan.FromMilliseconds(50));
|
||
|
||
StopPurgeActor(actor);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that a failing switch-out publishes a purge-failed event and increments the health failure counter.</summary>
|
||
[Fact]
|
||
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
|
||
{
|
||
// A silently failing retention job is a health-surface condition, not just a log line:
|
||
// the per-boundary catch must publish AuditLogPurgeFailedEvent AND bump the
|
||
// AuditCentralHealthSnapshot.PurgeFailures counter (arch-review 04, S2).
|
||
var boundary = new DateTime(2025, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||
var repo = new RecordingRepo
|
||
{
|
||
Boundaries = new List<DateTime> { boundary },
|
||
ThrowOnBoundary = boundary,
|
||
BoundaryException = new InvalidOperationException("simulated switch timeout"),
|
||
};
|
||
|
||
var snapshot = new AuditCentralHealthSnapshot();
|
||
var services = new ServiceCollection();
|
||
services.AddScoped<IAuditLogRepository>(_ => repo);
|
||
services.AddSingleton(snapshot);
|
||
services.AddSingleton<IAuditPurgeFailureCounter>(snapshot);
|
||
var sp = services.BuildServiceProvider();
|
||
|
||
var probe = CreateTestProbe();
|
||
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgeFailedEvent));
|
||
|
||
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||
sp,
|
||
Options.Create(FastTickOptions()),
|
||
Options.Create(new AuditLogOptions()),
|
||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||
|
||
var evt = probe.ExpectMsg<AuditLogPurgeFailedEvent>(TimeSpan.FromSeconds(5));
|
||
Assert.Equal(boundary, evt.MonthBoundary);
|
||
Assert.Contains("simulated switch timeout", evt.Error);
|
||
Assert.True(evt.ElapsedMilliseconds >= 0);
|
||
|
||
AwaitAssert(
|
||
() => Assert.True(snapshot.PurgeFailures >= 1,
|
||
$"expected PurgeFailures >= 1, got {snapshot.PurgeFailures}"),
|
||
duration: TimeSpan.FromSeconds(3),
|
||
interval: TimeSpan.FromMilliseconds(50));
|
||
|
||
StopPurgeActor(actor);
|
||
}
|
||
}
|