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