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); } /// /// Intervalle über 24,85 Tage (int.MaxValue Millisekunden) beendeten den Worker früher still: /// SemaphoreSlim.WaitAsync wirft darüber, die Ausnahme verließ den Lauf-Loop, der Task /// faultete – und riss beim Herunterfahren den ganzen Prozess mit. /// Zwei ausgelieferte Vorgaben lagen über der Grenze. /// [Fact] public async Task VeryLongInterval_KeepsWorkerAlive_AndStopsCleanly() { var worker = new TestWorker(TimeSpan.FromDays(400), _ => Task.CompletedTask); await worker.StartAsync(CancellationToken.None); await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt"); // Der Trigger beweist, dass die Warteschleife lebt statt an einer Ausnahme gestorben zu sein. await worker.TriggerAsync(); await WaitUntilAsync(() => worker.Runs == 2, "die Warteschleife nimmt weiter Trigger an"); worker.Info.Status.Should().NotBe(WorkerStatus.Error); // Kein erneutes Werfen beim Stoppen – das war der zweite Teil des Fehlers. await worker.StopAsync(CancellationToken.None); worker.Info.Status.Should().Be(WorkerStatus.Stopped); } /// /// "Letzter Lauf" muss auch nach einem Fehlschlag stehen: sonst ist ein Worker, der seit Tagen /// bei jedem Versuch scheitert, in der Tabelle nicht von einem zu unterscheiden, der noch nie /// gelaufen ist. /// [Fact] public async Task FailedRun_StillRecordsLastRuntime() { 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.Info.LastRuntime.Should().NotBeNull(); await worker.StopAsync(CancellationToken.None); } }