feat(auditlog): AuditLogPartitionMaintenanceService monthly roll-forward (#23 M6)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Central;
|
||||
using ScadaLink.Commons.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace ScadaLink.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>ScadaLink.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user