Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs
T
Joseph Doherty 2807b5a3ea fix(transport)+test: close deferred-work register rows 58, 60, 65 (Low-tail sweep)
Row 65 — trigger FIRED, fixed. BundleUnlockAttempt rows were pruned only by
nodes actively serving unlocks, so a cluster that stopped importing entirely
left its last rows in place forever. The row's own trigger was "Transport gains
a central scheduler a prune tick could ride" — it has one now:
BundleSessionEvictionService, the central-only 1-minute session sweep added by
the transport-session fixes. It now also ticks the new
IBundleUnlockAttemptWindow.PruneAsync. Both drivers share the ONE 15-minute CAS
cadence claim, so ticking on a 1-minute sweep costs at most one bounded DELETE
per quarter hour and the two can never prune concurrently. PruneAsync is
contractually non-throwing (unlike TryRegisterAttemptAsync, which throws and
lets the limiter own the fail-open decision) — a background sweep has no caller
to report to and must survive a database blip. Both Transport docs updated;
three tests in FleetWideUnlockThrottleTests, incl. the retention sweep running
with no unlock traffic anywhere in the test, which is the whole point.

Row 58 — closed with a pin, and the OLD pin was found blind. The existing
Deployments_Dispose_UnsubscribesFromNotifier is behavioural: it is satisfied by
the _disposed guard alone, so deleting the `-=` from Dispose leaves it green
while the singleton notifier keeps a delegate pinning the circuit-scoped
component. Verified by mutation — with the `-=` removed the old test still
passed and only the two new pins failed. Added:
  * a structural retention pin asserting the notifier's invocation list actually
    shrinks to 0 on Dispose;
  * a source scan pinning the row's stated TRIGGER ("a second page subscribes,
    or any subscriber lands without a matching Dispose unsubscribe") — every
    CentralUI file that subscribes must also unsubscribe and be IDisposable,
    with a non-empty guard so the scan cannot pass vacuously.

Row 60 — closed with a pin, and the register's premise corrected.
GlobalArtifactSnapshot does NOT hold EF-materialised entities: it is projected
into pure Commons DTOs before the fan-out. The EF state that genuinely crosses
into the parallel send is the tracked Site entities the lambda closes over, and
that is safe only while lazy loading is off. Pinned in ConfigurationDatabase
.Tests two ways — no proxies extension on the built DbContextOptions, and no
EntityFrameworkCore.Proxies package reference anywhere in the repo (the
structurally stronger half: UseLazyLoadingProxies() is not a compilable call
without it). The real invariant is now spelled out at the fan-out itself.

Row 59 — no product change; added the missing tripwire remark stating the
constraint the safety argument rests on (serial by design, one scoped DbContext,
do not Task.WhenAll; if it gets slow the fix is a scope PER SITE, not a shared
scope under fan-out). Line numbers in the register have drifted to :528-545.

Rows 36, 37, 64: verification only, no change — see the sweep report.
2026-08-17 03:42:31 -04:00

157 lines
7.2 KiB
C#

