513fa1aa16
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.
173 lines
7.4 KiB
C#
173 lines
7.4 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.TestSupport;
|
|
|
|
/// <summary>
|
|
/// Deferred-work register row 61 — the replacement for <c>SiteCallAuditActor</c>'s deleted
|
|
/// repository-taking "test-mode" constructors.
|
|
///
|
|
/// <para>
|
|
/// Those constructors cached a scoped repository on an actor field for the actor's whole
|
|
/// lifetime. Nothing in production selected them, but the shape was one call site away from
|
|
/// re-introducing a captive-dependency bug, so it had to be allowlisted in the Host tests'
|
|
/// <c>CaptiveDependencyGuard</c>. The actor now has a single <see cref="IServiceProvider"/>
|
|
/// constructor; a fixture that wants a concrete repository builds one of these instead.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The actor's contract is unchanged and unweakened — it still resolves per scope and holds
|
|
/// nothing. Handing the SAME instance out of every scope is now the FIXTURE's explicit
|
|
/// choice, which is also why the serializing decorator below lives here rather than in
|
|
/// product code: it exists solely to compensate for that choice.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class StubRepositoryProvider
|
|
{
|
|
/// <summary>
|
|
/// Builds a provider whose every scope yields the same repository instance, wrapped in
|
|
/// <see cref="SerializingSiteCallAuditRepository"/>, plus any collaborators supplied.
|
|
/// </summary>
|
|
/// <param name="repository">Repository instance shared by every scope.</param>
|
|
/// <param name="siteEnumerator">Optional site enumerator; omit to leave the reconciliation tick disarmed.</param>
|
|
/// <param name="pullClient">Optional pull client; omit to leave the reconciliation tick disarmed.</param>
|
|
/// <param name="auditWriter">Optional central direct-write audit sink for relay actions.</param>
|
|
/// <param name="serialize">
|
|
/// False registers the repository unwrapped — for fixtures asserting on call ordering or
|
|
/// identity where the decorator would get in the way.
|
|
/// </param>
|
|
/// <returns>A service provider whose scopes resolve the configured repository and collaborators.</returns>
|
|
public static ServiceProvider Build(
|
|
ISiteCallAuditRepository repository,
|
|
ISiteEnumerator? siteEnumerator = null,
|
|
IPullSiteCallsClient? pullClient = null,
|
|
ICentralAuditWriter? auditWriter = null,
|
|
bool serialize = true)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repository);
|
|
|
|
var shared = serialize ? new SerializingSiteCallAuditRepository(repository) : repository;
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => shared);
|
|
|
|
if (siteEnumerator is not null)
|
|
{
|
|
services.AddSingleton(siteEnumerator);
|
|
}
|
|
|
|
if (pullClient is not null)
|
|
{
|
|
services.AddSingleton(pullClient);
|
|
}
|
|
|
|
if (auditWriter is not null)
|
|
{
|
|
services.AddSingleton(auditWriter);
|
|
}
|
|
|
|
return services.BuildServiceProvider();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serializing wrapper for a repository instance shared across every DI scope in a test.
|
|
/// One call at a time, in arrival order.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// In production every consumer of <c>SiteCallAuditActor</c>'s repository gets its OWN DI
|
|
/// scope — one per message, one per background pass — so the off-mailbox reconciliation and
|
|
/// purge passes can safely run alongside mailbox handlers: separate scopes mean separate
|
|
/// <c>DbContext</c>s. A fixture that hands ONE instance (typically wrapping one
|
|
/// <c>DbContext</c>) to every scope breaks that assumption, and <c>DbContext</c> forbids
|
|
/// concurrent operations — "A second operation was started on this context instance" is a
|
|
/// hard fault, and the interleaving is nondeterministic, so it surfaces as a flaky test
|
|
/// rather than a reliable one.
|
|
/// </para>
|
|
/// <para>
|
|
/// Serializing at the CALL, not around the whole pass, is deliberate: it removes the
|
|
/// concurrency hazard while preserving the property the off-mailbox passes exist for — a long
|
|
/// drain (blocked in a network pull, holding no repository call) still lets ingest, query and
|
|
/// KPI messages be answered. Suspending the mailbox for the duration of a pass would have
|
|
/// been simpler and would have invalidated exactly those regression tests.
|
|
/// </para>
|
|
/// <para>
|
|
/// This type used to live inside the product actor, reachable only from its test
|
|
/// constructors. It moved here with register row 61: production never constructed it, and the
|
|
/// hazard it guards is created by the fixture, not by the actor.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditRepository
|
|
{
|
|
private readonly ISiteCallAuditRepository _inner;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
|
|
/// <summary>Wraps the inner repository so every call is serialized.</summary>
|
|
/// <param name="inner">The repository to wrap.</param>
|
|
public SerializingSiteCallAuditRepository(ISiteCallAuditRepository inner) => _inner = inner;
|
|
|
|
/// <inheritdoc />
|
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
|
|
|
|
/// <inheritdoc />
|
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.GetAsync(id, ct));
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.QueryAsync(filter, paging, ct));
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));
|
|
|
|
/// <inheritdoc />
|
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
|
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
|
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
|
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
|
|
RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
|
|
|
|
private async Task RunAsync(Func<Task> call)
|
|
{
|
|
await _gate.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
await call().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<T> RunAsync<T>(Func<Task<T>> call)
|
|
{
|
|
await _gate.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
return await call().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
}
|