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;
///
/// Deferred-work register row 61 — the replacement for SiteCallAuditActor's deleted
/// repository-taking "test-mode" constructors.
///
///
/// 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'
/// CaptiveDependencyGuard. The actor now has a single
/// constructor; a fixture that wants a concrete repository builds one of these instead.
///
///
///
/// 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.
///
///
internal static class StubRepositoryProvider
{
///
/// Builds a provider whose every scope yields the same repository instance, wrapped in
/// , plus any collaborators supplied.
///
/// Repository instance shared by every scope.
/// Optional site enumerator; omit to leave the reconciliation tick disarmed.
/// Optional pull client; omit to leave the reconciliation tick disarmed.
/// Optional central direct-write audit sink for relay actions.
///
/// False registers the repository unwrapped — for fixtures asserting on call ordering or
/// identity where the decorator would get in the way.
///
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();
}
}
///
/// Serializing wrapper for a repository instance shared across every DI scope in a test.
/// One call at a time, in arrival order.
///
///
///
/// In production every consumer of SiteCallAuditActor'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
/// DbContexts. A fixture that hands ONE instance (typically wrapping one
/// DbContext) to every scope breaks that assumption, and DbContext 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.
///
///
/// 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.
///
///
/// 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.
///
///
internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly SemaphoreSlim _gate = new(1, 1);
public SerializingSiteCallAuditRepository(ISiteCallAuditRepository inner) => _inner = inner;
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
RunAsync(() => _inner.GetAsync(id, ct));
public Task> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
RunAsync(() => _inner.QueryAsync(filter, paging, ct));
public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));
public Task ComputeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task> ComputePerSiteKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task> ComputePerNodeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
private async Task RunAsync(Func call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task RunAsync(Func> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
return await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}