using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddConfigurationDatabase_WithConnectionString_RegistersRepositoriesAndServices()
{
var services = new ServiceCollection();
services.AddConfigurationDatabase("Server=unused;Database=unused;");
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
}
[Fact]
public void AddConfigurationDatabase_Configures_RetryingExecutionStrategy()
{
// Task 12 (arch-review 04 S5): the central DbContext must survive transient
// SQL faults. EnableRetryOnFailure installs a non-null ExecutionStrategyFactory
// on the SqlServer options extension — covering every read-side path (KPI asks,
// outbox queries, Tracking.Status, execution trees) that previously surfaced
// transient faults raw.
var services = new ServiceCollection();
services.AddConfigurationDatabase("Server=unused;Database=unused;");
using var sp = services.BuildServiceProvider();
var options = sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>();
// EF1001: SqlServerOptionsExtension is an internal EF surface, but it is the
// only place the retrying-strategy factory is observable — asserting it is the
// point of this test. Scoped suppression, not a project-wide one.
#pragma warning disable EF1001
var sqlExt = options.Extensions
.OfType<Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal.SqlServerOptionsExtension>()
.Single();
Assert.NotNull(sqlExt.ExecutionStrategyFactory);
#pragma warning restore EF1001
}
/// <summary>
/// Deferred-work register row 60: <c>ArtifactDeploymentService</c> builds its per-site
/// deploy commands serially ("DbContext is not thread-safe") and then fans the SEND out
/// with <c>Task.WhenAll</c>, with EF-materialised <c>Site</c> entities captured by the
/// parallel lambda. That is safe for exactly one reason: with lazy loading off, reading
/// a property on a materialised entity is a plain field read that never re-enters the
/// DbContext. Turn lazy-loading proxies on and every one of those reads becomes a
/// database round-trip on the shared context from N threads at once — the row's named
/// trigger, and a failure mode that surfaces as sporadic "A second operation was
/// started on this context instance" rather than a clean break.
/// <para>
/// Pinned here rather than at the call site because the hazard is a DI/configuration
/// change made somewhere else entirely, by someone who will never read
/// <c>ArtifactDeploymentService</c>.
/// </para>
/// </summary>
[Fact]
public void AddConfigurationDatabase_DoesNotEnableLazyLoading()
{
var services = new ServiceCollection();
services.AddConfigurationDatabase("Server=unused;Database=unused;");
using var sp = services.BuildServiceProvider();
var options = sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>();
Assert.DoesNotContain(
options.Extensions,
e => e.GetType().Name.Contains("Proxies", StringComparison.Ordinal));
// Belt and braces, and the structurally stronger half: UseLazyLoadingProxies() is
// not even a compilable call without the Proxies package, so asserting the package
// is absent from the whole repo closes the hazard at the source rather than at one
// registration. (Mirrors Csproj_ShouldNotHaveForbiddenPackageReferences in
// Commons.Tests/ArchitecturalConstraintTests.)
var repoRoot = GetRepositoryRoot();
var projectFiles = Directory
.GetFiles(repoRoot, "*.csproj", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(repoRoot, "Directory.Packages.props", SearchOption.AllDirectories))
.Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}",
StringComparison.Ordinal)
&& !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}",
StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(projectFiles);
foreach (var file in projectFiles)
{
Assert.DoesNotContain(
"EntityFrameworkCore.Proxies",
File.ReadAllText(file),
StringComparison.Ordinal);
}
}
/// <summary>Walks up from the test output directory to the repository root.</summary>
private static string GetRepositoryRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
if (File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.ScadaBridge.slnx")))
{
return dir.FullName;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Could not locate the repository root (ZB.MOM.WW.ScadaBridge.slnx).");
}
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
// from source — that is the compile-time guard. Invoke it via reflection to verify the
// runtime defence-in-depth behaviour.
private static MethodInfo NoArgOverload =>
typeof(ServiceCollectionExtensions).GetMethod(
nameof(ServiceCollectionExtensions.AddConfigurationDatabase),
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: new[] { typeof(IServiceCollection) },
modifiers: null)!;
[Fact]
public void AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage()
{
// Regression guard for ConfigurationDatabase-003: the parameterless overload must not
// silently register nothing. Misuse must surface immediately at wire-up time with an
// actionable message — not later as an opaque DI resolution failure.
var services = new ServiceCollection();
var invocation = Assert.Throws<TargetInvocationException>(
() => NoArgOverload.Invoke(null, new object[] { services }));
var ex = Assert.IsType<InvalidOperationException>(invocation.InnerException);
Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError()
{
// The no-op overload must be flagged so misuse is caught at compile time.
var obsolete = NoArgOverload.GetCustomAttribute<ObsoleteAttribute>();
Assert.NotNull(obsolete);
Assert.True(obsolete!.IsError);
}
}