feat(application): CRUD handlers + validators + DI de ProductType (PRD-001)

Create/Update/Deactivate handlers con TransactionScope + audit; validators FluentValidation;
DI wiring NullProductQueryRepository + 5 handlers; SqlTestFixture V017 + permiso count 25→26.
This commit is contained in:
2026-04-19 09:46:31 -03:00
parent 3c9e852379
commit 5c8f19bf39
19 changed files with 1103 additions and 6 deletions

View File

@@ -73,7 +73,7 @@ public class PermisoRepositoryTests : IAsyncLifetime
// ── ListAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task ListAsync_Returns25CanonicalSeeds()
public async Task ListAsync_Returns26CanonicalSeeds()
{
var list = await _repository.ListAsync();
@@ -81,8 +81,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar' = 25 total
Assert.Equal(25, list.Count);
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total
Assert.Equal(26, list.Count);
}
[Fact]

View File

@@ -173,16 +173,17 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
[Fact]
public async Task GetByRolCodigoAsync_Admin_Returns25Permisos()
public async Task GetByRolCodigoAsync_Admin_Returns26Permisos()
{
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' = 25 total
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(25, permisos.Count);
Assert.Equal(26, permisos.Count);
}
[Fact]

View File

@@ -0,0 +1,182 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.ProductTypes.Create;
public class CreateProductTypeCommandHandlerTests
{
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
private readonly CreateProductTypeCommandHandler _handler;
public CreateProductTypeCommandHandlerTests()
{
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(1);
_handler = new CreateProductTypeCommandHandler(_repo, _audit, _timeProvider);
}
private static CreateProductTypeCommand ValidCommand(bool allowImages = false) => new(
Nombre: "Clasificados",
HasDuration: true, RequiresText: true, RequiresCategory: false, IsBundle: false,
AllowImages: allowImages,
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ValidCommand_InsertsAndReturnsDto()
{
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(42);
var result = await _handler.Handle(ValidCommand());
result.Id.Should().Be(42);
result.Nombre.Should().Be("Clasificados");
}
[Fact]
public async Task Handle_ValidCommand_CallsAddAsync()
{
await _handler.Handle(ValidCommand());
await _repo.Received(1).AddAsync(
Arg.Is<ProductType>(pt => pt.Nombre == "Clasificados"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_SuccessfulInsert_LogsAuditEventProductoTipoCreated()
{
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(7);
await _handler.Handle(ValidCommand());
await _audit.Received(1).LogAsync(
action: "producto_tipo.created",
targetType: "ProductType",
targetId: "7",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PreservesFlagsAsProvided()
{
var cmd = ValidCommand() with { HasDuration = true, IsBundle = true };
await _handler.Handle(cmd);
await _repo.Received(1).AddAsync(
Arg.Is<ProductType>(pt => pt.HasDuration && pt.IsBundle),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AllowImagesTrue_WithAllMaxNull_PersistsAllNull()
{
var cmd = ValidCommand(allowImages: true);
await _handler.Handle(cmd);
await _repo.Received(1).AddAsync(
Arg.Is<ProductType>(pt => pt.AllowImages && pt.MaxImages == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UsesTimeProvider_NotDateTimeNow()
{
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
await _handler.Handle(ValidCommand());
await _repo.Received(1).AddAsync(
Arg.Is<ProductType>(pt => pt.FechaCreacion == expectedDate),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ReturnsCreatedDtoWithPersistedId()
{
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(99);
var result = await _handler.Handle(ValidCommand());
result.Id.Should().Be(99);
result.IsActive.Should().BeTrue();
}
// ── AllowImages=false normalization ───────────────────────────────────────
[Fact]
public async Task Handle_AllowImagesFalse_MaxImagesInput5_PersistsWithMaxImagesNull()
{
var cmd = ValidCommand() with { AllowImages = false, MaxImages = 5 };
await _handler.Handle(cmd);
await _repo.Received(1).AddAsync(
Arg.Is<ProductType>(pt => pt.AllowImages == false && pt.MaxImages == null),
Arg.Any<CancellationToken>());
}
// ── Nombre duplicado ─────────────────────────────────────────────────────
[Fact]
public async Task Handle_NombreDuplicado_ThrowsProductTypeNombreDuplicadoException()
{
_repo.ExistsByNombreAsync("Clasificados", Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
var act = async () => await _handler.Handle(ValidCommand());
await act.Should().ThrowAsync<ProductTypeNombreDuplicadoException>();
}
[Fact]
public async Task Handle_NombreDuplicado_CheckedBeforeFactory()
{
// If duplicate check throws, AddAsync should never be called
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
try { await _handler.Handle(ValidCommand()); } catch (ProductTypeNombreDuplicadoException) { }
await _repo.DidNotReceive().AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
}
// ── Rollback scenarios ────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback()
{
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error"));
var act = async () => await _handler.Handle(ValidCommand());
await act.Should().ThrowAsync<InvalidOperationException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AuditThrows_TransactionRollback()
{
_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(ValidCommand());
await act.Should().ThrowAsync<InvalidOperationException>();
}
}

View File

@@ -0,0 +1,159 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.ProductTypes.Deactivate;
public class DeactivateProductTypeCommandHandlerTests
{
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
private readonly IProductQueryRepository _productQuery = Substitute.For<IProductQueryRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero));
private readonly DeactivateProductTypeCommandHandler _handler;
public DeactivateProductTypeCommandHandlerTests()
{
_productQuery.ExistsActiveByProductTypeAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(false);
_handler = new DeactivateProductTypeCommandHandler(_repo, _productQuery, _audit, _timeProvider);
}
private static ProductType ActiveType(int id = 1) =>
new(id, "Clasificados", false, false, false, false, false, null, null, null, null,
isActive: true, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
private static ProductType InactiveType(int id = 1) =>
new(id, "Clasificados", false, false, false, false, false, null, null, null, null,
isActive: false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
// ── Not found ────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(99));
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
.Where(e => e.ProductTypeId == 99);
}
// ── Already inactive (idempotent) ─────────────────────────────────────────
[Fact]
public async Task Handle_AlreadyInactive_ReturnsIdempotentDto_NoAudit_NoRepoUpdate()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveType());
var result = await _handler.Handle(new DeactivateProductTypeCommand(1));
result.Id.Should().Be(1);
result.IsActive.Should().BeFalse();
await _repo.DidNotReceive().UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── In use guard ─────────────────────────────────────────────────────────
[Fact]
public async Task Handle_InUseGuardReturnsTrue_ThrowsProductTypeEnUsoException()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
_productQuery.ExistsActiveByProductTypeAsync(1, Arg.Any<CancellationToken>()).Returns(true);
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
await act.Should().ThrowAsync<ProductTypeEnUsoException>()
.Where(e => e.ProductTypeId == 1);
}
[Fact]
public async Task Handle_CallsIProductQueryRepository_Received1()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
await _handler.Handle(new DeactivateProductTypeCommand(1));
await _productQuery.Received(1).ExistsActiveByProductTypeAsync(1, Arg.Any<CancellationToken>());
}
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ValidDeactivation_UpdatesAndAuditsProductoTipoDeactivated()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
await _handler.Handle(new DeactivateProductTypeCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<ProductType>(pt => !pt.IsActive),
Arg.Any<CancellationToken>());
await _audit.Received(1).LogAsync(
action: "producto_tipo.deactivated",
targetType: "ProductType",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UsesTimeProviderInDeactivate()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
await _handler.Handle(new DeactivateProductTypeCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<ProductType>(pt => pt.FechaModificacion == expectedDate),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ReturnsDtoWithIdAndIsActiveFalse()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
var result = await _handler.Handle(new DeactivateProductTypeCommand(1));
result.Id.Should().Be(1);
result.IsActive.Should().BeFalse();
}
// ── Rollback scenarios ────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_Rollback()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
_repo.UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB"));
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task Handle_AuditThrows_Rollback()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit"));
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
await act.Should().ThrowAsync<InvalidOperationException>();
}
}

View File

@@ -0,0 +1,179 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.ProductTypes.Update;
public class UpdateProductTypeCommandHandlerTests
{
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero));
private readonly UpdateProductTypeCommandHandler _handler;
public UpdateProductTypeCommandHandlerTests()
{
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
_handler = new UpdateProductTypeCommandHandler(_repo, _audit, _timeProvider);
}
private static ProductType ExistingType(int id = 1, string nombre = "Clasificados", bool isActive = true) =>
new(id, nombre, false, false, false, false, false, null, null, null, null,
isActive, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
private static UpdateProductTypeCommand ValidCommand(int id = 1, string nombre = "Clasificados Actualizado") => new(
Id: id,
Nombre: nombre,
HasDuration: true, RequiresText: false, RequiresCategory: true, IsBundle: false,
AllowImages: false,
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
// ── Not found ────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
var act = async () => await _handler.Handle(ValidCommand());
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
.Where(e => e.ProductTypeId == 1);
}
// ── Nombre duplicado ─────────────────────────────────────────────────────
[Fact]
public async Task Handle_RenameToSameName_DoesNotCheckDuplicate()
{
// Same nombre → no duplicate check needed
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Clasificados"));
await _handler.Handle(ValidCommand(nombre: "Clasificados"));
await _repo.DidNotReceive().ExistsByNombreAsync(
Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_RenameToExistingActiveName_ThrowsProductTypeNombreDuplicadoException()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Clasificados"));
_repo.ExistsByNombreAsync("Notables", 1, Arg.Any<CancellationToken>()).Returns(true);
var act = async () => await _handler.Handle(ValidCommand(nombre: "Notables"));
await act.Should().ThrowAsync<ProductTypeNombreDuplicadoException>();
}
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ValidUpdate_PersistsAndAuditsProductoTipoUpdated()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
await _handler.Handle(ValidCommand());
await _repo.Received(1).UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
await _audit.Received(1).LogAsync(
action: "producto_tipo.updated",
targetType: "ProductType",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_TurnOffAllowImages_NullifiesMultimedia()
{
var existing = new ProductType(
1, "Tipo", false, false, false, false,
allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: 800, maxImageHeight: 600,
isActive: true, DateTime.UtcNow, null);
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
var cmd = ValidCommand() with { AllowImages = false };
await _handler.Handle(cmd);
await _repo.Received(1).UpdateAsync(
Arg.Is<ProductType>(pt => !pt.AllowImages && pt.MaxImages == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UpdatesFechaModificacion_WithTimeProvider()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
await _handler.Handle(ValidCommand());
await _repo.Received(1).UpdateAsync(
Arg.Is<ProductType>(pt => pt.FechaModificacion == expectedDate),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PreservesIsActiveFromTarget()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(isActive: true));
await _handler.Handle(ValidCommand());
await _repo.Received(1).UpdateAsync(
Arg.Is<ProductType>(pt => pt.IsActive),
Arg.Any<CancellationToken>());
}
// ── Rollback scenarios ────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_NoAudit_Rollback()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
_repo.UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB"));
var act = async () => await _handler.Handle(ValidCommand());
await act.Should().ThrowAsync<InvalidOperationException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_AuditThrows_Rollback()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit"));
var act = async () => await _handler.Handle(ValidCommand());
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task Handle_AuditLoggerLogsBeforeAndAfter()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Antes"));
await _handler.Handle(ValidCommand(nombre: "Despues"));
await _audit.Received(1).LogAsync(
action: "producto_tipo.updated",
targetType: "ProductType",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,130 @@
using FluentAssertions;
using FluentValidation.TestHelper;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Update;
namespace SIGCM2.Application.Tests.ProductTypes.Validators;
public class ProductTypeValidatorsTests
{
private readonly CreateProductTypeCommandValidator _createValidator = new();
private readonly UpdateProductTypeCommandValidator _updateValidator = new();
// ── Create: Nombre validations ────────────────────────────────────────────
[Fact]
public void Create_NombreEmpty_FailsValidation()
{
var cmd = ValidCreateCommand() with { Nombre = "" };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.Nombre);
}
[Fact]
public void Create_NombreWhitespace_FailsValidation()
{
var cmd = ValidCreateCommand() with { Nombre = " " };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.Nombre);
}
[Fact]
public void Create_NombreOver200Chars_FailsValidation()
{
var cmd = ValidCreateCommand() with { Nombre = new string('A', 201) };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.Nombre);
}
// ── Create: MaxImages validations ─────────────────────────────────────────
[Fact]
public void Create_MaxImagesZero_FailsValidation()
{
var cmd = ValidCreateCommand() with { AllowImages = true, MaxImages = 0 };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.MaxImages);
}
[Fact]
public void Create_MaxImagesNegative_FailsValidation()
{
var cmd = ValidCreateCommand() with { AllowImages = true, MaxImages = -1 };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.MaxImages);
}
[Fact]
public void Create_MaxImageSizeMBZero_FailsValidation()
{
var cmd = ValidCreateCommand() with { MaxImageSizeMB = 0.0m };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.MaxImageSizeMB);
}
[Fact]
public void Create_MaxImageWidthZero_FailsValidation()
{
var cmd = ValidCreateCommand() with { MaxImageWidth = 0 };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.MaxImageWidth);
}
[Fact]
public void Create_MaxImageHeightZero_FailsValidation()
{
var cmd = ValidCreateCommand() with { MaxImageHeight = 0 };
var result = _createValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.MaxImageHeight);
}
// ── Create: valid commands pass ───────────────────────────────────────────
[Fact]
public void Create_ValidCommand_Passes()
{
var result = _createValidator.TestValidate(ValidCreateCommand());
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Create_AllowImagesFalse_WithMaxImagesSet_Passes()
{
// Normalization is handler's responsibility, not validator's
var cmd = ValidCreateCommand() with { AllowImages = false, MaxImages = 5 };
var result = _createValidator.TestValidate(cmd);
result.ShouldNotHaveAnyValidationErrors();
}
// ── Update: inherits same rules + Id > 0 ─────────────────────────────────
[Fact]
public void Update_IdZero_FailsValidation()
{
var cmd = ValidUpdateCommand() with { Id = 0 };
var result = _updateValidator.TestValidate(cmd);
result.ShouldHaveValidationErrorFor(x => x.Id);
}
[Fact]
public void Update_ValidCommand_Passes()
{
var result = _updateValidator.TestValidate(ValidUpdateCommand());
result.ShouldNotHaveAnyValidationErrors();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static CreateProductTypeCommand ValidCreateCommand() => new(
Nombre: "Clasificados",
HasDuration: false, RequiresText: false, RequiresCategory: false, IsBundle: false,
AllowImages: false,
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
private static UpdateProductTypeCommand ValidUpdateCommand() => new(
Id: 1,
Nombre: "Clasificados",
HasDuration: false, RequiresText: false, RequiresCategory: false, IsBundle: false,
AllowImages: false,
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
}

View File

@@ -60,6 +60,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
await EnsureV016SchemaAsync();
// V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'.
await EnsureV017SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -86,6 +89,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "ProductType_History"),
]
});
@@ -933,4 +938,77 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(createUqIndex);
await _connection.ExecuteAsync(createCoveringIndex);
}
private async Task EnsureV017SchemaAsync()
{
const string createProductType = """
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ProductType (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
MaxImages INT NULL,
MaxImageSizeMB DECIMAL(10,2) NULL,
MaxImageWidth INT NULL,
MaxImageHeight INT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL
);
END
""";
const string addProductTypePeriod = """
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.ProductType
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
END
""";
const string setProductTypeVersioning = """
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductType
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ProductType_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
END
""";
const string createUqIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
ON dbo.ProductType(Nombre)
WHERE IsActive = 1;
END
""";
const string createCoveringIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
CREATE INDEX IX_ProductType_IsActive_Cover
ON dbo.ProductType(IsActive)
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
END
""";
await _connection.ExecuteAsync(createProductType);
await _connection.ExecuteAsync(addProductTypePeriod);
await _connection.ExecuteAsync(setProductTypeVersioning);
await _connection.ExecuteAsync(createUqIndex);
await _connection.ExecuteAsync(createCoveringIndex);
}
}