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);
}
private sealed class MovingClock : TimeProvider
{
public DateTime Current = Now;
public override DateTimeOffset GetUtcNow() => new(Current);
}
[Fact]
public async Task Aggregate_ceiling_re_probes_wait_thirty_seconds_then_notice_a_raised_limit()
{
var fake = Store(100, ceiling: 50);
var clock = new MovingClock();
var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance, timeProvider: clock);
Task Read() => ds.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(100), HistoryAggregateType.Count, Ct);
await Should.ThrowAsync(Read);
await Should.ThrowAsync(Read);
Refusals(fake).ShouldBe(1);
fake.MaxValuesCeiling = 200;
await Should.ThrowAsync(Read);
Refusals(fake).ShouldBe(1);
clock.Current = Now.AddSeconds(30);
(await Read()).Samples.Single().Value.ShouldBe(100);
ds.MaxRawValuesPerRead.ShouldBe(200 - RawAggregateReader.PreWindowAllowance);
}
// ---- 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]);
}
// ---- the learned ceiling (review: a refused attempt counts as an error in the gateway's own metrics) -----------------
private static int Refusals(FakeHistorianGatewayClient fake) =>
fake.RawCalls.Count(c => fake.MaxValuesCeiling is { } n && c.MaxValues > n);
[Fact]
public async Task The_ceiling_is_learned_once_per_data_source_so_later_reads_are_not_refused()
{
var fake = Store(10, ceiling: 1000);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
await ds.ReadRawAsync("T", At(0), At(60), 995, Ct);
await ds.ReadProcessedAsync("T", At(0), At(10), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct);
fake.RawCalls.Select(c => c.MaxValues).Take(4).ShouldBe([int.MaxValue, 1000, 1000, 1000]);
fake.RawCalls[4].MaxValues.ShouldBe(1000); // the per-sample read shares it (its own budget 1,000,000)
Refusals(fake).ShouldBe(1);
}
[Fact]
public async Task A_gateway_that_now_streams_is_re_probed_when_a_learned_ceiling_read_comes_back_full()
{
var fake = Store(100, ceiling: 50);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct); // learns 50
fake.MaxValuesCeiling = null; // the operator enabled AllowStreamingBeyondCeiling
var r = await ds.ReadRawAsync("T", At(0), At(200), 0, Ct);
r.Samples.Count.ShouldBe(100); // not BadTooManyOperations from a stale ceiling
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([50, int.MaxValue]);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
fake.RawCalls[^1].MaxValues.ShouldBe(int.MaxValue); // forgotten
}
[Fact]
public async Task A_raised_but_still_strict_ceiling_is_re_learned_from_the_re_probe()
{
var fake = Store(100, ceiling: 50);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
fake.MaxValuesCeiling = 500;
(await ds.ReadRawAsync("T", At(0), At(200), 0, Ct)).Samples.Count.ShouldBe(100);
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([50, int.MaxValue, 500]);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
fake.RawCalls[^1].MaxValues.ShouldBe(500);
}
[Fact]
public async Task A_genuinely_over_ceiling_read_costs_one_re_probe_and_no_repeat_at_the_same_ceiling()
{
var fake = Store(100, ceiling: 50);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
await Should.ThrowAsync(() => ds.ReadRawAsync("T", At(0), At(200), 0, Ct));
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([50, int.MaxValue]);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task A_lowered_ceiling_is_re_learned_from_the_refusal_of_the_learned_one()
{
var fake = Store(10, ceiling: 1000);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
fake.MaxValuesCeiling = 50;
(await ds.ReadRawAsync("T", At(0), At(60), 0, Ct)).Samples.Count.ShouldBe(10);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([1000, 50, 50]);
}
[Fact]
public async Task Concurrent_reads_share_the_learned_ceiling_safely()
{
var fake = Store(10, ceiling: 1000);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
var results = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run(() => ds.ReadRawAsync("T", At(0), At(60), 0, Ct))));
results.ShouldAllBe(r => r.Samples.Count == 10);
Refusals(fake).ShouldBe(1);
}
// ---- 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));
}
[Theory] // review: the message names the limit that actually bound the read — the gateway's ceiling, not the cap
[InlineData(0)] // unbounded cap: used to say "more than 0 raw samples (MaxTimeWeightedRawRows)"
[InlineData(1_000_000)] // default cap: used to cite 1,000,000
public async Task A_ceiling_bound_per_sample_read_names_the_gateway_ceiling(int maxRawRows)
{
var ex = await Should.ThrowAsync(() => Source(Store(100, ceiling: 50), maxRawRows)
.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct));
ex.Message.ShouldContain("row ceiling (50,");
ex.Message.ShouldNotContain("MaxTimeWeightedRawRows); the Gateway");
}
[Fact]
public async Task A_cap_bound_per_sample_read_still_names_the_cap()
{
var ex = await Should.ThrowAsync(() => Source(Store(100, ceiling: null), 20)
.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct));
ex.Message.ShouldContain("more than 20 raw samples");
}
}