155 lines
5.9 KiB
C#
155 lines
5.9 KiB
C#
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);
|
|
}
|
|
}
|