using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; [Collection(SqlHistorianCollection.Name)] public sealed class ProvisioningTests(SqlHistorianFixture fx) { private (SqlServerTagProvisioner P, string S) Make(string? schema = null) { var s = schema ?? SqlHistorianFixture.UniqueSchema(); var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = s }; var c = new SqlHistorianConnection(o); return (new SqlServerTagProvisioner(c, new SqlServerSchemaBootstrapper(c, o)), s); } private async Task<(byte Type, string? Eu, string? Desc)> TagAsync(string s, string name) { await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(); await using var cmd = c.CreateCommand(); cmd.CommandText = $"SELECT DataType, EngineeringUnit, Description FROM [{s}].Tag WHERE Name=@n"; cmd.Parameters.AddWithValue("@n", name); await using var r = await cmd.ExecuteReaderAsync(); (await r.ReadAsync()).ShouldBeTrue(); return (r.GetByte(0), r.IsDBNull(1) ? null : r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2)); } [Fact] public async Task Upsert_creates_then_changes_DataType_on_rerun_and_every_type_is_accepted() { fx.SkipIfUnavailable(); var (p, s) = Make(); var r1 = await p.EnsureTagsAsync([new("a", DriverDataType.Boolean, "degC", "desc"), new("s", DriverDataType.String, null, null)], CancellationToken.None); r1.ShouldBe(new HistorianProvisionResult(2, 2, 0, 0)); (await TagAsync(s, "a")).ShouldBe(((byte)DriverDataType.Boolean, "degC", "desc")); var r2 = await p.EnsureTagsAsync([new("a", DriverDataType.Float64, null, null)], CancellationToken.None); r2.ShouldBe(new HistorianProvisionResult(1, 1, 0, 0)); (await TagAsync(s, "a")).ShouldBe(((byte)DriverDataType.Float64, "degC", "desc")); // null keeps stored metadata } [Fact] public async Task Duplicate_names_and_bad_names_are_tallied_not_thrown() { fx.SkipIfUnavailable(); var (p, _) = Make(); var r = await p.EnsureTagsAsync([ new("d", DriverDataType.Int32, null, null), new("d", DriverDataType.Int64, null, null), new("", DriverDataType.Int32, null, null), new(new string('x', 401), DriverDataType.Int32, null, null)], CancellationToken.None); r.ShouldBe(new HistorianProvisionResult(4, 2, 0, 2)); } [Fact] public async Task More_tags_than_one_chunk_all_land() { fx.SkipIfUnavailable(); var (p, s) = Make(); var reqs = Enumerable.Range(0, 1000).Select(i => new HistorianTagProvisionRequest($"bulk{i}", DriverDataType.Float32, null, null)).ToList(); (await p.EnsureTagsAsync(reqs, CancellationToken.None)).ShouldBe(new HistorianProvisionResult(1000, 1000, 0, 0)); } [Fact] public async Task Two_concurrent_provisioners_of_the_same_new_tags_both_report_Ensured_with_no_Failed() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); var (p0, _) = Make(s); await p0.EnsureTagsAsync([new("warm", DriverDataType.Int32, null, null)], CancellationToken.None); // bootstrap once for (var round = 0; round < 20; round++) { var ps = Enumerable.Range(0, 16).Select(_ => Make(s).P).ToList(); // Warm each instance (its own bootstrap-once) and release all together, else bootstrap serialisation staggers the race away. foreach (var p in ps) await p.EnsureTagsAsync([new("warm", DriverDataType.Int32, null, null)], CancellationToken.None); var gate = new TaskCompletionSource(); // Staggered windows over one pool: every worker's FIRST name differs, so a MERGE without HOLDLOCK cannot hide behind // a lock convoy on a shared first key and the overlap really races (union per round = 6 names; the single/few-row shape is the one T8 proved races). var tasks = ps.Select((p, k) => Task.Run(async () => { var reqs = Enumerable.Range(k % 4, 3).Select(i => new HistorianTagProvisionRequest($"r{round}.t{i}", DriverDataType.Float64, "u", null)).ToList(); await gate.Task; return await p.EnsureTagsAsync(reqs, CancellationToken.None); })).ToList(); gate.SetResult(); var rs = await Task.WhenAll(tasks); foreach (var r in rs) r.ShouldBe(new HistorianProvisionResult(3, 3, 0, 0)); // The production dup-key retry would mask a missing HOLDLOCK; HOLDLOCK must mean it never fires. ps.Sum(p => p.DupKeyRetryCount).ShouldBe(0); } await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(); await using var cmd = c.CreateCommand(); cmd.CommandText = $"SELECT COUNT(*) FROM [{s}].Tag"; Convert.ToInt32(await cmd.ExecuteScalarAsync()).ShouldBe(20 * 6 + 1); } [Fact] public async Task A_connection_failure_is_a_Failed_tally_not_an_exception() { fx.SkipIfUnavailable(); var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString + ";Connect Timeout=1", Schema = SqlHistorianFixture.UniqueSchema(), AutoCreateSchema = false }; var c = new SqlHistorianConnection(o); var p = new SqlServerTagProvisioner(c, new SqlServerSchemaBootstrapper(c, o)); (await p.EnsureTagsAsync([new("x", DriverDataType.Int32, null, null)], CancellationToken.None)).ShouldBe(new HistorianProvisionResult(1, 0, 0, 1)); } }