diff --git a/src/Marathon.Application/UseCases/UpdatePlacedBetUseCase.cs b/src/Marathon.Application/UseCases/UpdatePlacedBetUseCase.cs index 593bb95..3049c48 100644 --- a/src/Marathon.Application/UseCases/UpdatePlacedBetUseCase.cs +++ b/src/Marathon.Application/UseCases/UpdatePlacedBetUseCase.cs @@ -55,23 +55,33 @@ public sealed class UpdatePlacedBetUseCase "The event must already be present in the scrape store."); } - // Preserve the original entry time; re-grade from Pending so a changed - // selection/event settles against the current result. + // Only the selection or the event affects grading. When neither changed (e.g. a + // stake/notes-only edit) keep the existing outcome — re-grading from Pending here + // would SILENTLY UN-SETTLE a won/lost bet whose result row has since been pruned by + // snapshot retention (the journal is FK-free and outlives result pruning). A + // still-Pending bet is always (re)graded, mirroring RecordPlacedBetUseCase. + var gradingInputChanged = !existing.EventId.Equals(eventId) + || !existing.Selection.Equals(selection); + var regrade = gradingInputChanged || existing.Outcome == BetOutcome.Pending; + var toPersist = new PlacedBet( Id: id, EventId: eventId, Selection: selection, Stake: stake, PlacedAt: existing.PlacedAt, - Outcome: BetOutcome.Pending, + Outcome: regrade ? BetOutcome.Pending : existing.Outcome, Notes: notes); - var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false); - if (result is not null) + if (regrade) { - var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result); - if (graded is not null) - toPersist = toPersist.WithOutcome(graded.Value); + var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false); + if (result is not null) + { + var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result); + if (graded is not null) + toPersist = toPersist.WithOutcome(graded.Value); + } } await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false); diff --git a/tests/Marathon.Application.Tests/UseCases/UpdatePlacedBetUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/UpdatePlacedBetUseCaseTests.cs index 9b9edcd..a22fc6b 100644 --- a/tests/Marathon.Application.Tests/UseCases/UpdatePlacedBetUseCaseTests.cs +++ b/tests/Marathon.Application.Tests/UseCases/UpdatePlacedBetUseCaseTests.cs @@ -82,4 +82,40 @@ public sealed class UpdatePlacedBetUseCaseTests Arg.Any()); await _bets.Received(1).SaveChangesAsync(Arg.Any()); } + + [Fact] + public async Task NotesOnlyEdit_PreservesSettledOutcome_EvenWhenResultPruned() + { + var id = Guid.NewGuid(); + var eid = new EventId("evt-1"); + var sameSelection = Selection(side: Side.Side1, rate: 2.0m); + var settledWon = new PlacedBet(id, eid, sameSelection, 50m, PlacedAt, BetOutcome.Won, "old"); + _bets.GetAsync(id, Arg.Any()).Returns(settledWon); + _events.GetAsync(eid, Arg.Any()).Returns(Ev(eid)); + // Result row has been pruned by retention. + _results.GetAsync(eid, Arg.Any()).Returns((EventResult?)null); + + // Same selection/event — only the note changes. + var updated = await CreateSut().ExecuteAsync(id, eid, sameSelection, stake: 50m, notes: "new note"); + + updated.Outcome.Should().Be(BetOutcome.Won); // NOT silently reset to Pending + updated.Notes.Should().Be("new note"); + } + + [Fact] + public async Task ChangedSelection_WithNoResult_ResetsToPending() + { + var id = Guid.NewGuid(); + var eid = new EventId("evt-1"); + var settledWon = new PlacedBet(id, eid, Selection(side: Side.Side1, rate: 2.0m), 50m, PlacedAt, BetOutcome.Won, "old"); + _bets.GetAsync(id, Arg.Any()).Returns(settledWon); + _events.GetAsync(eid, Arg.Any()).Returns(Ev(eid)); + _results.GetAsync(eid, Arg.Any()).Returns((EventResult?)null); + + // Selection changed (Side1 → Side2) but no result to grade against → must be Pending, + // never the now-stale Won. + var updated = await CreateSut().ExecuteAsync(id, eid, Selection(side: Side.Side2, rate: 3.0m), stake: 50m, notes: "old"); + + updated.Outcome.Should().Be(BetOutcome.Pending); + } }