using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
///
/// Verifies the config-gated AddAlarmHistorian registration: when the
/// AlarmHistorian section is absent or disabled the
/// default survives; when it is enabled a real wins
/// (last-registration-wins over the TryAddSingleton Null default).
///
public sealed class AlarmHistorianRegistrationTests
{
/// A no-op writer the factory hands the sink; never actually invoked in these tests.
private sealed class FakeWriter : IAlarmHistorianWriter
{
public Task> WriteBatchAsync(
IReadOnlyList batch, CancellationToken cancellationToken)
=> Task.FromResult>(
batch.Select(_ => HistorianWriteOutcome.Ack).ToList());
}
/// Seed the Null default exactly the way AddOtOpcUaRuntime does, then add logging.
private static ServiceCollection BaseServices()
{
var services = new ServiceCollection();
services.AddLogging();
services.TryAddSingleton(NullAlarmHistorianSink.Instance);
return services;
}
private static IConfiguration ConfigFrom(Dictionary values)
=> new ConfigurationBuilder().AddInMemoryCollection(values).Build();
[Fact]
public void Section_absent_keeps_null_sink()
{
var services = BaseServices();
var config = ConfigFrom(new Dictionary());
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
using var provider = services.BuildServiceProvider();
provider.GetRequiredService().ShouldBeOfType();
}
[Fact]
public void Section_disabled_keeps_null_sink()
{
var services = BaseServices();
var config = ConfigFrom(new Dictionary
{
["AlarmHistorian:Enabled"] = "false",
});
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
using var provider = services.BuildServiceProvider();
provider.GetRequiredService().ShouldBeOfType();
}
[Fact]
public void Section_enabled_registers_the_localdb_sink()
{
var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
var dbPath = Path.Combine(tempDir, "node-local.db");
try
{
var config = ConfigFrom(new Dictionary
{
["AlarmHistorian:Enabled"] = "true",
["LocalDb:Path"] = dbPath,
});
var services = BaseServices();
services.AddZbLocalDb(config, db =>
{
using var connection = db.CreateConnection();
AlarmSfSchema.Apply(connection);
db.RegisterReplicated(AlarmSfSchema.EventsTable);
});
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
using (var provider = services.BuildServiceProvider())
{
provider.GetRequiredService().ShouldBeOfType();
} // dispose stops the drain loop + releases the SQLite file handle
}
finally
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
}
}
///
/// A node whose LocalDb is not replicated drains its own queue regardless of redundancy role.
///
///
///
/// Standing down is only safe because a peer holds the same rows and will send them
/// instead. Without LocalDb:Replication:PeerAddress there is no such peer: the rows
/// are here and nowhere else, so any deferral means nobody ever delivers them.
///
///
/// Found by the Phase 2 live gate. The redundancy role is a CLUSTER-WIDE election while the
/// queue is PAIR-LOCAL, and on the docker-dev rig the elected driver Primary turned out to
/// be a central node — one that carries the driver Akka role, replicates nobody's LocalDb
/// and does not even run the alarm historian. Every site node suspended its drain in favour
/// of a node that could not possibly deliver its events.
///
///
[Theory]
// Neither key: an unpaired node. Its rows exist nowhere else.
[InlineData(null, null, true)]
// The dialing half of a pair — gated, because its partner holds the same rows.
[InlineData("http://peer:9001", null, false)]
// The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would
// wrongly class it as unpaired and leave it permanently ungated.
[InlineData(null, "9001", false)]
public async Task Only_an_unreplicated_node_ignores_the_role_view(
string? peerAddress, string? syncListenPort, bool expectDrain)
{
var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
var dbPath = Path.Combine(tempDir, "node-local.db");
try
{
var config = ConfigFrom(new Dictionary
{
["AlarmHistorian:Enabled"] = "true",
["LocalDb:Path"] = dbPath,
["LocalDb:Replication:PeerAddress"] = peerAddress,
["LocalDb:SyncListenPort"] = syncListenPort,
});
var services = BaseServices();
services.AddZbLocalDb(config, db =>
{
using var connection = db.CreateConnection();
AlarmSfSchema.Apply(connection);
db.RegisterReplicated(AlarmSfSchema.EventsTable);
});
// A role view pinned SHUT: this node is a Secondary and a Primary exists elsewhere.
services.AddSingleton(new StandDownRoleView());
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
using (var provider = services.BuildServiceProvider())
{
var sink = provider.GetRequiredService();
await sink.EnqueueAsync(SampleEvent, CancellationToken.None);
await ((LocalDbStoreAndForwardSink)sink).DrainOnceAsync(CancellationToken.None);
var status = sink.GetStatus();
if (expectDrain)
{
// Delivered and removed — not parked behind a gate that nobody else will open.
status.DrainState.ShouldNotBe(HistorianDrainState.NotPrimary);
status.QueueDepth.ShouldBe(0);
}
else
{
// Held for the peer that shares this queue.
status.DrainState.ShouldBe(HistorianDrainState.NotPrimary);
status.QueueDepth.ShouldBe(1);
}
}
}
finally
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
}
}
/// A view that always says "some other node is the Primary".
private sealed class StandDownRoleView : IRedundancyRoleView
{
public bool ShouldDrainAlarmHistory => false;
public void Publish(bool shouldDrainAlarmHistory) { }
}
private static AlarmHistorianEvent SampleEvent => new(
AlarmId: "eq/alarm-1",
EquipmentPath: "line-1/eq-1",
AlarmName: "temp-high",
AlarmTypeName: "LimitAlarm",
Severity: ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High,
EventKind: "Activated",
Message: "temperature high",
User: "system",
Comment: null,
TimestampUtc: new DateTime(2026, 7, 21, 0, 0, 1, DateTimeKind.Utc));
///
/// An enabled historian with no LocalDb registered must fail at resolution, loudly.
///
///
/// The queue has nowhere to live without it. Falling back to the Null sink here would look
/// like a working historian while silently discarding every alarm event — the failure mode
/// this whole registration is meant to make impossible.
///
[Fact]
public void Section_enabled_without_localdb_throws_rather_than_silently_discarding()
{
var services = BaseServices();
var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Enabled"] = "true" });
services.AddAlarmHistorian(config, (_, _) => new FakeWriter());
using var provider = services.BuildServiceProvider();
Should.Throw(() => provider.GetRequiredService());
}
[Fact]
public void Section_binds_drain_capacity_and_retention_knobs()
{
var config = ConfigFrom(new Dictionary
{
["AlarmHistorian:Enabled"] = "true",
["AlarmHistorian:DrainIntervalSeconds"] = "11",
["AlarmHistorian:Capacity"] = "500",
["AlarmHistorian:DeadLetterRetentionDays"] = "7",
});
var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get();
opts.ShouldNotBeNull();
opts.DrainIntervalSeconds.ShouldBe(11);
opts.Capacity.ShouldBe(500);
opts.DeadLetterRetentionDays.ShouldBe(7);
}
[Fact]
public void Validate_is_silent_when_correctly_configured()
{
new AlarmHistorianOptions { Enabled = true }.Validate().ShouldBeEmpty();
}
[Fact]
public void Validate_is_silent_when_disabled()
{
new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty();
}
[Fact]
public void Validate_warns_on_non_positive_drain_interval()
{
var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0 };
opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds"));
}
[Fact]
public void Validate_warns_on_non_positive_capacity()
{
var opts = new AlarmHistorianOptions { Enabled = true, Capacity = 0 };
opts.Validate().ShouldContain(w => w.Contains("Capacity"));
}
[Fact]
public void Validate_warns_on_non_positive_retention()
{
var opts = new AlarmHistorianOptions { Enabled = true, DeadLetterRetentionDays = 0 };
opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays"));
}
[Fact]
public void Validate_warns_on_non_positive_max_attempts()
{
var opts = new AlarmHistorianOptions { Enabled = true, MaxAttempts = 0 };
opts.Validate().ShouldContain(w => w.Contains("MaxAttempts"));
}
[Fact]
public void Validate_accumulates_multiple_warnings()
{
// Two bad knobs ⇒ both warnings, not short-circuited on the first.
var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0, Capacity = 0 };
var warnings = opts.Validate();
warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
warnings.ShouldContain(w => w.Contains("Capacity"));
warnings.Count.ShouldBeGreaterThanOrEqualTo(2);
}
}