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 FiredIds { get; } = new(); public Task 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; } public Task PrepStartedAsync() => Task.CompletedTask; public Task PrepLineAsync(string line) => Task.CompletedTask; public Task PrepFinishedAsync(bool success) => 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, Days = PrimeDays.All, TimeOfDay = new TimeSpan(7, 0, 0), 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.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, Days = PrimeDays.All, TimeOfDay = new TimeSpan(7, 0, 0), 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.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.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, Days = PrimeDays.All, TimeOfDay = new TimeSpan(7, 0, 0), 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 cond, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!cond() && DateTime.UtcNow < deadline) await Task.Delay(20); } }