refactor: extract NATS.Server.Mqtt.Tests project

Move 29 MQTT test files from NATS.Server.Tests into a dedicated
NATS.Server.Mqtt.Tests project. Update namespaces, add
InternalsVisibleTo, and replace Task.Delay calls with
PollHelper.WaitUntilAsync for proper synchronization.
This commit is contained in:
Joseph Doherty
2026-03-12 15:03:12 -04:00
parent d2c04fcca5
commit a6be5e11ed
32 changed files with 88 additions and 38 deletions

View File

@@ -0,0 +1,248 @@
// MQTT flapper detection tests — exponential backoff for rapid reconnectors.
// Go reference: golang/nats-server/server/mqtt.go mqttCheckFlapper ~lines 300360.
using NATS.Server.Mqtt;
using Shouldly;
namespace NATS.Server.Mqtt.Tests.Mqtt;
public class MqttFlapperDetectionTests
{
// -------------------------------------------------------------------------
// Helper: FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing
// -------------------------------------------------------------------------
private static MqttSessionStore CreateStore(
FakeTimeProvider? time = null,
int flapThreshold = 3,
TimeSpan? flapWindow = null) =>
new(
flapWindow: flapWindow ?? TimeSpan.FromSeconds(10),
flapThreshold: flapThreshold,
timeProvider: time);
// -------------------------------------------------------------------------
// 1. TrackConnectDisconnect_counts_events
// -------------------------------------------------------------------------
[Fact]
public void TrackConnectDisconnect_counts_events()
{
// Go reference: server/mqtt.go mqttCheckFlapper — each connect increments the counter.
var store = CreateStore();
var s1 = store.TrackConnectDisconnect("client-a");
s1.ConnectDisconnectCount.ShouldBe(1);
var s2 = store.TrackConnectDisconnect("client-a");
s2.ConnectDisconnectCount.ShouldBe(2);
}
// -------------------------------------------------------------------------
// 2. Not_flapper_below_threshold
// -------------------------------------------------------------------------
[Fact]
public void Not_flapper_below_threshold()
{
// Go reference: server/mqtt.go mqttCheckFlapper — threshold is 3; 2 events should not mark as flapper.
var store = CreateStore();
store.TrackConnectDisconnect("client-b");
store.TrackConnectDisconnect("client-b");
store.IsFlapper("client-b").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// 3. Becomes_flapper_at_threshold
// -------------------------------------------------------------------------
[Fact]
public void Becomes_flapper_at_threshold()
{
// Go reference: server/mqtt.go mqttCheckFlapper — 3 events within window marks the client.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
store.TrackConnectDisconnect("client-c");
time.Advance(TimeSpan.FromSeconds(1));
store.TrackConnectDisconnect("client-c");
time.Advance(TimeSpan.FromSeconds(1));
store.TrackConnectDisconnect("client-c");
store.IsFlapper("client-c").ShouldBeTrue();
}
// -------------------------------------------------------------------------
// 4. Backoff_increases_exponentially
// -------------------------------------------------------------------------
[Fact]
public void Backoff_increases_exponentially()
{
// Go reference: server/mqtt.go mqttCheckFlapper — backoff doubles on each new flap trigger.
// Level 0 → 1 s, Level 1 → 2 s, Level 2 → 4 s.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
// First flap at level 0 (1 s backoff)
store.TrackConnectDisconnect("client-d");
store.TrackConnectDisconnect("client-d");
var s1 = store.TrackConnectDisconnect("client-d");
s1.BackoffLevel.ShouldBe(1); // incremented after applying level 0
s1.BackoffUntil.ShouldNotBeNull();
var backoff1 = s1.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
backoff1.TotalMilliseconds.ShouldBeInRange(900, 1100); // ~1 000 ms
// Advance past the backoff and trigger again — level 1 (2 s)
time.Advance(TimeSpan.FromSeconds(2));
var s2 = store.TrackConnectDisconnect("client-d");
s2.BackoffLevel.ShouldBe(2);
var backoff2 = s2.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
backoff2.TotalMilliseconds.ShouldBeInRange(1900, 2100); // ~2 000 ms
// Advance past and trigger once more — level 2 (4 s)
time.Advance(TimeSpan.FromSeconds(3));
var s3 = store.TrackConnectDisconnect("client-d");
s3.BackoffLevel.ShouldBe(3);
var backoff3 = s3.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
backoff3.TotalMilliseconds.ShouldBeInRange(3900, 4100); // ~4 000 ms
}
// -------------------------------------------------------------------------
// 5. Backoff_capped_at_60_seconds
// -------------------------------------------------------------------------
[Fact]
public void Backoff_capped_at_60_seconds()
{
// Go reference: server/mqtt.go mqttCheckFlapper — cap the maximum backoff at 60 s.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
// Trigger enough flaps to overflow past 60 s (level 6 = 64 s, which should cap at 60 s)
for (var i = 0; i < 10; i++)
{
store.TrackConnectDisconnect("client-e");
time.Advance(TimeSpan.FromMilliseconds(100));
}
var state = store.TrackConnectDisconnect("client-e");
var remaining = state.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
remaining.TotalMilliseconds.ShouldBeLessThanOrEqualTo(60_001); // max 60 s (±1 ms tolerance)
remaining.TotalMilliseconds.ShouldBeGreaterThan(0);
}
// -------------------------------------------------------------------------
// 6. GetBackoffMs_returns_remaining
// -------------------------------------------------------------------------
[Fact]
public void GetBackoffMs_returns_remaining()
{
// Go reference: server/mqtt.go mqttCheckFlapper — caller can query remaining backoff time.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
store.TrackConnectDisconnect("client-f");
store.TrackConnectDisconnect("client-f");
store.TrackConnectDisconnect("client-f"); // threshold hit
var ms = store.GetBackoffMs("client-f");
ms.ShouldBeGreaterThan(0);
ms.ShouldBeLessThanOrEqualTo(1000);
}
// -------------------------------------------------------------------------
// 7. GetBackoffMs_zero_when_not_flapping
// -------------------------------------------------------------------------
[Fact]
public void GetBackoffMs_zero_when_not_flapping()
{
// Not enough events to trigger backoff — remaining ms should be 0.
var store = CreateStore();
store.TrackConnectDisconnect("client-g");
store.TrackConnectDisconnect("client-g");
store.GetBackoffMs("client-g").ShouldBe(0);
}
// -------------------------------------------------------------------------
// 8. ClearFlapperState_removes_tracking
// -------------------------------------------------------------------------
[Fact]
public void ClearFlapperState_removes_tracking()
{
// Go reference: server/mqtt.go — stable clients should have state purged.
var store = CreateStore();
store.TrackConnectDisconnect("client-h");
store.TrackConnectDisconnect("client-h");
store.TrackConnectDisconnect("client-h");
store.IsFlapper("client-h").ShouldBeTrue();
store.ClearFlapperState("client-h");
store.IsFlapper("client-h").ShouldBeFalse();
store.GetBackoffMs("client-h").ShouldBe(0);
}
// -------------------------------------------------------------------------
// 9. Window_resets_after_10_seconds
// -------------------------------------------------------------------------
[Fact]
public void Window_resets_after_10_seconds()
{
// Go reference: server/mqtt.go mqttCheckFlapper — window-based detection resets.
// Track 2 events, advance past the window, add 1 more — should NOT be a flapper.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
store.TrackConnectDisconnect("client-i");
store.TrackConnectDisconnect("client-i");
// Advance past the 10 s flap window
time.Advance(TimeSpan.FromSeconds(11));
// Directly set the WindowStart via the returned state to simulate the old window
// being in the past. A single new event in a new window should not cross threshold.
store.TrackConnectDisconnect("client-i");
store.IsFlapper("client-i").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// 10. CheckAndClearStableClients_clears_old
// -------------------------------------------------------------------------
[Fact]
public void CheckAndClearStableClients_clears_old()
{
// Go reference: server/mqtt.go — periodic sweep clears long-stable flapper records.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = CreateStore(time);
// Trigger flap
store.TrackConnectDisconnect("client-j");
store.TrackConnectDisconnect("client-j");
var state = store.TrackConnectDisconnect("client-j");
store.IsFlapper("client-j").ShouldBeTrue();
// Manually backdate BackoffUntil so it's already expired
lock (state)
{
state.BackoffUntil = time.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(61);
}
// A stable-threshold sweep of 60 s should evict the now-expired entry
store.CheckAndClearStableClients(TimeSpan.FromSeconds(60));
store.IsFlapper("client-j").ShouldBeFalse();
store.GetBackoffMs("client-j").ShouldBe(0);
}
}