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();
}
}