using FluentAssertions; using IBKRTrader.Core.Logging; using IBKRTrader.Modules.Accounting.Models; using IBKRTrader.Modules.Accounting.Persistence; using IBKRTrader.Modules.Accounting.Services; using Microsoft.EntityFrameworkCore; namespace IBKRTrader.Tests.Modules.Accounting; /// Ingest-Kern gegen EF-InMemory: Idempotenz (Doppel-Ingest bucht nicht doppelt) + Balance-Anker. [Trait("cat", "unit")] public class AccountingIngestServiceTests { private sealed class Factory(DbContextOptions options) : IDbContextFactory { public AccountingDbContext CreateDbContext() => new(options); } private sealed class FakeStatement : IStatementSource { public IReadOnlyList Executions = Array.Empty(); public IReadOnlyList Cash = Array.Empty(); public Task> GetExecutionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Executions); public Task> GetCashTransactionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Cash); } private sealed class FakeBalance(decimal? v) : IBalanceAnchorSource { public Task GetBalanceAsync(string a, CancellationToken ct) => Task.FromResult(v); } private static (AccountingIngestService svc, ILedgerRepository ledger) Build(FakeStatement stmt, decimal? anchor = null) { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; var dbf = new Factory(opts); var ledger = new EfLedgerRepository(dbf); var svc = new AccountingIngestService( new NullAccountSource(), ledger, new EfIngestRunRepository(dbf), new EfRawSnapshotRepository(dbf), stmt, new FakeBalance(anchor), new LoggingService()); return (svc, ledger); } [Fact] public async Task DoubleIngest_IsIdempotent() { var stmt = new FakeStatement { Executions = new[] { new RawExecution { AccountId = "U1", TradeId = "T1", Side = "BUY", GrossBase = 1000m, FeeBase = 1m, Timestamp = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc) } }, Cash = new[] { new RawCashTransaction { AccountId = "U1", TransactionId = "C1", Type = "Dividends", AmountBase = 20m, Timestamp = new DateTime(2026, 1, 2, 10, 0, 0, DateTimeKind.Utc) } } }; var (svc, ledger) = Build(stmt); var run1 = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None); var run2 = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None); run1.NewEntries.Should().Be(2); run1.DuplicateEntries.Should().Be(0); run2.NewEntries.Should().Be(0); // zweiter Lauf bucht nichts neu run2.DuplicateEntries.Should().Be(2); ledger.Count("U1").Should().Be(2); } [Fact] public async Task ComputesBalanceDelta_AgainstAnchor() { var stmt = new FakeStatement { Cash = new[] { new RawCashTransaction { AccountId = "U1", TransactionId = "D1", Type = "Deposit", AmountBase = 1000m, Timestamp = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc) } } }; var (svc, _) = Build(stmt, anchor: 1000m); var run = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None); run.LedgerNetBase.Should().Be(1000m); run.BalanceAnchorBase.Should().Be(1000m); run.BalanceDeltaBase.Should().Be(0m); // vollständig } [Fact] public async Task NoAccounts_IngestAll_DoesNothing() { var (svc, ledger) = Build(new FakeStatement()); await svc.IngestAllAsync(backfill: false, CancellationToken.None); ledger.DistinctAccounts().Should().BeEmpty(); } }