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; /// /// Tests for SiteStorageService using file-based SQLite (temp files). /// Validates the schema, CRUD operations, and constraint behavior. /// /// /// The service now takes an ILocalDb 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 zb_hlc_next() UDF /// the site tables' capture triggers depend on. /// 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.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); } // ── Task 13: StoreDeployedConfigIfNewerAsync (guarded standby write) ── /// /// 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. /// 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); } }