Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs
T
Joseph Doherty bdc0dffea2 feat(localdb): migrate legacy store-and-forward.db into the consolidated DB
Task 8. Adds ResolveStoreAndForwardPath + MigrateStoreAndForward, wired as a
third call in Migrate alongside the two Phase 1 files.

Unlike the Phase 1 paths, the store-and-forward default sits INSIDE the data
volume, so a real deployment has a real file here holding undelivered
messages. This migration genuinely moves data; losing it would discard exactly
the buffered calls store-and-forward exists to protect.

No id synthesis: sf_messages.id is already a caller-assigned TEXT primary key,
so INSERT OR IGNORE is idempotent across a crash-then-rerun.

The copy intersects the legacy column set with the current one rather than
naming all 16 columns outright. A file from an older build predates
execution_id / parent_execution_id / last_attempt_at_ms, and naming a missing
column throws 'no such column' — which the existing reader treats as an
unrecognised shape and silently discards every row. A required-column (PK)
guard keeps that tolerance from degrading into copying NULL-keyed rows.

Four tests: copy+rename, crash-before-rename idempotence, the __localdb_oplog
assertion that catches migrate-before-register, and the older-column-set case.
All four verified to fail without the Migrate call.

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

462 lines
19 KiB
C#

using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 6) — the one-time copy of the pre-Phase-1 site databases into
/// the consolidated one.
/// </summary>
/// <remarks>
/// This is a data migration that runs during host startup, so its failure modes are
/// expensive: a duplicate-on-rerun bug silently doubles a site's event history, and a
/// half-committed copy leaves an operator with no clean state to recover to. Every bullet
/// of the plan's semantics gets its own test.
/// </remarks>
public class SiteLocalDbLegacyMigratorTests : IDisposable
{
private readonly string _root;
private readonly List<ServiceProvider> _providers = [];
public SiteLocalDbLegacyMigratorTests()
{
_root = Path.Combine(Path.GetTempPath(), $"localdb-migrator-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
foreach (var provider in _providers)
{
try { provider.Dispose(); } catch { /* best effort */ }
}
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
GC.SuppressFinalize(this);
}
private string Path_(string name) => System.IO.Path.Combine(_root, name);
/// <summary>A consolidated database with the tables created and registered, as the host has it.</summary>
/// <remarks>
/// <c>sf_messages</c> is registered here even though production <c>OnReady</c> does not
/// register it until the Task 14 cutover. The oplog assertion below needs live capture
/// triggers to mean anything, and the property under test — that the migrator runs after
/// registration — is the same either way. The corollary is a real constraint on Task 14:
/// <c>Migrate</c> must stay the LAST call in <c>OnReady</c>, after every registration.
/// </remarks>
private ILocalDb CreateConsolidated(IConfiguration config)
{
var provider = new ServiceCollection()
.AddZbLocalDb(config, db =>
{
using var connection = db.CreateConnection();
ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection);
ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogSchema.Apply(connection);
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection);
db.RegisterReplicated("OperationTracking");
db.RegisterReplicated("site_events");
db.RegisterReplicated("sf_messages");
})
.BuildServiceProvider();
_providers.Add(provider);
return provider.GetRequiredService<ILocalDb>();
}
private IConfiguration Config(
string? trackingPath = null,
string? eventsPath = null,
string nodeName = "node-a",
string? storeAndForwardPath = null)
{
var values = new Dictionary<string, string?>
{
["LocalDb:Path"] = Path_("consolidated.db"),
["ScadaBridge:Node:NodeName"] = nodeName,
// Always pinned inside the test's own directory, even when a test does not care
// about it. Left unset, the resolver falls back to the CWD-relative code default
// "./data/store-and-forward.db" — and the test run's CWD is the test binary's
// output directory, so an unlucky run could migrate (and RENAME) a real file.
["ScadaBridge:StoreAndForward:SqliteDbPath"] =
storeAndForwardPath ?? Path_("absent-store-and-forward.db"),
};
if (trackingPath is not null)
values["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={trackingPath}";
if (eventsPath is not null)
values["ScadaBridge:SiteEventLog:DatabasePath"] = eventsPath;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
/// <summary>Seeds a legacy store-and-forward file with the CURRENT column set.</summary>
private static void SeedLegacyStoreAndForward(string path, params string[] ids)
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection);
foreach (var id in ids)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
INSERT INTO sf_messages (
id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, status, execution_id)
VALUES ($id, 0, 'ERP', '{"order":1}', 0, 50, 30000, $now, 0, 'exec-1');
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("o"));
cmd.ExecuteNonQuery();
}
}
private static void SeedLegacyTracking(string path, params string[] ids)
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection);
foreach (var id in ids)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
INSERT INTO OperationTracking (
TrackedOperationId, Kind, TargetSummary, Status, RetryCount,
CreatedAtUtc, UpdatedAtUtc)
VALUES ($id, 'ApiCallCached', 'ERP.GetOrder', 'Submitted', 0, $now, $now);
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("o"));
cmd.ExecuteNonQuery();
}
}
/// <summary>Seeds the LEGACY event schema: an autoincrement integer id, not a GUID.</summary>
private static void SeedLegacyEvents(string path, int count)
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
using (var ddl = connection.CreateCommand())
{
ddl.CommandText = """
CREATE TABLE IF NOT EXISTS site_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL,
instance_id TEXT, source TEXT NOT NULL, message TEXT NOT NULL, details TEXT
);
""";
ddl.ExecuteNonQuery();
}
for (var i = 0; i < count; i++)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (timestamp, event_type, severity, source, message)
VALUES ($ts, 'script', 'Info', 'src', $msg);
""";
cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.AddMinutes(-i).ToString("o"));
cmd.Parameters.AddWithValue("$msg", $"legacy message {i}");
cmd.ExecuteNonQuery();
}
}
private static long CountRows(ILocalDb db, string table)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT COUNT(*) FROM {table}";
return (long)cmd.ExecuteScalar()!;
}
private static List<string> SelectIds(ILocalDb db, string table, string idColumn)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT {idColumn} FROM {table} ORDER BY {idColumn}";
using var reader = cmd.ExecuteReader();
var ids = new List<string>();
while (reader.Read()) ids.Add(reader.GetString(0));
return ids;
}
[Fact]
public void MissingLegacyFiles_AreANoOp()
{
// The expected case on the docker rig: neither legacy key is set anywhere, and
// the CWD-relative defaults point outside the data volume, so there is usually
// nothing there at all. That must be a clean no-op, not a startup failure.
var config = Config(Path_("absent-tracking.db"), Path_("absent-events.db"));
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(0, CountRows(db, "OperationTracking"));
Assert.Equal(0, CountRows(db, "site_events"));
}
[Fact]
public void LegacyTrackingRows_AreCopiedAndTheFileIsRenamed()
{
var trackingPath = Path_("site-tracking.db");
SeedLegacyTracking(trackingPath, "op-1", "op-2", "op-3");
var config = Config(trackingPath, Path_("absent-events.db"));
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(3, CountRows(db, "OperationTracking"));
Assert.Equal(["op-1", "op-2", "op-3"], SelectIds(db, "OperationTracking", "TrackedOperationId"));
// Renamed, not deleted — the operator can still inspect the original.
Assert.False(File.Exists(trackingPath));
Assert.True(File.Exists(trackingPath + ".migrated"));
}
[Fact]
public void LegacyEvents_GetDeterministicIds_NotFreshGuids()
{
// The whole reason a rerun cannot duplicate. Fresh GUIDs would make
// INSERT OR IGNORE useless and double the history on every retry.
var eventsPath = Path_("site_events.db");
SeedLegacyEvents(eventsPath, count: 3);
var config = Config(Path_("absent-tracking.db"), eventsPath, nodeName: "node-b");
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(
["mig-node-b-1", "mig-node-b-2", "mig-node-b-3"],
SelectIds(db, "site_events", "id"));
}
[Fact]
public void SecondBoot_IsANoOp_BecauseTheFileIsAlreadyMarkedMigrated()
{
var trackingPath = Path_("site-tracking.db");
var eventsPath = Path_("site_events.db");
SeedLegacyTracking(trackingPath, "op-1", "op-2");
SeedLegacyEvents(eventsPath, count: 2);
var config = Config(trackingPath, eventsPath);
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(2, CountRows(db, "OperationTracking"));
Assert.Equal(2, CountRows(db, "site_events"));
}
[Fact]
public void RerunAgainstAnUnrenamedFile_DoesNotDuplicate()
{
// Simulates the crash window: the copy committed but the rename did not. On the
// next boot the migrator sees the legacy file again and re-copies it. Both tables
// must absorb that with no duplicates — tracking via its natural TEXT key, events
// via the deterministic mig- id.
var trackingPath = Path_("site-tracking.db");
var eventsPath = Path_("site_events.db");
SeedLegacyTracking(trackingPath, "op-1", "op-2");
SeedLegacyEvents(eventsPath, count: 2);
var config = Config(trackingPath, eventsPath);
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
// Undo the rename, exactly as a crash between commit and rename would leave it.
File.Move(trackingPath + ".migrated", trackingPath);
File.Move(eventsPath + ".migrated", eventsPath);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(2, CountRows(db, "OperationTracking"));
Assert.Equal(2, CountRows(db, "site_events"));
}
[Fact]
public void MigratedRows_EnterTheOplog_SoTheyActuallyReplicate()
{
// The reason the migrator runs AFTER RegisterReplicated. Capture is trigger-based:
// migrate before registration and the rows are invisible to the peer forever, with
// no error anywhere. Asserting on the oplog is the only way to catch that ordering
// being reversed — every other test here would still pass.
var trackingPath = Path_("site-tracking.db");
SeedLegacyTracking(trackingPath, "op-1", "op-2");
var config = Config(trackingPath, Path_("absent-events.db"));
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'OperationTracking'";
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
}
[Fact]
public void SfMessages_AreCopiedFromTheLegacyFile()
{
// Phase 2 (Task 8). Unlike the two Phase 1 files, the store-and-forward default path
// is inside the data volume, so on a real deployment this actually moves data:
// undelivered messages that a lost migration would silently discard.
var sfPath = Path_("store-and-forward.db");
SeedLegacyStoreAndForward(sfPath, "msg-1", "msg-2", "msg-3");
var config = Config(storeAndForwardPath: sfPath);
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(3, CountRows(db, "sf_messages"));
Assert.Equal(["msg-1", "msg-2", "msg-3"], SelectIds(db, "sf_messages", "id"));
// Renamed, not deleted — same contract as the Phase 1 files.
Assert.False(File.Exists(sfPath));
Assert.True(File.Exists(sfPath + ".migrated"));
}
[Fact]
public void SfMessages_Migration_IsIdempotent_WhenRerunAfterACrashBeforeRename()
{
// The crash window: copy committed, rename did not. sf_messages.id is a natural
// TEXT key, so INSERT OR IGNORE absorbs the re-copy with no id synthesis needed.
var sfPath = Path_("store-and-forward.db");
SeedLegacyStoreAndForward(sfPath, "msg-1", "msg-2");
var config = Config(storeAndForwardPath: sfPath);
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
File.Move(sfPath + ".migrated", sfPath);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(2, CountRows(db, "sf_messages"));
}
[Fact]
public void MigratedSfMessages_EnterTheOplog_SoTheyActuallyReplicate()
{
// Same ordering trap as the Phase 1 tables: migrate before RegisterReplicated and
// the rows never enter the oplog, never reach the peer, and nothing errors. Only an
// assertion on __localdb_oplog catches the ordering being reversed.
var sfPath = Path_("store-and-forward.db");
SeedLegacyStoreAndForward(sfPath, "msg-1", "msg-2");
var config = Config(storeAndForwardPath: sfPath);
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'sf_messages'";
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
}
[Fact]
public void SfMessages_FromAnOlderBuild_MigrateDespiteMissingColumns()
{
// A legacy file written before execution_id / parent_execution_id / last_attempt_at_ms
// existed. Naming a missing column in the SELECT throws "no such column", and the
// reader treats that as an unrecognised shape — which would silently discard every
// buffered message. The copy intersects the column sets instead.
var sfPath = Path_("store-and-forward.db");
using (var legacy = new SqliteConnection($"Data Source={sfPath}"))
{
legacy.Open();
using var ddl = legacy.CreateCommand();
ddl.CommandText = """
CREATE TABLE sf_messages (
id TEXT PRIMARY KEY, category INTEGER NOT NULL, target TEXT NOT NULL,
payload_json TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 50,
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
created_at TEXT NOT NULL, last_attempt_at TEXT,
status INTEGER NOT NULL DEFAULT 0, last_error TEXT, origin_instance TEXT
);
INSERT INTO sf_messages (id, category, target, payload_json, created_at)
VALUES ('old-1', 0, 'ERP', '{}', '2026-01-01T00:00:00Z');
""";
ddl.ExecuteNonQuery();
}
var config = Config(storeAndForwardPath: sfPath);
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(1, CountRows(db, "sf_messages"));
Assert.Equal(["old-1"], SelectIds(db, "sf_messages", "id"));
}
[Fact]
public void LegacyFileWithoutTheExpectedTable_IsTreatedAsEmpty_NotAFailure()
{
// A file predating the table (or an unrecognised shape) must not fail host boot.
var trackingPath = Path_("site-tracking.db");
using (var connection = new SqliteConnection($"Data Source={trackingPath}"))
{
connection.Open();
using var ddl = connection.CreateCommand();
ddl.CommandText = "CREATE TABLE something_else (x INTEGER);";
ddl.ExecuteNonQuery();
}
var config = Config(trackingPath, Path_("absent-events.db"));
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(0, CountRows(db, "OperationTracking"));
Assert.True(File.Exists(trackingPath + ".migrated"));
}
[Fact]
public void InMemoryLegacyConnectionStrings_AreSkipped()
{
// Test/dev configurations point the legacy stores at in-memory databases. There is
// nothing durable to migrate, and Path.GetFullPath on ":memory:" would be nonsense.
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = Path_("consolidated.db"),
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:OperationTracking:ConnectionString"] =
"Data Source=file:legacy?mode=memory&cache=shared",
["ScadaBridge:SiteEventLog:DatabasePath"] = ":memory:",
}).Build();
Assert.Equal(string.Empty, SiteLocalDbLegacyMigrator.ResolveTrackingPath(config));
Assert.Equal(string.Empty, SiteLocalDbLegacyMigrator.ResolveEventLogPath(config));
var db = CreateConsolidated(config);
SiteLocalDbLegacyMigrator.Migrate(db, config);
Assert.Equal(0, CountRows(db, "site_events"));
}
[Fact]
public void UnsetLegacyKeys_FallBackToTheCwdRelativeCodeDefaults()
{
// Neither key is set in any docker appsettings, so the OLD code used these
// CWD-relative defaults. Resolving them anywhere else (e.g. against the data
// volume) would look for the legacy data in a directory it was never written to.
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = Path_("consolidated.db"),
}).Build();
Assert.Equal(
System.IO.Path.GetFullPath("site-tracking.db"),
SiteLocalDbLegacyMigrator.ResolveTrackingPath(config));
Assert.Equal(
System.IO.Path.GetFullPath("site_events.db"),
SiteLocalDbLegacyMigrator.ResolveEventLogPath(config));
}
}