using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests; [Collection(QuestDbCollection.Name)] public sealed class WriteTests(QuestDbFixture fx) { // µs-aligned (ticks % 10 == 0) so "same µs" offsets of a few ticks stay inside one 10-tick window. private static readonly DateTime T0 = new(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc); private static readonly DateTime ServerNow = new(2026, 10, 9, 12, 0, 0, DateTimeKind.Utc); private (QuestDbHistorianValueWriter W, QuestDbConnection C) Make(string? prefix = null, string? connectionString = null) { var o = fx.Options(prefix ?? fx.UniquePrefix()); if (connectionString is not null) o.ConnectionString = connectionString; var c = new QuestDbConnection(o); var b = new QuestDbSchemaBootstrapper(c, o); var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout); return (new QuestDbHistorianValueWriter(c, new QuestDbTagCatalog(c, b, wal), wal, utcNow: () => ServerNow), c); } private static async Task> RowsAsync( QuestDbConnection c, string tag) { await using var conn = await c.OpenAsync(default); await using var cmd = c.CreateCommand(conn, $"SELECT ts, kind, num_value, int_value, str_value, status, deleted, server_ts FROM {c.SampleTable} WHERE tag = $1 ORDER BY ts"); QuestDbConnection.BindText(cmd, tag); var l = new List<(DateTime, byte, double?, long?, string?, long, bool, DateTime)>(); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) l.Add((QuestDbConnection.ReadUtc(r, 0), QuestDbConnection.ReadKind(r, 1), r.IsDBNull(2) ? null : r.GetDouble(2), r.IsDBNull(3) ? null : r.GetInt64(3), r.IsDBNull(4) ? null : r.GetString(4), r.GetInt64(5), r.GetBoolean(6), QuestDbConnection.ReadUtc(r, 7))); return l; } private static async Task SeedTombstoneAsync(QuestDbConnection c, QuestDbWalVisibility wal, string tag, DateTime t) { await using (var conn = await c.OpenAsync(default)) { await using var cmd = c.CreateCommand(conn, $"INSERT INTO {c.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ($1,$2,$3,NULL,NULL,NULL,$4,true,$5)"); QuestDbConnection.BindTimestamp(cmd, t); QuestDbConnection.BindText(cmd, tag); QuestDbConnection.BindByte(cmd, 0); QuestDbConnection.BindLong(cmd, 0); QuestDbConnection.BindTimestamp(cmd, t); await cmd.ExecuteNonQueryAsync(); } await wal.WaitAsync(c.SampleTable, default); } [Fact] public async Task All_six_kinds_round_trip_with_a_non_Good_status() { fx.SkipIfUnavailable(); var (w, c) = Make(); const uint uncertain = HistorianStatusCodes.UncertainDataSubNormal; const uint bad = HistorianStatusCodes.BadOutOfRange; var dt = new DateTime(2025, 5, 6, 7, 8, 9, DateTimeKind.Utc).AddTicks(1230); HistorizationValue[] vals = [ new(T0, HistorianValue.FromDouble(1.5), uncertain), new(T0.AddSeconds(1), HistorianValue.FromInt64(-77), uncertain), new(T0.AddSeconds(2), HistorianValue.FromBoolean(true), uncertain), new(T0.AddSeconds(3), HistorianValue.FromString("héllo"), uncertain), new(T0.AddSeconds(4), HistorianValue.FromDateTime(dt), uncertain), new(T0.AddSeconds(5), HistorianValue.Empty, bad), ]; (await w.WriteLiveValuesAsync("tag.kinds", vals, CancellationToken.None)).ShouldBeTrue(); var rows = await RowsAsync(c, "tag.kinds"); rows.Count.ShouldBe(6); for (var i = 0; i < 6; i++) { rows[i].T.ShouldBe(vals[i].TimestampUtc!.Value); rows[i].Status.ShouldBe(SampleColumnMapper.ToLongStatus(vals[i].StatusCode)); rows[i].Deleted.ShouldBeFalse(); rows[i].ServerTs.ShouldBe(ServerNow); SampleColumnMapper.FromColumns(rows[i].Kind, rows[i].N, rows[i].I, rows[i].S).ShouldBe(vals[i].Value); } } [Fact] public async Task A_redelivered_batch_leaves_one_row_and_the_first_value() { fx.SkipIfUnavailable(); var (w, c) = Make(); (await w.WriteLiveValuesAsync("tag.redeliver", [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue(); (await w.WriteLiveValuesAsync("tag.redeliver", [new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await RowsAsync(c, "tag.redeliver"); rows.Count.ShouldBe(1); rows[0].N.ShouldBe(1d); } [Fact] public async Task Redelivery_straight_after_the_write_always_sees_it_because_the_writer_waits_for_WAL_visibility() { fx.SkipIfUnavailable(); var (w, c) = Make(); // Without the visibility wait ~35 % of immediate re-reads miss the row (T0 Q4) and the second value would replace the first. for (var i = 0; i < 40; i++) { var t = T0.AddSeconds(i); (await w.WriteLiveValuesAsync("tag.rw", [new(t, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue(); (await w.WriteLiveValuesAsync("tag.rw", [new(t, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); } var rows = await RowsAsync(c, "tag.rw"); rows.Count.ShouldBe(40); rows.ShouldAllBe(r => r.N == 1d); } [Fact] public async Task A_tombstoned_key_is_not_resurrected_by_redelivery() { fx.SkipIfUnavailable(); var (w, c) = Make(); var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5)); (await w.WriteLiveValuesAsync("tag.tomb", [new(T0, HistorianValue.FromDouble(9), 0)], CancellationToken.None)).ShouldBeTrue(); await SeedTombstoneAsync(c, wal, "tag.tomb", T0.AddSeconds(1)); // Redeliver the tombstoned key plus a fresh neighbour. (await w.WriteLiveValuesAsync("tag.tomb", [new(T0.AddSeconds(1), HistorianValue.FromDouble(5), 0), new(T0.AddSeconds(2), HistorianValue.FromDouble(6), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await RowsAsync(c, "tag.tomb"); rows.Count.ShouldBe(3); rows.Single(r => r.T == T0.AddSeconds(1)).Deleted.ShouldBeTrue(); rows.Single(r => r.T == T0.AddSeconds(2)).N.ShouldBe(6d); } [Fact] public async Task NaN_is_stored_as_Empty_with_BadOutOfRange() { fx.SkipIfUnavailable(); var (w, c) = Make(); (await w.WriteLiveValuesAsync("tag.nan", [new(T0, HistorianValue.FromDouble(double.NaN), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await RowsAsync(c, "tag.nan"); rows.Count.ShouldBe(1); rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty); rows[0].Status.ShouldBe((long)HistorianStatusCodes.BadOutOfRange); rows[0].N.ShouldBeNull(); } [Fact] public async Task A_microsecond_collision_within_a_batch_keeps_the_first() { fx.SkipIfUnavailable(); var (w, c) = Make(); // +2 and +7 ticks stay inside one 10-tick (1 µs) window of the aligned T0. (await w.WriteLiveValuesAsync("tag.us", [new(T0.AddTicks(2), HistorianValue.FromDouble(1), 0), new(T0.AddTicks(7), HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = await RowsAsync(c, "tag.us"); rows.Count.ShouldBe(1); rows[0].N.ShouldBe(1d); rows[0].T.ShouldBe(T0); } [Fact] public async Task Samples_before_1970_are_dropped_and_the_rest_still_land() { fx.SkipIfUnavailable(); var (w, c) = Make(); var old = new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc); (await w.WriteLiveValuesAsync("tag.old", [new(old, HistorianValue.FromDouble(1), 0), new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); w.DroppedPre1970.ShouldBe(1); var rows = await RowsAsync(c, "tag.old"); rows.Count.ShouldBe(1); rows[0].T.ShouldBe(T0); // A batch of nothing but unstorable samples succeeds (nothing to retry) and stores nothing. (await w.WriteLiveValuesAsync("tag.old2", [new(old, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue(); w.DroppedPre1970.ShouldBe(2); (await RowsAsync(c, "tag.old2")).ShouldBeEmpty(); } [Fact] public async Task A_batch_larger_than_one_insert_chunk_lands_completely() { fx.SkipIfUnavailable(); var (w, c) = Make(); var vals = Enumerable.Range(0, 2500).Select(i => new HistorizationValue(T0.AddMilliseconds(i), HistorianValue.FromInt64(i), 0)).ToArray(); (await w.WriteLiveValuesAsync("tag.big", vals, CancellationToken.None)).ShouldBeTrue(); var rows = await RowsAsync(c, "tag.big"); rows.Count.ShouldBe(2500); rows[1999].I.ShouldBe(1999); } [Fact] public async Task An_over_long_tag_name_returns_false() { fx.SkipIfUnavailable(); var (w, _) = Make(); (await w.WriteLiveValuesAsync(new string('x', 401), [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeFalse(); } [Fact] public async Task A_stopped_port_returns_false_without_throwing() { fx.SkipIfUnavailable(); var (w, _) = Make(connectionString: "Host=localhost;Port=1;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading;Timeout=2"); (await w.WriteLiveValuesAsync("x", [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeFalse(); } [Fact] public async Task A_cancelled_write_returns_false_without_throwing() { fx.SkipIfUnavailable(); var (w, _) = Make(); using var cts = new CancellationTokenSource(); cts.Cancel(); (await w.WriteLiveValuesAsync("x", [new(T0, HistorianValue.FromDouble(1), 0)], cts.Token)).ShouldBeFalse(); } private static async Task SeedRowAsync(QuestDbConnection c, QuestDbWalVisibility wal, string tag, DateTime t, double v) { await using (var conn = await c.OpenAsync(default)) { await using var cmd = c.CreateCommand(conn, $"INSERT INTO {c.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ($1,$2,1,$3,NULL,NULL,0,false,$4)"); QuestDbConnection.BindTimestamp(cmd, t); QuestDbConnection.BindText(cmd, tag); cmd.Parameters.Add(new Npgsql.NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Double, Value = v }); QuestDbConnection.BindTimestamp(cmd, t); await cmd.ExecuteNonQueryAsync(); } await wal.WaitAsync(c.SampleTable, default); } [Fact] public async Task A_row_on_another_tag_at_the_same_timestamp_does_not_suppress_the_write() { fx.SkipIfUnavailable(); var (w, c) = Make(); var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5)); (await w.WriteLiveValuesAsync("tag.A", [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue(); (await w.WriteLiveValuesAsync("tag.B", [new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue(); (await RowsAsync(c, "tag.A")).Single().N.ShouldBe(1d); (await RowsAsync(c, "tag.B")).Single().N.ShouldBe(2d); } [Fact] public async Task An_absent_interior_timestamp_is_written_while_both_present_ends_are_left_alone() { fx.SkipIfUnavailable(); var (w, c) = Make(); var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5)); await w.WriteLiveValuesAsync("tag.ends", [new(T0.AddSeconds(100), HistorianValue.FromDouble(0), 0)], CancellationToken.None); // creates the tag/tables await SeedRowAsync(c, wal, "tag.ends", T0, 10); await SeedRowAsync(c, wal, "tag.ends", T0.AddSeconds(20), 30); (await w.WriteLiveValuesAsync("tag.ends", [new(T0, HistorianValue.FromDouble(-1), 0), new(T0.AddSeconds(10), HistorianValue.FromDouble(20), 0), new(T0.AddSeconds(20), HistorianValue.FromDouble(-3), 0)], CancellationToken.None)).ShouldBeTrue(); var rows = (await RowsAsync(c, "tag.ends")).Where(r => r.T <= T0.AddSeconds(20)).ToList(); rows.Select(r => r.N).ShouldBe([10d, 20d, 30d]); } [Fact] public async Task An_outlier_timestamp_does_not_widen_the_existence_read_over_the_stored_rows_between() { fx.SkipIfUnavailable(); var (w, c) = Make(); // Behavioural check of the whole path with a far-apart batch: both samples land, a redelivery adds nothing. var far = T0.AddYears(1); HistorizationValue[] vals = [new(T0, HistorianValue.FromDouble(1), 0), new(far, HistorianValue.FromDouble(2), 0)]; (await w.WriteLiveValuesAsync("tag.far", vals, CancellationToken.None)).ShouldBeTrue(); (await w.WriteLiveValuesAsync("tag.far", vals, CancellationToken.None)).ShouldBeTrue(); (await RowsAsync(c, "tag.far")).Count.ShouldBe(2); } }