fix(management): purge terminal PendingSecuredWrites rows on the list sweep after a retention window (review 2026-10-21 08/G3)
This commit is contained in:
+47
@@ -358,6 +358,53 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Equal(4, rows.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 08/G3: the terminal purge deletes decided/executed rows whose last
|
||||
/// lifecycle instant is before the cutoff, and never touches Pending or recent rows.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task PurgeTerminalAsync_DeletesOnlyOldTerminalRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
var old = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
long oldExecuted, oldRejected, oldPending, recentlyExecuted;
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SecuredWriteRepository(context);
|
||||
oldExecuted = await repo.AddAsync(With(NewRow(siteId, "Executed"), old, executedAt: old.AddMinutes(1)));
|
||||
oldRejected = await repo.AddAsync(With(NewRow(siteId, "Rejected"), old, decidedAt: old.AddMinutes(1)));
|
||||
oldPending = await repo.AddAsync(With(NewRow(siteId, "Pending"), old));
|
||||
// Submitted long ago but executed recently: its LAST lifecycle instant is recent.
|
||||
recentlyExecuted = await repo.AddAsync(With(NewRow(siteId, "Executed"), old, executedAt: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
await using (var purgeContext = CreateContext())
|
||||
{
|
||||
var deleted = await new SecuredWriteRepository(purgeContext)
|
||||
.PurgeTerminalAsync(new DateTime(2002, 1, 1, 0, 0, 0, DateTimeKind.Utc), batchSize: 1000);
|
||||
Assert.True(deleted >= 2);
|
||||
}
|
||||
|
||||
await using var verify = CreateContext();
|
||||
var verifyRepo = new SecuredWriteRepository(verify);
|
||||
Assert.Null(await verifyRepo.GetAsync(oldExecuted));
|
||||
Assert.Null(await verifyRepo.GetAsync(oldRejected));
|
||||
Assert.NotNull(await verifyRepo.GetAsync(oldPending));
|
||||
Assert.NotNull(await verifyRepo.GetAsync(recentlyExecuted));
|
||||
}
|
||||
|
||||
private static PendingSecuredWrite With(
|
||||
PendingSecuredWrite row, DateTime submittedAt, DateTime? decidedAt = null, DateTime? executedAt = null)
|
||||
{
|
||||
row.SubmittedAtUtc = submittedAt;
|
||||
row.DecidedAtUtc = decidedAt;
|
||||
row.ExecutedAtUtc = executedAt;
|
||||
return row;
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 08/G3: terminal <c>PendingSecuredWrites</c> rows were never purged.
|
||||
/// The Secured Writes list sweep (which already expires overdue Pending rows) now also
|
||||
/// purges decided/executed rows older than <c>SecuredWriteTerminalRetention</c>.
|
||||
/// </summary>
|
||||
public class SecuredWriteTerminalPurgeTests : TestKit
|
||||
{
|
||||
private readonly ISecuredWriteRepository _repo = Substitute.For<ISecuredWriteRepository>();
|
||||
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
|
||||
|
||||
/// <summary>Initializes the harness with an empty secured-write table.</summary>
|
||||
public SecuredWriteTerminalPurgeTests()
|
||||
{
|
||||
_services.AddScoped(_ => _repo);
|
||||
_repo.QueryAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(),
|
||||
Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<PendingSecuredWrite>());
|
||||
}
|
||||
|
||||
private void UseOptions(ManagementServiceOptions options) =>
|
||||
_services.AddSingleton<IOptions<ManagementServiceOptions>>(Options.Create(options));
|
||||
|
||||
private IActorRef CreateActor() =>
|
||||
Sys.ActorOf(Props.Create(() => new ManagementActor(
|
||||
_services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
|
||||
|
||||
private static ManagementEnvelope List() =>
|
||||
new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()),
|
||||
new ListSecuredWritesCommand(null, null), Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <summary>The list sweep purges terminal rows older than the retention window.</summary>
|
||||
[Fact]
|
||||
public void List_PurgesTerminalRowsOlderThanRetention()
|
||||
{
|
||||
UseOptions(new ManagementServiceOptions
|
||||
{
|
||||
SecuredWriteTerminalRetention = TimeSpan.FromDays(30),
|
||||
SecuredWriteTerminalPurgeInterval = TimeSpan.Zero,
|
||||
});
|
||||
DateTime? cutoff = null;
|
||||
_repo.PurgeTerminalAsync(Arg.Do<DateTime>(c => cutoff = c), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(3);
|
||||
|
||||
CreateActor().Tell(List());
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(cutoff);
|
||||
Assert.InRange(cutoff!.Value, DateTime.UtcNow.AddDays(-30).AddMinutes(-1), DateTime.UtcNow.AddDays(-30).AddMinutes(1));
|
||||
}
|
||||
|
||||
/// <summary>A non-positive retention disables the purge.</summary>
|
||||
[Fact]
|
||||
public void List_WithRetentionDisabled_DoesNotPurge()
|
||||
{
|
||||
UseOptions(new ManagementServiceOptions
|
||||
{
|
||||
SecuredWriteTerminalRetention = TimeSpan.Zero,
|
||||
SecuredWriteTerminalPurgeInterval = TimeSpan.Zero,
|
||||
});
|
||||
|
||||
CreateActor().Tell(List());
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
_repo.DidNotReceive().PurgeTerminalAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>A purge failure is best-effort — the list still answers.</summary>
|
||||
[Fact]
|
||||
public void List_PurgeFailure_DoesNotFailTheList()
|
||||
{
|
||||
UseOptions(new ManagementServiceOptions { SecuredWriteTerminalPurgeInterval = TimeSpan.Zero });
|
||||
_repo.PurgeTerminalAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("db down"));
|
||||
|
||||
CreateActor().Tell(List());
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user