using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using PolyTrader.Modules.Accounting.Models; using PolyTrader.Modules.Accounting.Persistence; using PolyTrader.Modules.Accounting.Services; using PolyTraderSharp.Services; using Xunit; namespace PolyTrader.Tests.Accounting { /// /// Prüft das Einlesen der EZB-Kurse und den täglichen Abgleich (#533). Der Abruf selbst liegt /// hinter und wird hier durch eine Attrappe ersetzt — die Tests /// laufen ohne Netz. /// public class EcbFxRateSourceTests { /// Echte Antwort der EZB-Daten-API, gekürzt auf die hier interessanten Spalten. private const string RealCsv = "KEY,FREQ,CURRENCY,CURRENCY_DENOM,EXR_TYPE,EXR_SUFFIX,TIME_PERIOD,OBS_VALUE,OBS_STATUS,TITLE_COMPL\n" + "EXR.D.USD.EUR.SP00.A,D,USD,EUR,SP00,A,2026-09-08,1.1614,A,\"ECB reference exchange rate, US dollar/Euro, 2.15 pm (C.E.T.)\"\n" + "EXR.D.USD.EUR.SP00.A,D,USD,EUR,SP00,A,2026-09-09,1.1652,A,\"ECB reference exchange rate, US dollar/Euro, 2.15 pm (C.E.T.)\"\n"; [Fact] public void ParseCsv_kehrt_die_Kursrichtung_um() { var rates = EcbFxRateSource.ParseCsv(RealCsv); Assert.Equal(2, rates.Count); // Die Reihe liefert USD je EUR (1 EUR = 1,1652 USD). Gespeichert wird USD→EUR. var newest = rates.Last(); Assert.Equal(new DateTime(2026, 9, 9), newest.Date); Assert.Equal(Math.Round(1m / 1.1652m, 6), newest.UsdToEur); Assert.Equal("ECB", newest.Source); // Gegenprobe: 100 USD sind weniger als 100 EUR, solange der Euro über pari steht. Assert.True(newest.UsdToEur < 1m); } [Fact] public void ParseCsv_uebergeht_Kommas_in_Anfuehrungszeichen() { // TITLE_COMPL enthält Kommas. Naives Splitten verschöbe die Spalten. var rates = EcbFxRateSource.ParseCsv(RealCsv); Assert.All(rates, r => Assert.True(r.UsdToEur > 0m)); } [Theory] [InlineData("")] [InlineData("nur,eine,kopfzeile\n")] public void ParseCsv_liefert_bei_unbrauchbarer_Antwort_nichts(string csv) { Assert.Empty(EcbFxRateSource.ParseCsv(csv)); } [Fact] public void ParseCsv_ueberspringt_Zeilen_ohne_Beobachtung() { string csv = "TIME_PERIOD,OBS_VALUE\n" + "2026-09-08,1.1614\n" + "2026-09-09,\n" + // Feiertag: kein Wert "2026-09-10,0\n"; // 0 wäre eine Division durch null var rates = EcbFxRateSource.ParseCsv(csv); Assert.Single(rates); Assert.Equal(new DateTime(2026, 9, 8), rates[0].Date); } // ---- Abgleich ------------------------------------------------------ private sealed class FakeSource : IFxRateSource { public List Rates = new(); public DateTime? AskedFrom; public Exception? Throw; public Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) { AskedFrom = from; if (Throw != null) throw Throw; return Task.FromResult(Rates); } } private sealed class FakeRepo : IFxRateRepository { public readonly Dictionary Store = new(); public void Upsert(FxRate rate) => Store[rate.Date.Date] = rate; public List GetAll() => Store.Values.OrderBy(r => r.Date).ToList(); } private static FxRateSyncService Build(FakeSource src, FakeRepo repo) => new(src, repo, new TerminalLogger()); [Fact] public async Task Erster_Lauf_holt_die_Historie() { var src = new FakeSource(); var repo = new FakeRepo(); var today = new DateTime(2026, 9, 10); await Build(src, repo).SyncAsync(today, CancellationToken.None); Assert.Equal(today.AddDays(-FxRateSyncService.InitialBackfillDays), src.AskedFrom); } [Fact] public async Task Folgelauf_holt_nur_den_Rand_nach() { var src = new FakeSource(); var repo = new FakeRepo(); repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 1), UsdToEur = 0.86m, Source = "ECB" }); await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None); Assert.Equal(new DateTime(2026, 9, 1).AddDays(-FxRateSyncService.IncrementalLookbackDays), src.AskedFrom); } [Fact] public async Task Neue_Kurse_werden_uebernommen_bekannte_nicht_erneut() { var src = new FakeSource(); var repo = new FakeRepo(); repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 8), UsdToEur = 0.861030m, Source = "ECB" }); src.Rates = new List { new() { Date = new DateTime(2026, 9, 8), UsdToEur = 0.861030m, Source = "ECB" }, // unverändert new() { Date = new DateTime(2026, 9, 9), UsdToEur = 0.858221m, Source = "ECB" } // neu }; int written = await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None); Assert.Equal(1, written); Assert.Equal(2, repo.Store.Count); } [Fact] public async Task Abruffehler_laesst_den_Bestand_unangetastet() { var src = new FakeSource { Throw = new InvalidOperationException("EZB nicht erreichbar") }; var repo = new FakeRepo(); repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 1), UsdToEur = 0.86m, Source = "ECB" }); int written = await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None); Assert.Equal(0, written); Assert.Single(repo.Store); // der alte Kurs gilt weiter } /// /// Der manuelle Anstoss aus dem WebUI und die stuendliche Schleife trafen sich im Testlauf auf /// der VM in derselben Sekunde — im Log standen „280 Kurse uebernommen" und „53 Kurse /// uebernommen" untereinander. Beide hatten die EZB gefragt. /// [Fact] public async Task Gleichzeitige_Anstoesse_fragen_die_EZB_nur_einmal() { var repo = new FakeRepo(); var src = new CountingSource(); var svc = new FxRateSyncService(src, repo, new TerminalLogger()); await Task.WhenAll(Enumerable.Range(0, 8) .Select(_ => svc.SyncDailyAsync(force: false, CancellationToken.None))); Assert.Equal(1, src.Calls); } /// Quelle, die zaehlt und lange genug haelt, dass Aufrufe sich ueberlappen koennen. private sealed class CountingSource : IFxRateSource { private int _calls; public int Calls => _calls; public async Task> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) { Interlocked.Increment(ref _calls); await Task.Delay(50, ct); return new List { new() { Date = new DateTime(2026, 9, 9), UsdToEur = 0.858221m, Source = "ECB" } }; } } [Fact] public async Task SyncDaily_laeuft_hoechstens_einmal_je_Tag() { var src = new FakeSource(); var repo = new FakeRepo(); var svc = Build(src, repo); await svc.SyncDailyAsync(force: false, CancellationToken.None); src.AskedFrom = null; await svc.SyncDailyAsync(force: false, CancellationToken.None); Assert.Null(src.AskedFrom); // zweiter Aufruf hat nichts geholt } } }