using FluentAssertions; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Reconciliation; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers.BuiltIn; using Microsoft.EntityFrameworkCore; using NSubstitute; namespace IBKRTrader.Tests.Reconciliation; /// /// Der Worker um den Abgleich. Die Deutung selbst ist in /// geprüft – hier geht es um das Verhalten drumherum, vor allem um den Fall, in dem der Broker /// nichts meldet. /// [Trait("cat", "unit")] public class PositionReconcileWorkerTests { private sealed class InMemoryFactory(DbContextOptions o) : IDbContextFactory { public CoreDbContext CreateDbContext() => new(o); } /// Der Worker ohne DB-Protokollierung; ExecuteAsync wird direkt aufgerufen. private sealed class TestableWorker : PositionReconcileWorker { public TestableWorker(IPortfolioService portfolio, IBrokerPortfolioReader reader) : base(new LoggingService(), Dbf(), new ReconciliationService(portfolio, reader, new LoggingService())) { } private static IDbContextFactory Dbf() => new InMemoryFactory(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); public Task RunOnceAsync(CancellationToken ct = default) => ExecuteAsync(ct); } private static readonly InstrumentRef Aapl = InstrumentRef.ForStock("AAPL"); [Fact] public async Task EmptyBrokerAnswer_DoesNotWipeTheBooks() { // Der gefährlichste Fall: Die Leseschnittstelle liefert bei gestörter Verbindung eine // leere Liste. Würde der Worker sie für bare Münze nehmen, setzte ein Verbindungsabbruch // jede Position auf null - und mit ihr die gesamte Deckungslage. var portfolio = Substitute.For(); portfolio.GetAllPositionsAsync(Arg.Any()) .Returns>(_ => [new Position("OW", Aapl, 100, 140m)]); var reader = Substitute.For(); reader.GetPositionsAsync(Arg.Any()) .Returns>(_ => []); await new TestableWorker(portfolio, reader).RunOnceAsync(); await portfolio.DidNotReceiveWithAnyArgs().ApplyAdjustmentAsync( default!, default, default, default, default!, default); } [Fact] public async Task EmptyOnBothSides_IsSimplyNothingToDo() { var portfolio = Substitute.For(); portfolio.GetAllPositionsAsync(Arg.Any()) .Returns>(_ => []); var reader = Substitute.For(); reader.GetPositionsAsync(Arg.Any()) .Returns>(_ => []); await new TestableWorker(portfolio, reader).RunOnceAsync(); await portfolio.DidNotReceiveWithAnyArgs().ApplyAdjustmentAsync( default!, default, default, default, default!, default); } [Fact] public async Task ForeignPosition_IsBookedToUnassigned() { var portfolio = Substitute.For(); portfolio.GetAllPositionsAsync(Arg.Any()) .Returns>(_ => []); var msft = InstrumentRef.ForStock("MSFT"); var reader = Substitute.For(); reader.GetPositionsAsync(Arg.Any()) .Returns>(_ => [ new BrokerPosition { Symbol = "MSFT", SecType = "STK", Currency = "USD", ConId = 1, Quantity = 50, AvgCost = 400m, Instrument = msft } ]); await new TestableWorker(portfolio, reader).RunOnceAsync(); await portfolio.Received(1).ApplyAdjustmentAsync( "UNASSIGNED", Arg.Is(i => i.Key == msft.Key), 50, 400m, Arg.Any(), Arg.Any()); } }