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());
}
}