using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; /// /// Gitea #674: a database left at schema version 1 (no Part 11 SampleModified trail) with /// AutoCreateSchema=false keeps historizing. Before #674 the bootstrapper required v2 and refused EVERY call — /// live writes, raw reads, alarm events, retention — until script 002 was applied by hand. Now only the features /// that need the trail are refused (BadHistoryOperationUnsupported, nothing sent to SQL), and the /// bootstrapper logs ONE warning for the whole provider. Built through , the /// production wiring, so every seam shares the one bootstrapper the way a node does. /// [Collection(SqlHistorianCollection.Name)] public sealed class SchemaV1PostureTests(SqlHistorianFixture fx) { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly DateTime Recent = DateTime.UtcNow.AddHours(-1); private static readonly HistoryUpdateContext Who = new("editor", "k1"); [Fact] public async Task A_v1_schema_keeps_historizing_and_refuses_only_the_modified_trail_features() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); await ApplyV1Async(s); var old = DateTime.UtcNow.AddDays(-40); var loggers = new CapturingLoggerFactory(); var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = false, RetentionDays = 30, }); var sp = new LoggerServices(loggers); // Live writes (the continuous-historization drain) and raw reads work. var writer = provider.CreateValueWriter(sp); (await writer.WriteLiveValuesAsync("Plant.Speed", [new HistorizationValue(old, HistorianValue.FromDouble(1), 0), new HistorizationValue(Recent, HistorianValue.FromDouble(2), 0)], Ct)).ShouldBeTrue(); var source = provider.CreateDataSource(sp); var raw = await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct); raw.Samples.Select(v => v.Value).ShouldBe([2d]); // Alarm events are written. var alarms = provider.CreateAlarmWriter(sp); (await alarms.WriteBatchAsync( [new AlarmHistorianEvent("alarm-1", "Plant/Line/Eq", "HiTemp", "LimitAlarm", AlarmSeverity.High, "Activated", "m", "u", null, Recent)], Ct)).ShouldBe([HistorianWriteOutcome.Ack]); (await CountAsync($"SELECT COUNT(*) FROM [{s}].Event")).ShouldBe(1); // Modified reads: the interface's "no trail" signal (the node manager reports BadHistoryOperationUnsupported), // not a backend failure — health stays clean. await Should.ThrowAsync(() => source.ReadModifiedAsync("Plant.Speed", Recent.AddDays(-1), Recent.AddDays(1), 10, null, Ct)); source.GetHealthSnapshot().LastError.ShouldBeNull(); // HistoryUpdate: every arm refused as a whole (NotSupportedException ⇒ the node manager's node-level // BadHistoryOperationUnsupported), and NOTHING reaches SQL — a sent MERGE/DELETE would either change the samples or // fail on the missing SampleModified target (SqlException, not NotSupported); an Insert on an unknown tag would // create its Tag row. var updater = provider.CreateUpdater(sp)!; var tagsBefore = await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag"); await Should.ThrowAsync(() => updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update, [new HistorizationValue(Recent, HistorianValue.FromDouble(99), 0), new HistorizationValue(Recent.AddSeconds(1), HistorianValue.FromDouble(98), 0)], Who, Ct)); await Should.ThrowAsync(() => updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert, [new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct)); await Should.ThrowAsync(() => updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct)); await Should.ThrowAsync(() => updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct)); (await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag")).ShouldBe(tagsBefore); (await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct)) .Samples.Select(v => v.Value).ShouldBe([2d]); // Retention purges samples and skips the trail it does not have. var purge = await provider.CreateRetention(sp)!.PurgeAsync(Ct); purge.Select(r => r.Table).ShouldBe(["Sample", "Event"]); purge.Single(r => r.Table == "Sample").Rows.ShouldBe(1); (await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(1); // One warning for the whole provider (= per boot), however many seams and calls touched the bootstrapper. loggers.Warnings.Count(w => w.Contains("Part 11 modification trail is unavailable")).ShouldBe(1); (await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1); // verify-only: never upgraded } /// /// #674: AutoCreateSchema=true still upgrades a v1 schema to v2 — the modification trail appears and a /// HistoryUpdate is recorded in it — and logs no v1 warning. /// [Fact] public async Task Auto_create_still_upgrades_a_v1_schema_and_enables_the_trail() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); await ApplyV1Async(s); var loggers = new CapturingLoggerFactory(); var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = true, }); var sp = new LoggerServices(loggers); (await provider.CreateUpdater(sp)!.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Insert, [new HistorizationValue(Recent, HistorianValue.FromDouble(5), 0)], Who, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted]); (await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion); var trail = await provider.CreateDataSource(sp) .ReadModifiedAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, null, Ct); trail.Values.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert); loggers.Warnings.ShouldNotContain(w => w.Contains("Part 11 modification trail is unavailable")); } private async Task ApplyV1Async(string schema) { var v1 = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd(); await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(Ct); foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(v1.Replace("$(Schema)", schema))) { await using var cmd = c.CreateCommand(); cmd.CommandText = b; await cmd.ExecuteNonQueryAsync(Ct); } } private async Task CountAsync(string sql) { await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(Ct); await using var cmd = c.CreateCommand(); cmd.CommandText = sql; return Convert.ToInt64(await cmd.ExecuteScalarAsync(Ct)); } /// The only service the provider resolves is . private sealed class LoggerServices(ILoggerFactory loggers) : IServiceProvider { public object? GetService(Type serviceType) => serviceType == typeof(ILoggerFactory) ? loggers : null; } /// Captures every Warning-or-above message from every logger it creates. private sealed class CapturingLoggerFactory : ILoggerFactory { private readonly List _warnings = []; public IReadOnlyList Warnings { get { lock (_warnings) return _warnings.ToList(); } } public ILogger CreateLogger(string categoryName) => new Capturing(this); public void AddProvider(ILoggerProvider provider) { } public void Dispose() { } private sealed class Capturing(CapturingLoggerFactory owner) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (logLevel < LogLevel.Warning) return; lock (owner._warnings) owner._warnings.Add(formatter(state, exception)); } } } }