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
}
}
}