feat(application): commands/queries + IChargeableCharConfigService (PRC-001)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — ChargeableCharConfigService tests.
|
||||
/// Covers: per-medio wins over global, global fallback when no per-medio,
|
||||
/// empty result when no config at all.
|
||||
/// </summary>
|
||||
public class ChargeableCharConfigServiceTests
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>());
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
GlobalConfig("!", 2.0m),
|
||||
});
|
||||
|
||||
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||
|
||||
result.Should().ContainKey("!");
|
||||
result["!"].PricePerUnit.Should().Be(2.0m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Validator tests for CreateChargeableCharConfigCommand.
|
||||
/// Covers: Symbol length, Category enum, PricePerUnit > 0, ValidFrom >= today_AR.
|
||||
/// </summary>
|
||||
public class CreateChargeableCharConfigCommandValidatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly CreateChargeableCharConfigCommandValidator _validator;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
private static readonly DateOnly Yesterday = new(2026, 4, 19);
|
||||
private static readonly DateOnly Tomorrow = new(2026, 4, 21);
|
||||
|
||||
public CreateChargeableCharConfigCommandValidatorTests()
|
||||
{
|
||||
_validator = new CreateChargeableCharConfigCommandValidator(_time);
|
||||
}
|
||||
|
||||
private static CreateChargeableCharConfigCommand ValidCmd() => new(
|
||||
MedioId: null,
|
||||
Symbol: "$",
|
||||
Category: ChargeableCharCategories.Currency,
|
||||
PricePerUnit: 1.0m,
|
||||
ValidFrom: Today);
|
||||
|
||||
// ── Symbol ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Symbol_Empty_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { Symbol = "" };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Symbol_TooLong_FailsValidation()
|
||||
{
|
||||
// max 4 chars
|
||||
var cmd = ValidCmd() with { Symbol = "ABCDE" };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Symbol_SingleChar_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { Symbol = "$" };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Symbol_FourChars_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { Symbol = "ABCD" };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
|
||||
}
|
||||
|
||||
// ── Category ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Category_Invalid_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { Category = "Unknown" };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_Empty_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { Category = "" };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Currency")]
|
||||
[InlineData("Percentage")]
|
||||
[InlineData("Exclamation")]
|
||||
[InlineData("Question")]
|
||||
[InlineData("Other")]
|
||||
public void Category_ValidValues_Pass(string category)
|
||||
{
|
||||
var cmd = ValidCmd() with { Category = category };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Category);
|
||||
}
|
||||
|
||||
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Zero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Negative_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { PricePerUnit = -1m };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Positive_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { PricePerUnit = 0.01m };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ValidFrom_InPast_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { ValidFrom = Yesterday };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidFrom_Today_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { ValidFrom = Today };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidFrom_Future_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { ValidFrom = Tomorrow };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||
}
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ValidCommand_PassesAllRules()
|
||||
{
|
||||
_validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Transactions;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — CreateChargeableCharConfigCommandHandler tests.
|
||||
/// Covers: happy path, audit emit, audit fail → rollback, validator chain.
|
||||
/// NSubstitute + FakeTimeProvider.
|
||||
/// </summary>
|
||||
public class CreateChargeableCharConfigHandlerTests
|
||||
{
|
||||
// Hoy en ART: 2026-04-20T12:00:00 UTC → 2026-04-20T09:00:00 ART
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly CreateChargeableCharConfigCommandHandler _handler;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
private static readonly DateOnly Tomorrow = new(2026, 4, 21);
|
||||
|
||||
public CreateChargeableCharConfigHandlerTests()
|
||||
{
|
||||
_repo.InsertWithCloseAsync(
|
||||
Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns(42L);
|
||||
|
||||
_handler = new CreateChargeableCharConfigCommandHandler(_repo, _audit, _time);
|
||||
}
|
||||
|
||||
private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
|
||||
MedioId: null,
|
||||
Symbol: "$",
|
||||
Category: ChargeableCharCategories.Currency,
|
||||
PricePerUnit: 1.5m,
|
||||
ValidFrom: validFrom ?? Today);
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsCreateResponse()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(42L);
|
||||
result.Symbol.Should().Be("$");
|
||||
result.PricePerUnit.Should().Be(1.5m);
|
||||
result.ValidFrom.Should().Be(Today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsInsertWithCloseAsync()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).InsertWithCloseAsync(
|
||||
null, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_EmitsAuditEvent()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "tasacion.chargeable_char.create",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: "42",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithMedioId_PassesMedioIdToRepo()
|
||||
{
|
||||
var cmd = ValidCmd() with { MedioId = 7 };
|
||||
|
||||
await _handler.Handle(cmd);
|
||||
|
||||
await _repo.Received(1).InsertWithCloseAsync(
|
||||
7L, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FutureDateValidFrom_Succeeds()
|
||||
{
|
||||
var cmd = ValidCmd(validFrom: Tomorrow);
|
||||
|
||||
var result = await _handler.Handle(cmd);
|
||||
|
||||
result.ValidFrom.Should().Be(Tomorrow);
|
||||
}
|
||||
|
||||
// ── Audit fail → rollback ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_ExceptionPropagates()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit DB error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("Audit DB error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_RepoWasCalled_ButTransactionNotCompleted()
|
||||
{
|
||||
// Audit fail is fail-closed: the TransactionScope was NOT completed
|
||||
// (we can observe the exception propagating; if TX were committed, no exception would reach caller)
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit DB error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
// Repo was called (within the TX) but TX never completed
|
||||
await _repo.Received(1).InsertWithCloseAsync(
|
||||
Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — DeactivateChargeableCharConfigCommandHandler tests.
|
||||
/// Covers: happy path, not-found, audit emit, audit fail → rollback.
|
||||
/// </summary>
|
||||
public class DeactivateChargeableCharConfigHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly DeactivateChargeableCharConfigCommandHandler _handler;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
|
||||
private static ChargeableCharConfig ActiveConfig() =>
|
||||
ChargeableCharConfig.Rehydrate(1L, null, "$", ChargeableCharCategories.Currency, 1.0m, Today, null, true);
|
||||
|
||||
public DeactivateChargeableCharConfigHandlerTests()
|
||||
{
|
||||
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||
.Returns(ActiveConfig());
|
||||
|
||||
_handler = new DeactivateChargeableCharConfigCommandHandler(_repo, _audit, _time);
|
||||
}
|
||||
|
||||
private static DeactivateChargeableCharConfigCommand ValidCmd() => new(Id: 1L);
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsDeactivateResponse()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1L);
|
||||
result.ValidTo.Should().Be(Today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsDeactivateAsync()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).DeactivateAsync(1L, Today, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_EmitsAuditDeactivate()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "tasacion.chargeable_char.deactivate",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: "1",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Not found ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99L, Arg.Any<CancellationToken>())
|
||||
.Returns((ChargeableCharConfig?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd() with { Id = 99L });
|
||||
|
||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
// ── Audit fail → rollback ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_ExceptionPropagates()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("Audit error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_DeactivateWasCalled_TransactionNotCompleted()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
// Repo.DeactivateAsync was called but TX not completed (exception propagated)
|
||||
await _repo.Received(1).DeactivateAsync(1L, Today, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — GetChargeableCharConfigByIdQueryHandler tests.
|
||||
/// Covers: found → returns DTO, not-found → returns null.
|
||||
/// </summary>
|
||||
public class GetChargeableCharConfigByIdHandlerTests
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly GetChargeableCharConfigByIdQueryHandler _handler;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
|
||||
public GetChargeableCharConfigByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetChargeableCharConfigByIdQueryHandler(_repo);
|
||||
}
|
||||
|
||||
private static ChargeableCharConfig MakeConfig(long id) =>
|
||||
ChargeableCharConfig.Rehydrate(id, null, "$", ChargeableCharCategories.Currency, 1.0m, Today, null, true);
|
||||
|
||||
// ── Found ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Found_ReturnsDto()
|
||||
{
|
||||
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||
.Returns(MakeConfig(1L));
|
||||
|
||||
var result = await _handler.Handle(new GetChargeableCharConfigByIdQuery(1L));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(1L);
|
||||
result.Symbol.Should().Be("$");
|
||||
result.PricePerUnit.Should().Be(1.0m);
|
||||
result.ValidFrom.Should().Be(Today);
|
||||
result.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── Not found ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ReturnsNull()
|
||||
{
|
||||
_repo.GetByIdAsync(99L, Arg.Any<CancellationToken>())
|
||||
.Returns((ChargeableCharConfig?)null);
|
||||
|
||||
var result = await _handler.Handle(new GetChargeableCharConfigByIdQuery(99L));
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — ListChargeableCharConfigQueryHandler tests.
|
||||
/// Covers: happy path with items, empty page, projection to DTO, pagination metadata.
|
||||
/// </summary>
|
||||
public class ListChargeableCharConfigHandlerTests
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly ListChargeableCharConfigQueryHandler _handler;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
|
||||
private static ChargeableCharConfig MakeConfig(long id, string symbol, decimal price) =>
|
||||
ChargeableCharConfig.Rehydrate(id, null, symbol, ChargeableCharCategories.Currency, price, Today, null, true);
|
||||
|
||||
public ListChargeableCharConfigHandlerTests()
|
||||
{
|
||||
_handler = new ListChargeableCharConfigQueryHandler(_repo);
|
||||
}
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithItems_ReturnsPagedDtos()
|
||||
{
|
||||
var items = new List<ChargeableCharConfig>
|
||||
{
|
||||
MakeConfig(1, "$", 1.0m),
|
||||
MakeConfig(2, "%", 0.5m),
|
||||
};
|
||||
|
||||
_repo.ListAsync(null, true, 0, 20, Arg.Any<CancellationToken>())
|
||||
.Returns(items);
|
||||
_repo.CountAsync(null, true, Arg.Any<CancellationToken>())
|
||||
.Returns(2);
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(MedioId: null, ActiveOnly: true, Page: 1, PageSize: 20);
|
||||
var result = await _handler.Handle(query);
|
||||
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Total.Should().Be(2);
|
||||
result.Page.Should().Be(1);
|
||||
result.PageSize.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithItems_ProjectsToDto()
|
||||
{
|
||||
var items = new List<ChargeableCharConfig> { MakeConfig(5, "$", 1.5m) };
|
||||
|
||||
_repo.ListAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(items);
|
||||
_repo.CountAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(1);
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(null, true, 1, 20);
|
||||
var result = await _handler.Handle(query);
|
||||
|
||||
var dto = result.Items[0];
|
||||
dto.Id.Should().Be(5);
|
||||
dto.Symbol.Should().Be("$");
|
||||
dto.PricePerUnit.Should().Be(1.5m);
|
||||
dto.ValidFrom.Should().Be(Today);
|
||||
dto.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyPage_ReturnsEmptyList()
|
||||
{
|
||||
_repo.ListAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>());
|
||||
_repo.CountAsync(Arg.Any<long?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(null, true, 1, 20);
|
||||
var result = await _handler.Handle(query);
|
||||
|
||||
result.Items.Should().BeEmpty();
|
||||
result.Total.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SkipIsComputed_FromPageAndPageSize()
|
||||
{
|
||||
// Page 3, PageSize 10 → skip = 20
|
||||
_repo.ListAsync(null, false, 20, 10, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>());
|
||||
_repo.CountAsync(null, false, Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(null, false, 3, 10);
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).ListAsync(null, false, 20, 10, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FiltersByMedioId_WhenProvided()
|
||||
{
|
||||
_repo.ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>());
|
||||
_repo.CountAsync(7L, true, Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(7L, true, 1, 20);
|
||||
await _handler.Handle(query);
|
||||
|
||||
await _repo.Received(1).ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Validator tests for SchedulePriceChangeCommand.
|
||||
/// Covers: PricePerUnit > 0, ValidFrom >= today_AR.
|
||||
/// Forward-only check (ValidFrom > existing.ValidFrom) is done in handler, not validator.
|
||||
/// </summary>
|
||||
public class SchedulePriceChangeCommandValidatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly SchedulePriceChangeCommandValidator _validator;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
private static readonly DateOnly Yesterday = new(2026, 4, 19);
|
||||
private static readonly DateOnly Tomorrow = new(2026, 4, 21);
|
||||
|
||||
public SchedulePriceChangeCommandValidatorTests()
|
||||
{
|
||||
_validator = new SchedulePriceChangeCommandValidator(_time);
|
||||
}
|
||||
|
||||
private static SchedulePriceChangeCommand ValidCmd() => new(
|
||||
Id: 1L,
|
||||
PricePerUnit: 2.0m,
|
||||
ValidFrom: Tomorrow);
|
||||
|
||||
// ── PricePerUnit ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Zero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { PricePerUnit = 0m };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Negative_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { PricePerUnit = -1m };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PricePerUnit_Positive_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { PricePerUnit = 0.01m };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
|
||||
}
|
||||
|
||||
// ── ValidFrom ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ValidFrom_InPast_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { ValidFrom = Yesterday };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidFrom_Today_Passes()
|
||||
{
|
||||
// today is valid — forward-only check vs existing row is done in handler
|
||||
var cmd = ValidCmd() with { ValidFrom = Today };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidFrom_Future_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { ValidFrom = Tomorrow };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
|
||||
}
|
||||
|
||||
// ── Id ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Id_Zero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCmd() with { Id = 0L };
|
||||
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Id_Positive_Passes()
|
||||
{
|
||||
var cmd = ValidCmd() with { Id = 1L };
|
||||
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Id);
|
||||
}
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ValidCommand_PassesAllRules()
|
||||
{
|
||||
_validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
using SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — SchedulePriceChangeCommandHandler tests.
|
||||
/// Covers: happy path, forward-only validation, audit emit, audit fail → rollback.
|
||||
/// </summary>
|
||||
public class SchedulePriceChangeHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly SchedulePriceChangeCommandHandler _handler;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
private static readonly DateOnly NextMonth = new(2026, 5, 1);
|
||||
|
||||
private static ChargeableCharConfig ExistingConfig(DateOnly validFrom) =>
|
||||
ChargeableCharConfig.Rehydrate(1L, null, "$", ChargeableCharCategories.Currency, 1.0m, validFrom, null, true);
|
||||
|
||||
public SchedulePriceChangeHandlerTests()
|
||||
{
|
||||
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||
.Returns(ExistingConfig(Today));
|
||||
|
||||
_repo.InsertWithCloseAsync(
|
||||
Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
|
||||
.Returns(99L);
|
||||
|
||||
_handler = new SchedulePriceChangeCommandHandler(_repo, _audit, _time);
|
||||
}
|
||||
|
||||
private static SchedulePriceChangeCommand ValidCmd() => new(
|
||||
Id: 1L,
|
||||
PricePerUnit: 2.5m,
|
||||
ValidFrom: NextMonth);
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsScheduleResponse()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.NewId.Should().Be(99L);
|
||||
result.PreviousValidFrom.Should().Be(Today);
|
||||
result.NewValidFrom.Should().Be(NextMonth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsInsertWithCloseAsync()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).InsertWithCloseAsync(
|
||||
null, "$", ChargeableCharCategories.Currency, 2.5m, NextMonth, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_EmitsAuditPriceChange()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "tasacion.chargeable_char.price_change",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: "99",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Not found ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99L, Arg.Any<CancellationToken>())
|
||||
.Returns((ChargeableCharConfig?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd() with { Id = 99L });
|
||||
|
||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
// ── Forward-only enforcement ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidFromNotGreaterThanCurrent_ThrowsForwardOnlyException()
|
||||
{
|
||||
// The existing config has ValidFrom = Today; scheduling for Today is not > Today
|
||||
var cmd = ValidCmd() with { ValidFrom = Today };
|
||||
|
||||
var act = async () => await _handler.Handle(cmd);
|
||||
|
||||
await act.Should().ThrowAsync<ChargeableCharConfigForwardOnlyException>();
|
||||
}
|
||||
|
||||
// ── Audit fail → rollback ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_ExceptionPropagates()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("Audit error");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user