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:
+118
-28
@@ -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 =>
|
||||
|
||||
+20
-2
@@ -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()
|
||||
{
|
||||
|
||||
+9
@@ -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()
|
||||
{
|
||||
|
||||
+36
-3
@@ -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 =>
|
||||
|
||||
+9
-4
@@ -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()
|
||||
{
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+7
-1
@@ -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()
|
||||
{
|
||||
|
||||
+6
-2
@@ -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()
|
||||
{
|
||||
|
||||
+17
-1
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user