Files
Joseph Doherty 79ce51612e test(site): pin the active-node notification-config purge; scope the guarded write
Task 12 + Task 13. No production behaviour change in either.

Task 12: DeploymentManagerActor.HandleDeployArtifacts already purges
notification_lists and smtp_configurations on every artifact apply, but nothing
pinned the actor's CALL to it — ArtifactStorageTests covers the storage method
only. Task 15 deletes SiteReplicationActor's copy, making this the sole
remaining call site, and Task 16 edits this actor's wiring; dropping the call
would leave plaintext SMTP passwords on disk with every suite still green.
Verified red-first by commenting the call out: the pin fails with that message.

Task 13: StoreDeployedConfigIfNewerAsync STAYS. Re-verified both callers —
SiteReplicationActor:375 (dies at Task 15) and SiteReconciliationActor:166
(survives). Reconciliation is a per-node startup self-heal against central
whose fetch races real deploys, so the deployed_at guard still does real work
there. Doc comment rewritten to say so, and to warn against porting the guard
onto the replication path where it would fight the HLC rather than help it.
Also corrected a stale 'guarded standby write' section header in the tests.

Re-ran the Task 13 step 2 scope check: ConfigFetchRetryCount's only production
reader remains SiteReplicationActor:157, so its option + validator rule stay
until Task 17, after Task 15 deletes the actor.

Verified: build 0 warnings; SiteRuntime 533, Host 329, StoreAndForward 153,
LocalDb integration 16 — all pass.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 04:03:00 -04:00

