08486667c3
Exported journal notes and scraped event titles could begin with a formula trigger (= + - @, tab, CR) that Excel/LibreOffice execute on open. Csv.NeutralizeFormula apostrophe-prefixes such cells so they render as text; applied to user notes, raw event ids and scraped titles. Numeric/date cells the exporter formats itself stay numeric for downstream analysis.
122 lines
4.7 KiB
C#
122 lines
4.7 KiB
C#
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<IPlacedBetRepository>();
|
|
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
|
|
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
|
|
|
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<ExportToCsvUseCase>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportJournalAsync_ReturnsNull_When_NoBets()
|
|
{
|
|
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<PlacedBet>());
|
|
|
|
(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<CancellationToken>()).Returns(new[] { bet });
|
|
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<EventId, Event>
|
|
{
|
|
[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<CancellationToken>()).Returns(new[] { bet });
|
|
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<EventId, Event>());
|
|
|
|
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<CancellationToken>()).Returns(Array.Empty<PaperBet>());
|
|
|
|
(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<CancellationToken>()).Returns(new[] { paper });
|
|
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<EventId, Event>());
|
|
|
|
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
|
|
}
|
|
}
|