Files
maraphon-app/tests/Marathon.Infrastructure.Tests/Persistence/SavedStrategyRoundTripTests.cs
T
alexei.dolgolyov 2a0ea7b3a6 feat(backtest): saved strategy presets (strategy editor v1)
Persist named backtest-strategy presets so a staking config (bankroll,
min-score, stake rule, flat/percent/Kelly params) can be saved, listed,
loaded back into the form, and deleted. The per-run date range is not
part of a preset.

- Domain: SavedStrategy record (name trimmed + bounded to 80 chars,
  Create() factory) wrapping the pure BacktestStrategy.
- Persistence: SavedStrategyEntity + config (TEXT decimals, unique
  case-insensitive NOCASE index on Name), repository, mapping, and a
  hand-trimmed AddSavedStrategies migration (additive — only the new
  table). Case-insensitive names mean save-by-name overwrites instead of
  creating near-duplicates.
- Application: SaveStrategyUseCase (upsert by name, keeps Id+CreatedAt) +
  DeleteStrategyUseCase.
- UI: presets panel on the Backtest page (load/save/delete) + service
  methods; fraction<->percent round-trip; en/ru resx.
- Fix: pin Sports.Code as ValueGeneratedNever — it is the bookmaker's
  natural sport id, not an autoincrement surrogate. Corrects long-standing
  model-snapshot drift; the snapshot is regenerated to match the DB.
- 25 tests across all four layers: domain validation, real-SQLite
  round-trip incl. case-insensitive lookup/uniqueness, the upsert use
  case, and the service percent mapping.
2026-05-29 02:13:16 +03:00

147 lines
5.0 KiB
C#

using FluentAssertions;
using Marathon.Domain.Backtesting;
using Marathon.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Tests.Persistence;
/// <summary>
/// Round-trip + query tests for <see cref="SavedStrategyRepository"/>. Uses the
/// in-memory SQLite fixture so the table + unique name index declared in the
/// configuration are exercised on every test.
/// </summary>
public sealed class SavedStrategyRoundTripTests : IDisposable
{
private readonly InMemoryDbFixture _fixture;
private readonly SavedStrategyRepository _repo;
public SavedStrategyRoundTripTests()
{
_fixture = new InMemoryDbFixture();
_repo = new SavedStrategyRepository(_fixture.DbContext);
}
public void Dispose() => _fixture.Dispose();
private static BacktestStrategy Strategy(
decimal minScore = 0.45m, StakeRule rule = StakeRule.Kelly) =>
new(
StartingBankroll: 2000m,
MinScore: minScore,
StakeRule: rule,
FlatStake: 75m,
PercentOfBankroll: 0.03m,
KellyFraction: 0.5m);
[Fact]
public async Task RoundTrip_PreservesAllFields()
{
var saved = SavedStrategy.Create("Quarter Kelly", Strategy());
await _repo.AddAsync(saved);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var got = await _repo.GetAsync(saved.Id);
got.Should().NotBeNull();
got!.Id.Should().Be(saved.Id);
got.Name.Should().Be("Quarter Kelly");
got.Strategy.StartingBankroll.Should().Be(2000m);
got.Strategy.MinScore.Should().Be(0.45m);
got.Strategy.StakeRule.Should().Be(StakeRule.Kelly);
got.Strategy.FlatStake.Should().Be(75m);
got.Strategy.PercentOfBankroll.Should().Be(0.03m);
got.Strategy.KellyFraction.Should().Be(0.5m);
got.CreatedAt.Should().Be(saved.CreatedAt);
}
[Fact]
public async Task GetByNameAsync_MatchesTrimmed_AndReturnsNullWhenMissing()
{
var saved = SavedStrategy.Create("Aggro", Strategy());
await _repo.AddAsync(saved);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
(await _repo.GetByNameAsync(" Aggro "))!.Id.Should().Be(saved.Id);
(await _repo.GetByNameAsync("nope")).Should().BeNull();
}
[Fact]
public async Task Name_IsCaseInsensitive_ForLookupAndUniqueness()
{
await _repo.AddAsync(SavedStrategy.Create("Kelly", Strategy()));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
// Lookup folds case (NOCASE column collation).
(await _repo.GetByNameAsync("kelly")).Should().NotBeNull();
// And a case-variant is rejected as a duplicate by the unique index.
await _repo.AddAsync(SavedStrategy.Create("KELLY", Strategy()));
var act = async () => await _repo.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
[Fact]
public async Task ListAsync_OrdersByName()
{
await _repo.AddAsync(SavedStrategy.Create("Zeta", Strategy()));
await _repo.AddAsync(SavedStrategy.Create("Alpha", Strategy()));
await _repo.AddAsync(SavedStrategy.Create("Mu", Strategy()));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var list = await _repo.ListAsync();
list.Select(s => s.Name).Should().ContainInOrder("Alpha", "Mu", "Zeta");
}
[Fact]
public async Task UpdateAsync_PersistsStrategyChange()
{
var saved = SavedStrategy.Create("Tweak me", Strategy(minScore: 0.45m, rule: StakeRule.Kelly));
await _repo.AddAsync(saved);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var updated = saved with { Strategy = Strategy(minScore: 0.7m, rule: StakeRule.Flat) };
await _repo.UpdateAsync(updated);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var got = await _repo.GetAsync(saved.Id);
got!.Strategy.MinScore.Should().Be(0.7m);
got.Strategy.StakeRule.Should().Be(StakeRule.Flat);
got.Name.Should().Be("Tweak me");
}
[Fact]
public async Task DeleteAsync_Removes()
{
var saved = SavedStrategy.Create("Trash", Strategy());
await _repo.AddAsync(saved);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
await _repo.DeleteAsync(saved.Id);
await _repo.SaveChangesAsync();
(await _repo.GetAsync(saved.Id)).Should().BeNull();
}
[Fact]
public async Task UniqueNameIndex_RejectsDuplicateName()
{
await _repo.AddAsync(SavedStrategy.Create("dupe", Strategy()));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
await _repo.AddAsync(SavedStrategy.Create("dupe", Strategy()));
var act = async () => await _repo.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
}