using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; /// /// #668, SQL Server only: the modification-trail row is written by the SAME statement that changes /// hist.Sample (OUTPUT … INTO), so a change and its trail row commit or roll back together. The failure /// is forced INSIDE the audit insert (a unique index on the trail that the second modification of one timestamp /// violates), i.e. after the sample row was already changed within the statement — the strongest place to prove it. /// Each test has a positive control: the same operation succeeds, with its trail row, once the index is dropped. /// [Collection(SqlHistorianCollection.Name)] public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx) { private static readonly DateTime T1 = new(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc); private static readonly DateTime T2 = T1.AddSeconds(1); private static readonly HistoryUpdateContext Who = ConformanceData.Editor; private static HistorizationValue V(DateTime t, double d) => new(t, HistorianValue.FromDouble(d), 0u); private async Task ExecAsync(ReadTestDb db, string sql) { await using var c = await db.Conn.OpenAsync(CancellationToken.None); await using var cmd = db.Conn.CreateCommand(c, sql); await cmd.ExecuteNonQueryAsync(); } private async Task CountTrailAsync(ReadTestDb db) { await using var c = await db.Conn.OpenAsync(CancellationToken.None); await using var cmd = db.Conn.CreateCommand(c, $"SELECT COUNT(*) FROM {db.Conn.Q("SampleModified")}"); return Convert.ToInt64(await cmd.ExecuteScalarAsync()); } private Task BreakTheTrailAsync(ReadTestDb db) => ExecAsync(db, $"CREATE UNIQUE INDEX UX_Break ON {db.Conn.Q("SampleModified")} (TagId, SourceTimeUtc)"); private Task RepairTheTrailAsync(ReadTestDb db) => ExecAsync(db, $"DROP INDEX UX_Break ON {db.Conn.Q("SampleModified")}"); private static async Task ValueAtAsync(ReadTestDb db, string tag, DateTime t) { using var src = db.Source(); var r = await src.ReadRawAsync(tag, t, t, 0, CancellationToken.None); return (double)r.Samples.ShouldHaveSingleItem().Value!; } [Fact] public async Task A_replace_whose_trail_insert_fails_changes_nothing() { var db = await ReadTestDb.CreateAsync(fx); var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot)); (await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], Who, CancellationToken.None)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted]); (await CountTrailAsync(db)).ShouldBe(1); await BreakTheTrailAsync(db); await Should.ThrowAsync( () => u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T1, 99)], Who, CancellationToken.None)); (await ValueAtAsync(db, "t", T1)).ShouldBe(1d, "the sample change rolled back with its trail row"); (await CountTrailAsync(db)).ShouldBe(1, "a failed update leaves no trail row"); // Positive control: the identical replace succeeds, with its trail row, once the trail accepts it. await RepairTheTrailAsync(db); (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T1, 99)], Who, CancellationToken.None)) .ShouldBe([HistorianStatusCodes.GoodEntryReplaced]); (await ValueAtAsync(db, "t", T1)).ShouldBe(99d); (await CountTrailAsync(db)).ShouldBe(2); } [Fact] public async Task Deletes_whose_trail_insert_fails_delete_nothing() { var db = await ReadTestDb.CreateAsync(fx); var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot)); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1), V(T2, 2)], Who, CancellationToken.None); await BreakTheTrailAsync(db); await Should.ThrowAsync(() => u.DeleteAtTimeAsync("t", [T1], Who, CancellationToken.None)); await Should.ThrowAsync(() => u.DeleteRawAsync("t", T1, T2.AddSeconds(1), Who, CancellationToken.None)); (await ValueAtAsync(db, "t", T1)).ShouldBe(1d); (await ValueAtAsync(db, "t", T2)).ShouldBe(2d); (await CountTrailAsync(db)).ShouldBe(2, "only the two inserts"); await RepairTheTrailAsync(db); (await u.DeleteAtTimeAsync("t", [T1], Who, CancellationToken.None)).ShouldBe([HistorianStatusCodes.Good]); (await u.DeleteRawAsync("t", T1, T2.AddSeconds(1), Who, CancellationToken.None)).ShouldBe(HistorianStatusCodes.Good); (await CountTrailAsync(db)).ShouldBe(4); } [Fact] public async Task An_over_long_principal_is_truncated_never_refused() { var db = await ReadTestDb.CreateAsync(fx); var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot)); var who = new HistoryUpdateContext(new string('p', 500), new string('k', 500)); (await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], who, CancellationToken.None)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted]); using var src = db.Source(); var trail = await src.ReadModifiedAsync("t", T1.AddHours(-1), T1.AddHours(1), 0, null, CancellationToken.None); trail.Values.ShouldHaveSingleItem().UserName.ShouldBe(new string('p', HistoryUpdateContext.MaxIdentityLength)); } }