using FluentAssertions; using Marathon.Application.Abstractions; using Marathon.Application.Storage; using Marathon.Application.UseCases; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; namespace Marathon.Application.Tests.UseCases; public sealed class ExportToCsvUseCaseTests : IDisposable { private static readonly TimeSpan Msk = TimeSpan.FromHours(3); private static readonly DateTimeOffset T0 = new(2026, 5, 16, 12, 0, 0, Msk); private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "marathon_csv_" + Guid.NewGuid().ToString("N")); private readonly IPlacedBetRepository _bets = Substitute.For(); private readonly IPaperBetRepository _paperBets = Substitute.For(); private readonly IEventRepository _events = Substitute.For(); public void Dispose() { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } private ExportToCsvUseCase CreateSut() { var opts = Options.Create(new StorageOptions { DatabasePath = "x.db", ExportDirectory = _tempDir, SnapshotRetentionDays = 90, }); return new ExportToCsvUseCase(_bets, _paperBets, _events, opts, NullLogger.Instance); } [Fact] public async Task ExportJournalAsync_ReturnsNull_When_NoBets() { _bets.ListAsync(Arg.Any()).Returns(Array.Empty()); (await CreateSut().ExportJournalAsync()).Should().BeNull(); } [Fact] public async Task ExportJournalAsync_WritesFile_WithHeaderAndJoinedTitle() { var bet = new PlacedBet( Guid.NewGuid(), new EventId("evt-1"), new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)), 100m, T0, BetOutcome.Pending, "note"); _bets.ListAsync(Arg.Any()).Returns(new[] { bet }); _events.GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary { [new EventId("evt-1")] = new Event(new EventId("evt-1"), new SportCode(11), "GB", "L", "C", T0.AddDays(1), "Home", "Away"), }); var path = await CreateSut().ExportJournalAsync(); path.Should().NotBeNull(); File.Exists(path!).Should().BeTrue(); var content = await File.ReadAllTextAsync(path!); content.Should().StartWith("PlacedAt,Event,EventId"); content.Should().Contain("Home vs Away"); content.Should().Contain("evt-1"); } [Fact] public async Task ExportJournalAsync_NeutralizesFormulaInjection_InNotes() { var bet = new PlacedBet( Guid.NewGuid(), new EventId("evt-1"), new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)), 100m, T0, BetOutcome.Pending, "=cmd|'/c calc'!A1"); _bets.ListAsync(Arg.Any()).Returns(new[] { bet }); _events.GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); var path = await CreateSut().ExportJournalAsync(); var content = await File.ReadAllTextAsync(path!); // The dangerous note is apostrophe-prefixed so no cell opens as a live formula in // Excel; it sits in the last column, preceded by the CSV separator. content.Should().Contain(",'=cmd"); content.Should().NotContain(",=cmd"); } [Fact] public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets() { _paperBets.ListAsync(Arg.Any()).Returns(Array.Empty()); (await CreateSut().ExportPaperLedgerAsync()).Should().BeNull(); } [Fact] public async Task ExportPaperLedgerAsync_WritesFile_WithHeader() { var paper = PaperBet.Open(Guid.NewGuid(), new EventId("evt-2"), Side.Side2, 1.9m, 10m, T0); _paperBets.ListAsync(Arg.Any()).Returns(new[] { paper }); _events.GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); var path = await CreateSut().ExportPaperLedgerAsync(); path.Should().NotBeNull(); var content = await File.ReadAllTextAsync(path!); content.Should().StartWith("OpenedAt,Event,EventId,PickedSide"); content.Should().Contain("evt-2"); // title falls back to the raw id } }