Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/PollingServiceTests.cs

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));
}
}