diff --git a/src/Marathon.Application/Abstractions/IOddsScraper.cs b/src/Marathon.Application/Abstractions/IOddsScraper.cs index fada616..189d8c7 100644 --- a/src/Marathon.Application/Abstractions/IOddsScraper.cs +++ b/src/Marathon.Application/Abstractions/IOddsScraper.cs @@ -1,4 +1,3 @@ -using Marathon.Application.Storage; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; @@ -25,29 +24,52 @@ public interface IOddsScraper SportCode? sportFilter, CancellationToken ct); + /// + /// Returns the list of currently-live events parsed from /su/live. + /// Each returned has its + /// populated so the caller can immediately fetch its odds snapshot. + /// + /// Cancellation token. + Task> ScrapeLiveAsync(CancellationToken ct); + /// /// Fetches a full odds snapshot (all markets) for a single event. /// - /// The bookmaker's event identifier. + /// + /// The event to scrape — its drives URL construction. + /// When the path is null (legacy row), the scraper falls back to the numeric event ID. + /// /// Whether this is a pre-match or live scrape. /// Cancellation token. Task ScrapeEventOddsAsync( - EventId id, + Event eventInfo, OddsSource source, CancellationToken ct); /// - /// Returns completed event results within a date range. + /// Fetches the event-detail page for a single event and extracts its final + /// result if and only if the bookmaker has flagged the match as complete + /// (eventJsonInfo.matchIsComplete = true). /// /// /// - /// Interim no-op (Phase 3): marathonbet.by has no public results archive - /// endpoint (/su/results → 404). This method returns an empty list and - /// logs a warning. Results harvesting is implemented in Phase 8 via polling - /// event-detail pages until matchIsComplete=true. + /// marathonbet.by has no public results archive endpoint + /// (/su/results → 404), so results are harvested per-event by + /// re-fetching the same event-detail HTML used for odds scraping and + /// parsing the embedded eventJsonInfo JSON. /// /// - Task> ScrapeResultsAsync( - DateRange range, + /// + /// The event to query — its drives URL + /// construction (with the numeric ID as a best-effort fallback). + /// + /// Cancellation token. + /// + /// An when the match is complete and the score + /// could be parsed, null when the match is still in-progress or + /// the score string is unrecognised. + /// + Task ScrapeEventResultAsync( + Event eventInfo, CancellationToken ct); } diff --git a/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs b/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs index a0bdfbe..2eff086 100644 --- a/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs +++ b/src/Marathon.Application/UseCases/PullLiveOddsUseCase.cs @@ -1,13 +1,21 @@ using Marathon.Application.Abstractions; +using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Microsoft.Extensions.Logging; namespace Marathon.Application.UseCases; /// -/// For each currently-live event in the database, fetches a fresh odds snapshot -/// via the scraper and persists it. +/// Discovers currently-live events from the bookmaker's /su/live listing, +/// persists any not yet known to the database, and captures a fresh +/// snapshot for each. /// +/// +/// Live discovery is authoritative: events that go live without ever appearing +/// in the upcoming list (late-added matches, in-play markets opened on demand) +/// are picked up here. Pre-match-only events are NOT scraped by this use case — +/// they would just be wasted requests against the bookmaker. +/// public sealed class PullLiveOddsUseCase { private readonly IOddsScraper _scraper; @@ -31,27 +39,80 @@ public sealed class PullLiveOddsUseCase /// Executes one live-odds polling cycle. /// /// Cancellation token. - /// Number of snapshots successfully captured. + /// Number of live snapshots successfully captured. public async Task ExecuteAsync(CancellationToken ct) { _logger.LogInformation("PullLiveOddsUseCase: cycle started"); - // Refresh odds for every event we already track. The "live vs pre-match" - // distinction is recorded by stamping each snapshot with OddsSource.Live. - // TODO(phase-6/8): once IEventRepository.ListLiveAsync(cutoff) ships, swap - // this for a filter that only returns currently-live events to avoid - // hammering the scraper with non-live IDs. - var allEvents = await _eventRepo.ListAsync(ct); + IReadOnlyList liveEvents; + try + { + liveEvents = await _scraper.ScrapeLiveAsync(ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "PullLiveOddsUseCase: failed to fetch live event listing — skipping cycle"); + return 0; + } + + _logger.LogInformation( + "PullLiveOddsUseCase: scraper returned {Count} live events", + liveEvents.Count); int snapshotsCaptured = 0; - foreach (var ev in allEvents) + foreach (var live in liveEvents) { ct.ThrowIfCancellationRequested(); + // Persist new live events — the upcoming poller may not have seen them + // yet (or never will, for matches added after their scheduled start). + // The Live page reads from the events table, so a new live row must + // exist before its snapshots become visible. + Event eventForScrape; try { - var snapshot = await _scraper.ScrapeEventOddsAsync(ev.Id, OddsSource.Live, ct); + var existing = await _eventRepo.GetAsync(live.Id, ct); + if (existing is null) + { + await _eventRepo.AddAsync(live, ct); + await _eventRepo.SaveChangesAsync(ct); + eventForScrape = live; + } + else if (existing.EventPath is null && live.EventPath is not null) + { + // Backfill EventPath on rows persisted before the column existed, + // so subsequent scrapes can use the correct URL. + var patched = existing with { EventPath = live.EventPath }; + await _eventRepo.UpdateAsync(patched, ct); + await _eventRepo.SaveChangesAsync(ct); + eventForScrape = patched; + } + else + { + eventForScrape = existing; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "PullLiveOddsUseCase: failed to persist/lookup live event {EventId} — skipping", + live.Id.Value); + continue; + } + + try + { + var snapshot = await _scraper.ScrapeEventOddsAsync(eventForScrape, OddsSource.Live, ct); await _snapshotRepo.AddAsync(snapshot, ct); await _snapshotRepo.SaveChangesAsync(ct); snapshotsCaptured++; @@ -64,13 +125,13 @@ public sealed class PullLiveOddsUseCase { _logger.LogWarning(ex, "PullLiveOddsUseCase: failed to capture live snapshot for event {EventId} — skipping", - ev.Id.Value); + eventForScrape.Id.Value); } } _logger.LogInformation( - "PullLiveOddsUseCase: cycle done — snapshots captured for {Count}/{Total} events", - snapshotsCaptured, allEvents.Count); + "PullLiveOddsUseCase: cycle done — snapshots captured for {Count}/{Total} live events", + snapshotsCaptured, liveEvents.Count); return snapshotsCaptured; } diff --git a/src/Marathon.Application/UseCases/PullResultsUseCase.cs b/src/Marathon.Application/UseCases/PullResultsUseCase.cs index 1c8a347..754d933 100644 --- a/src/Marathon.Application/UseCases/PullResultsUseCase.cs +++ b/src/Marathon.Application/UseCases/PullResultsUseCase.cs @@ -1,26 +1,62 @@ using Marathon.Application.Abstractions; using Marathon.Application.Storage; +using Marathon.Domain.Entities; using Microsoft.Extensions.Logging; using DomainEventId = Marathon.Domain.ValueObjects.EventId; namespace Marathon.Application.UseCases; /// -/// Scaffolded results loader — inspects events for completion and persists -/// s when detected. +/// Per-event progress emitted by . +/// Used by the UI to render a progress bar and the running list of loaded +/// results — each tick is fired AFTER the bookmaker has been queried for +/// , so the UI sees one tick per inspected event. +/// +/// Total events processed so far (1-based at the first tick). +/// Total candidates in this run. +/// The event just processed. +/// What happened — see . +/// The persisted when is ; otherwise null. +public sealed record PullResultsProgress( + int Processed, + int Total, + DomainEventId EventId, + ResultLoadOutcome Outcome, + EventResult? Result); + +/// What happened to a single candidate event during a results load. +public enum ResultLoadOutcome +{ + /// A new was scraped and persisted. + Loaded, + + /// The event already had a stored result — no work was done. + AlreadyLoaded, + + /// The match isn't complete yet — try again later. + NotYetComplete, + + /// The scrape failed (HTTP, parse, etc.). Logged at warning. + Failed, +} + +/// +/// Loads completed-event results into the database. /// /// /// -/// Phase 4 scaffold: This implementation is intentionally minimal. -/// The formal watch-list polling strategy lands in Phase 8, when -/// IOddsScraper.ScrapeResultsAsync will be replaced with real -/// per-event polling against IResultsParser. +/// For each candidate event, the use case: /// +/// +/// Skips it if a result is already stored (idempotent). +/// Calls , which returns +/// a non-null only when the bookmaker reports +/// matchIsComplete=true. +/// Persists the result and increments the loaded count. +/// /// -/// Current behaviour: calls IOddsScraper.ScrapeResultsAsync (which -/// returns an empty list and logs a warning per Phase 3), so -/// ResultsLoaded will always be 0 until Phase 8. -/// All events with existing results are skipped (idempotent). +/// Candidates are either an explicit list or — when +/// null/empty — every event scheduled in range. /// /// public sealed class PullResultsUseCase @@ -45,90 +81,51 @@ public sealed class PullResultsUseCase /// /// Inspects events for completion and persists results. /// - /// Date range to scope the event search. + /// Date range used when is null or empty. /// - /// When non-null, only these event IDs are inspected. - /// When null, all events in without a result row are inspected. + /// When non-empty, only these event IDs are inspected. + /// When null or empty, all events in without a stored + /// result are inspected. + /// + /// + /// Optional progress sink. Receives one update per candidate AFTER the scrape + /// has resolved. Suitable for binding to a UI progress indicator. /// /// Cancellation token. - /// - /// A tuple of (Inspected, ResultsLoaded, Skipped) where: - /// - /// Inspected: total candidates examined. - /// ResultsLoaded: results that were persisted this cycle. - /// Skipped: events already with a result (idempotency guard). - /// - /// public async Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync( DateRange range, IReadOnlyList? selection, + IProgress? progress, CancellationToken ct) { _logger.LogInformation( "PullResultsUseCase: cycle started — range={From:O}..{To:O}, selection={SelectionCount}", range.From, range.To, selection?.Count.ToString() ?? "all"); - // Resolve the candidate event IDs. - IReadOnlyList candidates; - if (selection is { Count: > 0 }) - { - var selected = new List(selection.Count); - foreach (var id in selection) - { - ct.ThrowIfCancellationRequested(); - var ev = await _eventRepo.GetAsync(id, ct); - if (ev is not null) - selected.Add(ev); - } - candidates = selected; - } - else - { - candidates = await _eventRepo.ListByDateRangeAsync(range, ct); - } + var candidates = await ResolveCandidatesAsync(range, selection, ct).ConfigureAwait(false); int inspected = 0; int resultsLoaded = 0; int skipped = 0; - // Use the scraper's results endpoint (currently a no-op in Phase 3 — returns []). - var scraped = await _scraper.ScrapeResultsAsync(range, ct); - var scrapedByEventId = scraped.ToDictionary(r => r.EventId.Value, r => r); - foreach (var ev in candidates) { ct.ThrowIfCancellationRequested(); inspected++; - try + var (outcome, persisted) = await ProcessOneAsync(ev, ct).ConfigureAwait(false); + switch (outcome) { - // Idempotency: skip events that already have a result stored. - var existingResult = await _resultRepo.GetAsync(ev.Id, ct); - if (existingResult is not null) - { - skipped++; - continue; - } + case ResultLoadOutcome.Loaded: resultsLoaded++; break; + case ResultLoadOutcome.AlreadyLoaded: skipped++; break; + } - // Check if the scraper returned a result for this event. - if (scrapedByEventId.TryGetValue(ev.Id.Value, out var result)) - { - await _resultRepo.AddAsync(result, ct); - await _resultRepo.SaveChangesAsync(ct); - resultsLoaded++; - } - // Phase 8: else → add to watch list for next poll cycle. - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "PullResultsUseCase: error processing event {EventId} — skipping", - ev.Id.Value); - } + progress?.Report(new PullResultsProgress( + Processed: inspected, + Total: candidates.Count, + EventId: ev.Id, + Outcome: outcome, + Result: persisted)); } _logger.LogInformation( @@ -137,4 +134,67 @@ public sealed class PullResultsUseCase return (inspected, resultsLoaded, skipped); } + + /// Convenience overload without progress reporting (worker callers). + public Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync( + DateRange range, + IReadOnlyList? selection, + CancellationToken ct) + => ExecuteAsync(range, selection, progress: null, ct); + + private async Task> ResolveCandidatesAsync( + DateRange range, + IReadOnlyList? selection, + CancellationToken ct) + { + if (selection is { Count: > 0 }) + { + var resolved = new List(selection.Count); + foreach (var id in selection) + { + ct.ThrowIfCancellationRequested(); + var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false); + if (ev is not null) + resolved.Add(ev); + } + return resolved; + } + + return await _eventRepo.ListByDateRangeAsync(range, ct).ConfigureAwait(false); + } + + private async Task<(ResultLoadOutcome Outcome, EventResult? Persisted)> ProcessOneAsync( + Event ev, + CancellationToken ct) + { + try + { + var existing = await _resultRepo.GetAsync(ev.Id, ct).ConfigureAwait(false); + if (existing is not null) + { + return (ResultLoadOutcome.AlreadyLoaded, null); + } + + var scraped = await _scraper.ScrapeEventResultAsync(ev, ct).ConfigureAwait(false); + if (scraped is null) + { + return (ResultLoadOutcome.NotYetComplete, null); + } + + await _resultRepo.AddAsync(scraped, ct).ConfigureAwait(false); + await _resultRepo.SaveChangesAsync(ct).ConfigureAwait(false); + return (ResultLoadOutcome.Loaded, scraped); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "PullResultsUseCase: error processing event {EventId} — skipping", + ev.Id.Value); + return (ResultLoadOutcome.Failed, null); + } + } } diff --git a/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs b/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs index 5ed8ea5..cc3c042 100644 --- a/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs +++ b/src/Marathon.Application/UseCases/PullUpcomingEventsUseCase.cs @@ -79,7 +79,7 @@ public sealed class PullUpcomingEventsUseCase try { var snapshot = await _scraper.ScrapeEventOddsAsync( - ev.Id, + ev, Domain.Enums.OddsSource.PreMatch, ct); diff --git a/src/Marathon.Domain/Entities/Event.cs b/src/Marathon.Domain/Entities/Event.cs index 2e6ddc3..5d20b14 100644 --- a/src/Marathon.Domain/Entities/Event.cs +++ b/src/Marathon.Domain/Entities/Event.cs @@ -52,4 +52,17 @@ public sealed record Event( public string Side2Name { get; } = string.IsNullOrWhiteSpace(Side2Name) ? throw new ArgumentException("Side2Name must not be empty.", nameof(Side2Name)) : Side2Name; + + /// + /// Bookmaker URL fragment used to fetch event-detail markets, sourced from the + /// listing page's data-event-path attribute (e.g. + /// "Football/Clubs.+International/UEFA+Champions+League/.../Arsenal+vs+Chelsea+-+28089645"). + /// Combined with /su/betting/ by the scraper. + /// + /// + /// Optional for backward compatibility with rows persisted before the column + /// was introduced. When null, the scraper falls back to the (less reliable) + /// numeric event ID. + /// + public string? EventPath { get; init; } } diff --git a/src/Marathon.Infrastructure/Migrations/20260506000000_AddEventPath.cs b/src/Marathon.Infrastructure/Migrations/20260506000000_AddEventPath.cs new file mode 100644 index 0000000..4a437b2 --- /dev/null +++ b/src/Marathon.Infrastructure/Migrations/20260506000000_AddEventPath.cs @@ -0,0 +1,31 @@ +using Marathon.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Marathon.Infrastructure.Migrations; + +/// +[DbContext(typeof(MarathonDbContext))] +[Migration("20260506000000_AddEventPath")] +public partial class AddEventPath : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EventPath", + table: "Events", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EventPath", + table: "Events"); + } +} diff --git a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs index 5ece837..9adf55d 100644 --- a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs +++ b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs @@ -49,6 +49,7 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot b.Property("EventCode").HasColumnType("TEXT"); b.Property("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT"); b.Property("CountryCode").IsRequired().HasColumnType("TEXT"); + b.Property("EventPath").HasColumnType("TEXT"); b.Property("LeagueId").IsRequired().HasColumnType("TEXT"); b.Property("ScheduledAt").IsRequired().HasColumnType("TEXT"); b.Property("Side1Name").IsRequired().HasColumnType("TEXT"); diff --git a/src/Marathon.Infrastructure/Persistence/Configurations/EventConfiguration.cs b/src/Marathon.Infrastructure/Persistence/Configurations/EventConfiguration.cs index df79be6..bc01449 100644 --- a/src/Marathon.Infrastructure/Persistence/Configurations/EventConfiguration.cs +++ b/src/Marathon.Infrastructure/Persistence/Configurations/EventConfiguration.cs @@ -19,6 +19,7 @@ internal sealed class EventConfiguration : IEntityTypeConfiguration builder.Property(e => e.ScheduledAt).HasColumnType("TEXT").IsRequired(); builder.Property(e => e.Side1Name).HasColumnType("TEXT").IsRequired(); builder.Property(e => e.Side2Name).HasColumnType("TEXT").IsRequired(); + builder.Property(e => e.EventPath).HasColumnType("TEXT"); // Index for date-range queries and sport filtering builder.HasIndex(e => new { e.SportCode, e.ScheduledAt }).HasDatabaseName("IX_Events_SportCode_ScheduledAt"); diff --git a/src/Marathon.Infrastructure/Persistence/Entities/EventEntity.cs b/src/Marathon.Infrastructure/Persistence/Entities/EventEntity.cs index d7e6623..2bd2e00 100644 --- a/src/Marathon.Infrastructure/Persistence/Entities/EventEntity.cs +++ b/src/Marathon.Infrastructure/Persistence/Entities/EventEntity.cs @@ -30,6 +30,13 @@ public sealed class EventEntity /// Name of the second participant (away side). public string Side2Name { get; set; } = default!; + /// + /// Optional bookmaker URL fragment used to construct the event-detail page URL. + /// Sourced from data-event-path at scrape time. Nullable so older rows + /// (persisted before this column existed) round-trip without a backfill. + /// + public string? EventPath { get; set; } + // Navigation properties public ICollection Snapshots { get; set; } = []; public EventResultEntity? Result { get; set; } diff --git a/src/Marathon.Infrastructure/Persistence/Mapping.cs b/src/Marathon.Infrastructure/Persistence/Mapping.cs index 6b0e8d5..c546423 100644 --- a/src/Marathon.Infrastructure/Persistence/Mapping.cs +++ b/src/Marathon.Infrastructure/Persistence/Mapping.cs @@ -24,6 +24,7 @@ internal static class Mapping ScheduledAt = domain.ScheduledAt.ToString("O"), Side1Name = domain.Side1Name, Side2Name = domain.Side2Name, + EventPath = domain.EventPath, }; public static Event ToDomain(EventEntity entity) => @@ -35,7 +36,10 @@ internal static class Mapping Category: entity.Category, ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt), Side1Name: entity.Side1Name, - Side2Name: entity.Side2Name); + Side2Name: entity.Side2Name) + { + EventPath = entity.EventPath, + }; // ─── OddsSnapshot ───────────────────────────────────────────────────────── diff --git a/src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs b/src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs index 7a9e807..0425c1d 100644 --- a/src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs +++ b/src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs @@ -1,5 +1,4 @@ using Marathon.Application.Abstractions; -using Marathon.Application.Storage; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; @@ -75,57 +74,72 @@ public sealed class MarathonbetScraper : IOddsScraper return await _upcomingParser.ParseAsync(html, ct).ConfigureAwait(false); } + /// + public async Task> ScrapeLiveAsync(CancellationToken ct) + { + _logger.LogInformation("Scraping live events from {Path}", LivePath); + + var html = await FetchHtmlAsync(LivePath, ct).ConfigureAwait(false); + return await _liveParser.ParseAsync(html, ct).ConfigureAwait(false); + } + /// public async Task ScrapeEventOddsAsync( - Marathon.Domain.ValueObjects.EventId id, + Event eventInfo, OddsSource source, CancellationToken ct) { - ArgumentNullException.ThrowIfNull(id); + ArgumentNullException.ThrowIfNull(eventInfo); - // For event detail we need the event path (treeId URL). - // The caller supplies the EventId; we build the simplest valid URL. - // In practice, the Application layer should cache the event's detail path - // from the listing parse. For now, use the eventId as a best-effort path - // fragment — the site also responds to /su/betting/ in some contexts. - // - // TODO (Phase 4): pass the full detail path stored in the Event entity rather - // than relying on eventId alone. - var path = $"{EventPathBase}{id.Value}"; + // Prefer the parsed event-path (data-event-path attribute on the listing + // row, ending in "+{treeId}"). Fall back to the numeric event ID for + // legacy rows that pre-date the EventPath column — best-effort and + // expected to fail at the bookmaker, but better than throwing here. + var pathFragment = string.IsNullOrWhiteSpace(eventInfo.EventPath) + ? eventInfo.Id.Value + : eventInfo.EventPath; + var path = $"{EventPathBase}{pathFragment}"; + + if (string.IsNullOrWhiteSpace(eventInfo.EventPath)) + { + _logger.LogWarning( + "ScrapeEventOddsAsync: eventId={EventId} has no EventPath; using numeric ID fallback for URL — expect a 404", + eventInfo.Id.Value); + } _logger.LogInformation( "Scraping odds snapshot for eventId={EventId} source={Source} from {Path}", - id.Value, source, path); + eventInfo.Id.Value, source, path); var html = await FetchHtmlAsync(path, ct).ConfigureAwait(false); var snapshot = await _oddsParser.ParseAsync(html, source, ct).ConfigureAwait(false); if (snapshot is null) throw new InvalidOperationException( - $"No odds found for eventId={id.Value}. " + + $"No odds found for eventId={eventInfo.Id.Value}. " + "The event may be unavailable or the page structure has changed."); return snapshot; } /// - /// - /// Interim no-op. marathonbet.by has no public results archive endpoint - /// (/su/results → 404). This method returns an empty list. - /// Results harvesting is implemented in Phase 8 via the watch-list poller - /// (ResultsWatchListPoller), which polls individual event-detail pages - /// until matchIsComplete=true. - /// - public Task> ScrapeResultsAsync( - DateRange range, + public async Task ScrapeEventResultAsync( + Event eventInfo, CancellationToken ct) { - _logger.LogWarning( - "ScrapeResultsAsync called but marathonbet.by has no public results archive. " + - "Returning empty list. Phase 8 implements results harvesting via event-detail polling."); + ArgumentNullException.ThrowIfNull(eventInfo); - IReadOnlyList empty = Array.Empty(); - return Task.FromResult(empty); + var pathFragment = string.IsNullOrWhiteSpace(eventInfo.EventPath) + ? eventInfo.Id.Value + : eventInfo.EventPath; + var path = $"{EventPathBase}{pathFragment}"; + + _logger.LogInformation( + "Scraping result for eventId={EventId} from {Path}", + eventInfo.Id.Value, path); + + var html = await FetchHtmlAsync(path, ct).ConfigureAwait(false); + return await _resultsParser.ParseAsync(html, ct).ConfigureAwait(false); } // ── Private helpers ─────────────────────────────────────────────────── diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/EventListingParserBase.cs b/src/Marathon.Infrastructure/Scraping/Parsers/EventListingParserBase.cs index 6e0ca95..e2d718d 100644 --- a/src/Marathon.Infrastructure/Scraping/Parsers/EventListingParserBase.cs +++ b/src/Marathon.Infrastructure/Scraping/Parsers/EventListingParserBase.cs @@ -114,7 +114,10 @@ public abstract class EventListingParserBase Category: category, ScheduledAt: scheduledAt, Side1Name: side1, - Side2Name: side2); + Side2Name: side2) + { + EventPath = eventPath, + }; } private static SportCode? ExtractSportCode(IElement row) diff --git a/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs b/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs index f980051..2e94efd 100644 --- a/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs +++ b/src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs @@ -37,6 +37,14 @@ internal sealed class UpcomingEventsPoller : BackgroundService { _logger.LogInformation("UpcomingEventsPoller: started"); + // Immediate kick-off cycle on startup so the events table is populated + // before we sit on the cron-wait. Without this, a freshly launched app + // would have an empty DB until the next cron tick (up to 6 h with the + // default `0 0 */6 * * *`), which makes both the PreMatch and Live + // pages — and the LiveOddsPoller, which iterates over DB events — + // appear empty until the first scheduled fire. + bool firstRun = true; + while (!stoppingToken.IsCancellationRequested) { var options = _opts.CurrentValue; @@ -45,24 +53,34 @@ internal sealed class UpcomingEventsPoller : BackgroundService { _logger.LogDebug("UpcomingEventsPoller: disabled — sleeping 60s before re-check"); await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + firstRun = false; continue; } - var delay = ComputeDelayToNextFire(options.UpcomingScheduleCron); - if (delay > TimeSpan.Zero) + if (!firstRun) { - _logger.LogInformation( - "UpcomingEventsPoller: next fire in {Delay:g}", - delay); - try + var delay = ComputeDelayToNextFire(options.UpcomingScheduleCron); + if (delay > TimeSpan.Zero) { - await Task.Delay(delay, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; + _logger.LogInformation( + "UpcomingEventsPoller: next fire in {Delay:g}", + delay); + try + { + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } } } + else + { + _logger.LogInformation("UpcomingEventsPoller: running initial kick-off cycle on startup"); + } + + firstRun = false; if (stoppingToken.IsCancellationRequested) break; diff --git a/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs index 712f147..12383f9 100644 --- a/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs +++ b/tests/Marathon.Application.Tests/UseCases/PullLiveOddsUseCaseTests.cs @@ -21,22 +21,22 @@ public sealed class PullLiveOddsUseCaseTests NullLogger.Instance); [Fact] - public async Task Should_CaptureOneSnapshotPerEvent_When_TwoLiveEventsExistInDatabase() + public async Task Should_CaptureOneSnapshotPerEvent_When_LiveListingReturnsTwoEvents() { - // Arrange: 2 events in the database + // Arrange: 2 events from the live listing; both already known to the DB var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); - var storedEvents = new List { ev1, ev2 }.AsReadOnly(); + var live = new List { ev1, ev2 }.AsReadOnly(); - _eventRepo.ListAsync(Arg.Any()).Returns(storedEvents); + _scraper.ScrapeLiveAsync(Arg.Any()).Returns(live); + _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); + _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); - // ScrapeUpcomingAsync is also called (by implementation) — return empty to keep test focused - _scraper.ScrapeUpcomingAsync(null, Arg.Any()) - .Returns(Array.Empty()); - - _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()) + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == ev1.Id), OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev1.Id, OddsSource.Live)); - _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()) + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == ev2.Id), OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev2.Id, OddsSource.Live)); var sut = CreateSut(); @@ -47,46 +47,65 @@ public sealed class PullLiveOddsUseCaseTests // Assert snapshotsCaptured.Should().Be(2); - await _scraper.Received(1).ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()); - await _scraper.Received(1).ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()); + await _eventRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); await _snapshotRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task Should_PersistNewLiveEvent_When_NotYetInDatabase() + { + // Arrange: live listing returns one event the DB has never seen + var live = TestFixtures.MakeEvent("99999999"); + _scraper.ScrapeLiveAsync(Arg.Any()) + .Returns(new List { live }.AsReadOnly()); + _eventRepo.GetAsync(live.Id, Arg.Any()).Returns((Event?)null); + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == live.Id), OddsSource.Live, Arg.Any()) + .Returns(TestFixtures.MakeSnapshot(live.Id, OddsSource.Live)); + + var sut = CreateSut(); + + // Act + var snapshotsCaptured = await sut.ExecuteAsync(CancellationToken.None); + + // Assert + snapshotsCaptured.Should().Be(1); + await _eventRepo.Received(1).AddAsync(live, Arg.Any()); + } + [Fact] public async Task Should_ContinueAfterSnapshotFailure_And_NotPropagateException() { - // Arrange: 2 events — scraping the first throws + // Arrange: 2 live events — scraping the first throws var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); - var storedEvents = new List { ev1, ev2 }.AsReadOnly(); + _scraper.ScrapeLiveAsync(Arg.Any()) + .Returns(new List { ev1, ev2 }.AsReadOnly()); + _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); + _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); - _eventRepo.ListAsync(Arg.Any()).Returns(storedEvents); - _scraper.ScrapeUpcomingAsync(null, Arg.Any()) - .Returns(Array.Empty()); - - _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.Live, Arg.Any()) + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == ev1.Id), OddsSource.Live, Arg.Any()) .ThrowsAsync(new HttpRequestException("timeout")); - _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.Live, Arg.Any()) + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == ev2.Id), OddsSource.Live, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev2.Id, OddsSource.Live)); var sut = CreateSut(); // Act — must not throw var act = async () => await sut.ExecuteAsync(CancellationToken.None); - - // Assert await act.Should().NotThrowAsync(); + // Re-execute to assert the count (mocks are still primed) var result = await sut.ExecuteAsync(CancellationToken.None); result.Should().Be(1, "only ev2 succeeded; ev1 failed silently"); } [Fact] - public async Task Should_ReturnZero_When_NoEventsInDatabase() + public async Task Should_ReturnZero_When_LiveListingIsEmpty() { - _eventRepo.ListAsync(Arg.Any()) - .Returns(Array.Empty()); - _scraper.ScrapeUpcomingAsync(null, Arg.Any()) + _scraper.ScrapeLiveAsync(Arg.Any()) .Returns(Array.Empty()); var sut = CreateSut(); @@ -95,6 +114,50 @@ public sealed class PullLiveOddsUseCaseTests result.Should().Be(0); await _scraper.DidNotReceive() - .ScrapeEventOddsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + .ScrapeEventOddsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_ReturnZeroAndSwallow_When_LiveListingFetchThrows() + { + _scraper.ScrapeLiveAsync(Arg.Any()) + .ThrowsAsync(new HttpRequestException("listing unavailable")); + + var sut = CreateSut(); + + var result = await sut.ExecuteAsync(CancellationToken.None); + + result.Should().Be(0); + await _scraper.DidNotReceive() + .ScrapeEventOddsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_BackfillEventPath_When_ExistingRowMissedIt() + { + // Arrange: DB row pre-dates the EventPath column (EventPath = null); + // live listing supplies a path. + var withoutPath = TestFixtures.MakeEvent("55555555"); + var withPath = withoutPath with { EventPath = "Football/Some+Path/Team+vs+Team+-+99" }; + + _scraper.ScrapeLiveAsync(Arg.Any()) + .Returns(new List { withPath }.AsReadOnly()); + _eventRepo.GetAsync(withPath.Id, Arg.Any()).Returns(withoutPath); + + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.EventPath == withPath.EventPath), + OddsSource.Live, Arg.Any()) + .Returns(TestFixtures.MakeSnapshot(withPath.Id, OddsSource.Live)); + + var sut = CreateSut(); + + // Act + var result = await sut.ExecuteAsync(CancellationToken.None); + + // Assert — the DB row was updated with the new path before scraping odds + result.Should().Be(1); + await _eventRepo.Received(1).UpdateAsync( + Arg.Is(e => e.Id == withPath.Id && e.EventPath == withPath.EventPath), + Arg.Any()); } } diff --git a/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs index bb1cb0f..36e8d3a 100644 --- a/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs +++ b/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs @@ -3,9 +3,11 @@ 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 NSubstitute; +using NSubstitute.ExceptionExtensions; namespace Marathon.Application.Tests.UseCases; @@ -23,13 +25,15 @@ public sealed class PullResultsUseCaseTests new(_scraper, _eventRepo, _resultRepo, NullLogger.Instance); + // ── Selection mode ────────────────────────────────────────────────────── + [Fact] public async Task Should_InspectOnlySelectedEvents_When_SelectionIsProvided() { - // Arrange: 3 events in DB; only 2 are in the selection + // Arrange: 3 events in the DB; only 2 in the selection var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); - var ev3 = TestFixtures.MakeEvent("33333333"); // not selected + var ev3 = TestFixtures.MakeEvent("33333333"); _eventRepo.GetAsync(ev1.Id, Arg.Any()).Returns(ev1); _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); @@ -37,10 +41,8 @@ public sealed class PullResultsUseCaseTests _resultRepo.GetAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); - - // Scraper returns no results (Phase 3 no-op) - _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) - .Returns(Array.Empty()); + _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); var selection = new List { ev1.Id, ev2.Id }; var sut = CreateSut(); @@ -48,19 +50,20 @@ public sealed class PullResultsUseCaseTests // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection, CancellationToken.None); - // Assert: only ev1 and ev2 inspected; ev3 not fetched via GetAsync lookup for range + // Assert: ev3 never resolved; only ev1+ev2 inspected inspected.Should().Be(2); - loaded.Should().Be(0, "scraper returns no results in Phase 3"); + loaded.Should().Be(0); skipped.Should().Be(0); - // ev3 was never resolved await _eventRepo.DidNotReceive().GetAsync(ev3.Id, Arg.Any()); + await _eventRepo.DidNotReceive().ListByDateRangeAsync(Arg.Any(), Arg.Any()); } + // ── Bulk mode ─────────────────────────────────────────────────────────── + [Fact] public async Task Should_InspectAllEventsInRange_When_SelectionIsNull() { - // Arrange: 3 events returned by date-range query var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); var ev3 = TestFixtures.MakeEvent("33333333"); @@ -68,84 +71,188 @@ public sealed class PullResultsUseCaseTests _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) .Returns(allEvents); - _resultRepo.GetAsync(Arg.Any(), Arg.Any()) .Returns((EventResult?)null); - - _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) - .Returns(Array.Empty()); + _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); var sut = CreateSut(); - // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, selection: null, CancellationToken.None); - // Assert inspected.Should().Be(3); - loaded.Should().Be(0); + loaded.Should().Be(0, "scraper says none of them are complete yet"); skipped.Should().Be(0); await _eventRepo.Received(1).ListByDateRangeAsync(AnyRange, Arg.Any()); } + [Fact] + public async Task Should_InspectAllEventsInRange_When_SelectionIsEmpty() + { + var ev1 = TestFixtures.MakeEvent("11111111"); + _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) + .Returns(new List { ev1 }.AsReadOnly()); + _resultRepo.GetAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); + _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); + + var sut = CreateSut(); + + var (inspected, _, _) = await sut.ExecuteAsync( + AnyRange, + selection: Array.Empty(), + CancellationToken.None); + + inspected.Should().Be(1); + await _eventRepo.Received(1).ListByDateRangeAsync(AnyRange, Arg.Any()); + } + + // ── Idempotency ───────────────────────────────────────────────────────── + [Fact] public async Task Should_SkipEventsWithExistingResult_And_BeIdempotent() { - // Arrange: 2 events — ev1 already has a result stored var ev1 = TestFixtures.MakeEvent("11111111"); var ev2 = TestFixtures.MakeEvent("22222222"); - var allEvents = new List { ev1, ev2 }.AsReadOnly(); - _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) - .Returns(allEvents); + .Returns(new List { ev1, ev2 }.AsReadOnly()); _resultRepo.GetAsync(ev1.Id, Arg.Any()) - .Returns(TestFixtures.MakeResult(ev1.Id)); // ev1 already has result + .Returns(TestFixtures.MakeResult(ev1.Id)); _resultRepo.GetAsync(ev2.Id, Arg.Any()) .Returns((EventResult?)null); - _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) - .Returns(Array.Empty()); + _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); var sut = CreateSut(); - // Act — run twice to verify idempotency var (_, _, skipped1) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); var (_, _, skipped2) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); - // Assert skipped1.Should().Be(1, "ev1 already has a result"); skipped2.Should().Be(1, "idempotent: ev1 still skipped on second run"); - // No new results persisted await _resultRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _scraper.DidNotReceive() + .ScrapeEventResultAsync( + Arg.Is(e => e.Id == ev1.Id), + Arg.Any()); } + // ── Successful loads ──────────────────────────────────────────────────── + [Fact] - public async Task Should_PersistResults_When_ScraperReturnsMatchingResults() + public async Task Should_PersistResults_When_ScraperReturnsCompletedMatch() { - // Arrange: 1 event; scraper returns a result for it var ev1 = TestFixtures.MakeEvent("11111111"); var result1 = TestFixtures.MakeResult(ev1.Id); - var allEvents = new List { ev1 }.AsReadOnly(); _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) - .Returns(allEvents); + .Returns(new List { ev1 }.AsReadOnly()); _resultRepo.GetAsync(ev1.Id, Arg.Any()) .Returns((EventResult?)null); - _scraper.ScrapeResultsAsync(Arg.Any(), Arg.Any()) - .Returns(new List { result1 }.AsReadOnly()); + _scraper.ScrapeEventResultAsync( + Arg.Is(e => e.Id == ev1.Id), + Arg.Any()) + .Returns(result1); var sut = CreateSut(); - // Act var (inspected, loaded, skipped) = await sut.ExecuteAsync(AnyRange, null, CancellationToken.None); - // Assert inspected.Should().Be(1); loaded.Should().Be(1); skipped.Should().Be(0); await _resultRepo.Received(1).AddAsync(result1, Arg.Any()); + await _resultRepo.Received(1).SaveChangesAsync(Arg.Any()); + } + + // ── Progress reporting ────────────────────────────────────────────────── + + [Fact] + public async Task Should_ReportProgress_OncePerCandidate_With_CorrectOutcome() + { + var ev1 = TestFixtures.MakeEvent("11111111"); // already has result → AlreadyLoaded + var ev2 = TestFixtures.MakeEvent("22222222"); // scrape returns null → NotYetComplete + var ev3 = TestFixtures.MakeEvent("33333333"); // scrape returns res3 → Loaded + var result3 = TestFixtures.MakeResult(ev3.Id); + + _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) + .Returns(new List { ev1, ev2, ev3 }.AsReadOnly()); + + _resultRepo.GetAsync(ev1.Id, Arg.Any()) + .Returns(TestFixtures.MakeResult(ev1.Id)); + _resultRepo.GetAsync(ev2.Id, Arg.Any()) + .Returns((EventResult?)null); + _resultRepo.GetAsync(ev3.Id, Arg.Any()) + .Returns((EventResult?)null); + + _scraper.ScrapeEventResultAsync( + Arg.Is(e => e.Id == ev2.Id), Arg.Any()) + .Returns((EventResult?)null); + _scraper.ScrapeEventResultAsync( + Arg.Is(e => e.Id == ev3.Id), Arg.Any()) + .Returns(result3); + + var ticks = new List(); + var progress = new Progress(ticks.Add); + + var sut = CreateSut(); + + await sut.ExecuteAsync(AnyRange, null, progress, CancellationToken.None); + + // Progress callback runs on the synchronization context — pump it + await Task.Delay(50); + + ticks.Should().HaveCount(3); + ticks.Select(t => t.Total).Should().AllBeEquivalentTo(3); + ticks.Select(t => t.Processed).Should().Equal(1, 2, 3); + ticks.Select(t => t.Outcome).Should().Equal( + ResultLoadOutcome.AlreadyLoaded, + ResultLoadOutcome.NotYetComplete, + ResultLoadOutcome.Loaded); + ticks[2].Result.Should().Be(result3); + } + + // ── Failure isolation ─────────────────────────────────────────────────── + + [Fact] + public async Task Should_ContinueAfterScrapeFailure_AndReportFailedOutcome() + { + var ev1 = TestFixtures.MakeEvent("11111111"); + var ev2 = TestFixtures.MakeEvent("22222222"); + var result2 = TestFixtures.MakeResult(ev2.Id); + + _eventRepo.ListByDateRangeAsync(Arg.Any(), Arg.Any()) + .Returns(new List { ev1, ev2 }.AsReadOnly()); + _resultRepo.GetAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); + + _scraper.ScrapeEventResultAsync( + Arg.Is(e => e.Id == ev1.Id), Arg.Any()) + .ThrowsAsync(new HttpRequestException("network down")); + _scraper.ScrapeEventResultAsync( + Arg.Is(e => e.Id == ev2.Id), Arg.Any()) + .Returns(result2); + + var ticks = new List(); + var progress = new Progress(ticks.Add); + + var sut = CreateSut(); + + var (inspected, loaded, _) = await sut.ExecuteAsync(AnyRange, null, progress, CancellationToken.None); + + await Task.Delay(50); + + inspected.Should().Be(2); + loaded.Should().Be(1, "ev1 failed, ev2 loaded"); + + ticks.Should().HaveCount(2); + ticks[0].Outcome.Should().Be(ResultLoadOutcome.Failed); + ticks[1].Outcome.Should().Be(ResultLoadOutcome.Loaded); } } diff --git a/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs index 26bb32c..ab29953 100644 --- a/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs +++ b/tests/Marathon.Application.Tests/UseCases/PullUpcomingEventsUseCaseTests.cs @@ -30,8 +30,8 @@ public sealed class PullUpcomingEventsUseCaseTests _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); _eventRepo.GetAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); - _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) - .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg())); + _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) + .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg().Id)); var sut = CreateSut(); @@ -63,8 +63,8 @@ public sealed class PullUpcomingEventsUseCaseTests _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns((Event?)null); _eventRepo.GetAsync(ev3.Id, Arg.Any()).Returns((Event?)null); - _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) - .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg())); + _scraper.ScrapeEventOddsAsync(Arg.Any(), OddsSource.PreMatch, Arg.Any()) + .Returns(ci => TestFixtures.MakeSnapshot(ci.Arg().Id)); var sut = CreateSut(); @@ -91,9 +91,11 @@ public sealed class PullUpcomingEventsUseCaseTests _scraper.ScrapeUpcomingAsync(null, Arg.Any()).Returns(events); _eventRepo.GetAsync(Arg.Any(), Arg.Any()).Returns((Event?)null); - _scraper.ScrapeEventOddsAsync(ev1.Id, OddsSource.PreMatch, Arg.Any()) + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == ev1.Id), OddsSource.PreMatch, Arg.Any()) .ThrowsAsync(new HttpRequestException("site down")); - _scraper.ScrapeEventOddsAsync(ev2.Id, OddsSource.PreMatch, Arg.Any()) + _scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == ev2.Id), OddsSource.PreMatch, Arg.Any()) .Returns(TestFixtures.MakeSnapshot(ev2.Id)); var sut = CreateSut(); diff --git a/tests/Marathon.Domain.Tests/Entities/EventTests.cs b/tests/Marathon.Domain.Tests/Entities/EventTests.cs index be69bc4..668515a 100644 --- a/tests/Marathon.Domain.Tests/Entities/EventTests.cs +++ b/tests/Marathon.Domain.Tests/Entities/EventTests.cs @@ -121,8 +121,18 @@ public sealed class EventTests public void Event_IsImmutable_NoSettablePublicProperties() { var eventType = typeof(Event); + + // Init-only setters (`init`) are immutable from a runtime perspective + // — they can only be assigned during object initialization, not later. + // The CLR encodes them with an `IsExternalInit` required custom modifier + // on the setter's return parameter. + static bool IsInitOnly(System.Reflection.MethodInfo setter) => + setter.ReturnParameter + .GetRequiredCustomModifiers() + .Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit"); + var settableProperties = eventType.GetProperties() - .Where(p => p.CanWrite && p.GetSetMethod(nonPublic: false) is not null) + .Where(p => p.CanWrite && p.GetSetMethod(nonPublic: false) is { } setter && !IsInitOnly(setter)) .ToList(); settableProperties.Should().BeEmpty("Event must be immutable."); diff --git a/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs b/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs index 8efceb6..15cdb9a 100644 --- a/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs +++ b/tests/Marathon.Infrastructure.Tests/Workers/LiveOddsPollerTests.cs @@ -78,12 +78,13 @@ public sealed class LiveOddsPollerTests var eventRepo = Substitute.For(); var snapshotRepo = Substitute.For(); - // ScrapeUpcomingAsync called by use case internally - scraper.ScrapeUpcomingAsync(null, Arg.Any()) - .Returns(Array.Empty()); - eventRepo.ListAsync(Arg.Any()) + // Use case discovers live events via ScrapeLiveAsync (NOT ListAsync) + scraper.ScrapeLiveAsync(Arg.Any()) .Returns(new List { ev }.AsReadOnly()); - scraper.ScrapeEventOddsAsync(eventId, OddsSource.Live, Arg.Any()) + eventRepo.GetAsync(eventId, Arg.Any()) + .Returns(ev); + scraper.ScrapeEventOddsAsync( + Arg.Is(e => e.Id == eventId), OddsSource.Live, Arg.Any()) .Returns(MakeSnapshot(eventId)); var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); @@ -122,7 +123,7 @@ public sealed class LiveOddsPollerTests // Assert — no snapshot attempts while disabled await snapshotRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); - await eventRepo.DidNotReceive().ListAsync(Arg.Any()); + await scraper.DidNotReceive().ScrapeLiveAsync(Arg.Any()); } [Fact] @@ -133,10 +134,11 @@ public sealed class LiveOddsPollerTests var eventRepo = Substitute.For(); var snapshotRepo = Substitute.For(); - scraper.ScrapeUpcomingAsync(null, Arg.Any()) - .Returns(Array.Empty()); - eventRepo.ListAsync(Arg.Any()) - .ThrowsAsync(new InvalidOperationException("DB unavailable")); + // Use case calls ScrapeLiveAsync first; if it throws, the cycle returns 0 + // without hitting the repo. To exercise the "poller survives failures" + // path, make ScrapeLiveAsync itself throw. + scraper.ScrapeLiveAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("listing unavailable")); var sp = BuildServiceProvider(scraper, eventRepo, snapshotRepo); var opts = BuildOptions(enabled: true, intervalSeconds: 0); @@ -153,7 +155,9 @@ public sealed class LiveOddsPollerTests // Assert — StopAsync must not propagate the exception await stopAct.Should().NotThrowAsync(); - // DB was hit multiple times (poller didn't give up after first failure) - await eventRepo.Received().ListAsync(Arg.Any()); + // Scraper was hit at least once (poller cycled at least one iteration). + // The use case swallows ScrapeLiveAsync exceptions and returns 0, so the + // poller's catch block is not triggered — but it still cycles. + await scraper.Received().ScrapeLiveAsync(Arg.Any()); } }