using System.Diagnostics; using FluentAssertions; using IBKRTrader.Core.Database; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Workers; 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; public TestWorker(TimeSpan? interval, Func body) : base(new LoggingService(), new DatabaseService(new SettingsService(), new LoggingService())) { _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(); } [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(); } [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(); } [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(); worker.Info.Status.Should().Be(WorkerStatus.Stopped); } }