using Microsoft.Data.SqlClient; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; [Collection(SqlHistorianCollection.Name)] public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx) { private SqlServerHistorianOptions Opts(string schema, bool auto = true) => new() { ConnectionString = fx.ConnectionString, Schema = schema, AutoCreateSchema = auto, }; private static SqlServerSchemaBootstrapper Boot(SqlServerHistorianOptions o) => new(new SqlHistorianConnection(o), o); private async Task ScalarAsync(string sql) { await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(); await using var cmd = c.CreateCommand(); cmd.CommandText = sql; return Convert.ToInt64(await cmd.ExecuteScalarAsync()); } [Fact] public async Task Bootstrapping_twice_is_a_no_op_with_one_version_row() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); await Boot(Opts(s)).EnsureAsync(CancellationToken.None); await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // fresh instance => re-runs the guarded path (await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1); (await ScalarAsync($"SELECT COUNT(*) FROM sys.indexes WHERE name='CX_Event_Time' AND object_id=OBJECT_ID('[{s}].Event')")).ShouldBe(1); } [Fact] public async Task A_re_run_of_the_raw_script_is_idempotent() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); await Boot(Opts(s)).EnsureAsync(CancellationToken.None); var script = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd(); await using var c = new SqlConnection(fx.ConnectionString); await c.OpenAsync(); foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(script.Replace("$(Schema)", s))) { await using var cmd = c.CreateCommand(); cmd.CommandText = b; await cmd.ExecuteNonQueryAsync(); } (await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1); } [Fact] public async Task Concurrent_bootstrappers_both_succeed_with_one_version_row() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() => Boot(Opts(s)).EnsureAsync(CancellationToken.None))).ToArray(); await Task.WhenAll(tasks); (await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1); } [Fact] public async Task AutoCreateSchema_false_on_an_empty_schema_throws_missing() { fx.SkipIfUnavailable(); var ex = await Should.ThrowAsync( () => Boot(Opts(SqlHistorianFixture.UniqueSchema(), auto: false)).EnsureAsync(CancellationToken.None)); ex.Message.ShouldContain("missing version 1"); } [Fact] public async Task AutoCreateSchema_false_passes_once_the_schema_exists_and_failure_is_not_latched() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); var checker = Boot(Opts(s, auto: false)); await Should.ThrowAsync(() => checker.EnsureAsync(CancellationToken.None)); await Boot(Opts(s)).EnsureAsync(CancellationToken.None); await checker.EnsureAsync(CancellationToken.None); // same instance retries } [Fact] public async Task Concurrent_get_or_create_of_one_new_tag_yields_one_row_and_one_id() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); var o = Opts(s); var conn = new SqlHistorianConnection(o); var boot = new SqlServerSchemaBootstrapper(conn, o); await boot.EnsureAsync(CancellationToken.None); // Separate catalogs => no shared cache, so every task really races the MERGE. Several rounds on fresh // names, all callers released together, raise the odds of hitting the create window. for (var round = 0; round < 10; round++) { var name = $"Plant/Tag{round}"; using var start = new ManualResetEventSlim(false); var ids = Enumerable.Range(0, 32).Select(_ => Task.Run(async () => { var cat = new SqlServerTagCatalog(conn, boot); await cat.TryGetTagIdAsync("warm", CancellationToken.None); // opens the pool + bootstrap before the race start.Wait(); return await cat.GetOrCreateTagIdAsync(name, 1, CancellationToken.None); })).ToArray(); await Task.Delay(200); start.Set(); (await Task.WhenAll(ids)).Distinct().Count().ShouldBe(1); (await ScalarAsync($"SELECT COUNT(*) FROM [{s}].Tag WHERE Name = N'{name}'")).ShouldBe(1); } (await new SqlServerTagCatalog(conn, boot).TryGetTagIdAsync("nope", CancellationToken.None)).ShouldBeNull(); } [Fact] public async Task Sample_tvp_keeps_sub_millisecond_ticks_end_to_end() { fx.SkipIfUnavailable(); var s = SqlHistorianFixture.UniqueSchema(); var o = Opts(s); var conn = new SqlHistorianConnection(o); var boot = new SqlServerSchemaBootstrapper(conn, o); var tagId = await new SqlServerTagCatalog(conn, boot).GetOrCreateTagIdAsync("T", 1, CancellationToken.None); var t = new DateTime(2026, 10, 9, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234567); await using var c = await conn.OpenAsync(CancellationToken.None); await using var cmd = conn.CreateCommand(c, $"INSERT {conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " + "SELECT @id, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, SYSUTCDATETIME() FROM @rows"); cmd.Parameters.AddWithValue("@id", tagId); conn.AddStructured(cmd, "@rows", "SampleRow", SampleRowMapper.ToSampleRecords([new SqlSampleRow(t, ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistorianValue.FromDouble(1), 0u)])); await cmd.ExecuteNonQueryAsync(); await using var q = conn.CreateCommand(c, $"SELECT SourceTimeUtc FROM {conn.Q("Sample")}"); await using var r = await q.ExecuteReaderAsync(); (await r.ReadAsync()).ShouldBeTrue(); SqlHistorianConnection.GetUtc(r, 0).Ticks.ShouldBe(t.Ticks); } }