using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central.HeadExport;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central.HeadExport;
///
/// (ScadaBridge#107 item 1): first tick exports every
/// head, later ticks only the changed ones, a failed write is retried with the same heads, and
/// the options validator guards each sink's settings.
///
public sealed class AuditChainHeadExportServiceTests
{
private sealed class FakeStore : IAuditChainHeadStore
{
public List Heads { get; } = [];
public Task> ReadHeadsAsync(CancellationToken ct = default)
=> Task.FromResult>(Heads.ToList());
public Task VerifyExportedHeadsAsync(
IReadOnlyList records, CancellationToken ct = default) => throw new NotSupportedException();
}
private sealed class RecordingSink : IAuditChainHeadSink
{
public List> Writes { get; } = [];
public bool Fail { get; set; }
public string Describe => "recording";
public Task WriteAsync(IReadOnlyList records, CancellationToken ct)
{
if (Fail)
{
throw new IOException("share offline");
}
Writes.Add(records);
return Task.CompletedTask;
}
}
private static AuditChainHeadSnapshot Head(int month, long seq, long rows) => new(
new DateTime(2026, month, 1, 0, 0, 0, DateTimeKind.Utc), seq, Enumerable.Repeat((byte)seq, 32).ToArray(),
null, null, rows, 0, new DateTime(2026, month, 2, 0, 0, 0, DateTimeKind.Utc));
private static (AuditChainHeadExportService Service, FakeStore Store, RecordingSink Sink) Create(
bool exportUnchanged = false, AuditChainHeadExportStatus? status = null, TimeProvider? time = null)
{
var store = new FakeStore();
var sink = new RecordingSink();
var services = new ServiceCollection();
services.AddScoped(_ => store);
var options = Options.Create(new AuditLogHashChainOptions
{
HeadExport = new AuditChainHeadExportOptions { Enabled = true, ExportUnchanged = exportUnchanged },
});
var service = new AuditChainHeadExportService(
services.BuildServiceProvider().GetRequiredService(),
options, sink, "central-a", NullLogger.Instance, time, status);
return (service, store, sink);
}
/// First tick: every head; next tick: only the head that moved; unchanged: nothing.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Tick_ExportsAllHeadsFirst_ThenOnlyChangedOnes()
{
var (service, store, sink) = Create();
store.Heads.AddRange([Head(4, 10, 10), Head(5, 20, 20)]);
Assert.Equal(2, await service.TickAsync(default));
Assert.Equal(0, await service.TickAsync(default));
store.Heads[1] = Head(5, 25, 25);
Assert.Equal(1, await service.TickAsync(default));
// A per-channel purge (row -> tombstone) keeps rows + tombstones, so it is not a change;
// a row disappearing without a tombstone is.
store.Heads[0] = Head(4, 10, 9);
Assert.Equal(1, await service.TickAsync(default));
Assert.Equal(3, sink.Writes.Count);
Assert.Equal(["2026-04", "2026-05"], sink.Writes[0].Select(r => r.Month));
var moved = Assert.Single(sink.Writes[1]);
Assert.Equal(("2026-05", 25L, string.Concat(Enumerable.Repeat("19", 32))), (moved.Month, moved.LastSeq, moved.LastHash));
Assert.Equal("central-a", moved.Node);
Assert.Equal(AuditChainHeadRecord.CurrentVersion, moved.V);
}
/// ExportUnchanged re-exports every head every tick.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Tick_WithExportUnchanged_ExportsEveryHeadEveryTick()
{
var (service, store, _) = Create(exportUnchanged: true);
store.Heads.AddRange([Head(4, 10, 10), Head(5, 20, 20)]);
Assert.Equal(2, await service.TickAsync(default));
Assert.Equal(2, await service.TickAsync(default));
}
/// A failed write is contained and the same heads go out on the next tick.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Tick_AfterAFailedWrite_RetriesTheSameHeads()
{
var (service, store, sink) = Create();
store.Heads.Add(Head(4, 10, 10));
sink.Fail = true;
Assert.Equal(0, await service.TickAsync(default));
sink.Fail = false;
Assert.Equal(1, await service.TickAsync(default));
Assert.Equal(10, Assert.Single(Assert.Single(sink.Writes)).LastSeq);
}
///
/// ScadaBridge#115: a failed tick bumps the failure counter metric and the status
/// (consecutive + total + last error); the next success clears the consecutive count.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task FailedTick_RecordsMetricAndStatus_SuccessResetsConsecutive()
{
var status = new AuditChainHeadExportStatus();
var (service, store, sink) = Create(status: status);
store.Heads.Add(Head(4, 10, 10));
long measured = 0;
using var listener = new System.Diagnostics.Metrics.MeterListener();
listener.InstrumentPublished = (i, l) =>
{
if (i.Name == "scadabridge.audit.chain_head_export.failures")
{
l.EnableMeasurementEvents(i);
}
};
listener.SetMeasurementEventCallback((_, v, _, _) => Interlocked.Add(ref measured, v));
listener.Start();
sink.Fail = true;
await service.TickAsync(default);
await service.TickAsync(default);
Assert.Equal(2, Interlocked.Read(ref measured));
Assert.Equal(2, status.ConsecutiveFailures);
Assert.Equal(2, status.TotalFailures);
Assert.Equal("share offline", status.LastError);
Assert.Null(status.LastSuccessUtc);
sink.Fail = false;
await service.TickAsync(default);
Assert.Equal(0, status.ConsecutiveFailures);
Assert.Equal(2, status.TotalFailures);
Assert.NotNull(status.LastSuccessUtc);
Assert.Equal(2, Interlocked.Read(ref measured));
}
/// The validator ignores a disabled export and checks each sink's settings when enabled.
/// Which settings to apply.
/// A fragment of the expected failure, or null when valid.
[Theory]
[InlineData("disabled-bad", null)]
[InlineData("file-ok", null)]
[InlineData("file-relative", "File:Directory")]
[InlineData("file-missing", "File:Directory")]
[InlineData("syslog-ok", null)]
[InlineData("syslog-no-host", "Syslog:Host")]
[InlineData("syslog-bad-port", "Syslog:Port")]
[InlineData("syslog-bad-facility", "Syslog:Facility")]
[InlineData("interval-zero", "IntervalMinutes")]
public void Validator_ChecksTheSelectedSink(string configure, string? expectedError)
{
var root = OperatingSystem.IsWindows() ? @"C:\heads" : "/var/heads";
var export = configure switch
{
"disabled-bad" => new AuditChainHeadExportOptions { Enabled = false, IntervalMinutes = 0 },
"file-ok" => new AuditChainHeadExportOptions { Enabled = true, File = { Directory = root } },
"file-relative" => new AuditChainHeadExportOptions { Enabled = true, File = { Directory = "heads" } },
"file-missing" => new AuditChainHeadExportOptions { Enabled = true },
"syslog-ok" => new AuditChainHeadExportOptions { Enabled = true, Sink = AuditChainHeadSinkKind.Syslog, Syslog = { Host = "collector" } },
"syslog-no-host" => new AuditChainHeadExportOptions { Enabled = true, Sink = AuditChainHeadSinkKind.Syslog },
"syslog-bad-port" => new AuditChainHeadExportOptions { Enabled = true, Sink = AuditChainHeadSinkKind.Syslog, Syslog = { Host = "c", Port = 0 } },
"syslog-bad-facility" => new AuditChainHeadExportOptions { Enabled = true, Sink = AuditChainHeadSinkKind.Syslog, Syslog = { Host = "c", Facility = 24 } },
_ => new AuditChainHeadExportOptions { Enabled = true, IntervalMinutes = 0, File = { Directory = root } },
};
var result = new AuditLogHashChainOptionsValidator().Validate(null, new AuditLogHashChainOptions { HeadExport = export });
if (expectedError is null)
{
Assert.True(result.Succeeded, result.FailureMessage);
}
else
{
Assert.True(result.Failed);
Assert.Contains(expectedError, result.FailureMessage);
}
}
}