feat(worker): add PrimeScheduler hosted service
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
153
tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
Normal file
153
tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Prime;
|
||||
|
||||
public class PrimeSchedulerTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private sealed class FakeClock : IPrimeClock
|
||||
{
|
||||
public DateTimeOffset Now { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FakeRunner : IPrimeRunner
|
||||
{
|
||||
public List<Guid> FiredIds { get; } = new();
|
||||
public Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto s, CancellationToken ct)
|
||||
{
|
||||
FiredIds.Add(s.Id);
|
||||
return Task.FromResult(new PrimeRunOutcome(true, "ok"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeBroadcaster : IPrimeBroadcaster
|
||||
{
|
||||
public List<(Guid id, bool ok, string msg)> Calls { get; } = new();
|
||||
public Task PrimeFiredAsync(Guid id, bool ok, string msg, DateTimeOffset firedAt)
|
||||
{
|
||||
Calls.Add((id, ok, msg));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fires_Schedule_When_Within_CatchUp_On_Startup()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 5),
|
||||
EndDate = new DateOnly(2026, 5, 5),
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = false,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) };
|
||||
var runner = new FakeRunner();
|
||||
var broadcaster = new FakeBroadcaster();
|
||||
var signal = new PrimeScheduleSignal();
|
||||
var scheduler = new PrimeScheduler(
|
||||
_db.CreateFactory(), runner, clock, signal, broadcaster,
|
||||
PrimeSchedulerOptions.Default, NullLogger<PrimeScheduler>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var run = scheduler.StartAsync(cts.Token);
|
||||
|
||||
await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
cts.Cancel();
|
||||
await scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(runner.FiredIds);
|
||||
Assert.Equal(id, runner.FiredIds[0]);
|
||||
Assert.Single(broadcaster.Calls);
|
||||
Assert.True(broadcaster.Calls[0].ok);
|
||||
|
||||
using var read = _db.CreateContext();
|
||||
var row = await new PrimeScheduleRepository(read).GetAsync(id);
|
||||
Assert.NotNull(row!.LastRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_Not_Fire_Past_CatchUp_Window()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 5),
|
||||
EndDate = new DateOnly(2026, 5, 5),
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = false,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)) };
|
||||
var runner = new FakeRunner();
|
||||
var signal = new PrimeScheduleSignal();
|
||||
var scheduler = new PrimeScheduler(
|
||||
_db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(),
|
||||
PrimeSchedulerOptions.Default, NullLogger<PrimeScheduler>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await scheduler.StartAsync(cts.Token);
|
||||
await Task.Delay(200);
|
||||
cts.Cancel();
|
||||
await scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(runner.FiredIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_Recomputes_Mid_Wait()
|
||||
{
|
||||
var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) };
|
||||
var runner = new FakeRunner();
|
||||
var signal = new PrimeScheduleSignal();
|
||||
var scheduler = new PrimeScheduler(
|
||||
_db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(),
|
||||
PrimeSchedulerOptions.Default, NullLogger<PrimeScheduler>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await scheduler.StartAsync(cts.Token);
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
using (var ctx = _db.CreateContext())
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 5),
|
||||
EndDate = new DateOnly(2026, 5, 5),
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = false,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
signal.Signal();
|
||||
|
||||
await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3));
|
||||
cts.Cancel();
|
||||
await scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Single(runner.FiredIds);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> cond, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!cond() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user