Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceServiceTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
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.
2026-10-07 15:05:49 -04:00

214 lines
9.0 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// 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>ZB.MOM.WW.ScadaBridge.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;
/// <inheritdoc />
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>());
}
/// <inheritdoc />
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>
{
/// <summary>Gets the log entries captured so far.</summary>
public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
/// <summary>Returns no scope; the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="state">The state object to format.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
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();
}
/// <summary>Verifies that the service starts and stops without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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}");
}
/// <summary>Verifies that an exception thrown during maintenance is logged and not propagated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that stopping the service after it has been disposed does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_AfterDispose_DoesNotThrow()
{
// Regression: Dispose tears down the CTS that
// StopAsync cancels, and the host does not guarantee that every
// IHostedService.StopAsync is driven before the DI container is
// disposed — WebApplicationFactory's teardown reaches Dispose first and
// then calls StopAsync. Cancel() on a disposed CTS throws
// ObjectDisposedException, and letting it escape aborts the host's whole
// shutdown sequence (in Host.Tests it stranded the fixture's env-var
// restore, contaminating every later test in the run). Stop-after-Dispose
// must therefore be a no-op, not a throw.
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);
svc.Dispose();
// The assertion is the absence of ObjectDisposedException.
await svc.StopAsync(CancellationToken.None);
// Dispose is also idempotent — the host may reach it twice on the same path.
svc.Dispose();
}
}