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; /// /// Bundle D (#23 M6-T5) tests for . /// All tests use an in-memory stub — /// the real EF/MSSQL implementation is exercised by the /// AuditLogPartitionMaintenanceTests integration suite in /// ScadaLink.ConfigurationDatabase.Tests. This file is purely /// about the hosted service's policy decisions (start/stop, exception /// containment). /// public class AuditLogPartitionMaintenanceServiceTests { /// /// Recording stub — counts EnsureLookaheadAsync invocations and lets the /// test inject an exception per invocation to drive the catch-all path. /// private sealed class RecordingMaintenance : IPartitionMaintenance { public int EnsureCallCount; public Exception? ThrowOnce; public Task> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default) { Interlocked.Increment(ref EnsureCallCount); if (ThrowOnce is { } ex) { ThrowOnce = null; throw ex; } return Task.FromResult>(Array.Empty()); } public Task GetMaxBoundaryAsync(CancellationToken ct = default) => Task.FromResult(DateTime.UtcNow.AddMonths(6)); } /// /// 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. /// private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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(), opts, NullLogger.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(), 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); } }