344 lines
14 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// Tests for SiteStorageService using file-based SQLite (temp files).
/// Validates the schema, CRUD operations, and constraint behavior.
/// </summary>
/// <remarks>
/// The service now takes an <c>ILocalDb</c> rather than a connection string, so the fixture
/// is a real temp-file LocalDb. It stays a file (never in-memory): LocalDb has no in-memory
/// mode, and the connections it hands out carry the pragmas and the <c>zb_hlc_next()</c> UDF
/// the site tables' capture triggers depend on.
/// </remarks>
public class SiteStorageServiceTests : IAsyncLifetime, IDisposable
{
private readonly TestLocalDb _localDb;
private SiteStorageService _storage = null!;
public SiteStorageServiceTests()
{
_localDb = TestLocalDb.CreateTemp("site-storage-test");
}
public async Task InitializeAsync()
{
_storage = new SiteStorageService(
_localDb.Db,
NullLogger<SiteStorageService>.Instance);
await _storage.InitializeAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
{
// Dispose first — the master connection anchors the WAL, so the sidecars
// cannot be removed while it is open.
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
[Fact]
public async Task InitializeAsync_CreatesTablesWithoutError()
{
// Already called in InitializeAsync — just verify no exception
// Call again to verify idempotency (CREATE IF NOT EXISTS)
await _storage.InitializeAsync();
}
[Fact]
public async Task Initialize_EnablesWalJournalMode()
{
// ── Invariant that moved owner ──
// WAL used to be SiteStorageService's own job (an explicit PRAGMA in
// InitializeAsync). LocalDb now owns the file and its pragmas, so the service no
// longer sets it. The guarantee production depends on has NOT moved: without WAL
// the site's concurrent readers and writers start serializing on "database is
// locked". So rather than deleting this test with the code that used to provide
// the pragma, it is retargeted to assert the same guarantee against the new,
// LocalDb-backed service. journal_mode is persistent and file-scoped, so any
// connection observes it. A file-backed DB is still required — WAL is not
// available for :memory: databases, which is also why LocalDb has no in-memory mode.
await using var conn = _storage.CreateConnection(); // already open — do NOT call OpenAsync
await using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode;";
var mode = (string)(await cmd.ExecuteScalarAsync())!;
Assert.Equal("wal", mode.ToLowerInvariant());
}
[Fact]
public async Task StoreAndRetrieve_DeployedConfig_RoundTrips()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"test\":true}", "dep-001", "sha256:abc", isEnabled: true);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
Assert.Equal("Pump1", configs[0].InstanceUniqueName);
Assert.Equal("{\"test\":true}", configs[0].ConfigJson);
Assert.Equal("dep-001", configs[0].DeploymentId);
Assert.Equal("sha256:abc", configs[0].RevisionHash);
Assert.True(configs[0].IsEnabled);
}
[Fact]
public async Task StoreDeployedConfig_Upserts_OnConflict()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"v\":1}", "dep-001", "sha256:aaa", isEnabled: true);
await _storage.StoreDeployedConfigAsync(
"Pump1", "{\"v\":2}", "dep-002", "sha256:bbb", isEnabled: false);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
Assert.Equal("{\"v\":2}", configs[0].ConfigJson);
Assert.Equal("dep-002", configs[0].DeploymentId);
Assert.False(configs[0].IsEnabled);
}
[Fact]
public async Task RemoveDeployedConfig_RemovesConfigAndOverrides()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{}", "dep-001", "sha256:aaa", isEnabled: true);
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "100");
await _storage.RemoveDeployedConfigAsync("Pump1");
var configs = await _storage.GetAllDeployedConfigsAsync();
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Empty(configs);
Assert.Empty(overrides);
}
[Fact]
public async Task SetInstanceEnabled_UpdatesFlag()
{
await _storage.StoreDeployedConfigAsync(
"Pump1", "{}", "dep-001", "sha256:aaa", isEnabled: true);
await _storage.SetInstanceEnabledAsync("Pump1", false);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.False(configs[0].IsEnabled);
await _storage.SetInstanceEnabledAsync("Pump1", true);
configs = await _storage.GetAllDeployedConfigsAsync();
Assert.True(configs[0].IsEnabled);
}
[Fact]
public async Task SetInstanceEnabled_NonExistent_DoesNotThrow()
{
// Should not throw for a missing instance
await _storage.SetInstanceEnabledAsync("DoesNotExist", true);
}
// ── Static Override Tests ──
[Fact]
public async Task SetAndGetStaticOverride_RoundTrips()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Single(overrides);
Assert.Equal("98.6", overrides["Temperature"]);
}
[Fact]
public async Task SetStaticOverride_Upserts_OnConflict()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "100.0");
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Single(overrides);
Assert.Equal("100.0", overrides["Temperature"]);
}
[Fact]
public async Task ClearStaticOverrides_RemovesAll()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
await _storage.SetStaticOverrideAsync("Pump1", "Pressure", "50.0");
await _storage.ClearStaticOverridesAsync("Pump1");
var overrides = await _storage.GetStaticOverridesAsync("Pump1");
Assert.Empty(overrides);
}
[Fact]
public async Task GetStaticOverrides_IsolatedPerInstance()
{
await _storage.SetStaticOverrideAsync("Pump1", "Temperature", "98.6");
await _storage.SetStaticOverrideAsync("Pump2", "Pressure", "50.0");
var pump1 = await _storage.GetStaticOverridesAsync("Pump1");
var pump2 = await _storage.GetStaticOverridesAsync("Pump2");
Assert.Single(pump1);
Assert.Single(pump2);
Assert.True(pump1.ContainsKey("Temperature"));
Assert.True(pump2.ContainsKey("Pressure"));
}
[Fact]
public async Task MultipleInstances_IndependentLifecycle()
{
await _storage.StoreDeployedConfigAsync("Pump1", "{}", "d1", "h1", true);
await _storage.StoreDeployedConfigAsync("Pump2", "{}", "d2", "h2", true);
await _storage.StoreDeployedConfigAsync("Pump3", "{}", "d3", "h3", false);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Equal(3, configs.Count);
await _storage.RemoveDeployedConfigAsync("Pump2");
configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Equal(2, configs.Count);
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "Pump2");
}
// ── Negative Tests ──
[Fact]
public async Task Schema_DoesNotContain_AlarmStateTable()
{
// Per design: no alarm state table in site SQLite
var configs = await _storage.GetAllDeployedConfigsAsync();
var overrides = await _storage.GetStaticOverridesAsync("nonexistent");
Assert.Empty(configs);
Assert.Empty(overrides);
}
// ── StoreDeployedConfigIfNewerAsync (the deployed_at-guarded write) ──
//
// Originally the standby's notify-and-fetch write path. LocalDb Phase 2 replaced that
// with change-data-capture, so the surviving caller is SiteReconciliationActor's
// startup self-heal against central, where the guard still stops a slow reconcile
// response from overwriting a newer config that landed while it was in flight.
/// <summary>
/// Seeds a deployed_configurations row with an explicit deployed_at timestamp using the same
/// "O" format the service uses, so tests can establish deterministic older/newer/equal rows.
/// </summary>
private async Task SeedDeployedConfigAsync(
string instanceName, string configJson, string deploymentId,
string revisionHash, bool isEnabled, DateTimeOffset deployedAt)
{
// Seeded through the service's own (already-open) LocalDb connection: a raw
// SqliteConnection would lack the pragmas and the zb_hlc_next() UDF the site
// tables' capture triggers call.
await using var conn = _storage.CreateConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO deployed_configurations
(instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
ON CONFLICT(instance_unique_name) DO UPDATE SET
config_json = excluded.config_json,
deployment_id = excluded.deployment_id,
revision_hash = excluded.revision_hash,
is_enabled = excluded.is_enabled,
deployed_at = excluded.deployed_at";
cmd.Parameters.AddWithValue("@name", instanceName);
cmd.Parameters.AddWithValue("@json", configJson);
cmd.Parameters.AddWithValue("@depId", deploymentId);
cmd.Parameters.AddWithValue("@hash", revisionHash);
cmd.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
cmd.Parameters.AddWithValue("@deployedAt", deployedAt.ToString("O"));
await cmd.ExecuteNonQueryAsync();
}
[Fact]
public async Task StoreDeployedConfigIfNewer_NoExistingRow_Inserts()
{
var at = DateTimeOffset.UtcNow;
await _storage.StoreDeployedConfigIfNewerAsync(
"Pump1", "{\"v\":1}", "dep-001", "sha256:aaa", isEnabled: true, deployedAtOverride: at);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
Assert.Equal("Pump1", configs[0].InstanceUniqueName);
Assert.Equal("{\"v\":1}", configs[0].ConfigJson);
Assert.Equal("dep-001", configs[0].DeploymentId);
Assert.Equal("sha256:aaa", configs[0].RevisionHash);
Assert.True(configs[0].IsEnabled);
}
[Fact]
public async Task StoreDeployedConfigIfNewer_ExistingOlderRow_Overwrites()
{
var olderAt = new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero);
var newerAt = new DateTimeOffset(2026, 1, 1, 11, 0, 0, TimeSpan.Zero);
await SeedDeployedConfigAsync("Pump1", "{\"v\":1}", "dep-001", "sha256:aaa", true, olderAt);
await _storage.StoreDeployedConfigIfNewerAsync(
"Pump1", "{\"v\":2}", "dep-002", "sha256:bbb", isEnabled: false, deployedAtOverride: newerAt);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
Assert.Equal("{\"v\":2}", configs[0].ConfigJson);
Assert.Equal("dep-002", configs[0].DeploymentId);
Assert.Equal("sha256:bbb", configs[0].RevisionHash);
Assert.False(configs[0].IsEnabled);
}
[Fact]
public async Task StoreDeployedConfigIfNewer_ExistingNewerRow_IsNoop()
{
var newerAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
var olderAt = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
// Seed the row that is already newer than what the standby would write
await SeedDeployedConfigAsync("Pump1", "{\"v\":2}", "dep-002", "sha256:bbb", false, newerAt);
// Guarded write with an older timestamp — must be a NO-OP
await _storage.StoreDeployedConfigIfNewerAsync(
"Pump1", "{\"v\":1}", "dep-001", "sha256:aaa", isEnabled: true, deployedAtOverride: olderAt);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
// The newer seeded row must survive unchanged
Assert.Equal("{\"v\":2}", configs[0].ConfigJson);
Assert.Equal("dep-002", configs[0].DeploymentId);
Assert.Equal("sha256:bbb", configs[0].RevisionHash);
Assert.False(configs[0].IsEnabled);
}
[Fact]
public async Task StoreDeployedConfigIfNewer_EqualDeployedAt_IsNoop()
{
var at = new DateTimeOffset(2026, 3, 15, 9, 30, 0, TimeSpan.Zero);
await SeedDeployedConfigAsync("Pump1", "{\"v\":1}", "dep-001", "sha256:aaa", true, at);
// Guarded write with the IDENTICAL timestamp — must be a NO-OP (> not >=)
await _storage.StoreDeployedConfigIfNewerAsync(
"Pump1", "{\"v\":2}", "dep-002", "sha256:bbb", isEnabled: false, deployedAtOverride: at);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Single(configs);
// Original row preserved
Assert.Equal("{\"v\":1}", configs[0].ConfigJson);
Assert.Equal("dep-001", configs[0].DeploymentId);
}
}