Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs
T
Joseph Doherty ac5eb12cce refactor(site): extract SiteStorageSchema.Apply from SiteStorageService
Task 4 of the Phase 2 plan. All nine table definitions plus MigrateSchemaAsync
and TryAddColumnAsync move into a static sync Apply(SqliteConnection) depending
only on Microsoft.Data.Sqlite, so the Host can apply the DDL to a LocalDb-managed
connection before RegisterReplicated installs the capture triggers.

Per the plan, PRAGMA journal_mode=WAL stays in InitializeAsync — LocalDb owns
the connection's pragmas, and the service still opens its own connections until
Task 6.

One deviation: TryAddColumnAsync's `catch (SqliteException) when
(ex.Message.Contains("duplicate column"))` becomes a PRAGMA table_info probe,
matching OperationTrackingSchema. The message-matching form depends on an error
string that is not part of SQLite's contract, and it swallowed every
SqliteException whose message happened to contain that substring. The probe also
drops the ILogger dependency, which is what let the class become static. Cost:
the per-column "Migrated: added column" info log is gone — it fired once per
column per legacy database and nothing consumes it.

Also added a test beyond the plan's specified one, for the same reason as Task 3:
the specified test asserts against a freshly-created database, where CREATE TABLE
already lists the migration columns, so it would pass with every ALTER deleted.
The added test starts from the pre-migration shapes with a row present and proves
the migration path runs and preserves data across a NOT NULL DEFAULT add.

SiteRuntime suite 532 passed / 0 failed; full solution build 0 warnings.

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

129 lines
5.6 KiB
C#

using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
/// <summary>
/// Pins <see cref="SiteStorageSchema"/> as the single owner of the site config DDL.
/// The Host applies this to a LocalDb-managed connection before
/// <c>RegisterReplicated</c> installs the capture triggers, so the shape it produces
/// is the shape that replicates.
/// </summary>
public class SiteStorageSchemaTests
{
[Fact]
public void Apply_IsIdempotent_AndCreatesEveryTable()
{
var path = Path.Combine(Path.GetTempPath(), $"site-schema-{Guid.NewGuid():N}.db");
try
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
SiteStorageSchema.Apply(connection);
SiteStorageSchema.Apply(connection); // second run must not throw
// Every table is named explicitly on purpose — a loop asserting "9 tables"
// would still pass if one were renamed.
foreach (var table in new[]
{
"deployed_configurations", "static_attribute_overrides", "shared_scripts",
"external_systems", "database_connections", "notification_lists",
"data_connection_definitions", "smtp_configurations", "native_alarm_state",
})
{
Assert.True(TableExists(connection, table), $"missing table: {table}");
}
}
finally
{
SqliteConnection.ClearAllPools();
File.Delete(path);
}
}
/// <summary>
/// The migration columns exist to upgrade a database created by an older build in
/// place. Asserting only against a freshly-created table would pass even if every
/// <c>ALTER</c> were deleted, because <c>CREATE TABLE</c> already lists them — so
/// this starts from the pre-migration shape and proves the migration path runs.
/// </summary>
[Fact]
public void Apply_AddsMigrationColumns_ToALegacyDatabase()
{
var path = Path.Combine(Path.GetTempPath(), $"site-schema-legacy-{Guid.NewGuid():N}.db");
try
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
// The shapes as they existed before backup_configuration /
// failover_retry_count / metadata_json / timeout_seconds were added.
using (var legacy = connection.CreateCommand())
{
legacy.CommandText = """
CREATE TABLE data_connection_definitions (
name TEXT PRIMARY KEY,
protocol TEXT NOT NULL,
configuration TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE native_alarm_state (
instance_unique_name TEXT NOT NULL,
source_canonical_name TEXT NOT NULL,
source_reference TEXT NOT NULL,
condition_json TEXT NOT NULL,
last_transition_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
);
CREATE TABLE external_systems (
name TEXT PRIMARY KEY,
endpoint_url TEXT NOT NULL,
auth_type TEXT NOT NULL,
auth_configuration TEXT,
method_definitions TEXT,
updated_at TEXT NOT NULL
);
INSERT INTO external_systems (name, endpoint_url, auth_type, updated_at)
VALUES ('legacy-system', 'http://example.invalid', 'None', '2026-01-01T00:00:00Z');
""";
legacy.ExecuteNonQuery();
}
SiteStorageSchema.Apply(connection);
Assert.True(ColumnExists(connection, "data_connection_definitions", "backup_configuration"));
Assert.True(ColumnExists(connection, "data_connection_definitions", "failover_retry_count"));
Assert.True(ColumnExists(connection, "native_alarm_state", "metadata_json"));
Assert.True(ColumnExists(connection, "external_systems", "timeout_seconds"));
// The pre-existing row survives the upgrade and reads back the NOT NULL
// column's default rather than failing.
using var check = connection.CreateCommand();
check.CommandText = "SELECT timeout_seconds FROM external_systems WHERE name = 'legacy-system'";
Assert.Equal(0, Convert.ToInt32(check.ExecuteScalar()));
}
finally
{
SqliteConnection.ClearAllPools();
File.Delete(path);
}
}
private static bool TableExists(SqliteConnection connection, string table)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name";
cmd.Parameters.AddWithValue("$name", table);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
private static bool ColumnExists(SqliteConnection connection, string table, string column)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", column);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
}