using FluentAssertions; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using Marathon.Infrastructure.Persistence; using Marathon.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; namespace Marathon.Infrastructure.Tests.Persistence; /// /// Round-trip persistence tests: insert domain objects → retrieve → assert field equality. /// Uses an in-memory SQLite database per test class via InMemoryDbFixture. /// public sealed class RoundTripTests : IDisposable { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private readonly InMemoryDbFixture _fixture; private readonly EventRepository _eventRepo; private readonly SnapshotRepository _snapshotRepo; private readonly ResultRepository _resultRepo; private readonly AnomalyRepository _anomalyRepo; public RoundTripTests() { _fixture = new InMemoryDbFixture(); _eventRepo = new EventRepository(_fixture.DbContext); _snapshotRepo = new SnapshotRepository(_fixture.DbContext); _resultRepo = new ResultRepository(_fixture.DbContext); _anomalyRepo = new AnomalyRepository(_fixture.DbContext); } public void Dispose() => _fixture.Dispose(); // ── Event round-trip ──────────────────────────────────────────────────── [Fact] public async Task Event_RoundTrip_PreservesAllFields() { // Arrange var evt = new Event( Id: new EventId("26456117"), Sport: new SportCode(11), CountryCode: "England", LeagueId: "premier-league", Category: "Play-Offs", ScheduledAt: new DateTimeOffset(2026, 5, 10, 20, 30, 0, MoscowOffset), Side1Name: "Arsenal", Side2Name: "Chelsea"); // Act await _eventRepo.AddAsync(evt); await _eventRepo.SaveChangesAsync(); // Detach so the next read hits the DB _fixture.DbContext.ChangeTracker.Clear(); var retrieved = await _eventRepo.GetAsync(new EventId("26456117")); // Assert retrieved.Should().NotBeNull(); retrieved!.Id.Value.Should().Be("26456117"); retrieved.Sport.Value.Should().Be(11); retrieved.CountryCode.Should().Be("England"); retrieved.LeagueId.Should().Be("premier-league"); retrieved.Category.Should().Be("Play-Offs"); retrieved.ScheduledAt.Should().Be(new DateTimeOffset(2026, 5, 10, 20, 30, 0, MoscowOffset)); retrieved.ScheduledAt.Offset.Should().Be(MoscowOffset); retrieved.Side1Name.Should().Be("Arsenal"); retrieved.Side2Name.Should().Be("Chelsea"); } // ── OddsSnapshot round-trip ───────────────────────────────────────────── [Fact] public async Task OddsSnapshot_RoundTrip_PreservesAllBets() { // Arrange — persist event first (FK constraint) var evt = BuildEvent("99001"); await _eventRepo.AddAsync(evt); await _eventRepo.SaveChangesAsync(); var snapshot = new OddsSnapshot( eventId: new EventId("99001"), capturedAt: new DateTimeOffset(2026, 5, 10, 18, 0, 0, MoscowOffset), source: OddsSource.PreMatch, bets: new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.85m)), new(MatchScope.Instance, BetType.Draw, Side.Draw, null, new OddsRate(3.50m)), new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(4.20m)), new(MatchScope.Instance, BetType.WinFora, Side.Side1, new OddsValue(-1.5m), new OddsRate(2.10m)), new(MatchScope.Instance, BetType.Total, Side.Less, new OddsValue(2.5m), new OddsRate(1.95m)), new(new PeriodScope(1), BetType.Win, Side.Side1, null, new OddsRate(2.30m)), }.AsReadOnly()); // Act await _snapshotRepo.AddAsync(snapshot); await _snapshotRepo.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); var snapshots = await _snapshotRepo.ListByEventAsync( new EventId("99001"), DateTimeOffset.MinValue, DateTimeOffset.MaxValue); // Assert snapshots.Should().HaveCount(1); var retrieved = snapshots[0]; retrieved.EventId.Value.Should().Be("99001"); retrieved.Source.Should().Be(OddsSource.PreMatch); retrieved.Bets.Should().HaveCount(6); // Spot-check individual bets var win1 = retrieved.Bets.Single(b => b.Scope is MatchScope && b.Type == BetType.Win && b.Side == Side.Side1); win1.Rate.Value.Should().Be(1.85m); win1.Value.Should().BeNull(); var fora = retrieved.Bets.Single(b => b.Type == BetType.WinFora && b.Side == Side.Side1); fora.Value!.Value.Should().Be(-1.5m); fora.Rate.Value.Should().Be(2.10m); var period1Win1 = retrieved.Bets.Single(b => b.Scope is PeriodScope { Number: 1 } && b.Type == BetType.Win); period1Win1.Rate.Value.Should().Be(2.30m); } // ── BetScope round-trip ───────────────────────────────────────────────── [Fact] public async Task BetScope_RoundTrip_MatchScopeAndPeriodScope() { // Arrange var evt = BuildEvent("99002"); await _eventRepo.AddAsync(evt); await _eventRepo.SaveChangesAsync(); var snapshot = new OddsSnapshot( eventId: new EventId("99002"), capturedAt: new DateTimeOffset(2026, 5, 11, 10, 0, 0, MoscowOffset), source: OddsSource.Live, bets: new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.50m)), new(new PeriodScope(2), BetType.Win, Side.Side2, null, new OddsRate(2.75m)), }.AsReadOnly()); // Act await _snapshotRepo.AddAsync(snapshot); await _snapshotRepo.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); var snapshots = await _snapshotRepo.ListByEventAsync( new EventId("99002"), DateTimeOffset.MinValue, DateTimeOffset.MaxValue); // Assert var bets = snapshots[0].Bets; bets.Should().HaveCount(2); var matchBet = bets.Single(b => b.Scope is MatchScope); matchBet.Scope.Should().BeOfType(); matchBet.Rate.Value.Should().Be(1.50m); var periodBet = bets.Single(b => b.Scope is PeriodScope); var ps = periodBet.Scope.Should().BeOfType().Subject; ps.Number.Should().Be(2); periodBet.Rate.Value.Should().Be(2.75m); } // ── EventResult round-trip ────────────────────────────────────────────── [Fact] public async Task EventResult_RoundTrip_PreservesAllFields() { // Arrange var evt = BuildEvent("99003"); await _eventRepo.AddAsync(evt); await _eventRepo.SaveChangesAsync(); var result = new EventResult( EventId: new EventId("99003"), Side1Score: 2, Side2Score: 1, WinnerSide: Side.Side1, CompletedAt: new DateTimeOffset(2026, 5, 10, 22, 45, 0, MoscowOffset)); // Act await _resultRepo.AddAsync(result); await _resultRepo.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); var retrieved = await _resultRepo.GetAsync(new EventId("99003")); // Assert retrieved.Should().NotBeNull(); retrieved!.EventId.Value.Should().Be("99003"); retrieved.Side1Score.Should().Be(2); retrieved.Side2Score.Should().Be(1); retrieved.WinnerSide.Should().Be(Side.Side1); retrieved.CompletedAt.Offset.Should().Be(MoscowOffset); } // ── Anomaly round-trip ────────────────────────────────────────────────── [Fact] public async Task Anomaly_RoundTrip_PreservesAllFields() { // Arrange var evt = BuildEvent("99004"); await _eventRepo.AddAsync(evt); await _eventRepo.SaveChangesAsync(); var anomalyId = Guid.NewGuid(); var anomaly = new Anomaly( Id: anomalyId, EventId: new EventId("99004"), DetectedAt: new DateTimeOffset(2026, 5, 10, 19, 0, 0, MoscowOffset), Kind: AnomalyKind.SuspensionFlip, Score: 0.87m, EvidenceJson: "{\"snapshots\":[1,2,3]}"); // Act await _anomalyRepo.AddAsync(anomaly); await _anomalyRepo.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); var retrieved = await _anomalyRepo.GetAsync(anomalyId); // Assert retrieved.Should().NotBeNull(); retrieved!.Id.Should().Be(anomalyId); retrieved.EventId.Value.Should().Be("99004"); retrieved.Kind.Should().Be(AnomalyKind.SuspensionFlip); retrieved.Score.Should().Be(0.87m); retrieved.EvidenceJson.Should().Be("{\"snapshots\":[1,2,3]}"); } // ── ListByDateRange ───────────────────────────────────────────────────── [Fact] public async Task ListByDateRange_ReturnsOnlyEventsInRange() { // Arrange: three events at different times var e1 = BuildEvent("R001", new DateTimeOffset(2026, 5, 1, 12, 0, 0, MoscowOffset)); var e2 = BuildEvent("R002", new DateTimeOffset(2026, 5, 5, 18, 0, 0, MoscowOffset)); var e3 = BuildEvent("R003", new DateTimeOffset(2026, 5, 10, 20, 0, 0, MoscowOffset)); await _eventRepo.AddAsync(e1); await _eventRepo.AddAsync(e2); await _eventRepo.AddAsync(e3); await _eventRepo.SaveChangesAsync(); _fixture.DbContext.ChangeTracker.Clear(); var range = new Marathon.Application.Storage.DateRange( new DateTimeOffset(2026, 5, 3, 0, 0, 0, MoscowOffset), new DateTimeOffset(2026, 5, 7, 0, 0, 0, MoscowOffset)); // Act var results = await _eventRepo.ListByDateRangeAsync(range); // Assert results.Should().HaveCount(1); results[0].Id.Value.Should().Be("R002"); } // ── WAL mode ──────────────────────────────────────────────────────────── [Fact] public async Task Database_WalPragma_ExecutesWithoutError() { // In-memory SQLite does not support WAL (always returns "memory"), // but the PRAGMA command must execute without throwing an exception. // This test verifies the plumbing works — file-mode WAL is tested at runtime. var exception = await Record.ExceptionAsync(async () => await _fixture.DbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;")); exception.Should().BeNull("PRAGMA journal_mode=WAL should execute without error"); } // ── Helpers ───────────────────────────────────────────────────────────── private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) => new( Id: new EventId(id), Sport: new SportCode(11), CountryCode: "England", LeagueId: "premier-league", Category: string.Empty, ScheduledAt: scheduledAt ?? new DateTimeOffset(2026, 5, 10, 20, 0, 0, TimeSpan.FromHours(3)), Side1Name: "Home", Side2Name: "Away"); }