feat: implement Phase 1 — solution skeleton and domain model

Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain
fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue,
BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and
entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult,
Anomaly) with all invariants enforced in constructors. 96 domain tests pass
(FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props
centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx
are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.
This commit is contained in:
2026-05-05 01:20:28 +03:00
parent e4b03f42ef
commit 61114ea31b
60 changed files with 1845 additions and 19 deletions
@@ -0,0 +1,170 @@
using FluentAssertions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.Entities;
public sealed class BetTests
{
private static readonly BetScope MatchScope = Marathon.Domain.ValueObjects.MatchScope.Instance;
private static readonly OddsRate SampleRate = new(1.85m);
private static readonly OddsValue SampleValue = new(3.5m);
// ── Win bets ──────────────────────────────────────────────────────────────
[Theory]
[InlineData(Side.Side1)]
[InlineData(Side.Side2)]
public void Constructor_CreatesWinBet_WhenSideIs1Or2AndValueIsNull(Side side)
{
var bet = new Bet(MatchScope, BetType.Win, side, null, SampleRate);
bet.Type.Should().Be(BetType.Win);
bet.Side.Should().Be(side);
bet.Value.Should().BeNull();
}
[Theory]
[InlineData(Side.Draw)]
[InlineData(Side.Less)]
[InlineData(Side.More)]
public void Constructor_ThrowsArgumentException_WhenWinBetHasInvalidSide(Side side)
{
var act = () => new Bet(MatchScope, BetType.Win, side, null, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("side");
}
[Fact]
public void Constructor_ThrowsArgumentException_WhenWinBetHasNonNullValue()
{
var act = () => new Bet(MatchScope, BetType.Win, Side.Side1, SampleValue, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("value");
}
// ── Draw bets ─────────────────────────────────────────────────────────────
[Fact]
public void Constructor_CreatesDrawBet_WhenSideIsDrawAndValueIsNull()
{
var bet = new Bet(MatchScope, BetType.Draw, Side.Draw, null, SampleRate);
bet.Type.Should().Be(BetType.Draw);
bet.Side.Should().Be(Side.Draw);
bet.Value.Should().BeNull();
}
[Theory]
[InlineData(Side.Side1)]
[InlineData(Side.Side2)]
[InlineData(Side.Less)]
[InlineData(Side.More)]
public void Constructor_ThrowsArgumentException_WhenDrawBetHasInvalidSide(Side side)
{
var act = () => new Bet(MatchScope, BetType.Draw, side, null, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("side");
}
[Fact]
public void Constructor_ThrowsArgumentException_WhenDrawBetHasNonNullValue()
{
var act = () => new Bet(MatchScope, BetType.Draw, Side.Draw, SampleValue, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("value");
}
// ── WinFora bets ──────────────────────────────────────────────────────────
[Theory]
[InlineData(Side.Side1)]
[InlineData(Side.Side2)]
public void Constructor_CreatesWinForaBet_WhenSideIs1Or2AndValueIsNonNull(Side side)
{
var bet = new Bet(MatchScope, BetType.WinFora, side, SampleValue, SampleRate);
bet.Type.Should().Be(BetType.WinFora);
bet.Value.Should().NotBeNull();
}
[Fact]
public void Constructor_ThrowsArgumentException_WhenWinForaBetHasNullValue()
{
var act = () => new Bet(MatchScope, BetType.WinFora, Side.Side1, null, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("value");
}
[Theory]
[InlineData(Side.Draw)]
[InlineData(Side.Less)]
[InlineData(Side.More)]
public void Constructor_ThrowsArgumentException_WhenWinForaBetHasInvalidSide(Side side)
{
var act = () => new Bet(MatchScope, BetType.WinFora, side, SampleValue, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("side");
}
// ── Total bets ────────────────────────────────────────────────────────────
[Theory]
[InlineData(Side.Less)]
[InlineData(Side.More)]
public void Constructor_CreatesTotalBet_WhenSideIsLessOrMoreAndValueIsNonNull(Side side)
{
var bet = new Bet(MatchScope, BetType.Total, side, SampleValue, SampleRate);
bet.Type.Should().Be(BetType.Total);
bet.Value.Should().NotBeNull();
}
[Fact]
public void Constructor_ThrowsArgumentException_WhenTotalBetHasNullValue()
{
var act = () => new Bet(MatchScope, BetType.Total, Side.Less, null, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("value");
}
[Theory]
[InlineData(Side.Side1)]
[InlineData(Side.Side2)]
[InlineData(Side.Draw)]
public void Constructor_ThrowsArgumentException_WhenTotalBetHasInvalidSide(Side side)
{
var act = () => new Bet(MatchScope, BetType.Total, side, SampleValue, SampleRate);
act.Should().Throw<ArgumentException>()
.WithParameterName("side");
}
// ── Null guards ───────────────────────────────────────────────────────────
[Fact]
public void Constructor_ThrowsArgumentNullException_WhenScopeIsNull()
{
var act = () => new Bet(null!, BetType.Win, Side.Side1, null, SampleRate);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("scope");
}
[Fact]
public void Constructor_ThrowsArgumentNullException_WhenRateIsNull()
{
var act = () => new Bet(MatchScope, BetType.Win, Side.Side1, null, null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("rate");
}
// ── Immutability ──────────────────────────────────────────────────────────
[Fact]
public void Bet_IsImmutable_NoSettablePublicProperties()
{
var betType = typeof(Bet);
var settableProperties = betType.GetProperties()
.Where(p => p.CanWrite && p.GetSetMethod(nonPublic: false) is not null)
.ToList();
settableProperties.Should().BeEmpty(
"Bet is a sealed record with only get-only properties — immutability is required.");
}
}