42e62c1ed2
A stake/notes-only edit re-graded from Pending, which un-settled a won/lost bet once its result row had been pruned by snapshot retention (the journal is FK-free and outlives results). Now only re-grade when the selection or event actually changed, or the bet is still Pending — mirroring RecordPlacedBet.
122 lines
5.5 KiB
C#
122 lines
5.5 KiB
C#
using FluentAssertions;
|
|
using Marathon.Application.Abstractions;
|
|
using Marathon.Application.UseCases;
|
|
using Marathon.Domain.Entities;
|
|
using Marathon.Domain.Enums;
|
|
using Marathon.Domain.ValueObjects;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
|
|
namespace Marathon.Application.Tests.UseCases;
|
|
|
|
public sealed class UpdatePlacedBetUseCaseTests
|
|
{
|
|
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
|
private static readonly DateTimeOffset PlacedAt = new(2026, 5, 16, 12, 0, 0, Msk);
|
|
|
|
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
|
|
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
|
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
|
|
|
private UpdatePlacedBetUseCase CreateSut() =>
|
|
new(_bets, _events, _results, NullLogger<UpdatePlacedBetUseCase>.Instance);
|
|
|
|
private static Bet Selection(Side side = Side.Side1, decimal rate = 2.0m) =>
|
|
new(MatchScope.Instance, BetType.Win, side, null, new OddsRate(rate));
|
|
|
|
private static PlacedBet Existing(Guid id, EventId eventId) =>
|
|
new(id, eventId, Selection(rate: 1.5m), 50m, PlacedAt, BetOutcome.Pending, "old note");
|
|
|
|
private static Event Ev(EventId id) =>
|
|
new(id, new SportCode(11), "England", "L", "C", PlacedAt.AddDays(1), "Home", "Away");
|
|
|
|
[Fact]
|
|
public async Task Throws_When_BetUnknown()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns((PlacedBet?)null);
|
|
|
|
var act = async () => await CreateSut().ExecuteAsync(id, new EventId("e"), Selection(), 100m, null);
|
|
|
|
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*unknown bet*");
|
|
await _bets.DidNotReceive().UpdateAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Throws_When_EventUnknown()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var eid = new EventId("missing");
|
|
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(Existing(id, eid));
|
|
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
|
|
|
var act = async () => await CreateSut().ExecuteAsync(id, eid, Selection(), 100m, null);
|
|
|
|
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*unknown event*");
|
|
await _bets.DidNotReceive().UpdateAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Updates_PreservesPlacedAt_AppliesChanges_AndRegrades()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var eid = new EventId("evt-1");
|
|
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(Existing(id, eid));
|
|
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
|
// Side1 wins → the (new) Side1 selection re-grades to Won.
|
|
_results.GetAsync(eid, Arg.Any<CancellationToken>())
|
|
.Returns(new EventResult(eid, 2, 0, Side.Side1, PlacedAt.AddHours(3)));
|
|
|
|
var updated = await CreateSut().ExecuteAsync(
|
|
id, eid, Selection(side: Side.Side1, rate: 2.5m), stake: 120m, notes: "new note");
|
|
|
|
updated.Id.Should().Be(id);
|
|
updated.PlacedAt.Should().Be(PlacedAt); // original entry time preserved
|
|
updated.Stake.Should().Be(120m);
|
|
updated.Selection.Rate.Value.Should().Be(2.5m);
|
|
updated.Notes.Should().Be("new note");
|
|
updated.Outcome.Should().Be(BetOutcome.Won); // re-graded against the result
|
|
|
|
await _bets.Received(1).UpdateAsync(
|
|
Arg.Is<PlacedBet>(b => b.Id == id && b.Outcome == BetOutcome.Won && b.Stake == 120m),
|
|
Arg.Any<CancellationToken>());
|
|
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<CancellationToken>()).Returns(settledWon);
|
|
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
|
// Result row has been pruned by retention.
|
|
_results.GetAsync(eid, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns(settledWon);
|
|
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
|
_results.GetAsync(eid, Arg.Any<CancellationToken>()).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);
|
|
}
|
|
}
|