using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; 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. /// public class SiteStorageServiceTests : IAsyncLifetime, IDisposable { private readonly string _dbFile; private SiteStorageService _storage = null!; public SiteStorageServiceTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"site-storage-test-{Guid.NewGuid():N}.db"); } public async Task InitializeAsync() { _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); await _storage.InitializeAsync(); } public Task DisposeAsync() => Task.CompletedTask; public void Dispose() { try { File.Delete(_dbFile); } catch { /* cleanup */ } } [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 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) { await using var conn = new SqliteConnection($"Data Source={_dbFile}"); await conn.OpenAsync(); 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); } }