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.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -18,7 +18,7 @@ using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP-7: Tests for system-wide artifact deployment.
/// Tests for system-wide artifact deployment.
/// </summary>
public class ArtifactDeploymentServiceTests : TestKit
{
@@ -31,13 +31,14 @@ public class ArtifactDeploymentServiceTests : TestKit
private readonly ArtifactRevisionHashService _hashService = new();
private readonly ISecretStore _secretStore;
/// <summary>Initializes the test class with substitute repositories and an audit service.</summary>
public ArtifactDeploymentServiceTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_externalSystemRepo = Substitute.For<IExternalSystemRepository>();
// DeploymentManager-025/-027: the notification repo is retained only so the
// The notification repo is retained only so the
// tests can assert the artifact path NEVER touches it (it is no longer a
// constructor dependency of ArtifactDeploymentService).
_notificationRepo = Substitute.For<INotificationRepository>();
@@ -64,6 +65,8 @@ public class ArtifactDeploymentServiceTests : TestKit
UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
};
/// <summary>Verifies that deploying to all sites when none are configured returns a failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_NoSites_ReturnsFailure()
{
@@ -77,6 +80,7 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains("No sites", result.Error);
}
/// <summary>Verifies that a per-site artifact result carries the site's identifying information.</summary>
[Fact]
public void SiteArtifactResult_ContainsSiteInfo()
{
@@ -87,6 +91,7 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Null(result.ErrorMessage);
}
/// <summary>Verifies that the artifact deployment summary counts successes and failures correctly.</summary>
[Fact]
public void ArtifactDeploymentSummary_CountsCorrectly()
{
@@ -103,12 +108,14 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Equal(3, summary.SiteResults.Count);
}
// ── DeploymentManager-010: one logical deployment id across all per-site commands ──
// ── One logical deployment id across all per-site commands ──
/// <summary>Verifies that every per-site command carries the same deployment id as the summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_AllPerSiteCommandsShareTheSummaryDeploymentId()
{
// DeploymentManager-010: previously each per-site DeployArtifactsCommand
// Previously each per-site DeployArtifactsCommand
// minted its own GUID, so one logical deployment produced N+1 unrelated
// ids. Every per-site command must now carry the SAME id, equal to the
// id reported in the summary and audit log.
@@ -144,8 +151,10 @@ public class ArtifactDeploymentServiceTests : TestKit
Arg.Any<CancellationToken>());
}
// ── DeploymentManager-014: real per-site success/failure coverage ──
// ── Real per-site success/failure coverage ──
/// <summary>Verifies that a partial failure is reported as a per-site success and failure matrix.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_PartialFailure_ReportsPerSiteMatrix()
{
@@ -170,12 +179,14 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "fail-site" && !r.Success);
}
// ── DeploymentManager-023: global artifact queries hoisted out of the per-site loop ──
// ── Global artifact queries hoisted out of the per-site loop ──
/// <summary>Verifies that the global artifact queries run once in total rather than once per site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop()
{
// DeploymentManager-023: previously each per-site iteration of the deploy-many
// Previously each per-site iteration of the deploy-many
// loop re-issued the global artifact queries (shared scripts, external systems,
// DB connections) — a textbook N+1 over the global sets. With three sites the
// queries must now be issued ONCE in total, regardless of site count.
@@ -197,7 +208,7 @@ public class ArtifactDeploymentServiceTests : TestKit
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
// DeploymentManager-025/-027: notification lists and SMTP configuration are
// Notification lists and SMTP configuration are
// central-only — the artifact path must NEVER fetch them, and the per-site
// command must NEVER carry them.
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
@@ -221,6 +232,7 @@ public class ArtifactDeploymentServiceTests : TestKit
/// silently used the engine default (50 x 30 s) — while the sibling
/// <c>DatabaseConnectionArtifact</c> had always carried its equivalents.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployedExternalSystemArtifact_CarriesRetryPolicy()
{
@@ -246,10 +258,12 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Equal(TimeSpan.FromSeconds(120), artifact.RetryDelay);
}
/// <summary>Verifies that retrying a single site still runs the global artifact queries exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce()
{
// DeploymentManager-023: the single-site convenience overload still owns its
// The single-site convenience overload still owns its
// own global-fetch (it cannot inherit from a sweep), so for one site every
// global query is issued exactly once. Pin this so a future refactor cannot
// accidentally route RetryForSiteAsync through the multi-site loop and lose
@@ -264,7 +278,7 @@ public class ArtifactDeploymentServiceTests : TestKit
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
// DeploymentManager-025/-027: central-only — never fetched, never shipped.
// Central-only — never fetched, never shipped.
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
Assert.All(recorder.Received, cmd =>
@@ -274,6 +288,8 @@ public class ArtifactDeploymentServiceTests : TestKit
});
}
/// <summary>Verifies that a successful retry for a site returns success and writes an audit entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits()
{
@@ -290,8 +306,10 @@ public class ArtifactDeploymentServiceTests : TestKit
Arg.Any<string>(), "retry-site", Arg.Any<object>(), Arg.Any<CancellationToken>());
}
// ── PLAN-07 Task 4: single-site artifact deployment ──
// ── Single-site artifact deployment ──
/// <summary>Verifies that a single-site deploy sends artifacts only to the target site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToSiteAsync_DeploysOnlyToTargetSite()
{
@@ -313,6 +331,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a single-site deploy to an unknown site returns a failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
{
@@ -327,6 +347,8 @@ public class ArtifactDeploymentServiceTests : TestKit
// ── Site artifact revision stamp (pending-updates §3.1) ──
/// <summary>Verifies that a successful deploy stamps the site's artifact hash and timestamp and saves.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_SuccessfulSite_StampsHashAndTimestampAndSaves()
{
@@ -348,6 +370,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a failed site keeps its previous artifact stamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_FailedSite_KeepsItsPreviousStamp()
{
@@ -376,6 +400,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the deployment record is still persisted and success reported when saving the stamp throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSitesAsync_StampSaveThrows_DeploymentRecordStillPersistedAndSuccessReported()
{
@@ -406,6 +432,8 @@ public class ArtifactDeploymentServiceTests : TestKit
});
}
/// <summary>Verifies that a successful retry stamps the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_Success_StampsTheSite()
{
@@ -426,6 +454,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a failed retry does not stamp the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite()
{
@@ -443,6 +473,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the computed current artifact hashes match the hash a deploy stamps.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps()
{
@@ -470,10 +502,12 @@ public class ArtifactDeploymentServiceTests : TestKit
}
}
/// <summary>Verifies that the computed current artifact hashes match the stamped hash when data connections reference secrets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps_WithSecrets()
{
// Final review M7: the parity above runs with no data connections. Pin it for a site
// The parity above runs with no data connections. Pin it for a site
// whose connections reference secrets, so the stamped hash (built from a command that
// carries a non-empty Secrets list) equals the "what would it be now" hash — a
// successful secrets deploy must read Current, not Out of date.
@@ -510,6 +544,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Equal(site.ArtifactRevisionHash, current[site.Id]);
}
/// <summary>Verifies that computing the current artifact hashes runs the global queries once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce()
{
@@ -531,6 +567,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that computing the current hashes with no sites returns an empty result without querying.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying()
{
@@ -544,6 +582,8 @@ public class ArtifactDeploymentServiceTests : TestKit
// ── Secrets ride the artifact deploy (design §3.1/§3.3) ──
/// <summary>Verifies that a site with a missing secret fails alone and nothing is sent to it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployToAllSites_SiteWithMissingSecret_FailsThatSiteOnly_AndSendsNothingToIt()
{
@@ -589,6 +629,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Null(badSite.ArtifactRevisionHash);
}
/// <summary>Verifies that a missing secret is represented by the missing marker in the computed hash.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentHashes_MissingSecret_UsesMissingMarker()
{
@@ -612,6 +654,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.NotEqual(beforeCreated[1], afterCreated[1]);
}
/// <summary>Verifies that deploy logs never contain secret material.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployLogs_NeverContainSecretMaterial()
{
@@ -640,7 +684,7 @@ public class ArtifactDeploymentServiceTests : TestKit
.Returns(Task.FromResult<StoredSecret?>(resolvedRow));
var recorder = new ArtifactProbeRecorder();
// Task 7: ok-site's command ships one secret, so the probe must ack it (SecretsApplied
// ok-site's command ships one secret, so the probe must ack it (SecretsApplied
// = shipped count) for the site to reconcile as a success — an unset SecretsApplied
// now reads as an old site (see the dedicated reconciliation tests below).
var probe = Sys.ActorOf(Props.Create(() =>
@@ -665,8 +709,10 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.DoesNotContain(capturingLogger.Messages, m => m.Contains(ciphertextHex, StringComparison.OrdinalIgnoreCase));
}
// ── Task 7: stamp gating on secret application + DeploySiteSecrets audit row ──
// ── Stamp gating on secret application + DeploySiteSecrets audit row ──
/// <summary>Verifies that an old site that reports no applied secrets fails and is not stamped when secrets were shipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp()
{
@@ -703,6 +749,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.DidNotReceive().UpdateSiteAsync(site, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a partial secrets apply fails the deploy and does not stamp the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PartialApply_FailsAndDoesNotStamp()
{
@@ -740,6 +788,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.DidNotReceive().UpdateSiteAsync(site, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an old site is still stamped when no secrets were shipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OldSite_NoSecrets_StillStamps()
{
@@ -766,6 +816,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a site with no secret references receives an empty secrets list so it removes previously deployed ones.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes()
{
@@ -773,7 +825,7 @@ public class ArtifactDeploymentServiceTests : TestKit
// are marked removed and the mirror deletes them, while a NULL Secrets means "central
// shipped no secrets" and touches nothing. Dropping a site's LAST secret reference must
// therefore ship an empty list, not null — otherwise the previously deployed secret stays
// live on both site nodes forever (Task 13 live gate, check 5).
// live on both site nodes forever.
var site = new Site("Site One", "site-1") { Id = 1 };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
@@ -804,6 +856,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a secret deploy writes one audit row per site without any secret material.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SecretDeploy_WritesOneAuditRowPerSite_WithoutMaterial()
{
@@ -879,9 +933,11 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.DoesNotContain("\"tag\"", json, StringComparison.OrdinalIgnoreCase);
}
// ── Task 7 fix round 1: the same gate + audit row on the RetryForSiteAsync path ──
// ── The same gate + audit row on the RetryForSiteAsync path ──
// (review gap: only the DeployToAllSitesAsync/sweep path had coverage above)
/// <summary>Verifies that a retry for an old site that reports no applied secrets fails and is not stamped when secrets were shipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp()
{
@@ -910,6 +966,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a partial secrets apply on retry fails and does not stamp the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_PartialApply_FailsAndDoesNotStamp()
{
@@ -940,6 +998,8 @@ public class ArtifactDeploymentServiceTests : TestKit
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a retry with a missing secret fails closed, sends nothing and writes one audit row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_MissingSecret_FailsClosed_SendsNothing_WritesOneAuditRow()
{
@@ -988,6 +1048,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains("\"secrets\":[]", json);
}
/// <summary>Verifies that a successful retry with secrets stamps the site and writes one audit row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSiteAsync_HappyPath_WithSecrets_StampsAndWritesOneAuditRow()
{
@@ -1088,12 +1150,27 @@ public class ArtifactDeploymentServiceTests : TestKit
/// </summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the formatted log messages captured so far.</summary>
public List<string> Messages { get; } = [];
/// <summary>Returns no scope because the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="state">The scope or log state.</param>
/// <returns>A no-op scope.</returns>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Reports whether logging is enabled for the given level.</summary>
/// <param name="logLevel">The log level.</param>
/// <returns>True if the level is enabled; otherwise false.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event id.</param>
/// <param name="state">The scope or log state.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message text.</param>
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
@@ -1108,13 +1185,14 @@ public class ArtifactDeploymentServiceTests : TestKit
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
/// <summary>Does nothing; there is nothing to release.</summary>
public void Dispose() { }
}
}
/// <summary>
/// Per-test recorder for <see cref="ArtifactProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// Per-test recorder for <see cref="ArtifactProbeActor"/>.
/// Each test owns its own instance, passed into the actor's constructor, so
/// the received-command list is no longer shared static state that races
/// under parallel test execution.
/// </summary>
@@ -1131,6 +1209,9 @@ public class ArtifactDeploymentServiceTests : TestKit
/// </summary>
private class ArtifactProbeActor : ReceiveActor
{
/// <summary>Initializes the actor with the per-test recorder and the site ids that should fail.</summary>
/// <param name="recorder">The recorder that receives every deploy command.</param>
/// <param name="failingSites">The site ids for which the actor replies with a failure.</param>
public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites)
{
var failSet = new HashSet<string>(failingSites);
@@ -1149,16 +1230,10 @@ public class ArtifactDeploymentServiceTests : TestKit
}
}
/// <summary>
/// Task 7: stand-in CentralCommunicationActor that always reports the deploy itself as
/// successful but lets a test control exactly what <see cref="ArtifactDeploymentResponse.SecretsApplied"/>
/// comes back for a given command — an old site (<c>null</c>), a partial apply (a count
/// short of what was shipped), or a correct ack (the shipped count) — so the secrets-
/// reconciliation gate can be exercised independently of the wire-level success/failure
/// the shared <see cref="ArtifactProbeActor"/> models.
/// </summary>
// ── Final review M5: a central secret-store outage is contained to the sites that need it ──
// ── A central secret-store outage is contained to the sites that need it ──
/// <summary>Verifies that a central secret-store outage fails only the secret-referencing site and audits it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreOutage_FailsOnlyTheSecretReferencingSite_AndAuditsIt()
{
@@ -1186,7 +1261,7 @@ public class ArtifactDeploymentServiceTests : TestKit
Arg.Any<object>(), Arg.Any<CancellationToken>()))
.Do(ci => audited = ci[5]);
// Before M5 the store exception escaped the build loop and the whole sweep threw.
// Previously the store exception escaped the build loop and the whole sweep threw.
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
@@ -1210,6 +1285,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains("Central secret store could not be read", json);
}
/// <summary>Verifies that a secret-store outage omits only the secret-referencing site from the computed hashes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeCurrentHashes_StoreOutage_OmitsOnlyTheSecretReferencingSite()
{
@@ -1234,6 +1311,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.True(hashes.ContainsKey(2));
}
/// <summary>Verifies that a secret-store outage on retry fails the site without sending anything.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSite_StoreOutage_FailsWithoutSending()
{
@@ -1261,6 +1340,8 @@ public class ArtifactDeploymentServiceTests : TestKit
// ── Removal-only deploys are audited (the site reports the names it retired) ──
/// <summary>Verifies that a removal-only deploy writes an audit row naming the removed secrets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemovalOnlyDeploy_WritesAuditRowNamingTheRemovedSecrets()
{
@@ -1294,6 +1375,8 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains("\"success\":true", json);
}
/// <summary>Verifies that a removal-only retry writes an audit row naming the removed secrets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryForSite_RemovalOnly_WritesAuditRowNamingTheRemovedSecrets()
{
@@ -1320,6 +1403,9 @@ public class ArtifactDeploymentServiceTests : TestKit
/// <summary>Acks every command as applied (0 shipped secrets) and reports the given names as retired.</summary>
private class RemovingProbeActor : ReceiveActor
{
/// <summary>Initializes the actor with the recorder and the secret names to report as retired.</summary>
/// <param name="recorder">The recorder that receives every deploy command.</param>
/// <param name="removed">The secret names reported as removed by the site.</param>
public RemovingProbeActor(ArtifactProbeRecorder recorder, IReadOnlyList<string> removed)
{
Receive<SiteEnvelope>(env =>
@@ -1335,8 +1421,12 @@ public class ArtifactDeploymentServiceTests : TestKit
}
}
/// <summary>Stand-in CentralCommunicationActor that always reports the deploy itself as successful but lets a test control exactly what <see cref="ArtifactDeploymentResponse.SecretsApplied"/> comes back for a given command: an old site (<c>null</c>), a partial apply, or a correct ack, so the secrets-reconciliation gate can be exercised independently of the wire-level success or failure that <see cref="ArtifactProbeActor"/> models.</summary>
private class SecretsAckProbeActor : ReceiveActor
{
/// <summary>Initializes the actor with the recorder and the function that decides the applied-secrets count.</summary>
/// <param name="recorder">The recorder that receives every deploy command.</param>
/// <param name="secretsApplied">Computes the applied-secrets count to acknowledge for a command.</param>
public SecretsAckProbeActor(ArtifactProbeRecorder recorder, Func<DeployArtifactsCommand, int?> secretsApplied)
{
Receive<SiteEnvelope>(env =>
@@ -64,6 +64,7 @@ public class ArtifactRevisionHashServiceTests
IReadOnlyList<string>? missing = null) =>
Command() with { Secrets = secrets, MissingSecrets = missing };
/// <summary>Verifies that changing a secret changes the hash while reordering secrets does not.</summary>
[Fact]
public void SecretChanges_MoveTheHash_ButOrderDoesNot()
{
@@ -78,6 +79,7 @@ public class ArtifactRevisionHashServiceTests
Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { KekId = "kek-B" }, b])));
}
/// <summary>Verifies that a missing secret hashes differently from a present one.</summary>
[Fact]
public void MissingSecret_HashesDifferentlyFromPresent()
{
@@ -88,13 +90,14 @@ public class ArtifactRevisionHashServiceTests
svc.ComputeHash(Cmd(missing: ["A"])));
}
/// <summary>Verifies that the hash of a command without secrets still matches the golden value captured before secrets were added.</summary>
[Fact]
public void ComputeHash_PreSecretsGoldenValue_IsUnchanged()
{
// Captured on HEAD 738e4587 — BEFORE Task 4 wired Secrets/MissingSecrets into the
// Captured on HEAD 738e4587 — BEFORE Secrets/MissingSecrets were wired into the
// projection — using the fixed default baseline (one of each existing artifact
// kind) with a pinned deploymentId/timestamp. A command with Secrets == null and
// MissingSecrets == null must keep hashing to this exact value after Task 4, or a
// MissingSecrets == null must keep hashing to this exact value after that change, or a
// site with no secret references would spuriously flip to "out of date".
var golden = Command(
deploymentId: "golden",
@@ -105,6 +108,7 @@ public class ArtifactRevisionHashServiceTests
_sut.ComputeHash(golden));
}
/// <summary>Verifies that hashing the same input twice produces the same hash.</summary>
[Fact]
public void ComputeHash_SameInputTwice_ProducesSameHash()
{
@@ -114,6 +118,7 @@ public class ArtifactRevisionHashServiceTests
_sut.ComputeHash(Command(timestamp: timestamp)));
}
/// <summary>Verifies that the hash is a SHA-256 value encoded as hexadecimal.</summary>
[Fact]
public void ComputeHash_IsSha256HexEncoded()
{
@@ -126,6 +131,7 @@ public class ArtifactRevisionHashServiceTests
Assert.All(digest, c => Assert.Contains(c, "0123456789abcdef"));
}
/// <summary>Verifies that reordering the artifact lists produces the same hash.</summary>
[Fact]
public void ComputeHash_ReorderedArtifactLists_ProducesSameHash()
{
@@ -143,6 +149,7 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(reordered));
}
/// <summary>Verifies that reordering the methods of an external system produces the same hash.</summary>
[Fact]
public void ComputeHash_ReorderedExternalSystemMethods_ProducesSameHash()
{
@@ -162,6 +169,7 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
/// <summary>Verifies that reordering camelCase method names produces the same hash.</summary>
[Fact]
public void ComputeHash_ReorderedCamelCaseMethodNames_ProducesSameHash()
{
@@ -182,6 +190,7 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
/// <summary>Verifies that unparseable method definitions still participate in the hash.</summary>
[Fact]
public void ComputeHash_UnparseableMethodDefinitions_StillParticipatesInTheHash()
{
@@ -195,6 +204,7 @@ public class ArtifactRevisionHashServiceTests
Assert.NotEqual(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
/// <summary>Verifies that a different deployment id and timestamp produce the same hash.</summary>
[Fact]
public void ComputeHash_DifferentDeploymentIdAndTimestamp_ProducesSameHash()
{
@@ -206,6 +216,7 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
}
/// <summary>Verifies that empty and null artifact lists hash identically.</summary>
[Fact]
public void ComputeHash_EmptyAndNullArtifactLists_HashIdentically()
{
@@ -217,6 +228,7 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(withNulls), _sut.ComputeHash(withEmpties));
}
/// <summary>Verifies that notification and SMTP artifacts do not affect the hash.</summary>
[Fact]
public void ComputeHash_NotificationAndSmtpArtifacts_DoNotAffectTheHash()
{
@@ -233,6 +245,8 @@ public class ArtifactRevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(withCentralOnlyPayload));
}
/// <summary>Provides one deploy command per artifact field, each differing from its baseline in exactly that field.</summary>
/// <returns>Theory data of the changed field name and the changed command.</returns>
public static TheoryData<string, DeployArtifactsCommand> ChangedFieldCases()
{
var data = new TheoryData<string, DeployArtifactsCommand>();
@@ -288,6 +302,9 @@ public class ArtifactRevisionHashServiceTests
return data;
}
/// <summary>Verifies that changing any single artifact field changes the hash.</summary>
/// <param name="changedField">The name of the artifact field that differs from the baseline.</param>
/// <param name="changed">The command whose single field differs from the baseline.</param>
[Theory]
[MemberData(nameof(ChangedFieldCases))]
public void ComputeHash_EveryArtifactField_ParticipatesInTheHash(
@@ -312,6 +329,7 @@ public class ArtifactRevisionHashServiceTests
Assert.NotEqual(_sut.ComputeHash(baseline), _sut.ComputeHash(changed));
}
/// <summary>Verifies that the hashable records declare their properties in alphabetical order.</summary>
[Fact]
public void HashableRecords_PropertiesDeclaredAlphabetically()
{
@@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP2.5: bulk site deployment. Pins the properties that make the fan-out safe —
/// Bulk site deployment. Pins the properties that make the fan-out safe —
/// concurrency is BOUNDED by the configured degree, one slow instance does not stop
/// the others completing, a per-instance timeout contains a wedged instance, and
/// every instance still gets its own deployment id and its own operation lock.
@@ -38,6 +38,7 @@ public class DeploySiteAsyncTests : TestKit
private readonly IStaleInstanceProbe _staleProbe = Substitute.For<IStaleInstanceProbe>();
private readonly OperationLockManager _lockManager = new();
/// <summary>Initializes the test class with substituted repositories, a flattening pipeline and a stale-instance probe.</summary>
public DeploySiteAsyncTests()
{
_siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
@@ -111,6 +112,8 @@ public class DeploySiteAsyncTests : TestKit
NullLogger<DeploymentService>.Instance);
}
/// <summary>Verifies that one slow instance does not stop the others completing and that concurrency stays bounded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_SlowInstance_OthersStillComplete_AndConcurrencyIsBounded()
{
@@ -149,6 +152,8 @@ public class DeploySiteAsyncTests : TestKit
Assert.DoesNotContain(deploymentIds, id => string.IsNullOrEmpty(id));
}
/// <summary>Verifies that a wedged instance times out on its own while the rest of the batch succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_WedgedInstance_TimesOutAlone_RestSucceed()
{
@@ -175,6 +180,8 @@ public class DeploySiteAsyncTests : TestKit
Assert.Equal("Inst-02", wedged.UniqueName);
}
/// <summary>Verifies that a bulk site deploy releases every per-instance operation lock.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_ReleasesEveryOperationLock()
{
@@ -193,6 +200,8 @@ public class DeploySiteAsyncTests : TestKit
Assert.Equal(0, _lockManager.TrackedLockCount);
}
/// <summary>Verifies that deploying a site with no instances succeeds with an empty summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_SiteWithNoInstances_SucceedsWithEmptySummary()
{
@@ -207,6 +216,8 @@ public class DeploySiteAsyncTests : TestKit
Assert.Empty(result.Value.InstanceResults);
}
/// <summary>Verifies that deploying an unknown site returns a failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_UnknownSite_ReturnsFailure()
{
@@ -237,6 +248,7 @@ public class DeploySiteAsyncTests : TestKit
/// under the old shape the last instance's token would already be ~2 s old.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_StagesEachTokenImmediatelyBeforeItsOwnSend()
{
@@ -280,6 +292,7 @@ public class DeploySiteAsyncTests : TestKit
/// the process. Their records must also be finalised as Failed rather than left
/// InProgress.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_CancelledMidPrepare_ReleasesEveryLock_AndFailsPreparedRecords()
{
@@ -320,7 +333,7 @@ public class DeploySiteAsyncTests : TestKit
}
/// <summary>
/// P7-2: a bulk site deploy must not restart deliberately-stopped equipment.
/// A bulk site deploy must not restart deliberately-stopped equipment.
///
/// <para>
/// Deploy is a legal transition out of <c>Disabled</c> and the deploy path
@@ -332,6 +345,7 @@ public class DeploySiteAsyncTests : TestKit
/// this one" action.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_SkipsDisabledInstances_WithoutDeployingOrReEnablingThem()
{
@@ -376,7 +390,7 @@ public class DeploySiteAsyncTests : TestKit
}
/// <summary>
/// P7-3: phase 1 is serial, so an unreachable site used to cost the batch one
/// Phase 1 is serial, so an unreachable site used to cost the batch one
/// full 30-second <c>QueryTimeout</c> PER INSTANCE before a single deploy was
/// sent — hours for a large site, so the CLI's 5-minute bulk deadline cancelled
/// and the next attempt re-armed the same cost. Two bounds close it: a short
@@ -389,6 +403,7 @@ public class DeploySiteAsyncTests : TestKit
/// whole finding: without the breaker there would be six.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_UnreachableSite_ReconcilesOnceThenSkipsTheRestOfTheBatch()
{
@@ -457,7 +472,7 @@ public class DeploySiteAsyncTests : TestKit
}
/// <summary>
/// P7-6: a bulk site deploy must not redeploy instances whose configuration has
/// A bulk site deploy must not redeploy instances whose configuration has
/// not changed.
///
/// <para>
@@ -476,6 +491,7 @@ public class DeploySiteAsyncTests : TestKit
/// record, no staged config, no command — for the unchanged pair.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_SkipsUpToDateInstances_SoTheirSiteStateIsNeverReset()
{
@@ -519,10 +535,11 @@ public class DeploySiteAsyncTests : TestKit
}
/// <summary>
/// P7-6: <c>force</c> restores the unconditional behaviour for operators whose
/// <c>force</c> restores the unconditional behaviour for operators whose
/// site state central cannot see — a restored data volume or a rebuilt node
/// looks perfectly up to date from central and still needs the push.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_Force_RedeploysUpToDateInstances()
{
@@ -542,12 +559,14 @@ public class DeploySiteAsyncTests : TestKit
}
/// <summary>
/// P7-6 composed with P7-2: <c>force</c> suppresses the up-to-date filter and
/// Combining <c>force</c> with the disabled-instance skip: <c>force</c> suppresses the up-to-date filter and
/// ONLY that filter. A Disabled instance is never deployed at bulk scope,
/// because forcing a bulk push is an operator saying "the site's config may be
/// wrong", not "restart the equipment I stopped" — the single-instance deploy
/// remains the explicit re-enable action.
/// </summary>
/// <param name="force">Whether the bulk deploy is forced.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -576,6 +595,7 @@ public class DeploySiteAsyncTests : TestKit
/// NOT sufficient — each one would be a silently skipped instance that actually
/// needed the deploy.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_MatchingHashIsNotEnough_WhenTheInstanceStillNeedsDeploying()
{
@@ -614,6 +634,8 @@ public class DeploySiteAsyncTests : TestKit
/// <summary>Answers every deploy with Success, recording which instances the site was asked to apply.</summary>
private sealed class RecordingSiteActor : ReceiveActor
{
/// <summary>Initializes the actor with the collection that records applied instance names.</summary>
/// <param name="applied">The collection that receives each applied instance's unique name.</param>
public RecordingSiteActor(ConcurrentBag<string> applied)
{
Receive<SiteEnvelope>(env =>
@@ -637,6 +659,8 @@ public class DeploySiteAsyncTests : TestKit
/// </summary>
private sealed class SilentQuerySiteActor : ReceiveActor
{
/// <summary>Initializes the actor with the callback invoked for each unanswered query.</summary>
/// <param name="onQuery">The callback invoked each time a state query arrives.</param>
public SilentQuerySiteActor(Action onQuery)
{
Receive<SiteEnvelope>(env =>
@@ -663,6 +687,9 @@ public class DeploySiteAsyncTests : TestKit
/// </summary>
private sealed class TokenAgeRecordingSiteActor : ReceiveActor
{
/// <summary>Initializes the actor with the collection that records token ages and the reply delay.</summary>
/// <param name="ages">The collection that receives each command's token age on arrival.</param>
/// <param name="delay">The delay before each command is answered.</param>
public TokenAgeRecordingSiteActor(ConcurrentBag<TimeSpan> ages, TimeSpan delay)
{
Receive<SiteEnvelope>(env =>
@@ -719,6 +746,10 @@ public class DeploySiteAsyncTests : TestKit
/// </summary>
private sealed class ThrottledSiteActor : ReceiveActor
{
/// <summary>Initializes the actor with the concurrency tracker and the optional slow instance.</summary>
/// <param name="tracker">The tracker that records the observed concurrency.</param>
/// <param name="slowInstanceName">The name of the instance whose reply is delayed, or null for none.</param>
/// <param name="slowDelay">How long the named instance's reply is delayed.</param>
public ThrottledSiteActor(ConcurrencyTracker tracker, string? slowInstanceName, TimeSpan slowDelay)
{
Receive<SiteEnvelope>(env =>
@@ -11,9 +11,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP-8: Tests for deployed vs template-derived state comparison.
/// Tests for deployed vs template-derived state comparison.
///
/// PLAN-05 Task 16: adds behavioral coverage proving the read-only
/// Adds behavioral coverage proving the read-only
/// staleness/comparison path can flatten (and thus hash) an instance whose
/// script does NOT compile — because <c>validateScripts: false</c> skips the
/// expensive Roslyn <see cref="ValidationService.ValidateScriptCompilation"/>
@@ -22,6 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// </summary>
public class DeploymentComparisonTests
{
/// <summary>Verifies that a comparison with matching hashes is not stale.</summary>
[Fact]
public void DeploymentComparisonResult_MatchingHashes_NotStale()
{
@@ -33,6 +34,7 @@ public class DeploymentComparisonTests
Assert.Equal("sha256:abc", result.CurrentRevisionHash);
}
/// <summary>Verifies that a comparison with different hashes is stale.</summary>
[Fact]
public void DeploymentComparisonResult_DifferentHashes_IsStale()
{
@@ -43,6 +45,7 @@ public class DeploymentComparisonTests
Assert.NotEqual(result.DeployedRevisionHash, result.CurrentRevisionHash);
}
/// <summary>Verifies that a comparison result carries the deployed timestamp.</summary>
[Fact]
public void DeploymentComparisonResult_ContainsDeployedTimestamp()
{
@@ -52,7 +55,7 @@ public class DeploymentComparisonTests
Assert.Equal(deployedAt, result.DeployedAt);
}
// ── PLAN-05 Task 16: read-only paths skip Roslyn script compilation ──
// ── read-only paths skip Roslyn script compilation ──
private const int InstanceId = 1;
private const int TemplateId = 10;
@@ -100,6 +103,8 @@ public class DeploymentComparisonTests
return (templateRepo, siteRepo, sharedSchemaRepo);
}
/// <summary>Verifies that flattening with script validation off succeeds with a hash and no compilation error for a non-compiling script.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FlattenAndValidate_ValidateScriptsFalse_NonCompilingScript_SucceedsWithHashAndNoCompilationError()
{
@@ -116,6 +121,8 @@ public class DeploymentComparisonTests
e => e.Category == ValidationCategory.ScriptCompilation);
}
/// <summary>Verifies that flattening with the default script validation reports a compilation error for a non-compiling script.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FlattenAndValidate_DefaultValidateScripts_NonCompilingScript_ReportsCompilationError()
{
@@ -15,6 +15,7 @@ public class DeploymentManagerOptionsValidatorTests
private static ValidateOptionsResult Validate(DeploymentManagerOptions options) =>
new DeploymentManagerOptionsValidator().Validate(Options.DefaultName, options);
/// <summary>Verifies that the default options pass validation.</summary>
[Fact]
public void DefaultOptions_AreValid()
{
@@ -22,6 +23,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that a zero lifecycle command timeout is rejected.</summary>
[Fact]
public void ZeroLifecycleCommandTimeout_IsRejected()
{
@@ -30,6 +32,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("LifecycleCommandTimeout", result.FailureMessage);
}
/// <summary>Verifies that a negative lifecycle command timeout is rejected.</summary>
[Fact]
public void NegativeLifecycleCommandTimeout_IsRejected()
{
@@ -38,6 +41,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("LifecycleCommandTimeout", result.FailureMessage);
}
/// <summary>Verifies that a zero per-site artifact deployment timeout is rejected.</summary>
[Fact]
public void ZeroArtifactDeploymentTimeoutPerSite_IsRejected()
{
@@ -46,6 +50,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("ArtifactDeploymentTimeoutPerSite", result.FailureMessage);
}
/// <summary>Verifies that a zero operation lock timeout is rejected.</summary>
[Fact]
public void ZeroOperationLockTimeout_IsRejected()
{
@@ -54,6 +59,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("OperationLockTimeout", result.FailureMessage);
}
/// <summary>Verifies that a zero site deployment reconcile timeout is rejected.</summary>
[Fact]
public void ZeroSiteDeploymentReconcileTimeout_IsRejected()
{
@@ -62,6 +68,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("SiteDeploymentReconcileTimeout", result.FailureMessage);
}
/// <summary>Verifies that a negative site deployment reconcile timeout is rejected.</summary>
[Fact]
public void NegativeSiteDeploymentReconcileTimeout_IsRejected()
{
@@ -73,6 +80,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("SiteDeploymentReconcileTimeout", result.FailureMessage);
}
/// <summary>Verifies that a zero pending-updates cache TTL is rejected.</summary>
[Fact]
public void ZeroPendingUpdatesCacheTtl_IsRejected()
{
@@ -84,6 +92,7 @@ public class DeploymentManagerOptionsValidatorTests
Assert.Contains("PendingUpdatesCacheTtl", result.FailureMessage);
}
/// <summary>Verifies that a negative pending-updates cache TTL is rejected.</summary>
[Fact]
public void NegativePendingUpdatesCacheTtl_IsRejected()
{
@@ -89,13 +89,24 @@ public class DeploymentServiceStalenessTests
/// <summary>Every entry logged, in order.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <inheritdoc />
/// <summary>Begins a logical scope; this capturing logger does not track scopes.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state.</param>
/// <returns>Always <see langword="null"/>.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
/// <summary>Reports whether a log level is enabled; every level is.</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns>Always <see langword="true"/>.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
/// <summary>Records the level and formatted message in <see cref="Entries"/>.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The severity of the entry.</param>
/// <param name="eventId">The event id of the entry.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -135,6 +146,8 @@ public class DeploymentServiceStalenessTests
_repo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
.Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash));
/// <summary>Verifies that the staleness check shares one flatten session across the whole batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_SharesOneSessionAcrossTheBatch()
{
@@ -156,6 +169,8 @@ public class DeploymentServiceStalenessTests
await _pipeline.Received(1).FlattenAndValidateAsync(3, Arg.Any<CancellationToken>(), false, _session);
}
/// <summary>Verifies that a matching hash is reported as not stale and no snapshot is read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_HashMatches_NotStaleAndNoSnapshotRead()
{
@@ -172,6 +187,8 @@ public class DeploymentServiceStalenessTests
await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an old-form list snapshot is normalised to not stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_OldFormListSnapshot_NormalisesToNotStale()
{
@@ -214,6 +231,8 @@ public class DeploymentServiceStalenessTests
await _repo.Received(1).GetDeployedSnapshotByInstanceIdAsync(50, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a genuine hash mismatch is reported as stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_GenuineMismatch_IsStale()
{
@@ -231,6 +250,8 @@ public class DeploymentServiceStalenessTests
Assert.True(result[1]);
}
/// <summary>Verifies that a failing flatten is reported with unknown staleness.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_FlattenFails_ReportsUnknown()
{
@@ -246,6 +267,8 @@ public class DeploymentServiceStalenessTests
Assert.Null(result[1]);
}
/// <summary>Verifies that many flatten failures log exactly one warning.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_ManyFlattenFailures_LogsExactlyOneWarning()
{
@@ -274,6 +297,8 @@ public class DeploymentServiceStalenessTests
Assert.Equal(2, _logger.Entries.Count(e => e.Level == LogLevel.Debug));
}
/// <summary>Verifies that no flatten failures log no warning.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_NoFailures_LogsNoWarning()
{
@@ -287,6 +312,8 @@ public class DeploymentServiceStalenessTests
Assert.DoesNotContain(_logger.Entries, e => e.Level == LogLevel.Warning);
}
/// <summary>Verifies that an instance without a snapshot is omitted from the result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_InstanceWithoutSnapshot_IsOmitted()
{
@@ -304,6 +331,8 @@ public class DeploymentServiceStalenessTests
2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
/// <summary>Verifies that an instance whose snapshot vanishes between reads is omitted from the result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_SnapshotVanishedBetweenReads_IsOmitted()
{
@@ -319,6 +348,8 @@ public class DeploymentServiceStalenessTests
Assert.False(result.ContainsKey(1));
}
/// <summary>Verifies that duplicate instance ids are flattened only once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_DuplicateIds_FlattenedOnce()
{
@@ -334,6 +365,8 @@ public class DeploymentServiceStalenessTests
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
/// <summary>Verifies that empty input returns an empty result without any reads.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceStalenessAsync_EmptyInput_ReturnsEmptyWithoutReads()
{
@@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP-1/2/4/5/6/8/16: Tests for central-side DeploymentService.
/// Tests for central-side DeploymentService.
/// </summary>
public class DeploymentServiceTests : TestKit
{
@@ -34,6 +34,7 @@ public class DeploymentServiceTests : TestKit
private readonly IAuditService _audit;
private readonly DeploymentService _service;
/// <summary>Initializes a new instance of the <c>DeploymentServiceTests</c> class.</summary>
public DeploymentServiceTests()
{
_repo = Substitute.For<IDeploymentManagerRepository>();
@@ -62,7 +63,7 @@ public class DeploymentServiceTests : TestKit
}
/// <summary>
/// Notify-and-fetch (Task 6): the central HTTP base URL carried in the
/// Notify-and-fetch: the central HTTP base URL carried in the
/// <c>RefreshDeploymentCommand</c>. Must be non-empty for the deploy path to
/// clear the fail-fast configuration guard.
/// </summary>
@@ -86,8 +87,10 @@ public class DeploymentServiceTests : TestKit
PendingDeploymentTtl = TestPendingTtl
});
// ── WP-1: Deployment flow ──
// ── Deployment flow ──
/// <summary>Verifies that deploy instance async instance not found returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_InstanceNotFound_ReturnsFailure()
{
@@ -99,6 +102,8 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not found", result.Error);
}
/// <summary>Verifies that deploy instance async validation fails returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_ValidationFails_ReturnsFailure()
{
@@ -119,6 +124,8 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("validation failed", result.Error);
}
/// <summary>Verifies that deploy instance async flattening fails returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_FlatteningFails_ReturnsFailure()
{
@@ -134,12 +141,14 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("Validation failed", result.Error);
}
// ── DeploymentManager-021: missing Site row -> hard failure, no silent fabrication ──
// ── Missing Site row -> hard failure, no silent fabrication ──
/// <summary>Verifies that deploy instance async site row missing fails loudly instead of silently substituting.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_SiteRowMissing_FailsLoudlyInsteadOfSilentlySubstituting()
{
// DeploymentManager-021 regression: previously ResolveSiteIdentifierAsync
// Regression: Previously ResolveSiteIdentifierAsync
// silently returned the numeric siteId rendered as a string when the
// site row was missing (FK was deleted, race with admin delete, DB
// inconsistency). That bogus identifier then surfaced downstream as a
@@ -181,12 +190,14 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase);
}
// ── DeploymentManager-022: no transient Pending -> single InProgress insert ──
// ── No transient Pending -> single InProgress insert ──
/// <summary>Verifies that deploy instance async no transient pending write record created directly in progress.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_NoTransientPendingWrite_RecordCreatedDirectlyInProgress()
{
// DeploymentManager-022 regression: previously the deploy path wrote
// Regression: Previously the deploy path wrote
// the record as Pending, then immediately updated it to InProgress
// with no work in between — an extra SaveChangesAsync round-trip, an
// extra notifier push, and a Pending->InProgress flicker in the
@@ -222,8 +233,10 @@ public class DeploymentServiceTests : TestKit
Arg.Any<CancellationToken>());
}
// ── WP-2: Deployment identity ──
// ── Deployment identity ──
/// <summary>Verifies that deploy instance async creates unique deployment id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_CreatesUniqueDeploymentId()
{
@@ -257,8 +270,10 @@ public class DeploymentServiceTests : TestKit
Assert.Equal("sha256:abc", captured.RevisionHash);
}
// ── WP-4: State transition validation ──
// ── State transition validation ──
/// <summary>Verifies that deploy instance async enabled instance allows deploy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_EnabledInstance_AllowsDeploy()
{
@@ -277,8 +292,10 @@ public class DeploymentServiceTests : TestKit
await _repo.Received().AddDeploymentRecordAsync(Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
}
// ── DeploymentManager-001: unexpected exception must not leave record InProgress ──
// ── Unexpected exception must not leave record InProgress ──
/// <summary>Verifies that deploy instance async communication throws unexpected exception record marked failed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_CommunicationThrowsUnexpectedException_RecordMarkedFailed()
{
@@ -310,8 +327,10 @@ public class DeploymentServiceTests : TestKit
Assert.NotNull(captured.CompletedAt);
}
// ── DeploymentManager-002: failure write must not use a cancelled token ──
// ── Failure write must not use a cancelled token ──
/// <summary>Verifies that deploy instance async failure write uses non cancellable token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_FailureWrite_UsesNonCancellableToken()
{
@@ -355,6 +374,8 @@ public class DeploymentServiceTests : TestKit
// ── AskTimeoutException: Akka's AskTimeoutException must be classified as a deploy timeout ──
/// <summary>Verifies that deploy instance async ask timeout exception classified as timeout.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_AskTimeoutException_ClassifiedAsTimeout()
{
@@ -363,7 +384,7 @@ public class DeploymentServiceTests : TestKit
// AskTimeoutException (which does NOT derive from System.TimeoutException).
// Prior to the fix this fell through to the generic "Deployment error:"
// branch instead of the timeout "Communication failure:" branch, which
// broke the DeploymentManager-006 reconciliation that keys off that prefix.
// broke the query-the-site-before-redeploy reconciliation that keys off that prefix.
var instance = new Instance("AskTimeoutInst") { Id = 99, SiteId = 1, State = InstanceState.NotDeployed };
_repo.GetInstanceByIdAsync(99, Arg.Any<CancellationToken>()).Returns(instance);
SetupValidPipeline(99, "AskTimeoutInst", "sha256:target");
@@ -403,7 +424,7 @@ public class DeploymentServiceTests : TestKit
Assert.DoesNotContain("Deployment failed:", result.Error);
// The deployment record must be Failed with the timeout-failure prefix
// (which the DeploymentManager-006 reconciliation check keys off).
// (which the query-the-site-before-redeploy reconciliation check keys off).
Assert.NotNull(captured);
Assert.Equal(DeploymentStatus.Failed, captured!.Status);
Assert.NotNull(captured.ErrorMessage);
@@ -411,8 +432,10 @@ public class DeploymentServiceTests : TestKit
Assert.DoesNotContain("Deployment error:", captured.ErrorMessage);
}
// ── WP-6: Lifecycle commands ──
// ── Lifecycle commands ──
/// <summary>Verifies that disable instance async instance not found returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableInstanceAsync_InstanceNotFound_ReturnsFailure()
{
@@ -424,6 +447,8 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not found", result.Error);
}
/// <summary>Verifies that disable instance async when disabled returns transition error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableInstanceAsync_WhenDisabled_ReturnsTransitionError()
{
@@ -436,6 +461,8 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not allowed", result.Error);
}
/// <summary>Verifies that enable instance async when enabled returns transition error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnableInstanceAsync_WhenEnabled_ReturnsTransitionError()
{
@@ -448,6 +475,8 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not allowed", result.Error);
}
/// <summary>Verifies that delete instance async instance not found returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteInstanceAsync_InstanceNotFound_ReturnsFailure()
{
@@ -459,10 +488,12 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not found", result.Error);
}
/// <summary>Verifies that delete instance async not deployed removes record without site round trip.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteInstanceAsync_NotDeployed_RemovesRecordWithoutSiteRoundTrip()
{
// #05-T18: a NotDeployed instance has no live actor at any site (Transport-
// A NotDeployed instance has no live actor at any site (Transport-
// imported instances always land NotDeployed, often against an uncommissioned
// or unreachable site). Delete must succeed as a central-side record cleanup
// WITHOUT contacting the site. The default _service's CommunicationService has
@@ -478,8 +509,10 @@ public class DeploymentServiceTests : TestKit
await _repo.Received(1).DeleteInstanceAsync(70, Arg.Any<CancellationToken>());
}
// ── DeploymentManager-004: site-success but central-delete-failure must not escape uncaught ──
// ── Site-success but central-delete-failure must not escape uncaught ──
/// <summary>Verifies that delete instance async site succeeds central delete fails returns distinct failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteInstanceAsync_SiteSucceeds_CentralDeleteFails_ReturnsDistinctFailure()
{
@@ -507,8 +540,10 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("central", result.Error, StringComparison.OrdinalIgnoreCase);
}
// ── WP-8: Deployment comparison ──
// ── Deployment comparison ──
/// <summary>Verifies that get deployment comparison async no snapshot returns failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_NoSnapshot_ReturnsFailure()
{
@@ -520,6 +555,8 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("No deployed snapshot", result.Error);
}
/// <summary>Verifies that get deployment comparison async same hash not stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_SameHash_NotStale()
{
@@ -554,6 +591,8 @@ public class DeploymentServiceTests : TestKit
Assert.False(result.Value.IsStale);
}
/// <summary>Verifies that get deployment comparison async different hash is stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_DifferentHash_IsStale()
{
@@ -591,8 +630,10 @@ public class DeploymentServiceTests : TestKit
Assert.True(result.Value.IsStale);
}
// ── PLAN-05 Task 16: read-only paths skip Roslyn script compilation ──
// ── Read-only paths skip Roslyn script compilation ──
/// <summary>Verifies that get deployment comparison async requests validate scripts false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_RequestsValidateScriptsFalse()
{
@@ -622,6 +663,8 @@ public class DeploymentServiceTests : TestKit
await _pipeline.DidNotReceive().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true);
}
/// <summary>Verifies that deploy instance async requests validate scripts true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_RequestsValidateScriptsTrue()
{
@@ -642,6 +685,8 @@ public class DeploymentServiceTests : TestKit
await _pipeline.Received().FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), true);
}
/// <summary>Verifies that stale instance probe requests validate scripts false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleInstanceProbe_RequestsValidateScriptsFalse()
{
@@ -663,8 +708,10 @@ public class DeploymentServiceTests : TestKit
await _pipeline.Received(1).FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, Arg.Any<FlattenSession?>());
}
// ── DeploymentManager-007: comparison must produce a structured diff ──
// ── Comparison must produce a structured diff ──
/// <summary>Verifies that get deployment comparison async produces structured diff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_ProducesStructuredDiff()
{
@@ -710,6 +757,8 @@ public class DeploymentServiceTests : TestKit
// ── I-1 (latent): old-form List snapshot must not show a spurious stale/diff ──
/// <summary>Verifies that get deployment comparison async old form list value normalized to native not stale no diff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_OldFormListValue_NormalizedToNative_NotStaleNoDiff()
{
@@ -785,6 +834,8 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(nativeHash, result.Value.DeployedRevisionHash);
}
/// <summary>Verifies that get deployment comparison async genuinely changed list value still stale and diffs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentComparisonAsync_GenuinelyChangedListValue_StillStaleAndDiffs()
{
@@ -845,8 +896,10 @@ public class DeploymentServiceTests : TestKit
c => c.CanonicalName == "Thresholds" && c.ChangeType == DiffChangeType.Changed);
}
// ── WP-2: GetDeploymentStatusAsync ──
// ── GetDeploymentStatusAsync ──
/// <summary>Verifies that get deployment status async returns record by deployment id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeploymentStatusAsync_ReturnsRecordByDeploymentId()
{
@@ -865,10 +918,12 @@ public class DeploymentServiceTests : TestKit
// ── Audit logging ──
/// <summary>Verifies that deploy instance async flattening fails does not reach audit.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_FlatteningFails_DoesNotReachAudit()
{
// DeploymentManager-011: this test previously asserted nothing. A
// This test previously asserted nothing. A
// flatten failure returns before any site communication, so no audit
// entry is written.
var instance = new Instance("TestInst") { Id = 1, SiteId = 1, State = InstanceState.NotDeployed };
@@ -884,10 +939,12 @@ public class DeploymentServiceTests : TestKit
Arg.Any<string>(), Arg.Any<object>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async site succeeds writes deploy audit entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_SiteSucceeds_WritesDeployAuditEntry()
{
// DeploymentManager-011: a successful deployment must write a "Deploy"
// A successful deployment must write a "Deploy"
// audit entry referencing the deployed instance.
var instance = new Instance("AuditInst") { Id = 50, SiteId = 1, State = InstanceState.NotDeployed };
_repo.GetInstanceByIdAsync(50, Arg.Any<CancellationToken>()).Returns(instance);
@@ -908,10 +965,12 @@ public class DeploymentServiceTests : TestKit
Arg.Any<object>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async deploy audit write throws keeps committed success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_DeployAuditWriteThrows_KeepsCommittedSuccess()
{
// #05-T17: the post-success "Deploy" audit write is best-effort. If it
// The post-success "Deploy" audit write is best-effort. If it
// throws, the already-committed Success record must NOT be flipped to
// Failed by the outer catch — "audit-write failure NEVER aborts the
// user-facing action".
@@ -940,6 +999,8 @@ public class DeploymentServiceTests : TestKit
// ── Telemetry follow-on: scadabridge.deployments.applied on deploy success ──
/// <summary>Verifies that deploy instance async site succeeds emits deployments applied counter once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_SiteSucceeds_EmitsDeploymentsAppliedCounterOnce()
{
@@ -983,6 +1044,8 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, Interlocked.Read(ref applied));
}
/// <summary>Verifies that deploy instance async reconciled does not emit deployments applied counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Reconciled_DoesNotEmitDeploymentsAppliedCounter()
{
@@ -1038,8 +1101,10 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(0, Interlocked.Read(ref applied));
}
// ── DeploymentManager-011: lifecycle success paths ──
// ── Lifecycle success paths ──
/// <summary>Verifies that disable instance async site succeeds sets disabled state and audits.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableInstanceAsync_SiteSucceeds_SetsDisabledStateAndAudits()
{
@@ -1061,6 +1126,8 @@ public class DeploymentServiceTests : TestKit
Arg.Any<object>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that enable instance async site succeeds sets enabled state and audits.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnableInstanceAsync_SiteSucceeds_SetsEnabledStateAndAudits()
{
@@ -1082,6 +1149,8 @@ public class DeploymentServiceTests : TestKit
Arg.Any<object>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that delete instance async site succeeds removes record and audits.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteInstanceAsync_SiteSucceeds_RemovesRecordAndAudits()
{
@@ -1102,10 +1171,12 @@ public class DeploymentServiceTests : TestKit
Arg.Any<object>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async same instance operation lock serializes concurrent deploys.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_SameInstance_OperationLockSerializesConcurrentDeploys()
{
// DeploymentManager-011: two concurrent deploys of the SAME instance
// Two concurrent deploys of the SAME instance
// must be serialized by the per-instance operation lock — the site sees
// them one at a time, never overlapping.
var instance = new Instance("LockInst") { Id = 54, SiteId = 1, State = InstanceState.Enabled };
@@ -1130,7 +1201,7 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, serializationCounters.MaxConcurrent);
}
// ── DeploymentManager-006: query-the-site-before-redeploy idempotency ──
// ── Query-the-site-before-redeploy idempotency ──
/// <summary>
/// Builds a DeploymentService whose CommunicationService is backed by the
@@ -1172,11 +1243,11 @@ public class DeploymentServiceTests : TestKit
}
/// <summary>
/// DeploymentManager-021 test helper: returns an <see cref="ISiteRepository"/>
/// Test helper: Returns an <see cref="ISiteRepository"/>
/// substitute that resolves <see cref="ISiteRepository.GetSiteByIdAsync"/>
/// for ANY integer id to a stub <see cref="Site"/> whose
/// <c>SiteIdentifier</c> is <c>"site-{id}"</c>. Prior to the
/// DeploymentManager-021 fix the production `ResolveSiteIdentifierAsync`
/// missing-site-row fix the production `ResolveSiteIdentifierAsync`
/// silently substituted the numeric id when the site row was missing, so
/// these tests passed without seeding any Sites. After the fix a missing
/// site throws — every test that drives a deploy/lifecycle path needs a
@@ -1195,6 +1266,8 @@ public class DeploymentServiceTests : TestKit
return siteRepo;
}
/// <summary>Verifies that deploy instance async prior in progress record site has target hash marks success without redeploy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_PriorInProgressRecord_SiteHasTargetHash_MarksSuccessWithoutRedeploy()
{
@@ -1230,6 +1303,8 @@ public class DeploymentServiceTests : TestKit
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async prior in progress record site has different hash proceeds with deploy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_PriorInProgressRecord_SiteHasDifferentHash_ProceedsWithDeploy()
{
@@ -1262,6 +1337,8 @@ public class DeploymentServiceTests : TestKit
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async prior failed timeout record queries site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_PriorFailedTimeoutRecord_QueriesSite()
{
@@ -1292,6 +1369,8 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(DeploymentStatus.Success, prior.Status);
}
/// <summary>Verifies that deploy instance async prior success record skips site query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_PriorSuccessRecord_SkipsSiteQuery()
{
@@ -1321,6 +1400,8 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, counters.DeployCount);
}
/// <summary>Verifies that deploy instance async fresh first time deploy skips site query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_FreshFirstTimeDeploy_SkipsSiteQuery()
{
@@ -1343,6 +1424,8 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, counters.DeployCount);
}
/// <summary>Verifies that deploy instance async prior in progress record query fails falls through to deploy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_PriorInProgressRecord_QueryFails_FallsThroughToDeploy()
{
@@ -1375,8 +1458,10 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, counters.DeployCount);
}
// ── Task 6: notify-and-fetch send path (stage PendingDeployment + send RefreshDeploymentCommand) ──
// ── Notify-and-fetch send path (stage PendingDeployment + send RefreshDeploymentCommand) ──
/// <summary>Verifies that deploy instance async success stages pending config then sends refresh notify.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Success_StagesPendingConfigThenSendsRefreshNotify()
{
@@ -1433,6 +1518,8 @@ public class DeploymentServiceTests : TestKit
Arg.Any<PendingDeployment>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async success does not delete pending row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Success_DoesNotDeletePendingRow()
{
@@ -1461,6 +1548,8 @@ public class DeploymentServiceTests : TestKit
Arg.Any<string>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that deploy instance async central fetch base url empty fails fast without notify or staging.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_CentralFetchBaseUrlEmpty_FailsFastWithoutNotifyOrStaging()
{
@@ -1496,8 +1585,10 @@ public class DeploymentServiceTests : TestKit
Arg.Any<DeploymentRecord>(), Arg.Any<CancellationToken>());
}
// ── DeploymentManager-015: reconciliation must perform the normal success side effects ──
// ── Reconciliation must perform the normal success side effects ──
/// <summary>Verifies that deploy instance async reconciled sets instance enabled and stores snapshot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Reconciled_SetsInstanceEnabledAndStoresSnapshot()
{
@@ -1539,24 +1630,26 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// DeploymentManager-015: the instance State must reflect the deployed
// The instance State must reflect the deployed
// config the site is actually running.
Assert.Equal(InstanceState.Enabled, instance.State);
await _repo.Received().UpdateInstanceAsync(instance, Arg.Any<CancellationToken>());
// DeploymentManager-015: a deployed-config snapshot must be stored so
// A deployed-config snapshot must be stored so
// GetDeploymentComparisonAsync has something to compare against.
Assert.NotNull(storedSnapshot);
Assert.Equal(70, storedSnapshot!.InstanceId);
Assert.Equal("sha256:target", storedSnapshot.RevisionHash);
}
// ── DeploymentManager-018: reconciliation must preserve an intentional Disabled state ──
// ── Reconciliation must preserve an intentional Disabled state ──
/// <summary>Verifies that deploy instance async reconciled disabled instance preserves disabled state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Reconciled_DisabledInstance_PreservesDisabledState()
{
// DeploymentManager-018: after a central failover, the in-memory
// After a central failover, the in-memory
// OperationLockManager is lost (by design — in-progress treated as
// failed). The prior deployment record remains InProgress in the DB.
// The operator can legitimately invoke Disable on the instance between
@@ -1596,19 +1689,20 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(1, counters.QueryCount);
Assert.Equal(0, counters.DeployCount);
// DeploymentManager-018: the operator's explicit Disable must survive
// The operator's explicit Disable must survive
// the reconciliation — Instance.State stays Disabled, not silently
// flipped to Enabled.
Assert.Equal(InstanceState.Disabled, instance.State);
}
/// <summary>
/// The counterpart to the bulk path's Disabled skip (arch-review P7-2): a
/// The counterpart to the bulk path's Disabled skip: a
/// SINGLE-instance deploy of a Disabled instance still deploys AND enables it.
/// That is the documented "Deploy on a Disabled instance also enables it"
/// transition and the explicit operator action bulk deploy now refuses to
/// perform implicitly, so it must keep working exactly as before.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_DisabledInstance_StillDeploysAndEnables()
{
@@ -1632,8 +1726,10 @@ public class DeploymentServiceTests : TestKit
Assert.Equal(InstanceState.Enabled, instance.State);
}
// ── DeploymentManager-016: reconciled record must carry the target revision hash ──
// ── Reconciled record must carry the target revision hash ──
/// <summary>Verifies that deploy instance async reconciled prior record revision hash updated to target.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Reconciled_PriorRecordRevisionHashUpdatedToTarget()
{
@@ -1667,17 +1763,19 @@ public class DeploymentServiceTests : TestKit
Assert.True(result.IsSuccess);
Assert.Equal(DeploymentStatus.Success, prior.Status);
// DeploymentManager-016: the persisted record's RevisionHash must agree
// The persisted record's RevisionHash must agree
// with the site's applied revision, not keep the stale R1.
Assert.Equal("sha256:target", prior.RevisionHash);
}
// ── DeploymentManager-020: reconciliation audit attributes to the CURRENT user, not the prior deployer ──
// ── Reconciliation audit attributes to the CURRENT user, not the prior deployer ──
/// <summary>Verifies that deploy instance async reconciled audit attributes current user not prior deployer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_Reconciled_AuditAttributesCurrentUserNotPriorDeployer()
{
// DeploymentManager-020: a redeploy that reconciles a timed-out prior
// A redeploy that reconciles a timed-out prior
// record must be audited as the action of the user driving THIS
// redeploy — not the user who originally issued the now-reconciled
// deployment. The prior deployer is preserved in the detail object so
@@ -1707,7 +1805,7 @@ public class DeploymentServiceTests : TestKit
var result = await service.DeployInstanceAsync(73, "currentUser");
Assert.True(result.IsSuccess);
// DeploymentManager-020: audit row's actor is the current user.
// Audit row's actor is the current user.
await _audit.Received().LogAsync(
"currentUser", "DeployReconciled", "Instance", "73", "ReconcileAuditUser",
Arg.Any<object>(), Arg.Any<CancellationToken>());
@@ -1717,8 +1815,10 @@ public class DeploymentServiceTests : TestKit
Arg.Any<object>(), Arg.Any<CancellationToken>());
}
// ── DeploymentManager-012: LifecycleCommandTimeout must actually bound lifecycle commands ──
// ── LifecycleCommandTimeout must actually bound lifecycle commands ──
/// <summary>Verifies that disable instance async site unresponsive lifecycle command timeout bounds the wait.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableInstanceAsync_SiteUnresponsive_LifecycleCommandTimeoutBoundsTheWait()
{
@@ -1771,17 +1871,19 @@ public class DeploymentServiceTests : TestKit
/// <summary>Stand-in actor that never replies to anything.</summary>
private class SilentProbeActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>SilentProbeActor</c> class.</summary>
public SilentProbeActor() => ReceiveAny(_ => { });
}
// ── DeploymentManager-019: lifecycle timeouts must write an audit entry ──
// ── Lifecycle timeouts must write an audit entry ──
/// <summary>
/// DeploymentManager-019: when a Disable times out at the site, the
/// When a Disable times out at the site, the
/// operator's attempted action must still be recorded in the audit log
/// with the documented <c>DisableTimedOut</c> action — pre-fix nothing
/// was written and the audit trail was silent about the attempt.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisableInstanceAsync_LifecycleTimeout_WritesDisableTimedOutAuditEntry()
{
@@ -1833,8 +1935,9 @@ public class DeploymentServiceTests : TestKit
}
/// <summary>
/// DeploymentManager-019: same audit guarantee for the Enable path.
/// Same audit guarantee for the Enable path.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnableInstanceAsync_LifecycleTimeout_WritesEnableTimedOutAuditEntry()
{
@@ -1881,8 +1984,9 @@ public class DeploymentServiceTests : TestKit
}
/// <summary>
/// DeploymentManager-019: same audit guarantee for the Delete path.
/// Same audit guarantee for the Delete path.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteInstanceAsync_LifecycleTimeout_WritesDeleteTimedOutAuditEntry()
{
@@ -1928,8 +2032,10 @@ public class DeploymentServiceTests : TestKit
Arg.Any<CancellationToken>());
}
// ── DeploymentManager-003: post-success persistence must commit the Success status ──
// ── Post-success persistence must commit the Success status ──
/// <summary>Verifies that deploy instance async site succeeds snapshot write fails record still committed success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_SiteSucceeds_SnapshotWriteFails_RecordStillCommittedSuccess()
{
@@ -1974,8 +2080,8 @@ public class DeploymentServiceTests : TestKit
}
/// <summary>
/// Per-test counters for <see cref="SerializationProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// Per-test counters for <see cref="SerializationProbeActor"/>.
/// Each test owns its own instance, passed into the actor's constructor, so
/// counters are no longer shared static state that races under parallel
/// test execution.
/// </summary>
@@ -1992,13 +2098,18 @@ public class DeploymentServiceTests : TestKit
/// same instance were NOT serialized by the operation lock their windows
/// would overlap and <c>MaxConcurrent</c> would exceed 1.
///
/// Notify-and-fetch (Task 6): the deploy path now sends a
/// Notify-and-fetch: the deploy path now sends a
/// <see cref="RefreshDeploymentCommand"/> instead of <c>DeployInstanceCommand</c>.
/// </summary>
private class SerializationProbeActor : ReceiveActor, IWithTimers
{
/// <summary>Gets or sets the actor timer scheduler supplied by Akka.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
/// Initializes a new instance of the <c>SerializationProbeActor</c> class.
/// </summary>
/// <param name="counters">The counters.</param>
public SerializationProbeActor(SerializationProbeCounters counters)
{
Receive<SiteEnvelope>(env =>
@@ -2043,8 +2154,8 @@ public class DeploymentServiceTests : TestKit
}
/// <summary>
/// Per-test counters for <see cref="ReconcileProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance so counter assertions cannot race across
/// Per-test counters for <see cref="ReconcileProbeActor"/>.
/// Each test owns its own instance so counter assertions cannot race across
/// tests running in parallel.
/// </summary>
private sealed class ReconcileProbeCounters
@@ -2053,7 +2164,7 @@ public class DeploymentServiceTests : TestKit
public int DeployCount;
/// <summary>
/// Notify-and-fetch (Task 6): the most recent
/// Notify-and-fetch: the most recent
/// <see cref="RefreshDeploymentCommand"/> the probe received, so a test
/// can assert the deploy path notified the site with the staged token,
/// fetch base URL, and deployment id.
@@ -2068,13 +2179,19 @@ public class DeploymentServiceTests : TestKit
/// configurable applied revision hash, and (optionally) drops the query to
/// simulate an unreachable site so the central Ask times out.
///
/// Notify-and-fetch (Task 6): the deploy path now sends a
/// Notify-and-fetch: the deploy path now sends a
/// <see cref="RefreshDeploymentCommand"/> (not the fat
/// <c>DeployInstanceCommand</c>), so the probe counts that message as a
/// "deploy" and records it for assertion.
/// </summary>
private class ReconcileProbeActor : ReceiveActor
{
/// <summary>
/// Initializes a new instance of the <c>ReconcileProbeActor</c> class.
/// </summary>
/// <param name="counters">The counters.</param>
/// <param name="siteHash">The site hash.</param>
/// <param name="failQuery">The fail query.</param>
public ReconcileProbeActor(ReconcileProbeCounters counters, string siteHash, bool failQuery)
{
Receive<SiteEnvelope>(env =>
@@ -17,7 +17,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// CentralUI-006: regression tests proving <see cref="DeploymentService"/>
/// Regression tests proving <see cref="DeploymentService"/>
/// raises <see cref="IDeploymentStatusNotifier.StatusChanged"/> whenever it
/// writes a deployment record's status. This is the event source the Central
/// UI deployment-status page subscribes to instead of polling.
@@ -32,6 +32,7 @@ public class DeploymentStatusNotifierTests : TestKit
private readonly DeploymentStatusNotifier _notifier;
private readonly DeploymentService _service;
/// <summary>Initializes the test class with a notifier substitute and a deployment service wired to stubbed repositories.</summary>
public DeploymentStatusNotifierTests()
{
_repo = Substitute.For<IDeploymentManagerRepository>();
@@ -48,7 +49,7 @@ public class DeploymentStatusNotifierTests : TestKit
OperationLockTimeout = TimeSpan.FromSeconds(5)
});
// DeploymentManager-021: the resolver now throws when the site row
// The resolver now throws when the site row
// is missing, so seed the substitute to return a real-shaped Site for
// any id these tests touch.
var siteRepo = Substitute.For<ISiteRepository>();
@@ -63,13 +64,15 @@ public class DeploymentStatusNotifierTests : TestKit
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
new DiffService(), new RevisionHashService(), _notifier,
Substitute.For<IStaleInstanceProbe>(), options,
// Notify-and-fetch (Task 6): a non-empty CentralFetchBaseUrl lets the
// Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the
// deploy path reach the send (and, here, the catch block) rather than
// tripping the fail-fast configuration guard.
Options.Create(new CommunicationOptions { CentralFetchBaseUrl = "https://central.test:9000" }),
NullLogger<DeploymentService>.Instance);
}
/// <summary>Verifies that deploying an instance raises a status change for every deployment record status write.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployInstanceAsync_RaisesStatusChange_ForEveryRecordStatusWrite()
{
@@ -88,7 +91,7 @@ public class DeploymentStatusNotifierTests : TestKit
// the record ends Failed. The notifier must fire for the InProgress
// and Failed writes — not be silent (the pre-fix behaviour).
//
// DeploymentManager-022: the transient Pending write was dropped from
// The transient Pending write was dropped from
// the deploy path (the record is now created directly in InProgress),
// so there is no Pending notification any more. The remaining two
// writes — the initial InProgress insert and the catch-block Failed
@@ -108,6 +111,7 @@ public class DeploymentStatusNotifierTests : TestKit
Assert.All(changes, c => Assert.Equal(deploymentId, c.DeploymentId));
}
/// <summary>Verifies that notifying a status change with no subscribers does not throw.</summary>
[Fact]
public void NotifyStatusChanged_WithNoSubscribers_DoesNotThrow()
{
@@ -119,6 +123,7 @@ public class DeploymentStatusNotifierTests : TestKit
Assert.Null(ex);
}
/// <summary>Verifies that a throwing subscriber does not prevent other subscribers from being notified.</summary>
[Fact]
public void NotifyStatusChanged_ThrowingSubscriber_DoesNotBreakOtherSubscribers()
{
@@ -11,7 +11,7 @@ using TemplateAttribute = ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Templ
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP2.5: the flatten-session cache. Pins the two properties the bulk deploy path
/// The flatten-session cache. Pins the two properties the bulk deploy path
/// depends on — that N instances sharing a template chain load that chain ONCE per
/// session, and that a template mutation (observed through the watermark)
/// invalidates the memo so a session can never serve stale template state.
@@ -28,6 +28,7 @@ public class FlattenSessionCacheTests
private readonly TemplateGraphWatermark _watermark = new();
private readonly FlatteningPipeline _sut;
/// <summary>Initializes the test with an in-memory repository and a counting flattening pipeline.</summary>
public FlattenSessionCacheTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
@@ -67,6 +68,8 @@ public class FlattenSessionCacheTests
}
}
/// <summary>Verifies that shared session, ten instances one template, loads each template once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SharedSession_TenInstancesOneTemplate_LoadsEachTemplateOnce()
{
@@ -95,6 +98,8 @@ public class FlattenSessionCacheTests
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that no shared session, every instance reloads the chain.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoSharedSession_EveryInstanceReloadsTheChain()
{
@@ -102,7 +107,7 @@ public class FlattenSessionCacheTests
ArrangeSharedChain(instanceCount);
// Baseline: without a shared session each flatten gets its own private
// one, which is the pre-WP2.5 behaviour the batch path improves on.
// one, which is the earlier per-instance behaviour the batch path improves on.
for (var i = 1; i <= instanceCount; i++)
{
var result = await _sut.FlattenAndValidateAsync(i, CancellationToken.None, validateScripts: false);
@@ -113,6 +118,8 @@ public class FlattenSessionCacheTests
.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that template mutation, invalidates memo, so session never serves stale state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateMutation_InvalidatesMemo_SoSessionNeverServesStaleState()
{
@@ -133,6 +140,8 @@ public class FlattenSessionCacheTests
await _templateRepo.Received(1).GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that structural mutation, invalidates cached chain membership.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StructuralMutation_InvalidatesCachedChainMembership()
{
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// M2.8 (#23): proves the deploy path (FlatteningPipeline.FlattenAndValidateAsync)
/// Proves the deploy path (FlatteningPipeline.FlattenAndValidateAsync)
/// opts into connection-binding enforcement, so a data-sourced attribute with no
/// binding gates the deployment as an ERROR (not just a warning), and that a binding
/// resolving to a connection that actually exists at the target site passes.
@@ -30,6 +30,7 @@ public class FlatteningPipelineConnectionBindingTests
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
private readonly FlatteningPipeline _sut;
/// <summary>Initializes a new instance of the <c>FlatteningPipelineConnectionBindingTests</c> class.</summary>
public FlatteningPipelineConnectionBindingTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>())
@@ -79,6 +80,8 @@ public class FlatteningPipelineConnectionBindingTests
.Returns([connection]);
}
/// <summary>Verifies that flatten and validate, when data sourced attribute with no binding, reports binding error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FlattenAndValidate_DataSourcedAttributeWithNoBinding_ReportsBindingError()
{
@@ -92,6 +95,8 @@ public class FlatteningPipelineConnectionBindingTests
e => e.Category == ValidationCategory.ConnectionBinding);
}
/// <summary>Verifies that flatten and validate, when binding to existing site connection, no binding error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FlattenAndValidate_BindingToExistingSiteConnection_NoBindingError()
{
@@ -104,10 +109,12 @@ public class FlatteningPipelineConnectionBindingTests
e => e.Category == ValidationCategory.ConnectionBinding);
}
/// <summary>Verifies that flatten and validate, when binding to stale deleted connection, reports binding error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FlattenAndValidate_BindingToStaleDeletedConnection_ReportsBindingError()
{
// M2.8 (#23): FlatteningService.ApplyConnectionBindings silently drops a
// FlatteningService.ApplyConnectionBindings silently drops a
// binding whose DataConnectionId doesn't resolve to any loaded site
// DataConnection (stale / deleted connection). The flattener leaves
// BoundDataConnectionId == null, so the validator treats the attribute as
@@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// M2.1 (#22): proves the FlatteningPipeline actually computes the alarm-capable
/// Proves the FlatteningPipeline actually computes the alarm-capable
/// connection set from the loaded site data connections and threads it through
/// ValidationService → SemanticValidator. Before the fix the pipeline loaded the
/// connections but never passed the capable set, so the native-alarm-source
@@ -30,6 +30,7 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
private readonly FlatteningPipeline _sut;
/// <summary>Initializes a new instance of the <see cref="FlatteningPipelineNativeAlarmCapabilityTests"/> class.</summary>
public FlatteningPipelineNativeAlarmCapabilityTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>())
@@ -73,6 +74,8 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
.Returns([connection]);
}
/// <summary>Verifies that a native alarm source on a connection that is not alarm-capable reports a capability error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FlattenAndValidate_NativeAlarmSourceOnNonAlarmCapableConnection_ReportsCapabilityError()
{
@@ -87,6 +90,9 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
&& e.Message.Contains("alarm-capable"));
}
/// <summary>Verifies that a native alarm source on an alarm-capable connection reports no capability error.</summary>
/// <param name="protocol">The data connection protocol under test.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("OpcUa")]
[InlineData("MxGateway")]
@@ -24,6 +24,7 @@ public class FlatteningPipelinePreviewTests
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
private readonly FlatteningPipeline _sut;
/// <summary>Initializes the test class with in-memory repositories and the pipeline under test.</summary>
public FlatteningPipelinePreviewTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
@@ -60,6 +61,8 @@ public class FlatteningPipelinePreviewTests
.Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]);
}
/// <summary>Verifies that previewing matches flattening a freshly created instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Preview_matches_flatten_of_fresh_instance()
{
@@ -81,6 +84,8 @@ public class FlatteningPipelinePreviewTests
Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag");
}
/// <summary>Verifies that previewing an unknown template fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Preview_unknown_template_fails()
{
@@ -88,6 +93,8 @@ public class FlatteningPipelinePreviewTests
Assert.True(r.IsFailure);
}
/// <summary>Verifies that previewing never touches the instance repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Preview_never_touches_instance_repository()
{
@@ -1,12 +1,14 @@
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP-3: Tests for per-instance operation lock.
/// Tests for per-instance operation lock.
/// </summary>
public class OperationLockManagerTests
{
private readonly OperationLockManager _lockManager = new();
/// <summary>Verifies that acquiring the lock returns a disposable handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_ReturnsDisposable()
{
@@ -14,6 +16,8 @@ public class OperationLockManagerTests
Assert.NotNull(lockHandle);
}
/// <summary>Verifies that a second caller for the same instance is blocked until the lock is released.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_SameInstance_BlocksSecondCaller()
{
@@ -24,6 +28,8 @@ public class OperationLockManagerTests
_lockManager.AcquireAsync("inst1", TimeSpan.FromMilliseconds(50)));
}
/// <summary>Verifies that locks on different instances can both be acquired.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_DifferentInstances_BothSucceed()
{
@@ -34,6 +40,8 @@ public class OperationLockManagerTests
Assert.NotNull(lock2);
}
/// <summary>Verifies that the lock can be re-acquired after it is released.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_AfterRelease_CanReacquire()
{
@@ -45,6 +53,8 @@ public class OperationLockManagerTests
Assert.NotNull(secondLock);
}
/// <summary>Verifies that an instance reports as locked while the lock is held.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task IsLocked_ReturnsTrueWhileLocked()
{
@@ -54,6 +64,8 @@ public class OperationLockManagerTests
Assert.True(_lockManager.IsLocked("inst1"));
}
/// <summary>Verifies that an instance reports as not locked after the lock is released.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task IsLocked_ReturnsFalseAfterRelease()
{
@@ -63,6 +75,8 @@ public class OperationLockManagerTests
Assert.False(_lockManager.IsLocked("inst1"));
}
/// <summary>Verifies that disposing the lock handle twice does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_DoubleDispose_DoesNotThrow()
{
@@ -71,6 +85,8 @@ public class OperationLockManagerTests
lockHandle.Dispose(); // Should not throw
}
/// <summary>Verifies that acquiring the lock respects the cancellation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_CancellationToken_Respected()
{
@@ -81,6 +97,8 @@ public class OperationLockManagerTests
_lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(30), cts.Token));
}
/// <summary>Verifies that concurrent acquisitions on different instances all succeed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_ConcurrentDifferentInstances_AllSucceed()
{
@@ -93,8 +111,10 @@ public class OperationLockManagerTests
await Task.WhenAll(tasks);
}
// ── DeploymentManager-005: semaphore must not leak ──
// ── Semaphore must not leak ──
/// <summary>Verifies that releasing a lock removes its semaphore entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_ReleasedLock_RemovesSemaphoreEntry()
{
@@ -110,6 +130,8 @@ public class OperationLockManagerTests
Assert.Equal(0, _lockManager.TrackedLockCount);
}
/// <summary>Verifies that many distinct instances do not accumulate semaphores.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_ManyDistinctInstances_DoesNotAccumulateSemaphores()
{
@@ -123,6 +145,8 @@ public class OperationLockManagerTests
Assert.Equal(0, _lockManager.TrackedLockCount);
}
/// <summary>Verifies that a contended lock keeps its semaphore until the last release and then reclaims it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcquireAsync_ContendedLock_KeepsSemaphoreUntilLastReleaseThenReclaims()
{
@@ -149,6 +149,8 @@ public class PendingUpdatesEvaluatorTests
// ── artifact state ───────────────────────────────────────────────────────
/// <summary>Verifies that a site with a null stored hash is reported as never deployed and not as a pending update.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_NullStoredHash_IsNeverDeployedAndNotAPendingUpdate()
{
@@ -167,6 +169,8 @@ public class PendingUpdatesEvaluatorTests
Assert.False(snapshot.HasPendingUpdates);
}
/// <summary>Verifies that a stored hash that matches the current definitions is reported as current.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_StoredHashMatchesCurrentDefinitions_IsCurrent()
{
@@ -186,6 +190,8 @@ public class PendingUpdatesEvaluatorTests
Assert.False(snapshot.HasPendingUpdates);
}
/// <summary>Verifies that a stored hash that differs from the current definitions is reported as out of date and counted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_StoredHashDiffersFromCurrentDefinitions_IsOutOfDateAndCounted()
{
@@ -203,6 +209,8 @@ public class PendingUpdatesEvaluatorTests
Assert.True(snapshot.HasPendingUpdates);
}
/// <summary>Verifies that the evaluation carries the site identity onto each result row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_CarriesSiteIdentityOntoTheRow()
{
@@ -220,6 +228,8 @@ public class PendingUpdatesEvaluatorTests
// ── instance counts ──────────────────────────────────────────────────────
/// <summary>Verifies that deployed, stale and unknown instances are counted per site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_CountsDeployedStaleAndUnknownInstancesPerSite()
{
@@ -248,6 +258,8 @@ public class PendingUpdatesEvaluatorTests
Assert.True(snapshot.HasPendingUpdates);
}
/// <summary>Verifies that not-deployed instances are never handed to the staleness batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_NotDeployedInstancesAreNeverHandedToTheStalenessBatch()
{
@@ -278,6 +290,8 @@ public class PendingUpdatesEvaluatorTests
Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
}
/// <summary>Verifies that instance counts are attributed to their own site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_AttributesInstanceCountsToTheirOwnSite()
{
@@ -302,6 +316,8 @@ public class PendingUpdatesEvaluatorTests
Assert.Equal(1, snapshot.StaleInstanceCount);
}
/// <summary>Verifies that a fleet with nothing stale has no pending updates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_NothingStale_HasNoPendingUpdates()
{
@@ -325,6 +341,8 @@ public class PendingUpdatesEvaluatorTests
// ── ordering, ComputedAt, ForSites ───────────────────────────────────────
/// <summary>Verifies that sites are ordered by name, ignoring case.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_OrdersSitesByNameIgnoringCase()
{
@@ -339,6 +357,8 @@ public class PendingUpdatesEvaluatorTests
Assert.Equal(["Alpha", "bravo", "charlie"], snapshot.Sites.Select(s => s.SiteName));
}
/// <summary>Verifies that the evaluation stamps the computed-at time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_StampsComputedAt()
{
@@ -351,6 +371,8 @@ public class PendingUpdatesEvaluatorTests
Assert.InRange(snapshot.ComputedAt, before, DateTimeOffset.UtcNow);
}
/// <summary>Verifies that exceptions thrown during evaluation propagate to the caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateAsync_ExceptionsPropagate()
{
@@ -364,6 +386,7 @@ public class PendingUpdatesEvaluatorTests
Assert.Equal("db down", ex.Message);
}
/// <summary>Verifies that scoping to sites keeps only the named sites and the same computed-at time.</summary>
[Fact]
public void ForSites_KeepsOnlyTheNamedSitesAndTheSameComputedAt()
{
@@ -387,6 +410,7 @@ public class PendingUpdatesEvaluatorTests
Assert.True(scoped.HasPendingUpdates);
}
/// <summary>Verifies that scoping to an empty site set yields no pending updates.</summary>
[Fact]
public void ForSites_EmptyScope_YieldsNoPendingUpdates()
{
@@ -401,6 +425,7 @@ public class PendingUpdatesEvaluatorTests
Assert.False(scoped.HasPendingUpdates);
}
/// <summary>Verifies that the snapshot totals sum across all sites.</summary>
[Fact]
public void Snapshot_Totals_SumAcrossSites()
{
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// DeploymentManager-008: DeploymentManagerOptions must be resolvable via the
/// DeploymentManagerOptions must be resolvable via the
/// Options pattern and bindable to the "ScadaBridge:DeploymentManager"
/// configuration section. The component itself does not depend on
/// IConfiguration (enforced by Host's OptionsTests) — the Host binds the
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// </summary>
public class ServiceCollectionExtensionsTests
{
/// <summary>Verifies that AddDeploymentManager registers resolvable options with default values.</summary>
[Fact]
public void AddDeploymentManager_RegistersResolvableOptions_WithDefaults()
{
@@ -26,6 +27,7 @@ public class ServiceCollectionExtensionsTests
Assert.Equal(TimeSpan.FromSeconds(5), options.OperationLockTimeout);
}
/// <summary>Verifies that the options bind to the configuration section the way the host wires them.</summary>
[Fact]
public void AddDeploymentManager_OptionsBindToConfigurationSection_AsTheHostWires()
{
@@ -51,16 +53,18 @@ public class ServiceCollectionExtensionsTests
Assert.Equal(TimeSpan.FromMinutes(3), options.ArtifactDeploymentTimeoutPerSite);
}
/// <summary>Verifies that the options section name matches the conventional component section path.</summary>
[Fact]
public void OptionsSection_MatchesTheConventionalComponentSectionPath()
{
Assert.Equal("ScadaBridge:DeploymentManager", ServiceCollectionExtensions.OptionsSection);
}
// CentralUI-006: the deployment-status notifier must be a singleton so the
// The deployment-status notifier must be a singleton so the
// scoped DeploymentService and the Central UI's scoped Blazor page share
// one instance — without that, a push notification raised by the service
// would never reach the page's subscription.
/// <summary>Verifies that AddDeploymentManager registers the deployment status notifier as a singleton.</summary>
[Fact]
public void AddDeploymentManager_RegistersDeploymentStatusNotifier_AsSingleton()
{
@@ -32,6 +32,8 @@ public class SiteSecretsCollectorTests
UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
};
/// <summary>Verifies that collect returns only referenced rows and names missing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_ReturnsOnlyReferencedRows_AndNamesMissing()
{
@@ -48,6 +50,8 @@ public class SiteSecretsCollectorTests
Assert.Equal(["GW2"], result.Missing[0].ReferencedBy);
}
/// <summary>Verifies that collect resolved row maps every field from the stored secret verbatim.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_ResolvedRow_MapsEveryFieldFromTheStoredSecretVerbatim()
{
@@ -74,6 +78,8 @@ public class SiteSecretsCollectorTests
Assert.Equal(row.Description, artifact.Description);
}
/// <summary>Verifies that collect absent name is reported missing not thrown.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_AbsentName_IsReportedMissing_NotThrown()
{
@@ -88,6 +94,8 @@ public class SiteSecretsCollectorTests
Assert.Equal(["GW1"], missing.ReferencedBy);
}
/// <summary>Verifies that collect no secret references returns empty without querying the store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_NoSecretReferences_ReturnsEmptyWithoutQueryingTheStore()
{
@@ -100,10 +108,12 @@ public class SiteSecretsCollectorTests
Assert.Empty(result.Missing);
}
/// <summary>Verifies that collect store throws returns store error instead of throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_StoreThrows_ReturnsStoreError_InsteadOfThrowing()
{
// Final review M5: one site's store read failing must not abort a whole sweep.
// One site's store read failing must not abort a whole sweep.
var store = new FakeSecretStore(Row("a", 1)) { Fault = new InvalidOperationException("db down") };
var result = await new SiteSecretsCollector(store).CollectAsync(
@@ -114,6 +124,8 @@ public class SiteSecretsCollectorTests
Assert.Contains("InvalidOperationException: db down", result.StoreError);
}
/// <summary>Verifies that collect caller cancellation still propagates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_CallerCancellation_StillPropagates()
{
@@ -125,6 +137,8 @@ public class SiteSecretsCollectorTests
[Dc("GW1", """{"ApiKey":"secret:a"}""")], cts.Token));
}
/// <summary>Verifies that collect success has no store error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Collect_Success_HasNoStoreError()
{
@@ -144,30 +158,61 @@ public class SiteSecretsCollectorTests
{
private readonly Dictionary<SecretName, StoredSecret> _rows;
/// <summary>Initializes a new instance of <c>FakeSecretStore</c>.</summary>
/// <param name="rows">The rows.</param>
public FakeSecretStore(params StoredSecret[] rows) => _rows = rows.ToDictionary(r => r.Name);
/// <summary>When set, <see cref="GetAsync"/> throws this instead of reading.</summary>
public Exception? Fault { get; init; }
/// <summary>Returns the canned secret row, or faults when a fault is configured.</summary>
/// <param name="name">The secret name to look up.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that yields the stored secret, or null.</returns>
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
Fault is not null
? Task.FromException<StoredSecret?>(Fault)
: Task.FromResult(_rows.TryGetValue(name, out var row) ? row : null);
/// <summary>Not supported by this fake.</summary>
/// <param name="row">The secret row, unused.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
/// <summary>Not supported by this fake.</summary>
/// <param name="name">The secret name, unused.</param>
/// <param name="actor">The acting user, unused.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that yields whether a row was deleted.</returns>
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
throw new NotSupportedException();
/// <summary>Not supported by this fake.</summary>
/// <param name="includeDeleted">Whether tombstoned secrets are included.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that yields the secret metadata.</returns>
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
throw new NotSupportedException();
/// <summary>Not supported by this fake.</summary>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that yields the secret manifest.</returns>
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
throw new NotSupportedException();
/// <summary>Not supported by this fake.</summary>
/// <param name="row">The replicated secret row, unused.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException();
/// <summary>Not supported by this fake.</summary>
/// <param name="rewrappedRow">The rewrapped secret row, unused.</param>
/// <param name="expectedCurrentWrappedDek">The wrapped data key expected to be current, unused.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that yields whether the rewrap was applied.</returns>
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
throw new NotSupportedException();
@@ -32,6 +32,7 @@ public class StaleInstanceProbeTests
private string _currentHash = "sha256:first";
private int _flattenCount;
/// <summary>Initializes a new instance of the <c>StaleInstanceProbeTests</c> class.</summary>
public StaleInstanceProbeTests()
{
// Process-static memo: start every test from a clean slate.
@@ -51,6 +52,8 @@ public class StaleInstanceProbeTests
private StaleInstanceProbe CreateProbe() => new(_pipeline, _watermark);
/// <summary>Verifies that peer edit with independent watermark is observed by warm active probe.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PeerEdit_WithIndependentWatermark_IsObservedByWarmActiveProbe()
{
@@ -62,6 +65,8 @@ public class StaleInstanceProbeTests
Assert.Equal("sha256:peer-edit", await active.GetCurrentRevisionHashAsync(InstanceId));
}
/// <summary>Verifies that unchanged watermark still reflattens authoritatively.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnchangedWatermark_StillReflattensAuthoritatively()
{
@@ -79,6 +84,7 @@ public class StaleInstanceProbeTests
/// flattening input without an owning template id — data-connection saves and
/// bundle imports both rely on it. It must invalidate the memo.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BumpAll_InvalidatesTheMemo()
{
@@ -97,6 +103,8 @@ public class StaleInstanceProbeTests
Assert.Equal(3, _flattenCount);
}
/// <summary>Verifies that bump instance invalidates the memo for that instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BumpInstance_InvalidatesTheMemoForThatInstance()
{
@@ -110,7 +118,7 @@ public class StaleInstanceProbeTests
}
/// <summary>
/// P7-4: a non-structural template edit landing DURING the flatten must not be
/// A non-structural template edit landing DURING the flatten must not be
/// captured as if it were already included.
///
/// <para>
@@ -130,6 +138,7 @@ public class StaleInstanceProbeTests
/// which is correct, since that is the graph the flatten actually saw.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateEditedDuringFlatten_InvalidatesTheMemo_RatherThanPinningTheStaleHash()
{
@@ -3,12 +3,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP-4: Tests for instance state transition matrix.
/// Tests for the instance state transition matrix.
/// </summary>
public class StateTransitionValidatorTests
{
// ── Deploy transitions ──
/// <summary>Verifies that deploying is allowed from every instance state.</summary>
/// <param name="state">The current instance state.</param>
[Theory]
[InlineData(InstanceState.NotDeployed)]
[InlineData(InstanceState.Enabled)]
@@ -20,18 +22,21 @@ public class StateTransitionValidatorTests
// ── Disable transitions ──
/// <summary>Verifies that an enabled instance can be disabled.</summary>
[Fact]
public void CanDisable_WhenEnabled_ReturnsTrue()
{
Assert.True(StateTransitionValidator.CanDisable(InstanceState.Enabled));
}
/// <summary>Verifies that a disabled instance cannot be disabled again.</summary>
[Fact]
public void CanDisable_WhenDisabled_ReturnsFalse()
{
Assert.False(StateTransitionValidator.CanDisable(InstanceState.Disabled));
}
/// <summary>Verifies that a not-deployed instance cannot be disabled.</summary>
[Fact]
public void CanDisable_WhenNotDeployed_ReturnsFalse()
{
@@ -40,18 +45,21 @@ public class StateTransitionValidatorTests
// ── Enable transitions ──
/// <summary>Verifies that a disabled instance can be enabled.</summary>
[Fact]
public void CanEnable_WhenDisabled_ReturnsTrue()
{
Assert.True(StateTransitionValidator.CanEnable(InstanceState.Disabled));
}
/// <summary>Verifies that an enabled instance cannot be enabled again.</summary>
[Fact]
public void CanEnable_WhenEnabled_ReturnsFalse()
{
Assert.False(StateTransitionValidator.CanEnable(InstanceState.Enabled));
}
/// <summary>Verifies that a not-deployed instance cannot be enabled.</summary>
[Fact]
public void CanEnable_WhenNotDeployed_ReturnsFalse()
{
@@ -60,18 +68,21 @@ public class StateTransitionValidatorTests
// ── Delete transitions ──
/// <summary>Verifies that an enabled instance can be deleted.</summary>
[Fact]
public void CanDelete_WhenEnabled_ReturnsTrue()
{
Assert.True(StateTransitionValidator.CanDelete(InstanceState.Enabled));
}
/// <summary>Verifies that a disabled instance can be deleted.</summary>
[Fact]
public void CanDelete_WhenDisabled_ReturnsTrue()
{
Assert.True(StateTransitionValidator.CanDelete(InstanceState.Disabled));
}
/// <summary>Verifies that a not-deployed instance can be deleted.</summary>
[Fact]
public void CanDelete_WhenNotDeployed_ReturnsTrue()
{
@@ -80,6 +91,7 @@ public class StateTransitionValidatorTests
// ── ValidateTransition ──
/// <summary>Verifies that a valid deploy transition returns no error.</summary>
[Fact]
public void ValidateTransition_ValidDeploy_ReturnsNull()
{
@@ -87,6 +99,7 @@ public class StateTransitionValidatorTests
Assert.Null(error);
}
/// <summary>Verifies that an invalid enable transition returns an error.</summary>
[Fact]
public void ValidateTransition_InvalidEnable_ReturnsError()
{
@@ -95,6 +108,7 @@ public class StateTransitionValidatorTests
Assert.Contains("not allowed", error);
}
/// <summary>Verifies that an invalid disable transition returns an error.</summary>
[Fact]
public void ValidateTransition_InvalidDisable_ReturnsError()
{
@@ -102,6 +116,7 @@ public class StateTransitionValidatorTests
Assert.NotNull(error);
}
/// <summary>Verifies that deleting a not-deployed instance returns no error.</summary>
[Fact]
public void ValidateTransition_ValidDeleteOnNotDeployed_ReturnsNull()
{
@@ -109,6 +124,7 @@ public class StateTransitionValidatorTests
Assert.Null(error);
}
/// <summary>Verifies that an unknown operation returns an error.</summary>
[Fact]
public void ValidateTransition_UnknownOperation_ReturnsError()
{