using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
///
/// The one-time copy of the pre-consolidation alarm-historian.db queue into the
/// consolidated LocalDb.
///
///
///
/// What is being protected is undelivered alarm history: rows that are in the legacy file
/// precisely because the historian could not be reached. Dropping them on upgrade would
/// discard exactly the audit trail the store-and-forward queue exists to preserve.
///
///
/// Run against a real temp-file built through the production
/// , so the registration order the migrator depends on is
/// the one the host actually runs — a hand-written schema here would prove only that the
/// test agrees with itself.
///
///
public sealed class AlarmSfLegacyMigratorTests : IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"otopcua-alarmsf-mig-{Guid.NewGuid():N}.db");
private readonly string _legacyPath =
Path.Combine(Path.GetTempPath(), $"otopcua-alarmsf-legacy-{Guid.NewGuid():N}.db");
private ServiceProvider? _provider;
// ---- the cases ---------------------------------------------------------------------------
[Fact]
public async Task Every_legacy_row_lands_in_the_consolidated_table()
{
SeedLegacy(
(1, "eq/a", Payload("eq/a"), 0, dead: 0),
(2, "eq/b", Payload("eq/b"), 3, dead: 0),
(3, "eq/c", Payload("eq/c"), 9, dead: 1));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(3);
// Attempt counts and the dead-letter flag must survive: a migrated row that lost its
// attempt count would restart its retry budget, and one that lost its dead-letter flag
// would be re-sent to a historian that already refused it permanently.
var rows = await db.QueryAsync(
"SELECT alarm_id, attempt_count, dead_lettered FROM alarm_sf_events ORDER BY alarm_id",
r => (AlarmId: r.GetString(0), Attempts: r.GetInt64(1), Dead: r.GetInt64(2)),
parameters: null,
TestContext.Current.CancellationToken);
rows.ShouldBe([("eq/a", 0L, 0L), ("eq/b", 3L, 0L), ("eq/c", 9L, 1L)]);
}
[Fact]
public async Task Migrated_rows_enter_the_oplog()
{
// THE ordering pin. Capture is trigger-based, so rows copied in before
// RegisterReplicated would never enter the oplog and would never reach the peer —
// silently, and permanently. A migrator invoked too early passes every other case here.
SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
var oplog = await db.QueryAsync(
"SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'",
r => r.GetInt64(0),
parameters: null,
TestContext.Current.CancellationToken);
oplog[0].ShouldBe(1);
}
[Fact]
public async Task A_second_run_is_a_no_op()
{
SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0));
var db = BuildDb();
var config = ConfigWithLegacyPath();
AlarmSfLegacyMigrator.Migrate(db, config);
AlarmSfLegacyMigrator.Migrate(db, config);
(await CountAsync(db)).ShouldBe(1);
File.Exists(_legacyPath).ShouldBeFalse("the legacy file is renamed once the copy commits");
File.Exists(_legacyPath + ".migrated").ShouldBeTrue();
}
[Fact]
public async Task Re_migrating_the_same_rows_cannot_duplicate_them()
{
// The sidecar rename is the normal guard, but it is not the only one that has to hold:
// a crash between commit and rename leaves the file in place, and the next boot copies it
// again. Ids are derived from the payload, so the re-copy collides with itself and is
// ignored rather than doubling the queue.
SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
File.Move(_legacyPath + ".migrated", _legacyPath); // simulate the crash-before-rename
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(1);
}
[Fact]
public async Task An_older_legacy_file_missing_a_column_still_migrates()
{
// A file written by an older build predates LastError. Naming a missing column in the
// SELECT throws "no such column", which would discard every row in the table rather than
// the one field. Intersecting the column list first means the data migrates and the
// absent column keeps its schema default.
SeedLegacyWithoutLastError((1, "eq/a", Payload("eq/a")));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(1);
}
[Fact]
public async Task A_failed_copy_leaves_the_legacy_file_untouched()
{
// The legacy table exists but its payload column is missing, so the required-column guard
// rejects the file. Nothing is copied and nothing is renamed: the operator still has the
// original to recover from, which is the whole point of renaming only after commit.
SeedUnrecognisedLegacy();
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(0);
File.Exists(_legacyPath).ShouldBeTrue("an un-copied file must not be renamed away");
File.Exists(_legacyPath + ".migrated").ShouldBeFalse();
}
[Fact]
public async Task A_missing_legacy_file_is_a_clean_no_op()
{
// The expected case on a fresh install and on any node that never enabled the historian.
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
(await CountAsync(db)).ShouldBe(0);
}
[Theory]
[InlineData(":memory:")]
[InlineData("file:thing?mode=memory")]
[InlineData("")]
public async Task Non_file_legacy_sources_are_no_ops(string configured)
{
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, Config(configured));
(await CountAsync(db)).ShouldBe(0);
}
[Fact]
public async Task Two_nodes_migrating_the_same_event_converge_on_one_row()
{
// A warm pair's two legacy files overlap: HistorianAdapterActor default-writes while the
// redundancy role is unknown, so both nodes accepted the same transitions during every
// boot window. Node-prefixed ids would carry that duplication forward into the merged
// buffer permanently; payload-derived ids collapse it.
var shared = Payload("eq/shared");
SeedLegacy((1, "eq/shared", shared, 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
// The peer's file: a different legacy RowId for the very same event.
var peerLegacy = _legacyPath + ".peer";
SeedLegacyAt(peerLegacy, (77, "eq/shared", shared, 0, dead: 0));
AlarmSfLegacyMigrator.Migrate(db, Config(peerLegacy));
(await CountAsync(db)).ShouldBe(1);
try { File.Delete(peerLegacy + ".migrated"); } catch { /* best effort */ }
}
// ---- fixture -----------------------------------------------------------------------------
private ILocalDb BuildDb()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath })
.Build();
_provider = new ServiceCollection()
.AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration))
.BuildServiceProvider();
return _provider.GetRequiredService();
}
private IConfiguration ConfigWithLegacyPath() => Config(_legacyPath);
private static IConfiguration Config(string legacyPath) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
{
["AlarmHistorian:DatabasePath"] = legacyPath,
})
.Build();
private static async Task CountAsync(ILocalDb db)
{
var rows = await db.QueryAsync(
"SELECT COUNT(*) FROM alarm_sf_events",
r => r.GetInt64(0),
parameters: null,
TestContext.Current.CancellationToken);
return rows[0];
}
/// A payload shaped like a serialized AlarmHistorianEvent.
private static string Payload(string alarmId) =>
$$"""{"AlarmId":"{{alarmId}}","EquipmentPath":"line/eq","AlarmName":"hi","TimestampUtc":"2026-07-20T00:00:00Z"}""";
private void SeedLegacy(params (long RowId, string AlarmId, string Payload, long Attempts, long dead)[] rows) =>
SeedLegacyAt(_legacyPath, rows);
/// Creates a legacy queue file with the full pre-cutover schema and the given rows.
private static void SeedLegacyAt(
string path, params (long RowId, string AlarmId, string Payload, long Attempts, long dead)[] rows)
{
using var conn = Open(path);
Exec(conn, """
CREATE TABLE Queue (
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
AlarmId TEXT NOT NULL,
EnqueuedUtc TEXT NOT NULL,
PayloadJson TEXT NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
LastAttemptUtc TEXT NULL,
LastError TEXT NULL,
DeadLettered INTEGER NOT NULL DEFAULT 0
);
""");
foreach (var row in rows)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (RowId, AlarmId, EnqueuedUtc, PayloadJson, AttemptCount, DeadLettered)
VALUES ($id, $alarm, '2026-07-20T00:00:00.0000000Z', $payload, $attempts, $dead)
""";
cmd.Parameters.AddWithValue("$id", row.RowId);
cmd.Parameters.AddWithValue("$alarm", row.AlarmId);
cmd.Parameters.AddWithValue("$payload", row.Payload);
cmd.Parameters.AddWithValue("$attempts", row.Attempts);
cmd.Parameters.AddWithValue("$dead", row.dead);
cmd.ExecuteNonQuery();
}
}
/// A legacy file from an older build, predating the LastError column.
private void SeedLegacyWithoutLastError(params (long RowId, string AlarmId, string Payload)[] rows)
{
using var conn = Open(_legacyPath);
Exec(conn, """
CREATE TABLE Queue (
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
AlarmId TEXT NOT NULL,
EnqueuedUtc TEXT NOT NULL,
PayloadJson TEXT NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
DeadLettered INTEGER NOT NULL DEFAULT 0
);
""");
foreach (var row in rows)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Queue (RowId, AlarmId, EnqueuedUtc, PayloadJson)
VALUES ($id, $alarm, '2026-07-20T00:00:00.0000000Z', $payload)
""";
cmd.Parameters.AddWithValue("$id", row.RowId);
cmd.Parameters.AddWithValue("$alarm", row.AlarmId);
cmd.Parameters.AddWithValue("$payload", row.Payload);
cmd.ExecuteNonQuery();
}
}
/// A file with a Queue table this build cannot read — no payload column.
private void SeedUnrecognisedLegacy()
{
using var conn = Open(_legacyPath);
Exec(conn, "CREATE TABLE Queue (RowId INTEGER PRIMARY KEY AUTOINCREMENT, Something TEXT);");
Exec(conn, "INSERT INTO Queue (Something) VALUES ('x');");
}
private static SqliteConnection Open(string path)
{
var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
conn.Open();
return conn;
}
private static void Exec(SqliteConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
public void Dispose()
{
_provider?.Dispose();
SqliteConnection.ClearAllPools();
foreach (var baseName in new[] { _dbPath, _legacyPath, _legacyPath + ".migrated" })
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(baseName + suffix); } catch { /* best effort */ }
}
}
}
}