Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/TestSupport/StubRepositoryProvider.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

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();
}
}
}