using FluentAssertions; using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Domain.Pricing.ChargeableChars; namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; /// /// PRC-001 — ChargeableCharConfigService tests. /// Covers: per-medio wins over global, global fallback when no per-medio, /// empty result when no config at all. /// public class ChargeableCharConfigServiceTests { private readonly IChargeableCharConfigRepository _repo = Substitute.For(); private readonly ChargeableCharConfigService _service; private static readonly DateOnly AsOf = new(2026, 4, 20); public ChargeableCharConfigServiceTests() { _service = new ChargeableCharConfigService(_repo); } private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) => ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) => ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true); // ── Global fallback ───────────────────────────────────────────────────────── [Fact] public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs() { _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) .Returns(new List { GlobalConfig("$", 1.0m), GlobalConfig("%", 0.5m), }); var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); result.Should().ContainKey("$"); result["$"].PricePerUnit.Should().Be(1.0m); result.Should().ContainKey("%"); } // ── Per-medio wins over global ─────────────────────────────────────────────── [Fact] public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol() { _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any()) .Returns(new List { GlobalConfig("$", 1.0m), // global price = 1.0 MedioConfig(20L, 5, "$", 3.0m), // per-medio price = 3.0 → wins GlobalConfig("%", 0.5m), // global only }); var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None); result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins result["%"].PricePerUnit.Should().Be(0.5m); // global only } [Fact] public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory() { _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any()) .Returns(new List { MedioConfig(20L, 5, "$", 3.0m), }); var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None); result["$"].Category.Should().Be(ChargeableCharCategories.Currency); } // ── Empty result ───────────────────────────────────────────────────────────── [Fact] public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary() { _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) .Returns(new List()); var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); result.Should().BeEmpty(); } // ── Key: Symbol, Value: snapshot ───────────────────────────────────────────── [Fact] public async Task GetActiveConfig_KeyIsSymbol() { _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any()) .Returns(new List { GlobalConfig("!", 2.0m), }); var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None); result.Should().ContainKey("!"); result["!"].PricePerUnit.Should().Be(2.0m); } }