From 41148a87a66d733f6ed39c0f6dbbc624a0f05445 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 13:32:19 +0300 Subject: [PATCH] fix(anomalies): skip orphan-event rows in the feed instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnomalyBrowsingService.TryProject fell back to `new SportCode(0)` when an anomaly's event was missing — but SportCode throws for 0, which would blow up the whole feed/dashboard for that row. Anomalies have an FK to events so it was dead in practice, but an orphaned row now degrades gracefully (skipped, like a row with unparseable evidence). Closes the flagged latent crash. - TryProject returns false when the event lookup misses; +1 test. --- .../Services/AnomalyBrowsingService.cs | 16 +++++++++------- .../Services/AnomalyBrowsingServiceTests.cs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Marathon.UI/Services/AnomalyBrowsingService.cs b/src/Marathon.UI/Services/AnomalyBrowsingService.cs index 30f3b5d..12bbc06 100644 --- a/src/Marathon.UI/Services/AnomalyBrowsingService.cs +++ b/src/Marathon.UI/Services/AnomalyBrowsingService.cs @@ -137,14 +137,16 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService var severity = AnomalySeverityRules.FromScore(anomaly.Score); - events.TryGetValue(anomaly.EventId, out var ev); + // Skip orphan anomalies whose event has been pruned — degrade gracefully rather + // than throwing on a sentinel SportCode(0). Anomalies have an FK to events so this + // is defensive; the feed already drops rows whose evidence won't parse. + if (!events.TryGetValue(anomaly.EventId, out var ev) || ev is null) + return false; - var sport = ev?.Sport ?? new SportCode(0); - var country = ev?.CountryCode ?? string.Empty; - var league = ev?.LeagueId ?? string.Empty; - var title = ev is not null - ? ev.Title - : anomaly.EventId.Value; + var sport = ev.Sport; + var country = ev.CountryCode; + var league = ev.LeagueId; + var title = ev.Title; var preSnap = ToSnapshot(dto.PreSuspension); var postSnap = ToSnapshot(dto.PostSuspension); diff --git a/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs b/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs index f1c2f3c..4594a42 100644 --- a/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs +++ b/tests/Marathon.UI.Tests/Services/AnomalyBrowsingServiceTests.cs @@ -100,4 +100,20 @@ public sealed class AnomalyBrowsingServiceTests items.Select(i => i.Score).Should().ContainInOrder(0.80m, 0.60m, 0.40m); } + + [Fact] + public async Task ListAsync_SkipsOrphanAnomaly_When_EventMissing() + { + _anomalies + .ListByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new[] { Make(AnomalyKind.SuspensionFlip) }); + // Event pruned → not in the lookup. Must not throw on a SportCode(0) fallback. + _events + .GetManyAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var items = await CreateSut().ListAsync(new AnomalyFilter(), CancellationToken.None); + + items.Should().BeEmpty(); + } }