using ClosedXML.Excel; using FluentAssertions; using Marathon.Application.Storage; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Marathon.Infrastructure.Export; using Marathon.Infrastructure.Persistence; using Marathon.Infrastructure.Persistence.Entities; using Marathon.Infrastructure.Tests.Persistence; namespace Marathon.Infrastructure.Tests.Export; /// /// Tests for — verifies sheet names, header row, row count, /// filename pattern and WinnerSide computation. /// public sealed class ExcelExporterTests : IDisposable { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private readonly InMemoryDbFixture _fixture; private readonly ExcelExporter _exporter; private readonly string _outputDir; public ExcelExporterTests() { _fixture = new InMemoryDbFixture(); _exporter = new ExcelExporter(_fixture.DbContext); _outputDir = Path.Combine(Path.GetTempPath(), $"marathon_export_test_{Guid.NewGuid():N}"); } public void Dispose() { _fixture.Dispose(); if (Directory.Exists(_outputDir)) Directory.Delete(_outputDir, recursive: true); } // ── Filename pattern ──────────────────────────────────────────────────── [Fact] public async Task Export_Filename_MatchesDateRangePattern() { // Arrange await SeedThreeEvents(); var range = new DateRange( new DateTimeOffset(2026, 5, 1, 0, 0, 0, MoscowOffset), new DateTimeOffset(2026, 5, 31, 23, 59, 59, MoscowOffset)); // Act var path = await _exporter.ExportAsync(range, ExportKind.Combined, _outputDir); // Assert var fileName = Path.GetFileName(path); fileName.Should().Be("Marathon_2026-05-01_to_2026-05-31.xlsx"); File.Exists(path).Should().BeTrue(); } // ── Sheet names for Combined ───────────────────────────────────────────── [Fact] public async Task Export_Combined_ProducesTwoSheets_PreMatchAndLive() { // Arrange await SeedThreeEvents(); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.Combined, _outputDir); // Assert using var wb = new XLWorkbook(path); wb.Worksheets.Should().HaveCount(2); wb.Worksheets.Select(ws => ws.Name).Should().Contain("PreMatch").And.Contain("Live"); } // ── Sheet names for PreMatch-only ──────────────────────────────────────── [Fact] public async Task Export_PreMatchOnly_ProducesOneSheet() { // Arrange await SeedThreeEvents(); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir); // Assert using var wb = new XLWorkbook(path); wb.Worksheets.Should().HaveCount(1); wb.Worksheets.First().Name.Should().Be("PreMatch"); } // ── Header row matches canonical column list ────────────────────────────── [Fact] public async Task Export_HeaderRow_MatchesCanonicalColumnOrder() { // Arrange await SeedThreeEvents(); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir); // Assert using var wb = new XLWorkbook(path); var sheet = wb.Worksheets.First(); var headers = GetHeaderRow(sheet); // Metadata columns headers.Should().StartWith(new[] { "RowNum", "SportCode", "Sport", "Country", "League", "Category", "DateFull", "Day", "Month", "Year", "Time", "EventId", }); // Match-level bet columns (Bet_ prefix for PreMatch) headers.Should().Contain("Bet_Match_Win_1"); headers.Should().Contain("Bet_Match_Draw"); headers.Should().Contain("Bet_Match_Win_2"); headers.Should().Contain("Bet_Match_Win_Fora_1_Value"); headers.Should().Contain("Bet_Match_Win_Fora_1_Rate"); headers.Should().Contain("Bet_Match_Win_Fora_2_Value"); headers.Should().Contain("Bet_Match_Win_Fora_2_Rate"); headers.Should().Contain("Bet_Match_Total_Less_Value"); headers.Should().Contain("Bet_Match_Total_Less_Rate"); headers.Should().Contain("Bet_Match_Total_More_Value"); headers.Should().Contain("Bet_Match_Total_More_Rate"); // Period columns (we seeded period-1 bets) headers.Should().Contain("Bet_Period-1_Win_1"); headers.Should().Contain("Bet_Period-1_Win_2"); // Trailing WinnerSide headers.Last().Should().Be("WinnerSide"); } // ── Live sheet uses Live_ prefix ───────────────────────────────────────── [Fact] public async Task Export_LiveSheet_UsesLivePrefix() { // Arrange await SeedThreeEvents(); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.Live, _outputDir); // Assert using var wb = new XLWorkbook(path); var sheet = wb.Worksheets.First(); var headers = GetHeaderRow(sheet); headers.Should().Contain("Live_Match_Win_1"); headers.Should().NotContain("Bet_Match_Win_1"); } // ── Row count equals event count ───────────────────────────────────────── [Fact] public async Task Export_RowCount_MatchesSnapshotCount() { // Arrange: 3 pre-match snapshots await SeedThreeEvents(); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir); // Assert using var wb = new XLWorkbook(path); var sheet = wb.Worksheets.First(); // Row 1 = header; rows 2..N = data var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1; (lastRow - 1).Should().Be(3); // 3 pre-match snapshots seeded } // ── WinnerSide computation ──────────────────────────────────────────────── [Fact] public async Task Export_WinnerSide_IsOne_WhenWin1RateLowerThanWin2() { // Arrange: single event where win1=1.65 < win2=2.20 await SeedSingleEventWithKnownRates(win1: 1.65m, win2: 2.20m); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir); // Assert using var wb = new XLWorkbook(path); var sheet = wb.Worksheets.First(); var headers = GetHeaderRow(sheet); var winnerSideCol = headers.IndexOf("WinnerSide") + 1; // 1-based var cellValue = sheet.Cell(2, winnerSideCol).Value; cellValue.IsNumber.Should().BeTrue(); cellValue.GetNumber().Should().Be(1); } [Fact] public async Task Export_WinnerSide_IsTwo_WhenWin2RateLowerThanWin1() { // Arrange: single event where win1=2.50 > win2=1.70 await SeedSingleEventWithKnownRates(win1: 2.50m, win2: 1.70m); var range = FullRange(); // Act var path = await _exporter.ExportAsync(range, ExportKind.PreMatch, _outputDir); // Assert using var wb = new XLWorkbook(path); var sheet = wb.Worksheets.First(); var headers = GetHeaderRow(sheet); var winnerSideCol = headers.IndexOf("WinnerSide") + 1; var cellValue = sheet.Cell(2, winnerSideCol).Value; cellValue.IsNumber.Should().BeTrue(); cellValue.GetNumber().Should().Be(2); } // ── Helpers ────────────────────────────────────────────────────────────── private DateRange FullRange() => new( new DateTimeOffset(2026, 1, 1, 0, 0, 0, MoscowOffset), new DateTimeOffset(2026, 12, 31, 23, 59, 59, MoscowOffset)); private static List GetHeaderRow(IXLWorksheet sheet) { var headers = new List(); var lastCol = sheet.LastColumnUsed()?.ColumnNumber() ?? 0; for (var col = 1; col <= lastCol; col++) headers.Add(sheet.Cell(1, col).GetString()); return headers; } /// Seeds 3 pre-match and 1 live snapshot across 3 events. private async Task SeedThreeEvents() { var capturedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, MoscowOffset); var scheduledAt = new DateTimeOffset(2026, 5, 10, 20, 30, 0, MoscowOffset); for (var i = 1; i <= 3; i++) { var eventEntity = new EventEntity { EventCode = $"E{i:D4}", SportCode = 11, CountryCode = "England", LeagueId = "premier-league", Category = string.Empty, ScheduledAt = scheduledAt.ToString("O"), Side1Name = $"Home{i}", Side2Name = $"Away{i}", }; _fixture.DbContext.Events.Add(eventEntity); // Pre-match snapshot var preMatch = new SnapshotEntity { EventCode = $"E{i:D4}", CapturedAt = capturedAt.ToString("O"), Source = (int)OddsSource.PreMatch, Bets = [ new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = 1.85m }, new BetEntity { Scope = 0, Type = (int)BetType.Draw, Side = (int)Side.Draw, Rate = 3.50m }, new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side2, Rate = 4.20m }, new BetEntity { Scope = 1, PeriodNumber = 1, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = 2.10m }, ], }; _fixture.DbContext.Snapshots.Add(preMatch); } // One live snapshot on event 1 var liveSnapshot = new SnapshotEntity { EventCode = "E0001", CapturedAt = capturedAt.AddHours(1).ToString("O"), Source = (int)OddsSource.Live, Bets = [ new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = 1.90m }, new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side2, Rate = 3.80m }, ], }; _fixture.DbContext.Snapshots.Add(liveSnapshot); await _fixture.DbContext.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); } private async Task SeedSingleEventWithKnownRates(decimal win1, decimal win2) { var scheduledAt = new DateTimeOffset(2026, 5, 20, 18, 0, 0, MoscowOffset); var capturedAt = new DateTimeOffset(2026, 5, 20, 10, 0, 0, MoscowOffset); _fixture.DbContext.Events.Add(new EventEntity { EventCode = "W0001", SportCode = 11, CountryCode = "Spain", LeagueId = "la-liga", Category = string.Empty, ScheduledAt = scheduledAt.ToString("O"), Side1Name = "Real Madrid", Side2Name = "Barcelona", }); _fixture.DbContext.Snapshots.Add(new SnapshotEntity { EventCode = "W0001", CapturedAt = capturedAt.ToString("O"), Source = (int)OddsSource.PreMatch, Bets = [ new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side1, Rate = win1 }, new BetEntity { Scope = 0, Type = (int)BetType.Win, Side = (int)Side.Side2, Rate = win2 }, ], }); await _fixture.DbContext.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); } }