using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; /// /// #691.3: a gateway with Historian:ReadLimits:AllowStreamingBeyondCeiling=false refuses any ReadRaw whose /// max_values exceeds its MaxValuesCeiling (before streaming, naming the ceiling). An uncapped raw read /// (NumValuesPerNode = 0, sent as ) used to be refused outright — even a 10-row window. /// The read is now re-issued once with the ceiling the refusal named, and a stream that delivered the whole ceiling /// without reaching the window end is never answered from (BadTooManyOperations). The fake is AVEVA-shaped: /// a boundary row at the query start, [start, end] inclusive, capped at max. /// public sealed class GatewayStrictCeilingTests { private static readonly CancellationToken Ct = CancellationToken.None; private static readonly DateTime T0 = new(2026, 10, 12, 19, 0, 0, DateTimeKind.Utc); private static readonly DateTime Now = T0.AddDays(1); private static DateTime At(int seconds) => T0.AddSeconds(seconds); private static HistorianSample Row(DateTime t, double v, uint quality = 0) => new() { Tag = "T", Timestamp = Timestamp.FromDateTime(t), NumericValue = v, OpcQuality = 192, Quality = quality }; /// One stored sample per second, value = seconds, at +0 … +(count − 1); strict ceiling when given. private static FakeHistorianGatewayClient Store(int count, int? ceiling) { var stored = Enumerable.Range(0, count).Select(i => (T: At(i), V: (double)i)).ToList(); return new FakeHistorianGatewayClient { MaxValuesCeiling = ceiling, RawSource = (start, end, max) => { var rows = new List(); if (stored.All(s => s.T != start)) { var prior = stored.LastOrDefault(s => s.T < start); rows.Add(prior.T == default ? new HistorianSample { Tag = "T", Timestamp = Timestamp.FromDateTime(start), NumericValue = 0, OpcQuality = 0, Quality = 1 } : Row(start, prior.V, 133)); } rows.AddRange(stored.Where(s => s.T >= start && s.T <= end).Select(s => Row(s.T, s.V))); return rows.Take(max); }, }; } private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake, int maxRawRows = 1_000_000) => new(fake, NullLogger.Instance, maxRawRows, new FixedClock(Now)); private sealed class FixedClock(DateTime now) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(now); } // ---- raw, uncapped --------------------------------------------------------------------------------------------------- [Fact] public async Task An_uncapped_read_of_a_small_window_on_a_strict_gateway_is_served_with_the_ceiling_as_budget() { var fake = Store(10, ceiling: 1000); var ds = Source(fake); var r = await ds.ReadRawAsync("T", At(0), At(60), 0, Ct); r.Samples.Select(s => s.Value).ShouldBe(Enumerable.Range(0, 10).Select(i => (object?)(double)i)); fake.RawCalls.Select(c => c.MaxValues).ShouldBe([int.MaxValue, 1000]); // refused, then the ceiling it named fake.RawStreamsCompleted.ShouldBe(1); ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task An_uncapped_read_on_a_default_gateway_still_sends_one_unbounded_request() { var fake = Store(10, ceiling: null); await Source(fake).ReadRawAsync("T", At(0), At(60), 0, Ct); fake.RawCalls.Select(c => c.MaxValues).ShouldBe([int.MaxValue]); } [Fact] public async Task An_uncapped_window_holding_more_than_the_ceiling_is_a_request_limit_never_a_truncated_answer() { var fake = Store(100, ceiling: 50); var ds = Source(fake); await Should.ThrowAsync(() => ds.ReadRawAsync("T", At(0), At(200), 0, Ct)); fake.RawStreamsCompleted.ShouldBe(1); // drained, never abandoned ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task A_ceiling_full_stream_that_reached_the_window_end_is_complete() { // 50 rows: the boundary row + +0 … +48; the window end is +48 (the gateway's end is inclusive), so the stream // provably ended at the window end — complete although it delivered the whole ceiling. var fake = Store(100, ceiling: 50); var r = await Source(fake).ReadRawAsync("T", At(0), At(48), 0, Ct); r.Samples.Count.ShouldBe(48); // [+0, +48): the end is exclusive } [Fact] public async Task A_refusal_without_a_parseable_ceiling_retries_at_the_gateways_default_ceiling() { var fake = Store(10, ceiling: 1_000_000); fake.CeilingRejectionDetail = "max_values exceeds the server ceiling; read large ranges by time-slicing."; var r = await Source(fake).ReadRawAsync("T", At(0), At(60), 0, Ct); r.Samples.Count.ShouldBe(10); fake.RawCalls.Select(c => c.MaxValues).ShouldBe([int.MaxValue, RawAggregateReader.GatewayDefaultMaxValuesCeiling]); } // ---- raw, capped ----------------------------------------------------------------------------------------------------- [Fact] public async Task A_cap_whose_allowance_exceeds_the_ceiling_is_served_with_the_ceiling_as_budget() { // cap 995 + 16 = 1011 > 1000: refused, re-asked with 1000 — a small window is served in full. var fake = Store(10, ceiling: 1000); var r = await Source(fake).ReadRawAsync("T", At(0), At(60), 995, Ct); r.Samples.Count.ShouldBe(10); fake.RawCalls.Select(c => c.MaxValues).ShouldBe([995 + RawAggregateReader.PreWindowAllowance, 1000]); } [Fact] public async Task A_cap_above_the_ceiling_over_a_dense_window_is_a_request_limit_not_a_short_page() { // A short page would end the client's paging early (silent truncation), so it must fail instead. var fake = Store(100, ceiling: 50); var ds = Source(fake); await Should.ThrowAsync(() => ds.ReadRawAsync("T", At(0), At(200), 80, Ct)); fake.RawCalls.Count.ShouldBe(2); // the ceiling re-read is final: no third read at the same ceiling ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task A_cap_that_fits_the_ceiling_is_unaffected() { var fake = Store(100, ceiling: 50); var r = await Source(fake).ReadRawAsync("T", At(0), At(200), 10, Ct); r.Samples.Count.ShouldBe(10); fake.RawCalls.Select(c => c.MaxValues).ShouldBe([10 + RawAggregateReader.PreWindowAllowance]); } // ---- per-sample aggregates (#688 clamp vs a ceiling below 1,000,000) --------------------------------------------------- [Fact] public async Task A_per_sample_aggregate_on_a_gateway_with_a_lower_ceiling_is_served_when_the_window_fits() { var fake = Store(30, ceiling: 1000); // default cap 1,000,000 ⇒ budget 1,000,000 > 1000 var s = (await Source(fake).ReadProcessedAsync("T", At(0), At(30), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct)).Samples; s.Select(x => x.Value).ShouldBe([10, 10, 10]); fake.RawCalls[0].MaxValues.ShouldBe(RawAggregateReader.GatewayDefaultMaxValuesCeiling); fake.RawCalls[1].MaxValues.ShouldBe(1000); } [Fact] public async Task A_per_sample_aggregate_whose_window_fills_the_lower_ceiling_is_a_request_limit() { var fake = Store(100, ceiling: 50); var ds = Source(fake); await Should.ThrowAsync(() => ds.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct)); ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); } [Fact] public async Task An_unbounded_per_sample_aggregate_on_a_strict_gateway_is_served_with_the_ceiling() { var fake = Store(30, ceiling: 1000); var s = (await Source(fake, maxRawRows: 0).ReadProcessedAsync("T", At(0), At(30), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct)).Samples; s.Select(x => x.Value).ShouldBe([10, 10, 10]); fake.RawCalls.Take(2).Select(c => c.MaxValues).ShouldBe([int.MaxValue, 1000]); } [Fact] public async Task An_unbounded_per_sample_aggregate_filling_the_ceiling_is_a_request_limit() { var fake = Store(100, ceiling: 50); await Should.ThrowAsync(() => Source(fake, maxRawRows: 0).ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct)); } }