From f2efeb37b73762aee672ac6eb068a192ff0856ee Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Jul 2026 03:05:45 -0400 Subject: [PATCH] refactor(sf,site): both stores take ILocalDb instead of a connection string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 5 and 6 of the Phase 2 plan, committed together because their test fallout is entangled — several fixtures construct both stores. StoreAndForwardStorage and SiteStorageService now take ILocalDb. Connections come from ILocalDb.CreateConnection(), which hands out an already-open, pragma-configured connection carrying the zb_hlc_next() UDF the capture triggers call; a raw connection would lack the UDF and every write to a replicated table would fail closed. Deleted with the connection strings: S&F's EnsureDatabaseDirectoryExists and its per-open busy_timeout pragma, and the site service's BusyTimeoutFloorSeconds normalization — LocalDb owns all of it now. DI: AddSiteRuntime's string overload is gone (nothing left to supply), so the Host calls the no-arg form. ScadaBridge:Database:SiteDbPath and StoreAndForwardOptions.SqliteDbPath survive only as the migrator's source locations in Tasks 8/9. Two things the plan did not anticipate, both worth reading: 1. FOUND A REAL LATENT DEFECT, from Phase 1, now fixed. The plan assumed directory creation simply moved to LocalDb along with file ownership. It did not: the LocalDb library never creates the parent directory, and SqliteLocalDb opens the file eagerly in its constructor — so a missing directory is a hard boot failure ("SQLite Error 14: unable to open database file"), not a degraded start. The default site config points at the RELATIVE path ./data/site-localdb.db, so any site node without a pre-existing data/ directory fails to boot. The docker rig escapes only because its volume mount happens to create /app/data — a coincidence that would have hidden this until a bare-metal or fresh deployment. This has been latent since Phase 1 made LocalDb:Path required; deleting S&F's EnsureDatabaseDirectoryExists here would have widened it. Re-established the guarantee at the layer that now owns the path (SiteLocalDbDirectory.Ensure, called before AddZbLocalDb) and pinned it with SiteLocalDbDirectoryTests. Non-vacuity is not assumed: two tests written against the wrong assumption failed with exactly this SQLite Error 14 before the fix existed. 2. Test fallout was ~7x the plan's estimate. The plan named "fixtures" in one project; the constructor change actually reaches 40 files across 7 test projects, and most used Mode=Memory;Cache=Shared — which LocalDb has no equivalent for, so every one had to move to a real temp file. Rather than copy the Phase 1 TestLocalDb fixture into 7 projects, added a shared tests/ZB.MOM.WW.ScadaBridge.TestSupport library (not a test project) so the WAL-sidecar cleanup and the "real, not stubbed" rationale live in one place. Retargeted rather than deleted, in both directions: the S&F WAL test now asserts against the LocalDb-backed store (WAL genuinely is LocalDb's job), while the directory-creation test moved to Host.Tests (that guarantee is NOT LocalDb's). SiteStorageServiceTests.Initialize_EnablesWalJournalMode got the same treatment. DeploymentManagerMediumFindingsTests induced a persistence failure via an unopenable path, which no longer reaches the assertion since the fixture now throws first; it induces the same failure shape via an uninitialized store. Verified: full solution build 0 warnings; SiteRuntime 532, Host 318, AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97, StoreAndForward 153 — 1597 passed, 0 failed. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts --- ZB.MOM.WW.ScadaBridge.slnx | 1 + .../SiteLocalDbDirectory.cs | 59 +++ .../SiteServiceRegistration.cs | 23 +- .../Persistence/SiteStorageService.cs | 143 +++----- .../SiteExternalSystemRepository.cs | 15 +- .../ServiceCollectionExtensions.cs | 30 +- .../ServiceCollectionExtensions.cs | 15 +- .../StoreAndForwardStorage.cs | 119 +++---- .../ParentExecutionIdCorrelationTests.cs | 335 +++++++++--------- ...B.MOM.WW.ScadaBridge.AuditLog.Tests.csproj | 3 +- .../DatabaseGatewayTests.cs | 121 ++++--- .../ExternalSystemClientTests.cs | 85 +++-- ...aBridge.ExternalSystemGateway.Tests.csproj | 3 +- .../HealthReportSenderTests.cs | 89 ++--- ....ScadaBridge.HealthMonitoring.Tests.csproj | 3 +- .../SiteLocalDbDirectoryTests.cs | 70 ++++ .../Cluster/SfBufferResyncPredicateTests.cs | 78 ++-- .../DualNodeRecoveryTests.cs | 51 ++- .../IntegrationSurfaceTests.cs | 10 +- .../RecoveryDrillTests.cs | 32 +- .../SiteFailoverTests.cs | 52 ++- ...MOM.WW.ScadaBridge.IntegrationTests.csproj | 3 +- .../Actors/DeploymentManagerActorTests.cs | 13 +- .../DeploymentManagerCertReconcileTests.cs | 13 +- .../DeploymentManagerLoggerFactoryTests.cs | 13 +- .../DeploymentManagerMediumFindingsTests.cs | 65 ++-- .../Actors/DeploymentManagerRedeployTests.cs | 13 +- .../InstanceActorChildAttributeRaceTests.cs | 13 +- .../Actors/InstanceActorChildRoutingTests.cs | 13 +- .../Actors/InstanceActorIntegrationTests.cs | 13 +- .../Actors/InstanceActorNativeAlarmTests.cs | 13 +- .../Actors/InstanceActorSetAttributeTests.cs | 13 +- .../Actors/InstanceActorTests.cs | 13 +- .../InstanceActorWaitForAttributeTests.cs | 13 +- .../Actors/NativeAlarmActorTests.cs | 20 +- .../Actors/SiteReconciliationActorTests.cs | 17 +- .../Actors/SiteReplicationActorTests.cs | 21 +- .../NegativeTests.cs | 51 ++- .../Persistence/ArtifactStorageTests.cs | 29 +- .../Persistence/NativeAlarmStateStoreTests.cs | 20 +- .../Persistence/SiteStorageServiceTests.cs | 41 ++- .../Repositories/SiteRepositoryTests.cs | 20 +- .../Scripts/NotifyHelperTests.cs | 16 +- .../Scripts/NotifySendAuditEmissionTests.cs | 16 +- ...OM.WW.ScadaBridge.SiteRuntime.Tests.csproj | 3 +- .../CachedCallAttemptEmissionTests.cs | 32 +- .../ParkedMessageHandlerActorTests.cs | 18 +- .../ParkedOperationRelayTests.cs | 18 +- .../QueueDepthGaugeTests.cs | 18 +- .../ReplicationServiceTests.cs | 18 +- .../StoreAndForwardReplicationTests.cs | 17 +- .../StoreAndForwardServiceTests.cs | 47 +-- .../StoreAndForwardSiteEventTests.cs | 18 +- .../StoreAndForwardStorageTests.cs | 105 +++--- ...W.ScadaBridge.StoreAndForward.Tests.csproj | 3 +- .../TestLocalDb.cs | 91 +++++ .../ZB.MOM.WW.ScadaBridge.TestSupport.csproj | 29 ++ 57 files changed, 1368 insertions(+), 848 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbDirectory.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.TestSupport/ZB.MOM.WW.ScadaBridge.TestSupport.csproj diff --git a/ZB.MOM.WW.ScadaBridge.slnx b/ZB.MOM.WW.ScadaBridge.slnx index 9f3c14d7..21748f21 100644 --- a/ZB.MOM.WW.ScadaBridge.slnx +++ b/ZB.MOM.WW.ScadaBridge.slnx @@ -36,6 +36,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbDirectory.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbDirectory.cs new file mode 100644 index 00000000..c850ea3b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbDirectory.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.Host; + +/// +/// Ensures the directory holding the consolidated site database (LocalDb:Path) +/// exists before LocalDb opens the file. +/// +/// +/// +/// SQLite creates the database file on demand but never its parent directory, and +/// SqliteLocalDb opens the file eagerly in its constructor — so a missing directory +/// is a hard boot failure ("SQLite Error 14: unable to open database file"), not a degraded +/// start. Neither the LocalDb library nor its options validation does this. +/// +/// +/// The guarantee previously lived in StoreAndForwardStorage.EnsureDatabaseDirectoryExists, +/// which created the directory for the store's own SQLite file. Once that file was folded +/// into the consolidated database the store stopped owning a path, so the guarantee had to +/// be re-established at the layer that now configures the path: the site host. +/// +/// +/// It matters because the default site configuration uses the relative path +/// ./data/site-localdb.db. The docker rig never noticed the gap only because its +/// volume mount happens to create /app/data. +/// +/// +public static class SiteLocalDbDirectory +{ + /// + /// Creates the directory containing LocalDb:Path when it does not already exist. + /// No-op when the key is unset. + /// + /// + /// Best-effort by design: if the directory cannot be created (permissions, read-only + /// mount) this stays silent and lets LocalDb raise the real error, which names the + /// actual path and is a better diagnostic than anything thrown from here. + /// + /// Configuration carrying LocalDb:Path. + public static void Ensure(IConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); + + var path = config["LocalDb:Path"]; + if (string.IsNullOrWhiteSpace(path)) + return; + + try + { + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + // Deliberately swallowed — see the remarks above. + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index afc500f0..3e696238 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -65,10 +65,13 @@ public static class SiteServiceRegistration services.AddSingleton(); services.AddSingleton(); - // Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path - // and site-local repository implementations (IExternalSystemRepository, INotificationRepository) - var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db"; - services.AddSiteRuntime($"Data Source={siteDbPath}"); + // Site-only components — AddSiteRuntime registers SiteStorageService and the + // site-local repository implementations (IExternalSystemRepository, + // INotificationRepository). It takes no connection string any more: + // SiteStorageService persists to the consolidated LocalDb database registered + // just below (LocalDb:Path). ScadaBridge:Database:SiteDbPath survives only as + // the legacy migrator's source location. + services.AddSiteRuntime(); // Consolidated site database (LocalDb Phase 1). Holds OperationTracking and // site_events as replicated tables so the pair stops losing them on failover. @@ -78,6 +81,18 @@ public static class SiteServiceRegistration // initiator idles. // // Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md + // + // Create the parent directory FIRST. SQLite creates the database file on demand + // but not its directory, and neither the LocalDb library nor its options + // validation does this — SqliteLocalDb's constructor opens the file eagerly, so a + // missing directory is a hard boot failure ("SQLite Error 14: unable to open + // database file"), not a degraded start. The default site path is the relative + // "./data/site-localdb.db", so this bites any site node whose data directory has + // not been pre-created; the docker rig only escapes it because the volume mount + // creates /app/data. StoreAndForwardStorage used to do this for its own file + // (EnsureDatabaseDirectoryExists) and that guarantee has to keep existing + // somewhere now that LocalDb owns the file. + SiteLocalDbDirectory.Ensure(config); services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config)); // The replication engine, likewise unconditional but INERT by default: with no diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index 4ccb0c0b..746e8547 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; +using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; @@ -10,70 +11,59 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; /// public class SiteStorageService { - private readonly string _connectionString; + private readonly ILocalDb _localDb; private readonly ILogger _logger; /// - /// Initializes a new instance of the SiteStorageService with the specified SQLite connection string and logger. + /// Initializes the service over the consolidated site database. /// - /// SQLite connection string for the site database. + /// + /// The consolidated site database. Every connection it hands out is already open and carries + /// the per-connection pragmas (including the busy timeout that this class used to pin itself) + /// plus the zb_hlc_next() UDF that the config tables' capture triggers call — which is + /// exactly why the service no longer builds its own connection string. A raw connection would + /// lack the UDF and every write to a replicated table would fail closed. + /// /// Logger instance for diagnostic messages. - public SiteStorageService(string connectionString, ILogger logger) + public SiteStorageService(ILocalDb localDb, ILogger logger) { - // Normalize the connection string and pin a busy-timeout floor (S8). WAL lets a reader - // and a writer proceed concurrently, but two writers still contend; Microsoft.Data.Sqlite - // drives its busy handler off the command timeout, so a floor of 5 s means a briefly - // busy database is waited-on rather than failing fast with SQLITE_BUSY. Only raise a - // caller-supplied value that is lower than the floor (the library default of 30 s stays). - var builder = new SqliteConnectionStringBuilder(connectionString); - if (builder.DefaultTimeout < BusyTimeoutFloorSeconds) - builder.DefaultTimeout = BusyTimeoutFloorSeconds; - _connectionString = builder.ToString(); + ArgumentNullException.ThrowIfNull(localDb); + ArgumentNullException.ThrowIfNull(logger); + _localDb = localDb; _logger = logger; } - /// Busy-timeout floor in seconds applied to the site connection string (S8). - private const int BusyTimeoutFloorSeconds = 5; + /// + /// Returns an already-open connection against the site database. + /// Exposed so site-local repositories can get their own connections without reaching + /// into private state via reflection. The caller owns the connection and is + /// responsible for disposing it. + /// + /// + /// Contract change: this used to return an unopened connection that the + /// caller opened. LocalDb hands out connections already open, pragma-configured, and + /// carrying the zb_hlc_next() UDF — calling Open/OpenAsync on one + /// throws. Callers must not open it. + /// + /// An open against the site database. + public SqliteConnection CreateConnection() => _localDb.CreateConnection(); /// - /// Creates a new (unopened) SQLite connection against the site database. - /// Exposed so site-local repositories can open their own connections without - /// reaching into private state via reflection. The caller owns - /// the connection and is responsible for opening and disposing it. - /// - /// The database runs in WAL journal mode (set once in ) with a - /// busy-timeout floor (see the constructor), so connections handed out here inherit - /// concurrent-reader/writer behavior and wait out a briefly-busy database instead of - /// failing with SQLITE_BUSY. - /// + /// Convenience alias used internally, mirroring the other LocalDb-backed stores. /// - /// A new, unopened against the site database. - public SqliteConnection CreateConnection() => new(_connectionString); + private SqliteConnection OpenConnection() => _localDb.CreateConnection(); /// /// Creates the SQLite tables if they do not exist. /// Called once on site startup. /// /// A task that completes when all tables have been created or verified. - public async Task InitializeAsync() + public Task InitializeAsync() { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); - - // Switch to WAL journal mode (S8). WAL is persistent (database-level, survives across - // connections) so this one-time set is enough. It lets readers and a writer proceed - // concurrently instead of every access serializing on the rollback journal. A - // :memory: database or a filesystem that cannot support WAL falls back to its prior - // mode — we log the result rather than throwing. - await using (var walCommand = connection.CreateCommand()) - { - walCommand.CommandText = "PRAGMA journal_mode=WAL;"; - var resultingMode = (await walCommand.ExecuteScalarAsync()) as string ?? "unknown"; - if (!string.Equals(resultingMode, "wal", StringComparison.OrdinalIgnoreCase)) - _logger.LogWarning( - "Site SQLite could not enable WAL journal mode (got '{Mode}') — concurrent access may serialize", - resultingMode); - } + // No journal-mode pragma and no busy-timeout normalization here any more: + // LocalDb owns the file and sets WAL plus the per-connection pragmas on every + // connection it hands out. + using var connection = OpenConnection(); // The DDL itself lives in SiteStorageSchema so the Host can apply it to a // LocalDb-managed connection before RegisterReplicated installs the capture @@ -81,7 +71,8 @@ public class SiteStorageService // (tests, tooling) remains self-sufficient. SiteStorageSchema.Apply(connection); - _logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString); + _logger.LogInformation("Site SQLite storage initialized"); + return Task.CompletedTask; } // ── Deployed Configuration CRUD ── @@ -92,8 +83,7 @@ public class SiteStorageService /// A task that resolves to the list of all deployed instance configurations. public async Task> GetAllDeployedConfigsAsync() { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -134,8 +124,7 @@ public class SiteStorageService string revisionHash, bool isEnabled) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -197,8 +186,7 @@ public class SiteStorageService { var deployedAt = (deployedAtOverride ?? DateTimeOffset.UtcNow).ToString("O"); - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -231,8 +219,7 @@ public class SiteStorageService /// A task that completes when the configuration and its overrides have been removed. public async Task RemoveDeployedConfigAsync(string instanceName) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var transaction = await connection.BeginTransactionAsync(); @@ -272,8 +259,7 @@ public class SiteStorageService /// A task that completes when the enabled flag has been updated. public async Task SetInstanceEnabledAsync(string instanceName, bool isEnabled) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -300,8 +286,7 @@ public class SiteStorageService /// A task that resolves to a dictionary mapping attribute names to their override values. public async Task> GetStaticOverridesAsync(string instanceName) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -329,8 +314,7 @@ public class SiteStorageService /// A task that completes when the override has been saved. public async Task SetStaticOverrideAsync(string instanceName, string attributeName, string value) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -356,8 +340,7 @@ public class SiteStorageService /// A task that completes when all overrides for the instance have been deleted. public async Task ClearStaticOverridesAsync(string instanceName) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name"; @@ -384,8 +367,7 @@ public class SiteStorageService string instanceName, string sourceCanonicalName, string sourceReference, string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -424,8 +406,7 @@ public class SiteStorageService if (rows.Count == 0) return; - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(); await using var command = connection.CreateCommand(); @@ -469,8 +450,7 @@ public class SiteStorageService /// A task that completes when the alarm condition row has been deleted. public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -494,8 +474,7 @@ public class SiteStorageService /// A task that resolves to the list of stored native alarm condition rows for the binding. public async Task> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -526,8 +505,7 @@ public class SiteStorageService /// A task that completes when all native alarm rows for the instance have been deleted. public async Task ClearNativeAlarmsForInstanceAsync(string instanceName) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name"; @@ -549,8 +527,7 @@ public class SiteStorageService /// A task that completes when the shared script has been stored or updated. public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -578,8 +555,7 @@ public class SiteStorageService /// A task that resolves to the list of all stored shared scripts. public async Task> GetAllSharedScriptsAsync() { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts"; @@ -616,8 +592,7 @@ public class SiteStorageService string name, string endpointUrl, string authType, string? authConfig, string? methodDefs, int timeoutSeconds = 0) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -655,8 +630,7 @@ public class SiteStorageService public async Task StoreDatabaseConnectionAsync( string name, string connectionString, int maxRetries, TimeSpan retryDelay) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -699,8 +673,7 @@ public class SiteStorageService /// A task that completes when both tables have been emptied. public async Task PurgeCentralOnlyNotificationConfigAsync() { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -723,8 +696,7 @@ public class SiteStorageService public async Task StoreDataConnectionDefinitionAsync( string name, string protocol, string? configJson, string? backupConfigJson = null, int failoverRetryCount = 3) { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -754,8 +726,7 @@ public class SiteStorageService /// A task that resolves to the list of all stored data connection definitions. public async Task> GetAllDataConnectionDefinitionsAsync() { - await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); + await using var connection = OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT name, protocol, configuration, backup_configuration, failover_retry_count FROM data_connection_definitions"; diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs index efb8a48e..1fe373f4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs @@ -31,8 +31,9 @@ public class SiteExternalSystemRepository : IExternalSystemRepository public async Task> GetAllExternalSystemsAsync( CancellationToken cancellationToken = default) { + // Already open — SiteStorageService.CreateConnection now hands out a + // LocalDb-managed connection. Opening it again would throw. await using var connection = CreateConnection(); - await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -63,8 +64,9 @@ public class SiteExternalSystemRepository : IExternalSystemRepository public async Task GetExternalSystemByNameAsync( string name, CancellationToken cancellationToken = default) { + // Already open — SiteStorageService.CreateConnection now hands out a + // LocalDb-managed connection. Opening it again would throw. await using var connection = CreateConnection(); - await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -91,8 +93,9 @@ public class SiteExternalSystemRepository : IExternalSystemRepository if (system is null) return Array.Empty(); + // Already open — SiteStorageService.CreateConnection now hands out a + // LocalDb-managed connection. Opening it again would throw. await using var connection = CreateConnection(); - await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -140,8 +143,9 @@ public class SiteExternalSystemRepository : IExternalSystemRepository public async Task> GetAllDatabaseConnectionsAsync( CancellationToken cancellationToken = default) { + // Already open — SiteStorageService.CreateConnection now hands out a + // LocalDb-managed connection. Opening it again would throw. await using var connection = CreateConnection(); - await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" @@ -178,8 +182,9 @@ public class SiteExternalSystemRepository : IExternalSystemRepository public async Task GetDatabaseConnectionByNameAsync( string name, CancellationToken cancellationToken = default) { + // Already open — SiteStorageService.CreateConnection now hands out a + // LocalDb-managed connection. Opening it again would throw. await using var connection = CreateConnection(); - await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = @" diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs index 3d499a2e..6b018167 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; @@ -14,32 +15,19 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime; public static class ServiceCollectionExtensions { /// - /// Registers Site Runtime services including SiteStorageService for SQLite persistence. - /// The caller must register an or call the - /// overload with an explicit connection string. + /// Registers Site Runtime services including SiteStorageService, which persists to the + /// consolidated site database resolved from ILocalDb (LocalDb:Path). /// /// The DI service collection to register services into. /// The same to allow chaining. public static IServiceCollection AddSiteRuntime(this IServiceCollection services) { - // SiteStorageService is registered by the Host using AddSiteRuntime(connectionString) - // This overload is for backward compatibility / skeleton placeholder - return services; - } - - /// - /// Registers Site Runtime services with an explicit SQLite connection string. - /// - /// The DI service collection to register services into. - /// The SQLite connection string for the site local storage database. - /// The same to allow chaining. - public static IServiceCollection AddSiteRuntime(this IServiceCollection services, string siteDbConnectionString) - { - services.AddSingleton(sp => - { - var logger = sp.GetRequiredService>(); - return new SiteStorageService(siteDbConnectionString, logger); - }); + // SiteStorageService takes ILocalDb (the consolidated site database at + // LocalDb:Path) rather than a connection string, so there is nothing left for a + // caller to supply — the string overload is gone and this is the only entry point. + services.AddSingleton(sp => new SiteStorageService( + sp.GetRequiredService(), + sp.GetRequiredService>())); services.AddHostedService(); diff --git a/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ServiceCollectionExtensions.cs index 0f9082f1..a5c2e513 100644 --- a/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; @@ -15,14 +16,12 @@ public static class ServiceCollectionExtensions /// The same collection, for chaining. public static IServiceCollection AddStoreAndForward(this IServiceCollection services) { - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>().Value; - var logger = sp.GetRequiredService>(); - return new StoreAndForwardStorage( - $"Data Source={options.SqliteDbPath}", - logger); - }); + // The buffer now lives in the consolidated LocalDb database at LocalDb:Path, not + // at StoreAndForwardOptions.SqliteDbPath — that option survives only as the + // migrator's source location (Task 8). + services.AddSingleton(sp => new StoreAndForwardStorage( + sp.GetRequiredService(), + sp.GetRequiredService>())); services.AddSingleton(sp => { diff --git a/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs b/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs index 187d4436..fbe87cc6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs +++ b/src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward; @@ -11,17 +12,25 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward; /// public class StoreAndForwardStorage { - private readonly string _connectionString; + private readonly ILocalDb _localDb; private readonly ILogger _logger; /// - /// Initializes a new instance of with the given SQLite connection string. + /// Initializes the store over the consolidated site database and applies the schema. /// - /// SQLite connection string for the store-and-forward database. + /// + /// The consolidated site database. Every connection it hands out is already open and carries + /// the per-connection pragmas (including busy_timeout) plus the zb_hlc_next() + /// UDF that sf_messages' capture triggers call — which is exactly why the store no + /// longer opens its own from a connection string. A raw + /// connection would lack the UDF and every write to the replicated table would fail closed. + /// /// Logger for diagnostics. - public StoreAndForwardStorage(string connectionString, ILogger logger) + public StoreAndForwardStorage(ILocalDb localDb, ILogger logger) { - _connectionString = connectionString; + ArgumentNullException.ThrowIfNull(localDb); + ArgumentNullException.ThrowIfNull(logger); + _localDb = localDb; _logger = logger; } @@ -29,22 +38,12 @@ public class StoreAndForwardStorage /// Creates the sf_messages table if it does not exist. /// /// A task that represents the asynchronous operation. - public async Task InitializeAsync() + public Task InitializeAsync() { - EnsureDatabaseDirectoryExists(); - - await using var connection = await OpenConnectionAsync(); - - // Enable WAL so the concurrent writers this store has by design (script - // enqueues, the retry sweep's per-target lanes, standby replication applies, - // central pull queries) read/write without "database is locked". WAL is - // persistent + file-scoped; in-memory DBs report "memory" instead — harmless, - // so it is not asserted here. - await using (var walCmd = connection.CreateCommand()) - { - walCmd.CommandText = "PRAGMA journal_mode=WAL"; - await walCmd.ExecuteNonQueryAsync(); - } + // No directory creation and no journal-mode pragma here any more: LocalDb owns + // the file (it creates the directory) and sets WAL plus the per-connection + // pragmas on every connection it hands out. + using var connection = OpenConnection(); // The DDL itself lives in StoreAndForwardSchema so the Host can apply it to a // LocalDb-managed connection before RegisterReplicated installs the capture @@ -53,50 +52,16 @@ public class StoreAndForwardStorage StoreAndForwardSchema.Apply(connection); _logger.LogInformation("Store-and-forward SQLite storage initialized"); + return Task.CompletedTask; } /// - /// Ensures the directory for a file-backed SQLite database exists. SQLite creates - /// the database file on demand but not its parent directory, so a configured path - /// such as "./data/store-and-forward.db" fails to open ("unable to open database - /// file") when the "data" directory does not yet exist. In-memory databases and - /// bare filenames in the working directory have no directory to create and are - /// skipped. + /// CreateConnection returns an ALREADY-OPEN, pragma-configured connection with the + /// zb_hlc_next() UDF registered. Calling OpenAsync on it throws, and a raw + /// would lack the UDF, making every capture trigger fail + /// closed. /// - private void EnsureDatabaseDirectoryExists() - { - var builder = new SqliteConnectionStringBuilder(_connectionString); - if (builder.Mode == SqliteOpenMode.Memory) - return; - - var dataSource = builder.DataSource; - if (string.IsNullOrEmpty(dataSource) || dataSource == ":memory:") - return; - - var directory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(dataSource)); - if (!string.IsNullOrEmpty(directory) && !System.IO.Directory.Exists(directory)) - { - System.IO.Directory.CreateDirectory(directory); - _logger.LogInformation("Created store-and-forward database directory: {Directory}", directory); - } - } - - /// - /// Opens a connection with a 5s busy_timeout. Concurrent writers exist by - /// design (script enqueues, the sweep's lanes, standby replication applies, - /// central pull queries); with connection-per-operation the pragma must be - /// set per open (it is per-connection, and Microsoft.Data.Sqlite pooling - /// makes the extra statement cheap on a pooled physical connection). - /// - private async Task OpenConnectionAsync() - { - var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync(); - await using var pragma = connection.CreateCommand(); - pragma.CommandText = "PRAGMA busy_timeout = 5000"; - await pragma.ExecuteNonQueryAsync(); - return connection; - } + private SqliteConnection OpenConnection() => _localDb.CreateConnection(); /// /// INSERT statement for a full message row. Shared by @@ -149,7 +114,7 @@ public class StoreAndForwardStorage /// A task that represents the asynchronous operation. public async Task EnqueueAsync(StoreAndForwardMessage message) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = InsertMessageSql; @@ -169,7 +134,7 @@ public class StoreAndForwardStorage /// The oldest-first page (at most rows) and whether more rows exist beyond it. public async Task<(List Messages, bool Truncated)> GetAllMessagesAsync(int limit) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -197,7 +162,7 @@ public class StoreAndForwardStorage /// A task that represents the asynchronous operation. public async Task ReplaceAllAsync(IReadOnlyList messages) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(); await using (var deleteCmd = connection.CreateCommand()) @@ -232,7 +197,7 @@ public class StoreAndForwardStorage /// A task that represents the asynchronous operation. public async Task UpsertMessageAsync(StoreAndForwardMessage message) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -289,7 +254,7 @@ public class StoreAndForwardStorage /// A task that resolves to the list of messages due for retry, ordered by creation time ascending. public async Task> GetMessagesForRetryAsync(int limit = 0) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -323,7 +288,7 @@ public class StoreAndForwardStorage /// A task that represents the asynchronous operation. public async Task UpdateMessageAsync(StoreAndForwardMessage message) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -360,7 +325,7 @@ public class StoreAndForwardStorage StoreAndForwardMessage message, StoreAndForwardMessageStatus expectedStatus) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -393,7 +358,7 @@ public class StoreAndForwardStorage /// A task that represents the asynchronous operation. public async Task RemoveMessageAsync(string messageId) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = "DELETE FROM sf_messages WHERE id = @id"; @@ -414,7 +379,7 @@ public class StoreAndForwardStorage int pageNumber = 1, int pageSize = 50) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(); @@ -459,7 +424,7 @@ public class StoreAndForwardStorage /// A task that resolves to true if the message was found and reset to Pending; false if not found or not in Parked status. public async Task RetryParkedMessageAsync(string messageId) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -483,7 +448,7 @@ public class StoreAndForwardStorage /// A task that resolves to true if the message was found and deleted; false if not found or not in Parked status. public async Task DiscardParkedMessageAsync(string messageId) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = "DELETE FROM sf_messages WHERE id = @id AND status = @parked"; @@ -500,7 +465,7 @@ public class StoreAndForwardStorage /// A task that resolves to a dictionary mapping each category to its pending message count. public async Task> GetBufferDepthByCategoryAsync() { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -530,7 +495,7 @@ public class StoreAndForwardStorage /// A task that resolves to the number of messages whose origin instance matches . public async Task GetMessageCountByOriginInstanceAsync(string instanceName) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -549,7 +514,7 @@ public class StoreAndForwardStorage /// A task that resolves to the matching message, or null if not found. public async Task GetMessageByIdAsync(string messageId) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = @" @@ -570,7 +535,7 @@ public class StoreAndForwardStorage /// A task that resolves to the number of messages currently in Parked status. public async Task GetParkedMessageCountAsync() { - await using var conn = await OpenConnectionAsync(); + await using var conn = OpenConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM sf_messages WHERE status = @parked"; cmd.Parameters.AddWithValue("@parked", (int)StoreAndForwardMessageStatus.Parked); @@ -588,7 +553,7 @@ public class StoreAndForwardStorage /// A task that resolves to the oldest parked row's creation time, or null if none are parked. public async Task GetOldestParkedCreatedAtAsync() { - await using var conn = await OpenConnectionAsync(); + await using var conn = OpenConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT MIN(created_at) FROM sf_messages WHERE status = @parked"; cmd.Parameters.AddWithValue("@parked", (int)StoreAndForwardMessageStatus.Parked); @@ -605,7 +570,7 @@ public class StoreAndForwardStorage /// A task that resolves to the count of messages with the specified status. public async Task GetMessageCountByStatusAsync(StoreAndForwardMessageStatus status) { - await using var connection = await OpenConnectionAsync(); + await using var connection = OpenConnection(); await using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM sf_messages WHERE status = @status"; diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs index 1b21ffec..ac72f09f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -35,6 +35,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery; using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Messages; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration; @@ -197,174 +198,184 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture.Instance); - await safStorage.InitializeAsync(); - var storeAndForward = new StoreAndForwardService( - safStorage, - new StoreAndForwardOptions + // The storage takes an ILocalDb and LocalDb has no in-memory mode, so this is a + // real temp file, disposed (the master connection anchors the WAL) and deleted in + // the finally below. + var safLocalDb = TestLocalDb.CreateTemp("parentexec-saf"); + var safDbPath = safLocalDb.Path; + try + { + var safStorage = new StoreAndForwardStorage( + safLocalDb.Db, NullLogger.Instance); + await safStorage.InitializeAsync(); + var storeAndForward = new StoreAndForwardService( + safStorage, + new StoreAndForwardOptions + { + DefaultRetryInterval = TimeSpan.Zero, + DefaultMaxRetries = 3, + RetryTimerInterval = TimeSpan.FromMinutes(10), + }, + NullLogger.Instance); + + // ── Outbound external-system client (routed run): sync Call succeeds, + // CachedCall completes immediately (WasBuffered=false) so the script + // helper emits the Submit + Attempted + CachedResolve lifecycle. ── + var externalClient = Substitute.For(); + externalClient + .CallAsync(ExternalSystemName, ExternalMethodName, + Arg.Any?>(), Arg.Any()) + .Returns(new ExternalCallResult(true, "{\"ok\":true}", null)); + externalClient + .CachedCallAsync(ExternalSystemName, ExternalMethodName, + Arg.Any?>(), + Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + + // ── The routing transport stand-in: builds the routed ScriptRuntimeContext + // carrying RouteToCallRequest.ParentExecutionId — exactly what the + // production site handler (DeploymentManagerActor) does. ── + var router = new BridgingInstanceRouter( + siteId, + externalClient, + siteAuditWriter, + cachedForwarder, + storeAndForward); + + // ── The inbound API method script: it calls Route.Call into the site + // instance. The real InboundScriptExecutor binds the inbound request's + // ExecutionId onto the RouteHelper, so the routed call carries it as + // ParentExecutionId. ── + var inboundMethod = new ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod( + "submitOrder", + $"return await Route.To(\"{RoutedInstanceCode}\").Call(\"{RoutedScriptName}\", new {{ order = 7 }});"); + var locator = Substitute.For(); + locator.GetSiteIdForInstanceAsync(RoutedInstanceCode, Arg.Any()) + .Returns(siteId); + var scriptExecutor = new InboundScriptExecutor( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + Assert.True(scriptExecutor.CompileAndRegister(inboundMethod)); + + // ── Act — issue the inbound HTTP request through a TestHost pipeline + // fronted by the real AuditWriteMiddleware. The endpoint handler reads + // the middleware-stashed inbound ExecutionId and runs the inbound + // method script with it as parentExecutionId. ── + using var host = await BuildInboundHostAsync(centralAuditWriter, async ctx => { - DefaultRetryInterval = TimeSpan.Zero, - DefaultMaxRetries = 3, - RetryTimerInterval = TimeSpan.FromMinutes(10), - }, - NullLogger.Instance); + var inboundExecutionId = (Guid)ctx.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!; + var route = new RouteHelper(locator, router); + var result = await scriptExecutor.ExecuteAsync( + inboundMethod, + new Dictionary(), + route, + TimeSpan.FromSeconds(30), + ctx.RequestAborted, + parentExecutionId: inboundExecutionId); - // ── Outbound external-system client (routed run): sync Call succeeds, - // CachedCall completes immediately (WasBuffered=false) so the script - // helper emits the Submit + Attempted + CachedResolve lifecycle. ── - var externalClient = Substitute.For(); - externalClient - .CallAsync(ExternalSystemName, ExternalMethodName, - Arg.Any?>(), Arg.Any()) - .Returns(new ExternalCallResult(true, "{\"ok\":true}", null)); - externalClient - .CachedCallAsync(ExternalSystemName, ExternalMethodName, - Arg.Any?>(), - Arg.Any(), Arg.Any(), - Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + ctx.Response.StatusCode = result.Success ? 200 : 500; + await ctx.Response.WriteAsync(result.Success ? "ok" : "fail"); + }); - // ── The routing transport stand-in: builds the routed ScriptRuntimeContext - // carrying RouteToCallRequest.ParentExecutionId — exactly what the - // production site handler (DeploymentManagerActor) does. ── - var router = new BridgingInstanceRouter( - siteId, - externalClient, - siteAuditWriter, - cachedForwarder, - storeAndForward); + var client = host.GetTestClient(); + var response = await client.PostAsync( + "/api/submitOrder", + new StringContent("{}", Encoding.UTF8, "application/json")); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - // ── The inbound API method script: it calls Route.Call into the site - // instance. The real InboundScriptExecutor binds the inbound request's - // ExecutionId onto the RouteHelper, so the routed call carries it as - // ParentExecutionId. ── - var inboundMethod = new ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod( - "submitOrder", - $"return await Route.To(\"{RoutedInstanceCode}\").Call(\"{RoutedScriptName}\", new {{ order = 7 }});"); - var locator = Substitute.For(); - locator.GetSiteIdForInstanceAsync(RoutedInstanceCode, Arg.Any()) - .Returns(siteId); - var scriptExecutor = new InboundScriptExecutor( - NullLogger.Instance, - new ServiceCollection().BuildServiceProvider()); - Assert.True(scriptExecutor.CompileAndRegister(inboundMethod)); + // The routed run emits its sync-ApiCall and NotifySend audit rows on a + // deliberately fire-and-forget path (alog.md §7 — an audit write must + // never block the user-facing script call). `Notify.Send` therefore + // returns — and the routed `RouteToCallAsync` completes — BEFORE the + // SqliteAuditWriter background loop has flushed the NotifySend row into + // the site hot-path. Wait for all five site rows to be durably present + // in SQLite before the central assertion: this is the production + // durability point (the row IS in SQLite before it is considered + // audited), and pinning it removes the emit-vs-drain race that + // otherwise let the SiteAuditTelemetryADrain forward only four rows on + // its first tick and leave NotifySend stranded for a full drain + // interval under heavy parallel load. + await WaitForSiteRowsPersistedAsync(sqliteWriter); - // ── Act — issue the inbound HTTP request through a TestHost pipeline - // fronted by the real AuditWriteMiddleware. The endpoint handler reads - // the middleware-stashed inbound ExecutionId and runs the inbound - // method script with it as parentExecutionId. ── - using var host = await BuildInboundHostAsync(centralAuditWriter, async ctx => + // The routed run produced a NotifySend that buffered a NotificationSubmit + // into S&F. Drain that genuine site-produced submission to the central + // NotificationOutboxActor so the NotifyDeliver dispatch rows materialise. + await ForwardBufferedNotificationToCentralAsync( + storeAndForward, router.NotificationId!, centralProvider, centralAuditWriter); + + // ── Assert ────────────────────────────────────────────────────────── + await AwaitAssertAsync(async () => + { + await using var readContext = CreateContext(); + var repo = new AuditLogRepository(readContext); + + // Every audit row this site produced (sync ApiCall + cached lifecycle + // + NotifySend) plus the central NotifyDeliver rows. + var siteRows = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), + new AuditLogPaging(PageSize: 100)); + + // sync ApiCall (1) + cached Submit/Attempted/Resolve (3) + NotifySend (1) + // + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run. + Assert.True(siteRows.Count == 7, + "Expected 7 routed-run audit rows; saw: " + + string.Join(", ", siteRows.Select(r => $"{r.AsRow().Channel}/{r.AsRow().Kind}/{r.AsRow().Status}"))); + Assert.Single(siteRows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall); + Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedSubmit); + Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedResolve); + Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.NotifySend); + Assert.Equal(2, siteRows.Count(r => r.AsRow().Kind == AuditKind.NotifyDeliver)); + + // CORE PROMISE: every routed-run row carries the SAME non-null + // ParentExecutionId — the inbound request's ExecutionId. + var parentIds = siteRows.Select(r => r.AsRow().ParentExecutionId).Distinct().ToList(); + Assert.Single(parentIds); + Assert.NotNull(parentIds[0]); + var inboundExecutionId = parentIds[0]!.Value; + + // The routed run has its OWN distinct ExecutionId — not the parent's. + var routedExecutionIds = siteRows + .Select(r => r.AsRow().ExecutionId) + .Distinct() + .ToList(); + Assert.Single(routedExecutionIds); + Assert.NotNull(routedExecutionIds[0]); + var routedExecutionId = routedExecutionIds[0]!.Value; + Assert.NotEqual(inboundExecutionId, routedExecutionId); + + // The inbound request's own InboundRequest row is TOP-LEVEL — + // ExecutionId = the propagated id, ParentExecutionId = NULL. + var inboundRows = await repo.QueryAsync( + new AuditLogQueryFilter(ExecutionId: inboundExecutionId), + new AuditLogPaging(PageSize: 10)); + var inboundRow = Assert.Single(inboundRows, + r => r.AsRow().Channel == AuditChannel.ApiInbound && r.AsRow().Kind == AuditKind.InboundRequest); + Assert.Equal(AuditStatus.Delivered, inboundRow.AsRow().Status); + Assert.Null(inboundRow.AsRow().ParentExecutionId); + + // The parentExecutionId filter pulls the routed run's complete + // trust-boundary footprint (all 7 routed rows, none of the inbound). + var byParent = await repo.QueryAsync( + new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId), + new AuditLogPaging(PageSize: 100)); + Assert.Equal(7, byParent.Count); + Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.AsRow().ExecutionId)); + + // GetExecutionTreeAsync returns BOTH executions in one chain — + // inbound (root) and routed (child), regardless of entry point. + var treeFromChild = await repo.GetExecutionTreeAsync(routedExecutionId); + AssertChain(treeFromChild, inboundExecutionId, routedExecutionId); + var treeFromRoot = await repo.GetExecutionTreeAsync(inboundExecutionId); + AssertChain(treeFromRoot, inboundExecutionId, routedExecutionId); + }, TimeSpan.FromSeconds(90)); + } + finally { - var inboundExecutionId = (Guid)ctx.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!; - var route = new RouteHelper(locator, router); - var result = await scriptExecutor.ExecuteAsync( - inboundMethod, - new Dictionary(), - route, - TimeSpan.FromSeconds(30), - ctx.RequestAborted, - parentExecutionId: inboundExecutionId); - - ctx.Response.StatusCode = result.Success ? 200 : 500; - await ctx.Response.WriteAsync(result.Success ? "ok" : "fail"); - }); - - var client = host.GetTestClient(); - var response = await client.PostAsync( - "/api/submitOrder", - new StringContent("{}", Encoding.UTF8, "application/json")); - Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - - // The routed run emits its sync-ApiCall and NotifySend audit rows on a - // deliberately fire-and-forget path (alog.md §7 — an audit write must - // never block the user-facing script call). `Notify.Send` therefore - // returns — and the routed `RouteToCallAsync` completes — BEFORE the - // SqliteAuditWriter background loop has flushed the NotifySend row into - // the site hot-path. Wait for all five site rows to be durably present - // in SQLite before the central assertion: this is the production - // durability point (the row IS in SQLite before it is considered - // audited), and pinning it removes the emit-vs-drain race that - // otherwise let the SiteAuditTelemetryADrain forward only four rows on - // its first tick and leave NotifySend stranded for a full drain - // interval under heavy parallel load. - await WaitForSiteRowsPersistedAsync(sqliteWriter); - - // The routed run produced a NotifySend that buffered a NotificationSubmit - // into S&F. Drain that genuine site-produced submission to the central - // NotificationOutboxActor so the NotifyDeliver dispatch rows materialise. - await ForwardBufferedNotificationToCentralAsync( - storeAndForward, router.NotificationId!, centralProvider, centralAuditWriter); - - // ── Assert ────────────────────────────────────────────────────────── - await AwaitAssertAsync(async () => - { - await using var readContext = CreateContext(); - var repo = new AuditLogRepository(readContext); - - // Every audit row this site produced (sync ApiCall + cached lifecycle - // + NotifySend) plus the central NotifyDeliver rows. - var siteRows = await repo.QueryAsync( - new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), - new AuditLogPaging(PageSize: 100)); - - // sync ApiCall (1) + cached Submit/Attempted/Resolve (3) + NotifySend (1) - // + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run. - Assert.True(siteRows.Count == 7, - "Expected 7 routed-run audit rows; saw: " - + string.Join(", ", siteRows.Select(r => $"{r.AsRow().Channel}/{r.AsRow().Kind}/{r.AsRow().Status}"))); - Assert.Single(siteRows, r => r.AsRow().Channel == AuditChannel.ApiOutbound && r.AsRow().Kind == AuditKind.ApiCall); - Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedSubmit); - Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.CachedResolve); - Assert.Single(siteRows, r => r.AsRow().Kind == AuditKind.NotifySend); - Assert.Equal(2, siteRows.Count(r => r.AsRow().Kind == AuditKind.NotifyDeliver)); - - // CORE PROMISE: every routed-run row carries the SAME non-null - // ParentExecutionId — the inbound request's ExecutionId. - var parentIds = siteRows.Select(r => r.AsRow().ParentExecutionId).Distinct().ToList(); - Assert.Single(parentIds); - Assert.NotNull(parentIds[0]); - var inboundExecutionId = parentIds[0]!.Value; - - // The routed run has its OWN distinct ExecutionId — not the parent's. - var routedExecutionIds = siteRows - .Select(r => r.AsRow().ExecutionId) - .Distinct() - .ToList(); - Assert.Single(routedExecutionIds); - Assert.NotNull(routedExecutionIds[0]); - var routedExecutionId = routedExecutionIds[0]!.Value; - Assert.NotEqual(inboundExecutionId, routedExecutionId); - - // The inbound request's own InboundRequest row is TOP-LEVEL — - // ExecutionId = the propagated id, ParentExecutionId = NULL. - var inboundRows = await repo.QueryAsync( - new AuditLogQueryFilter(ExecutionId: inboundExecutionId), - new AuditLogPaging(PageSize: 10)); - var inboundRow = Assert.Single(inboundRows, - r => r.AsRow().Channel == AuditChannel.ApiInbound && r.AsRow().Kind == AuditKind.InboundRequest); - Assert.Equal(AuditStatus.Delivered, inboundRow.AsRow().Status); - Assert.Null(inboundRow.AsRow().ParentExecutionId); - - // The parentExecutionId filter pulls the routed run's complete - // trust-boundary footprint (all 7 routed rows, none of the inbound). - var byParent = await repo.QueryAsync( - new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId), - new AuditLogPaging(PageSize: 100)); - Assert.Equal(7, byParent.Count); - Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.AsRow().ExecutionId)); - - // GetExecutionTreeAsync returns BOTH executions in one chain — - // inbound (root) and routed (child), regardless of entry point. - var treeFromChild = await repo.GetExecutionTreeAsync(routedExecutionId); - AssertChain(treeFromChild, inboundExecutionId, routedExecutionId); - var treeFromRoot = await repo.GetExecutionTreeAsync(inboundExecutionId); - AssertChain(treeFromRoot, inboundExecutionId, routedExecutionId); - }, TimeSpan.FromSeconds(90)); + safLocalDb.Dispose(); + TestLocalDb.DeleteFiles(safDbPath); + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj index 9af02ae7..337d93b2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj @@ -76,7 +76,8 @@ central MSSQL AuditLog. --> - + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs index 7ecba161..0971d130 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs @@ -3,18 +3,56 @@ using System.Data.Common; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// /// WP-9: Tests for Database access — connection resolution, cached writes. /// -public class DatabaseGatewayTests +public class DatabaseGatewayTests : IDisposable { private readonly IExternalSystemRepository _repository = Substitute.For(); + /// + /// Temp local databases opened by the Store-and-Forward tests, torn down in + /// . + /// + private readonly List _localDbs = new(); + + /// + /// Opens a real ILocalDb over a fresh temp file for a test that needs a live + /// . The + /// store takes an ILocalDb and LocalDb has no in-memory mode, so each test gets + /// its own file rather than a shared-cache in-memory database held open by a + /// keep-alive connection. + /// + private TestLocalDb CreateLocalDb(string prefix) + { + var localDb = TestLocalDb.CreateTemp(prefix); + _localDbs.Add(localDb); + return localDb; + } + + /// + /// Disposes every temp local database and then deletes its files — in that order, + /// because the master connection LocalDb holds anchors the WAL sidecars. + /// + public void Dispose() + { + foreach (var localDb in _localDbs) + { + var path = localDb.Path; + localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + GC.SuppressFinalize(this); + } + /// /// Configures the repository substitute for the name-keyed connection-resolution /// path used by DatabaseGateway (ExternalSystemGateway-011). A null @@ -84,12 +122,9 @@ public class DatabaseGatewayTests }; StubConnection(conn); - var dbName = $"EsgCachedWrite_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr); - keepAlive.Open(); + var localDb = CreateLocalDb("EsgCachedWrite"); var storage = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage( - connStr, NullLogger.Instance); + localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardOptions { @@ -121,7 +156,7 @@ public class DatabaseGatewayTests var depth = await storage.GetBufferDepthByCategoryAsync(); Assert.Equal(1, depth[ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite]); - var buffered = ReadBufferedRetrySettings(connStr); + var buffered = ReadBufferedRetrySettings(localDb.Db); Assert.Equal(5, buffered.MaxRetries); Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs); @@ -148,12 +183,9 @@ public class DatabaseGatewayTests }; StubConnection(conn); - var dbName = $"EsgCachedWriteZero_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr); - keepAlive.Open(); + var localDb = CreateLocalDb("EsgCachedWriteZero"); var storage = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage( - connStr, NullLogger.Instance); + localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardOptions { @@ -172,7 +204,7 @@ public class DatabaseGatewayTests await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"); - var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr); + var (maxRetries, _, _, _) = ReadBufferedRetrySettings(localDb.Db); // Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever. Assert.Equal(99, maxRetries); Assert.NotEqual(0, maxRetries); @@ -182,19 +214,15 @@ public class DatabaseGatewayTests // cached-write attempt + the buffered retry path ── /// - /// Builds a real, initialised in-memory store-and-forward service plus a - /// keep-alive connection (the SQLite shared-cache DB lives only while a - /// connection is open). The caller disposes . + /// Builds a real, initialised store-and-forward service over a fresh temp local + /// database. The database is torn down by . /// - private static (ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService Sf, string ConnStr, Microsoft.Data.Sqlite.SqliteConnection KeepAlive) + private (ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService Sf, ILocalDb Db) NewStoreAndForward() { - var dbName = $"EsgCachedWriteClassify_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr); - keepAlive.Open(); + var localDb = CreateLocalDb("EsgCachedWriteClassify"); var storage = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardStorage( - connStr, NullLogger.Instance); + localDb.Db, NullLogger.Instance); storage.InitializeAsync().GetAwaiter().GetResult(); var sfOptions = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardOptions { @@ -204,7 +232,7 @@ public class DatabaseGatewayTests }; var sf = new ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardService( storage, sfOptions, NullLogger.Instance); - return (sf, connStr, keepAlive); + return (sf, localDb.Db); } [Fact] @@ -217,8 +245,7 @@ public class DatabaseGatewayTests var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); var gateway = new ExecuteStubGateway( _repository, @@ -233,7 +260,7 @@ public class DatabaseGatewayTests Assert.NotNull(result.ErrorMessage); // Nothing buffered — the permanent failure short-circuited S&F. - Assert.Equal(0, ReadBufferDepth(connStr)); + Assert.Equal(0, ReadBufferDepth(sfDb)); } [Fact] @@ -249,8 +276,7 @@ public class DatabaseGatewayTests }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); var gateway = new ExecuteStubGateway( _repository, @@ -265,7 +291,7 @@ public class DatabaseGatewayTests Assert.True(result.WasBuffered); // handed to S&F, not synchronously failed Assert.Null(result.ErrorMessage); - Assert.Equal(1, ReadBufferDepth(connStr)); + Assert.Equal(1, ReadBufferDepth(sfDb)); } [Fact] @@ -277,8 +303,7 @@ public class DatabaseGatewayTests var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); var gateway = new ExecuteStubGateway(_repository, sf, onExecute: () => { /* succeeds */ }); @@ -288,7 +313,7 @@ public class DatabaseGatewayTests Assert.False(result.WasBuffered); Assert.Null(result.ErrorMessage); - Assert.Equal(0, ReadBufferDepth(connStr)); + Assert.Equal(0, ReadBufferDepth(sfDb)); } [Fact] @@ -381,8 +406,7 @@ public class DatabaseGatewayTests }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); // RawExecuteStubGateway routes the raw throw through the PRODUCTION // ExecuteWriteAsync classification (the seam under test), unlike @@ -395,7 +419,7 @@ public class DatabaseGatewayTests Assert.True(result.WasBuffered); // handed to S&F as transient Assert.Null(result.ErrorMessage); // not a permanent Failed result - Assert.Equal(1, ReadBufferDepth(connStr)); + Assert.Equal(1, ReadBufferDepth(sfDb)); } [Fact] @@ -408,8 +432,7 @@ public class DatabaseGatewayTests var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); using var cts = new CancellationTokenSource(); cts.Cancel(); @@ -421,7 +444,7 @@ public class DatabaseGatewayTests () => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)", cancellationToken: cts.Token)); // Cancellation is not a transient failure — nothing must have been buffered. - Assert.Equal(0, ReadBufferDepth(connStr)); + Assert.Equal(0, ReadBufferDepth(sfDb)); } [Fact] @@ -434,8 +457,7 @@ public class DatabaseGatewayTests var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); var gateway = new RawExecuteStubGateway( _repository, sf, onRunSql: () => throw new ArgumentException("authoring bug")); @@ -443,7 +465,7 @@ public class DatabaseGatewayTests await Assert.ThrowsAsync( () => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)")); - Assert.Equal(0, ReadBufferDepth(connStr)); + Assert.Equal(0, ReadBufferDepth(sfDb)); } [Fact] @@ -486,8 +508,7 @@ public class DatabaseGatewayTests var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; StubConnection(conn); - var (sf, connStr, keepAlive) = NewStoreAndForward(); - using var _ = keepAlive; + var (sf, sfDb) = NewStoreAndForward(); using var cts = new CancellationTokenSource(); cts.Cancel(); @@ -505,7 +526,7 @@ public class DatabaseGatewayTests // The cancel won — it must NOT have been classified as transient (buffered) // nor returned as a permanent Failed result. - Assert.Equal(0, ReadBufferDepth(connStr)); + Assert.Equal(0, ReadBufferDepth(sfDb)); } /// @@ -542,10 +563,10 @@ public class DatabaseGatewayTests /// Reads the current buffered-message count off the S&F SQLite DB by /// counting sf_messages rows (the engine's persistence table). /// - private static int ReadBufferDepth(string connStr) + private static int ReadBufferDepth(ILocalDb localDb) { - using var conn = new Microsoft.Data.Sqlite.SqliteConnection(connStr); - conn.Open(); + // CreateConnection hands back an already-open, pragma-configured connection. + using var conn = localDb.CreateConnection(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM sf_messages"; return Convert.ToInt32(cmd.ExecuteScalar()); @@ -615,10 +636,10 @@ public class DatabaseGatewayTests } private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript) - ReadBufferedRetrySettings(string connStr) + ReadBufferedRetrySettings(ILocalDb localDb) { - using var conn = new Microsoft.Data.Sqlite.SqliteConnection(connStr); - conn.Open(); + // CreateConnection hands back an already-open, pragma-configured connection. + using var conn = localDb.CreateConnection(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages"; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index 206c592f..ebea6eca 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -1,24 +1,60 @@ using System.Net; using System.Net.Http.Headers; using System.Text; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests; /// /// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling. /// -public class ExternalSystemClientTests +public class ExternalSystemClientTests : IDisposable { private readonly IExternalSystemRepository _repository = Substitute.For(); private readonly IHttpClientFactory _httpClientFactory = Substitute.For(); + /// + /// Temp local databases opened by the Store-and-Forward tests, torn down in + /// . + /// + private readonly List _localDbs = new(); + + /// + /// Opens a real ILocalDb over a fresh temp file for a test that needs a live + /// . The store takes an ILocalDb and LocalDb + /// has no in-memory mode, so each test gets its own file rather than a shared-cache + /// in-memory database held open by a keep-alive connection. + /// + private TestLocalDb CreateLocalDb(string prefix) + { + var localDb = TestLocalDb.CreateTemp(prefix); + _localDbs.Add(localDb); + return localDb; + } + + /// + /// Disposes every temp local database and then deletes its files — in that order, + /// because the master connection LocalDb holds anchors the WAL sidecars. + /// + public void Dispose() + { + foreach (var localDb in _localDbs) + { + var path = localDb.Path; + localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + + GC.SuppressFinalize(this); + } + /// /// Configures the repository substitute for the name-keyed resolution path used by /// ExternalSystemClient (ExternalSystemGateway-011). A null system or @@ -275,11 +311,8 @@ public class ExternalSystemClientTests _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); // A real S&F service with a registered delivery handler that counts invocations. - var dbName = $"EsgDoubleDispatch_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var localDb = CreateLocalDb("EsgDoubleDispatch"); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new StoreAndForwardOptions { @@ -422,11 +455,8 @@ public class ExternalSystemClientTests var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); - var dbName = $"EsgRetry_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var localDb = CreateLocalDb("EsgRetry"); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); // S&F defaults deliberately different from the system's settings. var sfOptions = new StoreAndForwardOptions @@ -454,7 +484,7 @@ public class ExternalSystemClientTests var depth = await storage.GetBufferDepthByCategoryAsync(); Assert.Equal(1, depth[ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]); - var buffered = ReadBufferedRetrySettings(connStr); + var buffered = ReadBufferedRetrySettings(localDb.Db); Assert.Equal(7, buffered.MaxRetries); Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs); @@ -466,10 +496,10 @@ public class ExternalSystemClientTests } private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript) - ReadBufferedRetrySettings(string connStr) + ReadBufferedRetrySettings(ILocalDb localDb) { - using var conn = new SqliteConnection(connStr); - conn.Open(); + // CreateConnection hands back an already-open, pragma-configured connection. + using var conn = localDb.CreateConnection(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages"; @@ -505,11 +535,8 @@ public class ExternalSystemClientTests var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); - var dbName = $"EsgRetryZero_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var localDb = CreateLocalDb("EsgRetryZero"); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new StoreAndForwardOptions { @@ -525,7 +552,7 @@ public class ExternalSystemClientTests await client.CachedCallAsync("TestAPI", "postData"); - var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr); + var (maxRetries, _, _, _) = ReadBufferedRetrySettings(localDb.Db); // Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever. Assert.Equal(99, maxRetries); Assert.NotEqual(0, maxRetries); @@ -651,11 +678,8 @@ public class ExternalSystemClientTests var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10))); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); - var dbName = $"EsgCancel_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var localDb = CreateLocalDb("EsgCancel"); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new StoreAndForwardOptions { @@ -1060,11 +1084,8 @@ public class ExternalSystemClientTests .Returns(_ => new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, hugeBody))); // A real S&F service so we can assert the oversized response is NOT buffered. - var dbName = $"EsgOversize_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var localDb = CreateLocalDb("EsgOversize"); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var sfOptions = new StoreAndForwardOptions { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests.csproj index d2b57969..abb801e5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests.csproj @@ -24,6 +24,7 @@ - + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs index 5ea6a969..55d8fa57 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthReportSenderTests.cs @@ -1,10 +1,10 @@ -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests; @@ -191,50 +191,55 @@ public class HealthReportSenderTests [Fact] public async Task ReportsIncludeStoreAndForwardBufferDepthsFromStorage() { - var dbName = $"HealthSfDepth_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - // Keep one connection alive so the in-memory DB persists for the test. - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); - await storage.InitializeAsync(); - - // Two pending ExternalSystem messages and one pending Notification message. - await storage.EnqueueAsync(MakePendingMessage("m1", StoreAndForwardCategory.ExternalSystem)); - await storage.EnqueueAsync(MakePendingMessage("m2", StoreAndForwardCategory.ExternalSystem)); - await storage.EnqueueAsync(MakePendingMessage("m3", StoreAndForwardCategory.Notification)); - - var transport = new FakeTransport(); - var collector = new SiteHealthCollector(); - collector.SetActiveNode(true); - var options = Options.Create(new HealthMonitoringOptions - { - ReportInterval = TimeSpan.FromMilliseconds(50) - }); - - var sender = new HealthReportSender( - collector, - transport, - options, - NullLogger.Instance, - new FakeSiteIdentityProvider(), - sfStorage: storage); - - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + // LocalDb has no in-memory mode, so the store runs over a real temp file. + var localDb = TestLocalDb.CreateTemp("HealthSfDepth"); try { - await sender.StartAsync(cts.Token); - await Task.Delay(250, CancellationToken.None); - await sender.StopAsync(CancellationToken.None); - } - catch (OperationCanceledException) { } + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); + await storage.InitializeAsync(); - Assert.True(transport.SentReports.Count >= 1); - var depths = transport.SentReports[^1].StoreAndForwardBufferDepths; - Assert.Equal(2, depths[nameof(StoreAndForwardCategory.ExternalSystem)]); - Assert.Equal(1, depths[nameof(StoreAndForwardCategory.Notification)]); - Assert.False(depths.ContainsKey(nameof(StoreAndForwardCategory.CachedDbWrite))); + // Two pending ExternalSystem messages and one pending Notification message. + await storage.EnqueueAsync(MakePendingMessage("m1", StoreAndForwardCategory.ExternalSystem)); + await storage.EnqueueAsync(MakePendingMessage("m2", StoreAndForwardCategory.ExternalSystem)); + await storage.EnqueueAsync(MakePendingMessage("m3", StoreAndForwardCategory.Notification)); + + var transport = new FakeTransport(); + var collector = new SiteHealthCollector(); + collector.SetActiveNode(true); + var options = Options.Create(new HealthMonitoringOptions + { + ReportInterval = TimeSpan.FromMilliseconds(50) + }); + + var sender = new HealthReportSender( + collector, + transport, + options, + NullLogger.Instance, + new FakeSiteIdentityProvider(), + sfStorage: storage); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + try + { + await sender.StartAsync(cts.Token); + await Task.Delay(250, CancellationToken.None); + await sender.StopAsync(CancellationToken.None); + } + catch (OperationCanceledException) { } + + Assert.True(transport.SentReports.Count >= 1); + var depths = transport.SentReports[^1].StoreAndForwardBufferDepths; + Assert.Equal(2, depths[nameof(StoreAndForwardCategory.ExternalSystem)]); + Assert.Equal(1, depths[nameof(StoreAndForwardCategory.Notification)]); + Assert.False(depths.ContainsKey(nameof(StoreAndForwardCategory.CachedDbWrite))); + } + finally + { + // The master connection anchors the WAL — dispose before deleting. + localDb.Dispose(); + TestLocalDb.DeleteFiles(localDb.Path); + } } private static StoreAndForwardMessage MakePendingMessage(string id, StoreAndForwardCategory category) => diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj index 2f7c9b4b..1cda3a29 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj @@ -28,6 +28,7 @@ - + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs new file mode 100644 index 00000000..74b9c2f2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbDirectoryTests.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.LocalDb; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Pins that a site node creates the directory holding LocalDb:Path before the +/// database is opened. +/// +/// +/// +/// This guarantee used to live in StoreAndForwardStorage.EnsureDatabaseDirectoryExists, +/// which created the parent directory for its own SQLite file. LocalDb Phase 2 moved that +/// file into the consolidated database — but the LocalDb library does not create the +/// directory, and SqliteLocalDb opens the file eagerly in its constructor, so a +/// missing directory is a hard boot failure ("SQLite Error 14: unable to open database +/// file") rather than a degraded start. +/// +/// +/// This is not hypothetical: the default site configuration points at the relative +/// path ./data/site-localdb.db, so any site node whose working directory has no +/// data/ subdirectory hits it. The docker rig escapes only because its volume mount +/// creates /app/data — which is exactly the kind of coincidence that hides a defect +/// until a bare-metal or fresh deployment. +/// +/// +public class SiteLocalDbDirectoryTests +{ + [Fact] + public void SiteRegistration_CreatesTheLocalDbDirectory_WhenItDoesNotExist() + { + var root = Path.Combine(Path.GetTempPath(), "localdb-dir-test-" + Guid.NewGuid().ToString("N")); + var dbPath = Path.Combine(root, "nested", "site-localdb.db"); + Assert.False(Directory.Exists(root)); + + try + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["LocalDb:Path"] = dbPath, + }) + .Build(); + + // The registration path under test. Resolving ILocalDb is what actually opens + // the file, so this fails with SQLite Error 14 if the directory step regresses. + var services = new ServiceCollection(); + SiteLocalDbDirectory.Ensure(config); + services.AddZbLocalDb(config); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + Assert.NotNull(db); + Assert.True(Directory.Exists(Path.GetDirectoryName(dbPath)!)); + Assert.True(File.Exists(dbPath)); + } + finally + { + SqliteConnectionPoolCleanup(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static void SqliteConnectionPoolCleanup() => + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs index 0dac6c10..9e9f3cb2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs @@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.StoreAndForward; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; @@ -25,40 +26,67 @@ public class SfBufferResyncPredicateTests var p2 = TwoNodeClusterFixture.GetFreeTcpPort(); var (portHigh, portLow) = p1 > p2 ? (p1, p2) : (p2, p1); - await using var fixture = await TwoNodeClusterFixture.StartAsync( + var fixture = await TwoNodeClusterFixture.StartAsync( role: "site-int", portA: portHigh, portB: portLow); - // Real S&F storage + replication actor per node, production default predicate - // (no isActiveOverride) — the exact wiring under test. - var (storageOldest, _) = await CreateReplicationActorAsync(fixture.NodeA, "oldest"); - var (storageJoiner, _) = await CreateReplicationActorAsync(fixture.NodeB, "joiner"); + // The S&F stores AND SiteStorageService take an ILocalDb (LocalDb has no in-memory + // mode), so each node gets its own temp-file local databases. They are disposed + // AFTER the cluster is shut down — the actors hold connections while the systems + // are alive — and only then are the files (plus their WAL sidecars) deleted. + var localDbs = new List(); - // The delivering (oldest) node has a live buffered row the standby never saw. - await storageOldest.EnqueueAsync(NewMessage("live-row")); + try + { + // Real S&F storage + replication actor per node, production default predicate + // (no isActiveOverride) — the exact wiring under test. + var (storageOldest, _, sfDbOldest, siteDbOldest) = + await CreateReplicationActorAsync(fixture.NodeA, "oldest"); + localDbs.Add(sfDbOldest); + localDbs.Add(siteDbOldest); + var (storageJoiner, _, sfDbJoiner, siteDbJoiner) = + await CreateReplicationActorAsync(fixture.NodeB, "joiner"); + localDbs.Add(sfDbJoiner); + localDbs.Add(siteDbJoiner); - // Trigger peer (re)tracking on both sides: each actor got InitialStateAsSnapshot - // in PreStart, but the enqueue raced it — re-deliver via a fresh MemberUp is not - // needed; OnPeerTracked already fired on join. The resync exchange is async: - // wait until the JOINER holds the row (proves the snapshot flowed oldest→joiner, - // the correct direction). Pre-fix this times out (the joiner, as leader, never - // requests) AND the oldest node's row is deleted by the stale wipe. - await AwaitAsync(async () => await storageJoiner.GetMessageByIdAsync("live-row") != null, - TimeSpan.FromSeconds(20), - "joiner never received the resync snapshot (resync ran in the wrong direction)"); + // The delivering (oldest) node has a live buffered row the standby never saw. + await storageOldest.EnqueueAsync(NewMessage("live-row")); - // And the delivering node's buffer is untouched — the N1 wipe assertion. - Assert.NotNull(await storageOldest.GetMessageByIdAsync("live-row")); + // Trigger peer (re)tracking on both sides: each actor got InitialStateAsSnapshot + // in PreStart, but the enqueue raced it — re-deliver via a fresh MemberUp is not + // needed; OnPeerTracked already fired on join. The resync exchange is async: + // wait until the JOINER holds the row (proves the snapshot flowed oldest→joiner, + // the correct direction). Pre-fix this times out (the joiner, as leader, never + // requests) AND the oldest node's row is deleted by the stale wipe. + await AwaitAsync(async () => await storageJoiner.GetMessageByIdAsync("live-row") != null, + TimeSpan.FromSeconds(20), + "joiner never received the resync snapshot (resync ran in the wrong direction)"); + + // And the delivering node's buffer is untouched — the N1 wipe assertion. + Assert.NotNull(await storageOldest.GetMessageByIdAsync("live-row")); + } + finally + { + await fixture.DisposeAsync(); + + foreach (var localDb in localDbs) + { + var path = localDb.Path; + localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + } } - private static async Task<(StoreAndForwardStorage Storage, IActorRef Actor)> CreateReplicationActorAsync( - ActorSystem node, string tag) + private static async Task<( + StoreAndForwardStorage Storage, IActorRef Actor, TestLocalDb SfLocalDb, TestLocalDb SiteLocalDb)> + CreateReplicationActorAsync(ActorSystem node, string tag) { - var sfDb = Path.Combine(Path.GetTempPath(), $"sf-resync-{tag}-{Guid.NewGuid():N}.db"); - var siteDb = Path.Combine(Path.GetTempPath(), $"site-resync-{tag}-{Guid.NewGuid():N}.db"); - var sfStorage = new StoreAndForwardStorage($"Data Source={sfDb}", + var sfLocalDb = TestLocalDb.CreateTemp($"sf-resync-{tag}"); + var sfStorage = new StoreAndForwardStorage(sfLocalDb.Db, NullLogger.Instance); await sfStorage.InitializeAsync(); - var siteStorage = new SiteStorageService($"Data Source={siteDb}", + var siteLocalDb = TestLocalDb.CreateTemp($"site-resync-{tag}"); + var siteStorage = new SiteStorageService(siteLocalDb.Db, NullLogger.Instance); var replicationService = new ReplicationService( new StoreAndForwardOptions(), NullLogger.Instance); @@ -67,7 +95,7 @@ public class SfBufferResyncPredicateTests siteStorage, sfStorage, replicationService, "site-int", NullLogger.Instance, null, null, null, null)), "site-replication"); - return (sfStorage, actor); + return (sfStorage, actor, sfLocalDb, siteLocalDb); } private static StoreAndForwardMessage NewMessage(string id) => new() diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs index 0cfc6485..87c3d89f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DualNodeRecoveryTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; @@ -18,12 +19,17 @@ public class DualNodeRecoveryTests // Scenario: both site nodes crash. First node to restart opens the existing // SQLite database and finds all buffered S&F messages intact. var dbPath = Path.Combine(Path.GetTempPath(), $"sf_dual_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + // The store takes an ILocalDb; the "restarted node" opens its own local database + // over the SAME file, which is how this test models recovery from disk. + TestLocalDb? crashedDb = null; + TestLocalDb? recoveryDb = null; try { // Setup: populate SQLite with messages (simulating pre-crash state) - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + crashedDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(crashedDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var messageIds = new List(); @@ -48,7 +54,8 @@ public class DualNodeRecoveryTests // Both nodes down — simulate by creating a fresh storage instance // (new process connecting to same SQLite file) - var recoveryStorage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + recoveryDb = TestLocalDb.Create(dbPath); + var recoveryStorage = new StoreAndForwardStorage(recoveryDb.Db, NullLogger.Instance); await recoveryStorage.InitializeAsync(); // Verify all messages are available for retry @@ -69,8 +76,10 @@ public class DualNodeRecoveryTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + recoveryDb?.Dispose(); + crashedDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } @@ -79,11 +88,14 @@ public class DualNodeRecoveryTests public async Task SiteTopology_DualCrash_ParkedMessagesPreserved() { var dbPath = Path.Combine(Path.GetTempPath(), $"sf_dual_parked_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + TestLocalDb? crashedDb = null; + TestLocalDb? recoveryDb = null; try { - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + crashedDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(crashedDb.Db, NullLogger.Instance); await storage.InitializeAsync(); // Mix of pending and parked messages @@ -114,7 +126,8 @@ public class DualNodeRecoveryTests }); // Dual crash recovery - var recoveryStorage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + recoveryDb = TestLocalDb.Create(dbPath); + var recoveryStorage = new StoreAndForwardStorage(recoveryDb.Db, NullLogger.Instance); await recoveryStorage.InitializeAsync(); var pendingCount = await recoveryStorage.GetMessageCountByStatusAsync(StoreAndForwardMessageStatus.Pending); @@ -134,8 +147,10 @@ public class DualNodeRecoveryTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + recoveryDb?.Dispose(); + crashedDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } @@ -176,11 +191,14 @@ public class DualNodeRecoveryTests { // CREATE TABLE IF NOT EXISTS is idempotent — safe to call on recovery var dbPath = Path.Combine(Path.GetTempPath(), $"sf_idempotent_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + TestLocalDb? localDb1 = null; + TestLocalDb? localDb2 = null; try { - var storage1 = new StoreAndForwardStorage(connStr, NullLogger.Instance); + localDb1 = TestLocalDb.Create(dbPath); + var storage1 = new StoreAndForwardStorage(localDb1.Db, NullLogger.Instance); await storage1.InitializeAsync(); await storage1.EnqueueAsync(new StoreAndForwardMessage @@ -196,7 +214,8 @@ public class DualNodeRecoveryTests }); // Second InitializeAsync on same DB should be safe (no data loss) - var storage2 = new StoreAndForwardStorage(connStr, NullLogger.Instance); + localDb2 = TestLocalDb.Create(dbPath); + var storage2 = new StoreAndForwardStorage(localDb2.Db, NullLogger.Instance); await storage2.InitializeAsync(); var msg = await storage2.GetMessageByIdAsync("test-1"); @@ -205,8 +224,10 @@ public class DualNodeRecoveryTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + localDb2?.Dispose(); + localDb1?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs index e52462db..e3276844 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs @@ -91,12 +91,12 @@ public class IntegrationSurfaceTests { // Notification Outbox: Notify.Send enqueues into the site Store-and-Forward // Engine and returns the NotificationId handle immediately. - var dbName = $"NotifyWired_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new Microsoft.Data.Sqlite.SqliteConnection(connStr); - keepAlive.Open(); + // A real temp-file LocalDb, not the shared-cache in-memory database this used + // before: StoreAndForwardStorage takes ILocalDb now, and LocalDb has no + // in-memory mode. + using var localDb = ZB.MOM.WW.ScadaBridge.TestSupport.TestLocalDb.CreateTemp("NotifyWired"); var storage = new StoreAndForward.StoreAndForwardStorage( - connStr, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + localDb.Db, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); await storage.InitializeAsync(); var saf = new StoreAndForward.StoreAndForwardService( storage, new StoreAndForward.StoreAndForwardOptions(), diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs index f1a3eeb3..b9b4cb7c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/RecoveryDrillTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; @@ -59,11 +60,15 @@ public class RecoveryDrillTests // Scenario: Communication drops while deploying system-wide artifacts. // The deployment command is buffered by S&F and retried when connection restores. var dbPath = Path.Combine(Path.GetTempPath(), $"sf_commdrop_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + // The store takes an ILocalDb (LocalDb has no in-memory mode), so the buffer + // lives in a real temp file for the duration of the drill. + TestLocalDb? localDb = null; try { - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + localDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var options = new StoreAndForwardOptions @@ -102,8 +107,9 @@ public class RecoveryDrillTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connection anchors the WAL sidecars. + localDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } @@ -115,12 +121,17 @@ public class RecoveryDrillTests // On startup, the Deployment Manager Actor reads configs from SQLite and // recreates Instance Actors. var dbPath = Path.Combine(Path.GetTempPath(), $"sf_restart_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + // The restarted "process" opens its own local database over the SAME file — that + // is what this drill verifies survives. + TestLocalDb? preRestartDb = null; + TestLocalDb? restartedDb = null; try { // Pre-restart: S&F messages in buffer - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + preRestartDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(preRestartDb.Db, NullLogger.Instance); await storage.InitializeAsync(); for (var i = 0; i < 3; i++) @@ -140,7 +151,8 @@ public class RecoveryDrillTests } // Post-restart: new storage instance reads same DB - var restartedStorage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + restartedDb = TestLocalDb.Create(dbPath); + var restartedStorage = new StoreAndForwardStorage(restartedDb.Db, NullLogger.Instance); await restartedStorage.InitializeAsync(); var pending = await restartedStorage.GetMessagesForRetryAsync(); @@ -153,8 +165,10 @@ public class RecoveryDrillTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + restartedDb?.Dispose(); + preRestartDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs index 0eb2d110..762f64b1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SiteFailoverTests.cs @@ -4,6 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; @@ -21,12 +22,18 @@ public class SiteFailoverTests // Simulates site failover: messages buffered in SQLite survive process restart. // The standby node picks up the same SQLite file and retries pending messages. var dbPath = Path.Combine(Path.GetTempPath(), $"sf_failover_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + // The store takes an ILocalDb; each "node" opens its own local database over the + // SAME file, which is how this test models the standby picking up the primary's + // buffer after failover. + TestLocalDb? primaryDb = null; + TestLocalDb? standbyDb = null; try { // Phase 1: Buffer messages on "primary" node - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + primaryDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(primaryDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var message = new StoreAndForwardMessage @@ -46,7 +53,8 @@ public class SiteFailoverTests await storage.EnqueueAsync(message); // Phase 2: "Standby" node opens the same database (simulating failover) - var standbyStorage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + standbyDb = TestLocalDb.Create(dbPath); + var standbyStorage = new StoreAndForwardStorage(standbyDb.Db, NullLogger.Instance); await standbyStorage.InitializeAsync(); var pending = await standbyStorage.GetMessagesForRetryAsync(); @@ -57,8 +65,10 @@ public class SiteFailoverTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + standbyDb?.Dispose(); + primaryDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } @@ -67,11 +77,14 @@ public class SiteFailoverTests public async Task StoreAndForward_ParkedMessages_SurviveFailover() { var dbPath = Path.Combine(Path.GetTempPath(), $"sf_parked_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + TestLocalDb? primaryDb = null; + TestLocalDb? standbyDb = null; try { - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + primaryDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(primaryDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var parkedMsg = new StoreAndForwardMessage @@ -93,7 +106,8 @@ public class SiteFailoverTests await storage.EnqueueAsync(parkedMsg); // Standby opens same DB - var standbyStorage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + standbyDb = TestLocalDb.Create(dbPath); + var standbyStorage = new StoreAndForwardStorage(standbyDb.Db, NullLogger.Instance); await standbyStorage.InitializeAsync(); var (parked, count) = await standbyStorage.GetParkedMessagesAsync(); @@ -102,8 +116,10 @@ public class SiteFailoverTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + standbyDb?.Dispose(); + primaryDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } @@ -160,11 +176,14 @@ public class SiteFailoverTests public async Task StoreAndForward_BufferDepth_ReportedAfterFailover() { var dbPath = Path.Combine(Path.GetTempPath(), $"sf_depth_{Guid.NewGuid():N}.db"); - var connStr = $"Data Source={dbPath}"; + + TestLocalDb? primaryDb = null; + TestLocalDb? standbyDb = null; try { - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + primaryDb = TestLocalDb.Create(dbPath); + var storage = new StoreAndForwardStorage(primaryDb.Db, NullLogger.Instance); await storage.InitializeAsync(); // Enqueue messages in different categories @@ -199,7 +218,8 @@ public class SiteFailoverTests } // After failover, standby reads buffer depths - var standbyStorage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + standbyDb = TestLocalDb.Create(dbPath); + var standbyStorage = new StoreAndForwardStorage(standbyDb.Db, NullLogger.Instance); await standbyStorage.InitializeAsync(); var depths = await standbyStorage.GetBufferDepthByCategoryAsync(); @@ -208,8 +228,10 @@ public class SiteFailoverTests } finally { - if (File.Exists(dbPath)) - File.Delete(dbPath); + // Dispose before deleting — the master connections anchor the WAL sidecars. + standbyDb?.Dispose(); + primaryDb?.Dispose(); + TestLocalDb.DeleteFiles(dbPath); } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj index 9eddb8c5..5090cd4d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj @@ -42,6 +42,7 @@ - + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index 2f848369..8852d3fc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; using System.Threading; @@ -28,13 +29,13 @@ public class DeploymentManagerActorTests : TestKit, IDisposable private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public DeploymentManagerActorTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"dm-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("dm-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -45,8 +46,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private IActorRef CreateDeploymentManager( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs index 20dbf736..7ea67d09 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs @@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -46,13 +47,13 @@ akka { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile") { - _dbFile = Path.Combine(Path.GetTempPath(), $"dm-cert-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("dm-cert-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", NullLogger.Instance); + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); @@ -62,8 +63,12 @@ akka { void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } /// Forwards every message it receives to a probe, preserving the original sender. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs index ad74f50b..087b7f0d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerLoggerFactoryTests.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -23,13 +24,13 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public DeploymentManagerLoggerFactoryTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("dm-loggerfactory-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -40,8 +41,12 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private static string MakeConfigJson(string instanceName) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs index a2351233..ab9adb9c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerMediumFindingsTests.cs @@ -1,12 +1,14 @@ using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -20,11 +22,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable { private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public DeploymentManagerMediumFindingsTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"dm-medium-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("dm-medium-test"); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( @@ -33,12 +35,16 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } - private SiteStorageService NewStorage(string connectionString) - => new(connectionString, NullLogger.Instance); + private SiteStorageService NewStorage(ILocalDb localDb) + => new(localDb, NullLogger.Instance); private IActorRef CreateDeploymentManager(SiteStorageService storage, IActorRef? dclManager = null) { @@ -100,22 +106,37 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable [Fact] public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess() { - // A connection string pointing at an unwritable path makes every storage - // write throw, so StoreDeployedConfigAsync fails. - var badPath = Path.Combine( - Path.GetTempPath(), $"no-such-dir-{Guid.NewGuid():N}", "site.db"); - var storage = NewStorage($"Data Source={badPath}"); + // A storage over a database whose site tables were never created makes every + // storage operation throw ("no such table"), so StoreDeployedConfigAsync fails. + // + // This replaces the old "connection string pointing at an unwritable path" trick: + // the service now takes an ILocalDb rather than a connection string, and LocalDb + // opens its file eagerly in its own constructor — so an unopenable path fails + // while building the fixture, before there is a storage to hand the actor at all. + // Skipping InitializeAsync is the equivalent lever on the new seam, and fails the + // same way the old one did: reads AND writes both throw. + var uninitialized = TestLocalDb.CreateTemp("dm-medium-persist-fail"); + try + { + var storage = NewStorage(uninitialized.Db); - var actor = CreateDeploymentManager(storage); - await Task.Delay(500); // empty startup + var actor = CreateDeploymentManager(storage); + await Task.Delay(500); // empty startup - actor.Tell(new DeployInstanceCommand( - "dep-fail", "FailPump", "h1", MakeConfigJson("FailPump"), "admin", DateTimeOffset.UtcNow)); + actor.Tell(new DeployInstanceCommand( + "dep-fail", "FailPump", "h1", MakeConfigJson("FailPump"), "admin", DateTimeOffset.UtcNow)); - var response = ExpectMsg(TimeSpan.FromSeconds(10)); - Assert.Equal("FailPump", response.InstanceUniqueName); - Assert.Equal(DeploymentStatus.Failed, response.Status); - Assert.False(string.IsNullOrEmpty(response.ErrorMessage)); + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("FailPump", response.InstanceUniqueName); + Assert.Equal(DeploymentStatus.Failed, response.Status); + Assert.False(string.IsNullOrEmpty(response.ErrorMessage)); + } + finally + { + var path = uninitialized.Path; + uninitialized.Dispose(); + TestLocalDb.DeleteFiles(path); + } } /// @@ -126,7 +147,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable [Fact] public async Task Deploy_Success_ReportsSuccessAndPersistsConfig() { - var storage = NewStorage($"Data Source={_dbFile}"); + var storage = NewStorage(_localDb.Db); await storage.InitializeAsync(); var actor = CreateDeploymentManager(storage); @@ -152,7 +173,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable [Fact] public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesCreateCommand() { - var storage = NewStorage($"Data Source={_dbFile}"); + var storage = NewStorage(_localDb.Db); await storage.InitializeAsync(); var dcl = CreateTestProbe(); @@ -191,7 +212,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable [Fact] public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand() { - var storage = NewStorage($"Data Source={_dbFile}"); + var storage = NewStorage(_localDb.Db); await storage.InitializeAsync(); var dcl = CreateTestProbe(); @@ -223,7 +244,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable [Fact] public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive() { - var storage = NewStorage($"Data Source={_dbFile}"); + var storage = NewStorage(_localDb.Db); await storage.InitializeAsync(); // Several shared scripts to compile during startup. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs index e4e90fdf..a853d061 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerRedeployTests.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -25,13 +26,13 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public DeploymentManagerRedeployTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"dm-redeploy-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("dm-redeploy-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -42,8 +43,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private IActorRef CreateDeploymentManager( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs index f01cde4e..437b653b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildAttributeRaceTests.cs @@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Reflection; using System.Text.Json; @@ -39,13 +40,13 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorChildAttributeRaceTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-race-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("instance-race-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -61,8 +62,12 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private static FlattenedConfiguration BuildConfig(string instanceName) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs index 93b4b1ed..cb80b5a4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorChildRoutingTests.cs @@ -5,6 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -22,13 +23,13 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorChildRoutingTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-routing-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("instance-routing-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -44,8 +45,12 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private static FlattenedConfiguration ScriptsAB_and_Expression(string instanceName) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs index 6933cef0..e2d0af31 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorIntegrationTests.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -22,13 +23,13 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorIntegrationTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-int-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("instance-int-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -45,8 +46,12 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private IActorRef CreateInstanceWithScripts( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs index 33b7bd37..8cfcbf41 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs @@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -25,12 +26,12 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options = new(); - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorNativeAlarmTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-native-{Guid.NewGuid():N}.db"); - _storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger.Instance); + _localDb = TestLocalDb.CreateTemp("instance-native"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService(NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger.Instance); @@ -149,7 +150,11 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs index 90256837..c1540b93 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorSetAttributeTests.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -23,13 +24,13 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorSetAttributeTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-setattr-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("instance-setattr-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -41,8 +42,12 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config, IActorRef? dclManager) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index bc8f3c31..5657c1a8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -25,13 +26,13 @@ public class InstanceActorTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-actor-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("instance-actor-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -56,8 +57,12 @@ public class InstanceActorTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } // ── M1.6: site event log `instance_lifecycle` category ────────────────── diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs index ca13817d..102947e9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs @@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; +using ZB.MOM.WW.ScadaBridge.TestSupport; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -26,13 +27,13 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public InstanceActorWaitForAttributeTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"instance-waitfor-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("instance-waitfor-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( @@ -57,8 +58,12 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable void IDisposable.Dispose() { + // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb; + // then dispose the database before deleting — the master connection anchors the WAL. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } // ── 1. Fast-path: attribute already at target ──────────────────────────── diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs index f8dd29ec..c8874722 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs @@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -21,14 +22,16 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// public class NativeAlarmActorTests : TestKit, IDisposable { - private readonly string _dbFile; + private readonly TestLocalDb _localDb; private readonly SiteStorageService _storage; private readonly SiteRuntimeOptions _options = new(); public NativeAlarmActorTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"naa-{Guid.NewGuid():N}.db"); - _storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger.Instance); + // SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the + // fixture is a real temp file (deleted in Dispose, after the TestKit shutdown). + _localDb = TestLocalDb.CreateTemp("naa"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); } @@ -442,11 +445,14 @@ public class NativeAlarmActorTests : TestKit, IDisposable void IDisposable.Dispose() { + // Shut the actor system down FIRST: in-flight alarm actors still hold the + // ILocalDb, and their coalesced flush would hit a disposed database otherwise. Shutdown(); - if (File.Exists(_dbFile)) - { - File.Delete(_dbFile); - } + // Then dispose — 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] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs index 621cf6b4..7fa1825d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs @@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -22,20 +23,28 @@ public class SiteReconciliationActorTests : TestKit, IDisposable private const string NodeId = "node-a"; private readonly SiteStorageService _storage; - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public SiteReconciliationActorTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"site-reconcile-test-{Guid.NewGuid():N}.db"); + // SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the + // fixture is a real temp file (deleted in Dispose, after the TestKit shutdown). + _localDb = TestLocalDb.CreateTemp("site-reconcile-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _localDb.Db, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); } void IDisposable.Dispose() { + // Shut the actor system down FIRST: a reconcile continuation may still be + // writing through the ILocalDb, which must outlive the actors. Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } + // Then dispose — 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); } private IActorRef CreateReconciliationActor( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs index eb67ee4f..0dae7d19 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs @@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.StoreAndForward; using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; @@ -48,22 +49,26 @@ akka { private const string SiteRole = "site-test"; private readonly SiteStorageService _storage; + private readonly TestLocalDb _siteLocalDb; + private readonly TestLocalDb _sfLocalDb; private readonly StoreAndForwardStorage _sfStorage; private readonly ReplicationService _replicationService; - private readonly string _dbFile; private readonly string _sfDbFile; public SiteReplicationActorTests() : base(ClusterConfig, "site-repl") { - _dbFile = Path.Combine(Path.GetTempPath(), $"site-repl-test-{Guid.NewGuid():N}.db"); _sfDbFile = Path.Combine(Path.GetTempPath(), $"site-repl-sf-{Guid.NewGuid():N}.db"); + // SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the + // site store gets its own temp-file database alongside the S&F one. + _siteLocalDb = TestLocalDb.CreateTemp("site-repl-test"); _storage = new SiteStorageService( - $"Data Source={_dbFile}", NullLogger.Instance); + _siteLocalDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); + _sfLocalDb = TestLocalDb.Create(_sfDbFile); _sfStorage = new StoreAndForwardStorage( - $"Data Source={_sfDbFile}", NullLogger.Instance); + _sfLocalDb.Db, NullLogger.Instance); _sfStorage.InitializeAsync().GetAwaiter().GetResult(); _replicationService = new ReplicationService( @@ -73,8 +78,12 @@ akka { void IDisposable.Dispose() { Shutdown(); - try { File.Delete(_dbFile); } catch { /* cleanup */ } - try { File.Delete(_sfDbFile); } catch { /* cleanup */ } + // The master connection anchors the WAL — dispose before deleting. + var siteDbPath = _siteLocalDb.Path; + _siteLocalDb.Dispose(); + _sfLocalDb.Dispose(); + TestLocalDb.DeleteFiles(siteDbPath); + TestLocalDb.DeleteFiles(_sfDbFile); } private IActorRef CreateReplicationActor(IDeploymentConfigFetcher fetcher) => diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs index 133a1feb..f10835d5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/NegativeTests.cs @@ -1,6 +1,7 @@ using Microsoft.Data.Sqlite; 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; @@ -14,18 +15,27 @@ public class NegativeTests { // Per design decision: no alarm state table in site SQLite schema. // The site SQLite stores only deployed configs and static attribute overrides. - var storage = new SiteStorageService( - "Data Source=:memory:", - NullLogger.Instance); - await storage.InitializeAsync(); + // + // SiteStorageService takes an ILocalDb now, and LocalDb has no in-memory mode + // (its Path is a filesystem path), so the service is initialized over a real + // temp file instead of the "Data Source=:memory:" it used before. The manually + // built schema subset below is still a plain in-memory SqliteConnection — it is + // not a LocalDb store, just a scratch database this test asserts against. + var localDb = TestLocalDb.CreateTemp("negative-schema"); + try + { + var storage = new SiteStorageService( + localDb.Db, + NullLogger.Instance); + await storage.InitializeAsync(); - // Try querying a non-existent alarm_states table — should throw - await using var connection = new SqliteConnection("Data Source=:memory:"); - await connection.OpenAsync(); + // Try querying a non-existent alarm_states table — should throw + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); - // Re-initialize on this connection to get the schema - await using var initCmd = connection.CreateCommand(); - initCmd.CommandText = @" + // Re-initialize on this connection to get the schema + await using var initCmd = connection.CreateCommand(); + initCmd.CommandText = @" CREATE TABLE IF NOT EXISTS deployed_configurations ( instance_unique_name TEXT PRIMARY KEY, config_json TEXT NOT NULL, @@ -41,13 +51,22 @@ public class NegativeTests updated_at TEXT NOT NULL, PRIMARY KEY (instance_unique_name, attribute_name) );"; - await initCmd.ExecuteNonQueryAsync(); + await initCmd.ExecuteNonQueryAsync(); - // Verify alarm_states does NOT exist - await using var checkCmd = connection.CreateCommand(); - checkCmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='alarm_states'"; - var result = await checkCmd.ExecuteScalarAsync(); - Assert.Null(result); + // Verify alarm_states does NOT exist + await using var checkCmd = connection.CreateCommand(); + checkCmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='alarm_states'"; + var result = await checkCmd.ExecuteScalarAsync(); + Assert.Null(result); + } + finally + { + // 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] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs index 7d3e8423..997c244b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs @@ -1,5 +1,6 @@ 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; @@ -7,20 +8,24 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; /// WP-33: Local Artifact Storage tests — shared scripts, external systems, /// database connections, notification lists. /// +/// +/// Backed by a real temp-file LocalDb: takes an +/// ILocalDb rather than a connection string, and LocalDb has no in-memory mode. +/// public class ArtifactStorageTests : IAsyncLifetime, IDisposable { - private readonly string _dbFile; + private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; public ArtifactStorageTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"artifact-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("artifact-test"); } public async Task InitializeAsync() { _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); await _storage.InitializeAsync(); } @@ -29,7 +34,11 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable public void Dispose() { - try { File.Delete(_dbFile); } catch { /* cleanup */ } + // 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); } // ── Shared Script Storage ── @@ -132,8 +141,10 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable private async Task SeedNotificationRowAsync(string name, string emailsJson) { - await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}"); - await connection.OpenAsync(); + // 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 connection = _storage.CreateConnection(); await using var command = connection.CreateCommand(); command.CommandText = "INSERT INTO notification_lists (name, recipient_emails, updated_at) VALUES (@n, @e, @u)"; @@ -145,8 +156,7 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable private async Task SeedSmtpRowAsync(string name, string password) { - await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}"); - await connection.OpenAsync(); + await using var connection = _storage.CreateConnection(); await using var command = connection.CreateCommand(); command.CommandText = @"INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at) @@ -159,8 +169,7 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable private async Task RowCountAsync(string table) { - await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbFile}"); - await connection.OpenAsync(); + await using var connection = _storage.CreateConnection(); await using var command = connection.CreateCommand(); command.CommandText = $"SELECT COUNT(*) FROM {table}"; return (long)(await command.ExecuteScalarAsync())!; diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs index 4063ab37..9a38bd45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs @@ -1,5 +1,6 @@ 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; @@ -7,19 +8,23 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; /// Task 14: site-local SQLite native_alarm_state store — mirrored native alarm /// condition snapshots keyed by (instance, source canonical name, source reference). /// +/// +/// Backed by a real temp-file LocalDb: takes an +/// ILocalDb rather than a connection string, and LocalDb has no in-memory mode. +/// public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable { - private readonly string _dbFile; + private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; public NativeAlarmStateStoreTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"nas-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("nas"); } public async Task InitializeAsync() { - _storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger.Instance); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); await _storage.InitializeAsync(); } @@ -93,9 +98,10 @@ public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable public void Dispose() { - if (File.Exists(_dbFile)) - { - File.Delete(_dbFile); - } + // 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); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs index 549a73ad..585e09c3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageServiceTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; 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; @@ -9,20 +9,26 @@ 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 string _dbFile; + private readonly TestLocalDb _localDb; private SiteStorageService _storage = null!; public SiteStorageServiceTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"site-storage-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("site-storage-test"); } public async Task InitializeAsync() { _storage = new SiteStorageService( - $"Data Source={_dbFile}", + _localDb.Db, NullLogger.Instance); await _storage.InitializeAsync(); } @@ -31,7 +37,11 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable public void Dispose() { - try { File.Delete(_dbFile); } catch { /* cleanup */ } + // 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] @@ -45,10 +55,17 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable [Fact] public async Task Initialize_EnablesWalJournalMode() { - // WAL is set once at InitializeAsync (persistent, database-level). A file-backed DB - // is required — WAL is not available for :memory: databases. - await using var conn = _storage.CreateConnection(); - await conn.OpenAsync(); + // ── 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())!; @@ -219,8 +236,10 @@ public class SiteStorageServiceTests : IAsyncLifetime, IDisposable string instanceName, string configJson, string deploymentId, string revisionHash, bool isEnabled, DateTimeOffset deployedAt) { - await using var conn = new SqliteConnection($"Data Source={_dbFile}"); - await conn.OpenAsync(); + // 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs index a5b68ffb..366d5eb8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Repositories; @@ -17,21 +18,32 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Repositories; /// public class SiteRepositoryTests : IDisposable { - private readonly string _dbFile; + private readonly TestLocalDb _localDb; public SiteRepositoryTests() { - _dbFile = Path.Combine(Path.GetTempPath(), $"site-repo-test-{Guid.NewGuid():N}.db"); + _localDb = TestLocalDb.CreateTemp("site-repo-test"); } public void Dispose() { - try { File.Delete(_dbFile); } catch { /* cleanup */ } + // 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); GC.SuppressFinalize(this); } + /// + /// A brand-new instance over the same site database. + /// The service now takes an ILocalDb instead of a connection string, so the + /// single fixture database is shared while each call still yields a fresh service + /// object — which is what the restart tests below actually vary (the synthetic IDs are + /// derived per service/repository instance, not per connection). + /// private SiteStorageService NewStorage() - => new($"Data Source={_dbFile}", NullLogger.Instance); + => new(_localDb.Db, NullLogger.Instance); /// /// SiteRuntime-006: an external system stored via diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index 8b1f7f4f..b256e5a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -1,12 +1,12 @@ using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; @@ -24,18 +24,16 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; public NotifyHelperTests() { - var dbName = $"NotifyTests_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + // LocalDb has no in-memory mode, so the store runs over a real temp file. + _localDb = TestLocalDb.CreateTemp("NotifyTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero, @@ -53,7 +51,9 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable { if (disposing) { - _keepAlive.Dispose(); + // The master connection anchors the WAL — dispose before deleting. + _localDb.Dispose(); + TestLocalDb.DeleteFiles(_localDb.Path); } base.Dispose(disposing); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs index 9bfa69c8..4c1f5d7c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; @@ -10,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; @@ -63,18 +63,16 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable /// private static readonly Guid TestExecutionId = Guid.NewGuid(); - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _saf; public NotifySendAuditEmissionTests() { - var dbName = $"NotifySendAudit_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + // LocalDb has no in-memory mode, so the store runs over a real temp file. + _localDb = TestLocalDb.CreateTemp("NotifySendAudit"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero, @@ -92,7 +90,9 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable { if (disposing) { - _keepAlive.Dispose(); + // The master connection anchors the WAL — dispose before deleting. + _localDb.Dispose(); + TestLocalDb.DeleteFiles(_localDb.Path); } base.Dispose(disposing); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.csproj index 0f1db5ff..581a977d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.csproj @@ -28,6 +28,7 @@ - + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs index d04e8bb9..4bf217e7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -20,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; private readonly StoreAndForwardOptions _options; @@ -28,12 +28,9 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable public CachedCallAttemptEmissionTests() { - var dbName = $"E4Tests_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("E4Tests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); _options = new StoreAndForwardOptions { @@ -55,7 +52,12 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable public async Task InitializeAsync() => await _storage.InitializeAsync(); public Task DisposeAsync() => Task.CompletedTask; - public void Dispose() => _keepAlive.Dispose(); + public void Dispose() + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } /// /// Captures every observer notification so tests can assert on the @@ -476,10 +478,8 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable // Fresh service over its own storage so StartAsync's pump/timer is isolated // from the shared _service. - var connStr = $"Data Source=SlowObs_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var localDb = TestLocalDb.CreateTemp("SlowObs"); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var service = new StoreAndForwardService( storage, @@ -515,6 +515,12 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable } lock (observed) Assert.Single(observed); } - finally { await service.StopAsync(); } + finally + { + await service.StopAsync(); + var path = localDb.Path; + localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs index 93240a69..717636a2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedMessageHandlerActorTests.cs @@ -1,9 +1,9 @@ using Akka.Actor; using Akka.TestKit.Xunit2; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -14,17 +14,15 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; public ParkedMessageHandlerActorTests() { - var connStr = $"Data Source=ActorTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("ActorTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { @@ -44,7 +42,13 @@ public class ParkedMessageHandlerActorTests : TestKit, IAsyncLifetime, IDisposab protected override void Dispose(bool disposing) { - if (disposing) _keepAlive.Dispose(); + if (disposing) + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + base.Dispose(disposing); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs index 91861693..426bf681 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ParkedOperationRelayTests.cs @@ -1,10 +1,10 @@ using Akka.Actor; using Akka.TestKit.Xunit2; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -19,17 +19,15 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; public ParkedOperationRelayTests() { - var connStr = $"Data Source=RelayTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("RelayTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { @@ -49,7 +47,13 @@ public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable protected override void Dispose(bool disposing) { - if (disposing) _keepAlive.Dispose(); + if (disposing) + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } + base.Dispose(disposing); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs index 57dcea6c..6b435f5f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/QueueDepthGaugeTests.cs @@ -1,8 +1,8 @@ using System.Diagnostics.Metrics; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -20,18 +20,15 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; public QueueDepthGaugeTests() { - var dbName = $"QueueDepthTests_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("QueueDepthTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { @@ -56,7 +53,12 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable public async Task DisposeAsync() => await _service.StopAsync(); - public void Dispose() => _keepAlive.Dispose(); + public void Dispose() + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } /// /// Reads the current value of the scadabridge.store_and_forward.queue.depth diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs index 7df0d8e8..19fa4b2b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs @@ -1,7 +1,7 @@ -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -10,18 +10,15 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class ReplicationServiceTests : IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly ReplicationService _replicationService; public ReplicationServiceTests() { - var dbName = $"RepTests_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("RepTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { ReplicationEnabled = true }; _replicationService = new ReplicationService( @@ -32,7 +29,12 @@ public class ReplicationServiceTests : IAsyncLifetime, IDisposable public Task DisposeAsync() => Task.CompletedTask; - public void Dispose() => _keepAlive.Dispose(); + public void Dispose() + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } [Fact] public void ReplicateEnqueue_NoHandler_DoesNotThrow() diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardReplicationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardReplicationTests.cs index 64e584fb..8080ce78 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardReplicationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardReplicationTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -11,18 +11,16 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class StoreAndForwardReplicationTests : IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; private readonly List _replicated = new(); public StoreAndForwardReplicationTests() { - var connStr = $"Data Source=ReplTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("ReplTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); var options = new StoreAndForwardOptions { @@ -45,7 +43,12 @@ public class StoreAndForwardReplicationTests : IAsyncLifetime, IDisposable public async Task InitializeAsync() => await _storage.InitializeAsync(); public Task DisposeAsync() => Task.CompletedTask; - public void Dispose() => _keepAlive.Dispose(); + public void Dispose() + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } /// Replication is fire-and-forget (Task.Run); poll until the expected ops arrive. private async Task> WaitForReplicationAsync(int count) diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs index 3c7a4c94..434b3d60 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -9,20 +9,17 @@ namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardService _service; private readonly StoreAndForwardOptions _options; - private readonly List _extraKeepAlives = new(); + private readonly List _extraLocalDbs = new(); public StoreAndForwardServiceTests() { - var dbName = $"SvcTests_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("SvcTests"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); _options = new StoreAndForwardOptions { @@ -41,23 +38,29 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable public void Dispose() { - _keepAlive.Dispose(); - foreach (var c in _extraKeepAlives) c.Dispose(); + DisposeLocalDb(_localDb); + foreach (var db in _extraLocalDbs) DisposeLocalDb(db); + } + + /// Disposes a local database, then removes its file and WAL sidecars. + private static void DisposeLocalDb(TestLocalDb localDb) + { + var path = localDb.Path; + localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } /// - /// Builds a fresh service over its own in-memory (shared-cache) SQLite so a test - /// can call StartAsync without racing the shared _service's timer. The - /// keep-alive connection is tracked for disposal. + /// Builds a fresh service over its own local database so a test can call + /// StartAsync without racing the shared _service's timer. The database + /// is tracked for disposal. /// private StoreAndForwardService CreateService(TimeSpan? retryTimerInterval = null) { - var connStr = $"Data Source=DeferTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; - var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); - _extraKeepAlives.Add(keepAlive); + var localDb = TestLocalDb.CreateTemp("DeferTests"); + _extraLocalDbs.Add(localDb); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); storage.InitializeAsync().GetAwaiter().GetResult(); var options = new StoreAndForwardOptions @@ -557,12 +560,10 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable // Build a service whose timer fires almost immediately, with a handler // that pauses in the middle of delivery so we can observe StopAsync's // wait behaviour. - var dbName = $"StopWait_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - using var keepAlive = new SqliteConnection(connStr); - keepAlive.Open(); + var localDb = TestLocalDb.CreateTemp("StopWait"); + _extraLocalDbs.Add(localDb); - var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + var storage = new StoreAndForwardStorage(localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); var options = new StoreAndForwardOptions diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs index 2c3dcea7..52144ee7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardSiteEventTests.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; -using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; @@ -34,7 +34,7 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable public long FailedWriteCount => 0; } - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; private readonly StoreAndForwardOptions _options; private readonly FakeSiteEventLogger _siteLog = new(); @@ -42,12 +42,9 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable public StoreAndForwardSiteEventTests() { - var dbName = $"SiteEvt_{Guid.NewGuid():N}"; - var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); + _localDb = TestLocalDb.CreateTemp("SiteEvt"); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); _options = new StoreAndForwardOptions { DefaultRetryInterval = TimeSpan.Zero, @@ -63,7 +60,12 @@ public class StoreAndForwardSiteEventTests : IAsyncLifetime, IDisposable public async Task InitializeAsync() => await _storage.InitializeAsync(); public Task DisposeAsync() => Task.CompletedTask; - public void Dispose() => _keepAlive.Dispose(); + public void Dispose() + { + var path = _localDb.Path; + _localDb.Dispose(); + TestLocalDb.DeleteFiles(path); + } [Fact] public async Task BufferForRetry_ExternalSystem_EmitsStoreAndForwardSiteEvent() diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index ad6057fb..668a3007 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -1,27 +1,29 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests; /// /// WP-9: Tests for SQLite persistence layer. -/// Uses in-memory SQLite with a kept-alive connection for test isolation. /// +/// +/// Backed by a real temp-file LocalDb rather than the shared-cache in-memory database +/// this class used before: now takes +/// ILocalDb, and LocalDb has no in-memory mode (LocalDb:Path is a +/// filesystem path). Isolation still comes from a fresh database per test — xUnit +/// constructs one instance per test — it is just a file now. +/// public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable { - private readonly SqliteConnection _keepAlive; + private readonly TestLocalDb _localDb; private readonly StoreAndForwardStorage _storage; - private readonly string _dbName; public StoreAndForwardStorageTests() { - _dbName = $"StorageTests_{Guid.NewGuid():N}"; - var connStr = $"Data Source={_dbName};Mode=Memory;Cache=Shared"; - // Keep one connection alive so the in-memory DB persists - _keepAlive = new SqliteConnection(connStr); - _keepAlive.Open(); - _storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + _localDb = TestLocalDb.CreateTemp("StorageTests"); + _storage = new StoreAndForwardStorage(_localDb.Db, NullLogger.Instance); } public async Task InitializeAsync() => await _storage.InitializeAsync(); @@ -30,7 +32,11 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable public void Dispose() { - _keepAlive.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] @@ -360,9 +366,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable // schema by dropping the table and recreating it without the columns, // inserting directly, then running InitializeAsync (which ALTER-adds // the columns) and reading the row back. - await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared")) + await using (var setup = _localDb.Db.CreateConnection()) { - await setup.OpenAsync(); await using var drop = setup.CreateCommand(); drop.CommandText = @" DROP TABLE IF EXISTS sf_messages; @@ -415,9 +420,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable bad.LastAttemptAt = null; // due immediately await _storage.EnqueueAsync(bad); - await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared")) + await using (var conn = _localDb.Db.CreateConnection()) { - await conn.OpenAsync(); await using var corrupt = conn.CreateCommand(); corrupt.CommandText = "UPDATE sf_messages SET execution_id = 'not-a-guid' WHERE id = 'bad1';"; @@ -514,9 +518,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable // ExecutionId rollout) by recreating the table without // parent_execution_id, inserting directly, then running InitializeAsync // which ALTER-adds the column. - await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared")) + await using (var setup = _localDb.Db.CreateConnection()) { - await setup.OpenAsync(); await using var drop = setup.CreateCommand(); drop.CommandText = @" DROP TABLE IF EXISTS sf_messages; @@ -569,9 +572,8 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable bad.LastAttemptAt = null; // due immediately await _storage.EnqueueAsync(bad); - await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared")) + await using (var conn = _localDb.Db.CreateConnection()) { - await conn.OpenAsync(); await using var corrupt = conn.CreateCommand(); corrupt.CommandText = "UPDATE sf_messages SET parent_execution_id = 'not-a-guid' WHERE id = 'pbad1';"; @@ -630,57 +632,46 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable }; } - [Fact] - public async Task InitializeAsync_FileInMissingDirectory_CreatesDirectory() - { - // SQLite creates the database file on demand but not its parent directory; - // the storage must create the directory itself or OpenAsync fails with - // "unable to open database file" (the cause of the SiteActorPathTests failures). - var directory = Path.Combine(Path.GetTempPath(), "sf-storage-test-" + Guid.NewGuid().ToString("N")); - var dbPath = Path.Combine(directory, "store-and-forward.db"); - Assert.False(Directory.Exists(directory)); - - try - { - var storage = new StoreAndForwardStorage( - $"Data Source={dbPath}", NullLogger.Instance); - - await storage.InitializeAsync(); - - Assert.True(Directory.Exists(directory)); - Assert.True(File.Exists(dbPath)); - } - finally - { - if (Directory.Exists(directory)) - Directory.Delete(directory, recursive: true); - } - } + // ── Invariants that moved owner ── + // + // Directory creation and WAL journal mode used to be this storage class's own job + // (EnsureDatabaseDirectoryExists / an explicit PRAGMA in InitializeAsync). Both moved + // out when the store stopped owning its file. They landed in DIFFERENT places, so the + // coverage split rather than moving wholesale: + // + // * WAL is genuinely LocalDb's — it sets the journal mode on the database it owns. + // The test below still asserts it, now against the LocalDb-backed store. + // + // * Directory creation is NOT LocalDb's. The library does not create the parent + // directory and opens the file eagerly, so a missing directory is a hard boot + // failure. The guarantee had to be re-established explicitly in the Host + // (SiteServiceRegistration.EnsureLocalDbDirectoryExists), and it is pinned there + // by SiteLocalDbDirectoryTests — the layer that now owns it. Asserting it here + // would be asserting a guarantee this class no longer provides. [Fact] - public async Task Initialize_EnablesWalJournalMode_OnFileDatabase() + public async Task Initialize_LeavesTheDatabaseInWalJournalMode() { // WAL lets the retry-sweep lanes, script enqueues, and standby replication - // applies read/write concurrently without "database is locked". journal_mode - // is persistent + file-scoped, so a fresh connection observes it. - var directory = Path.Combine(Path.GetTempPath(), "sf-wal-test-" + Guid.NewGuid().ToString("N")); - var path = Path.Combine(directory, "wal-test.db"); + // applies read/write concurrently without "database is locked". + using var localDb = TestLocalDb.CreateTemp("sf-wal-test"); + var path = localDb.Path; try { var storage = new StoreAndForwardStorage( - $"Data Source={path}", NullLogger.Instance); + localDb.Db, NullLogger.Instance); await storage.InitializeAsync(); - await using var conn = new SqliteConnection($"Data Source={path}"); - await conn.OpenAsync(); + // journal_mode is persistent + file-scoped, so any connection observes it. + await using var conn = localDb.Db.CreateConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "PRAGMA journal_mode"; Assert.Equal("wal", (string)(await cmd.ExecuteScalarAsync())!); } finally { - if (Directory.Exists(directory)) - Directory.Delete(directory, recursive: true); + localDb.Dispose(); + TestLocalDb.DeleteFiles(path); } } @@ -731,8 +722,7 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable private async Task ExecRawAsync(string sql, params (string, object)[] parameters) { - await using var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"); - await conn.OpenAsync(); + await using var conn = _localDb.Db.CreateConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value); @@ -741,8 +731,7 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable private async Task ScalarRawAsync(string sql) { - await using var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"); - await conn.OpenAsync(); + await using var conn = _localDb.Db.CreateConnection(); await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; var result = await cmd.ExecuteScalarAsync(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests.csproj index ad27b629..e6ebb6b8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests.csproj @@ -27,6 +27,7 @@ - + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs b/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs new file mode 100644 index 00000000..46a68f1f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.TestSupport/TestLocalDb.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.LocalDb; + +namespace ZB.MOM.WW.ScadaBridge.TestSupport; + +/// +/// A real over a temp file, for tests that construct a +/// LocalDb-backed store directly. +/// +/// +/// +/// The stores take rather than a connection string, and there is +/// no in-memory mode — LocalDbOptions.Path is a filesystem path. A real one is +/// used rather than a stub on purpose: connections handed out by ILocalDb carry +/// the zb_hlc_next() UDF that the capture triggers call, so a stub returning a +/// bare SqliteConnection would pass these tests while failing closed the moment +/// the table is registered for replication. +/// +/// +/// No onReady callback and no RegisterReplicated: each store's own schema +/// initialization creates its table, which is what keeps a directly-constructed store +/// self-sufficient. Registration is the host's job (SiteLocalDbSetup.OnReady). +/// +/// +/// This lives in a shared support project rather than being copied per test project: +/// seven test projects construct stores that now take , and +/// duplicating the fixture would mean seven places to keep the WAL-sidecar cleanup and +/// the "real, not stubbed" rationale correct. +/// +/// +public sealed class TestLocalDb : IDisposable +{ + private readonly ServiceProvider _provider; + + private TestLocalDb(ServiceProvider provider, ILocalDb db) + { + _provider = provider; + Db = db; + } + + /// The live local database. + public ILocalDb Db { get; } + + /// Opens a local database at . + public static TestLocalDb Create(string databasePath) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["LocalDb:Path"] = databasePath, + }) + .Build(); + + var provider = new ServiceCollection() + .AddZbLocalDb(configuration) + .BuildServiceProvider(); + + return new TestLocalDb(provider, provider.GetRequiredService()); + } + + /// + /// Creates a local database at a fresh temp path. Dispose the instance, then call + /// with to clean up. + /// + public static TestLocalDb CreateTemp(string prefix = "localdb") + { + var path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}.db"); + var db = Create(path); + db.Path = path; + return db; + } + + /// The filesystem path, when created via . + public string Path { get; private set; } = string.Empty; + + /// + /// Deletes and its WAL sidecars. Call only after the + /// owning is disposed — the master connection anchors the WAL. + /// + public static void DeleteFiles(string databasePath) + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + { + try { File.Delete(databasePath + suffix); } catch { /* best effort */ } + } + } + + public void Dispose() => _provider.Dispose(); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.TestSupport/ZB.MOM.WW.ScadaBridge.TestSupport.csproj b/tests/ZB.MOM.WW.ScadaBridge.TestSupport/ZB.MOM.WW.ScadaBridge.TestSupport.csproj new file mode 100644 index 00000000..dc5e2997 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.TestSupport/ZB.MOM.WW.ScadaBridge.TestSupport.csproj @@ -0,0 +1,29 @@ + + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + +