using System.Diagnostics;
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Workers;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Tests.Workers;
[Trait("cat", "unit")]
public class WorkerBaseTests
{
///
/// Testbarer Worker: überschreibt den DB-Log-Seam (kein MySQL-Zugriff) und
/// zählt seine Läufe. Zustände werden per Polling geprüft, weil WorkerBase
/// Info.Status intern (und nach dem Log-Abschluss) setzt.
///
private sealed class TestWorker : WorkerBase
{
private readonly TimeSpan? _interval;
private readonly Func _body;
public int Runs;
public override string Name => "TestWorker";
public override string Module => "TEST";
protected override TimeSpan? Interval => _interval;
private sealed class InMemoryFactory(DbContextOptions o) : IDbContextFactory
{
public CoreDbContext CreateDbContext() => new(o);
}
private static IDbContextFactory Dbf() =>
new InMemoryFactory(new DbContextOptionsBuilder()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
public TestWorker(TimeSpan? interval, Func body)
: base(new LoggingService(), Dbf())
{
_interval = interval;
_body = body;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
Interlocked.Increment(ref Runs);
await _body(ct);
}
// Seam überschreiben → kein DB-Zugriff. Positive Id, damit die End-Log-Aufrufe
// (in WorkerBase mit `logId > 0` geschützt) auch im Fehlerpfad laufen.
protected override Task BeginRunLogAsync() => Task.FromResult(1L);
protected override Task EndRunLogAsync(long logId, bool success, string? message = null)
=> Task.CompletedTask;
}
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static async Task WaitUntilAsync(Func condition, string because)
{
var sw = Stopwatch.StartNew();
while (!condition())
{
if (sw.Elapsed > Timeout)
throw new TimeoutException($"Bedingung nicht innerhalb {Timeout.TotalSeconds}s erfüllt: {because}");
await Task.Delay(15);
}
}
[Fact]
public async Task RunsOnce_WhenIntervalIsNull_AndReportsIdle()
{
var worker = new TestWorker(interval: null, _ => Task.CompletedTask);
await worker.StartAsync(CancellationToken.None);
// Hinweis: WorkerBase startet bereits im Zustand Idle – deshalb auf den
// abgeschlossenen Lauf warten (Runs == 1 UND wieder Idle).
await WaitUntilAsync(
() => worker.Runs == 1 && worker.Info.Status == WorkerStatus.Idle,
"ein Lauf ist abgeschlossen und Status zurück auf Idle");
worker.Runs.Should().Be(1);
await worker.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Trigger_ForcesImmediateRun_BeforeIntervalElapses()
{
// Langes Intervall → ein zweiter Lauf kann nur durch Trigger entstehen.
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
await worker.TriggerAsync();
await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus");
await worker.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Exception_SetsStatusError_AndCapturesMessage()
{
var worker = new TestWorker(interval: null,
_ => throw new InvalidOperationException("boom"));
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Info.Status == WorkerStatus.Error, "Status wird Error");
worker.Runs.Should().Be(1);
worker.Info.Info.Should().Contain("boom");
await worker.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Stop_SetsStatusStopped()
{
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
await worker.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
await worker.StopAsync(CancellationToken.None);
worker.Info.Status.Should().Be(WorkerStatus.Stopped);
}
}