forked from admin/VolcanicAgenticCore
140 lines
5.8 KiB
C#
140 lines
5.8 KiB
C#
using System.Diagnostics;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Telegram.Bot.Types;
|
|
using Volcanic.AgenticCore.Telegram.Polling;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
public sealed class PollingServiceTests
|
|
{
|
|
private sealed class RecordingRouter(Func<Update, Task>? onRoute = null) : IUpdateRouter
|
|
{
|
|
private readonly List<int> _ids = [];
|
|
public IReadOnlyList<int> Ids { get { lock (_ids) return _ids.ToList(); } }
|
|
|
|
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
|
|
{
|
|
lock (_ids) _ids.Add(update.Id);
|
|
if (onRoute is not null) await onRoute(update);
|
|
}
|
|
}
|
|
|
|
private static (PollingService Service, ReconnectPolicy Policy, FakeLifetime Lifetime) Create(
|
|
FakeBotClient bot, IUpdateRouter router, int initialMs = 5, int maxMs = 20)
|
|
{
|
|
var policy = new ReconnectPolicy(TimeSpan.FromMilliseconds(initialMs), TimeSpan.FromMilliseconds(maxMs), jitter: 0);
|
|
var lifetime = new FakeLifetime();
|
|
return (new PollingService(bot, router, policy, lifetime, NullLogger<PollingService>.Instance), policy, lifetime);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Recovers_from_connection_loss_and_delivers_every_update_once_in_order()
|
|
{
|
|
var bot = new FakeBotClient()
|
|
.EnqueueFailure(FakeBotClient.NetworkDown())
|
|
.EnqueueFailure(FakeBotClient.NetworkDown())
|
|
.EnqueueUpdates(Updates.WithId(1), Updates.WithId(2))
|
|
.EnqueueFailure(FakeBotClient.NetworkDown())
|
|
.EnqueueFailure(FakeBotClient.NetworkDown())
|
|
.EnqueueFailure(FakeBotClient.NetworkDown())
|
|
.EnqueueUpdates(Updates.WithId(3));
|
|
var router = new RecordingRouter();
|
|
var (service, policy, lifetime) = Create(bot, router);
|
|
|
|
await service.StartAsync(CancellationToken.None);
|
|
await Wait.UntilAsync(() => router.Ids.Count == 3, "three updates");
|
|
await Wait.UntilAsync(() => bot.PollOffsets.Count == 8, "the poll after the last update");
|
|
await service.StopAsync(CancellationToken.None);
|
|
|
|
Assert.Equal([1, 2, 3], router.Ids);
|
|
// After a failure the same offset is asked again; after updates the offset moves past them.
|
|
Assert.Equal([0, 0, 0, 3, 3, 3, 3, 4], bot.PollOffsets);
|
|
Assert.Equal(0, policy.Failures);
|
|
Assert.False(lifetime.StopRequested);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Backs_off_between_failed_polls_instead_of_spinning()
|
|
{
|
|
var bot = new FakeBotClient();
|
|
for (var i = 0; i < 100; i++) bot.EnqueueFailure(FakeBotClient.NetworkDown());
|
|
var (service, policy, _) = Create(bot, new RecordingRouter(), initialMs: 40, maxMs: 80);
|
|
|
|
var clock = Stopwatch.StartNew();
|
|
await service.StartAsync(CancellationToken.None);
|
|
await Wait.UntilAsync(() => bot.PollOffsets.Count >= 4, "four polls");
|
|
var elapsed = clock.Elapsed;
|
|
await service.StopAsync(CancellationToken.None);
|
|
|
|
// Delays 40 + 80 + 80 ms between the four polls (no jitter).
|
|
Assert.True(elapsed >= TimeSpan.FromMilliseconds(190), $"four polls took only {elapsed.TotalMilliseconds} ms");
|
|
Assert.True(policy.Failures >= 3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failures_after_a_success_start_again_from_the_initial_delay()
|
|
{
|
|
var policy = new ReconnectPolicy(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(8), jitter: 0);
|
|
Assert.Equal([1, 2, 4, 8, 8], Enumerable.Range(0, 5).Select(_ => policy.NextDelay().TotalSeconds));
|
|
Assert.Equal(5, policy.Reset());
|
|
Assert.Equal(1, policy.NextDelay().TotalSeconds);
|
|
}
|
|
|
|
[Fact]
|
|
public void Jitter_stays_within_bounds()
|
|
{
|
|
var policy = new ReconnectPolicy(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10), jitter: 0.2);
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
var delay = policy.NextDelay().TotalSeconds;
|
|
Assert.InRange(delay, 8, 12);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_rejected_token_stops_the_application()
|
|
{
|
|
var bot = new FakeBotClient().EnqueueFailure(FakeBotClient.Unauthorized());
|
|
var (service, _, lifetime) = Create(bot, new RecordingRouter());
|
|
|
|
await service.StartAsync(CancellationToken.None);
|
|
await Wait.UntilAsync(() => lifetime.StopRequested, "StopApplication");
|
|
await service.StopAsync(CancellationToken.None);
|
|
|
|
Assert.Single(bot.PollOffsets);
|
|
Assert.NotNull(service.FatalError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_failing_handler_does_not_stop_polling()
|
|
{
|
|
var bot = new FakeBotClient()
|
|
.EnqueueUpdates(Updates.WithId(10))
|
|
.EnqueueUpdates(Updates.WithId(11));
|
|
var router = new RecordingRouter(u => u.Id == 10 ? throw new InvalidOperationException("boom") : Task.CompletedTask);
|
|
var (service, _, _) = Create(bot, router);
|
|
|
|
await service.StartAsync(CancellationToken.None);
|
|
await Wait.UntilAsync(() => router.Ids.Count == 2, "both updates");
|
|
await service.StopAsync(CancellationToken.None);
|
|
|
|
Assert.Equal([10, 11], router.Ids);
|
|
Assert.Equal(12, service.Offset);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stops_promptly_while_waiting_out_a_long_backoff()
|
|
{
|
|
var bot = new FakeBotClient().EnqueueFailure(FakeBotClient.NetworkDown()).EnqueueFailure(FakeBotClient.NetworkDown());
|
|
var (service, _, _) = Create(bot, new RecordingRouter(), initialMs: 60_000, maxMs: 60_000);
|
|
|
|
await service.StartAsync(CancellationToken.None);
|
|
await Wait.UntilAsync(() => bot.PollOffsets.Count == 1, "the first poll");
|
|
var clock = Stopwatch.StartNew();
|
|
await service.StopAsync(CancellationToken.None);
|
|
|
|
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(5));
|
|
}
|
|
}
|