diff --git a/src/api/SIGCM2.Domain/Entities/ProductType.cs b/src/api/SIGCM2.Domain/Entities/ProductType.cs new file mode 100644 index 0000000..ea3de1a --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/ProductType.cs @@ -0,0 +1,186 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// Immutable product-type descriptor for the commercial catalog. +/// Flags drive form behavior (HasDuration, RequiresText, RequiresCategory, IsBundle). +/// Multimedia limits (Max*) are null = no limit. +/// Invariant: if AllowImages == false, the 4 Max* fields must be null +/// (enforced by ForCreation and WithUpdatedMultimedia). +/// +public sealed class ProductType +{ + private const int NombreMaxLength = 200; + + public int Id { get; } + public string Nombre { get; } + public bool HasDuration { get; } + public bool RequiresText { get; } + public bool RequiresCategory { get; } + public bool IsBundle { get; } + public bool AllowImages { get; } + public int? MaxImages { get; } + public decimal? MaxImageSizeMB { get; } + public int? MaxImageWidth { get; } + public int? MaxImageHeight { get; } + public bool IsActive { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + /// + /// Full hydration constructor — used by the repository to reconstruct from DB rows. + /// + public ProductType( + int id, + string nombre, + bool hasDuration, + bool requiresText, + bool requiresCategory, + bool isBundle, + bool allowImages, + int? maxImages, + decimal? maxImageSizeMB, + int? maxImageWidth, + int? maxImageHeight, + bool isActive, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + Nombre = nombre; + HasDuration = hasDuration; + RequiresText = requiresText; + RequiresCategory = requiresCategory; + IsBundle = isBundle; + AllowImages = allowImages; + MaxImages = maxImages; + MaxImageSizeMB = maxImageSizeMB; + MaxImageWidth = maxImageWidth; + MaxImageHeight = maxImageHeight; + IsActive = isActive; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + /// + /// Factory for creating a new ProductType. + /// Id=0 — DB assigns via IDENTITY. + /// IsActive=true, FechaModificacion=null by default. + /// AllowImages=false normalizes all 4 Max* fields to null. + /// + public static ProductType ForCreation( + string nombre, + bool hasDuration, + bool requiresText, + bool requiresCategory, + bool isBundle, + bool allowImages, + int? maxImages, + decimal? maxImageSizeMB, + int? maxImageWidth, + int? maxImageHeight, + TimeProvider timeProvider) + { + ValidateNombre(nombre); + + var (mi, ms, mw, mh) = NormalizeMultimedia(allowImages, maxImages, maxImageSizeMB, maxImageWidth, maxImageHeight); + + return new ProductType( + id: 0, + nombre: nombre, + hasDuration: hasDuration, + requiresText: requiresText, + requiresCategory: requiresCategory, + isBundle: isBundle, + allowImages: allowImages, + maxImages: mi, + maxImageSizeMB: ms, + maxImageWidth: mw, + maxImageHeight: mh, + isActive: true, + fechaCreacion: timeProvider.GetUtcNow().UtcDateTime, + fechaModificacion: null); + } + + /// + /// Returns a new ProductType with an updated Nombre and FechaModificacion. + /// Does NOT mutate the current instance. + /// + public ProductType WithRenamed(string nuevoNombre, TimeProvider timeProvider) + { + ValidateNombre(nuevoNombre); + + return new ProductType( + Id, nuevoNombre, HasDuration, RequiresText, RequiresCategory, IsBundle, + AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight, + IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime); + } + + /// + /// Returns a new ProductType with updated flags, preserving multimedia fields. + /// Does NOT mutate the current instance. + /// + public ProductType WithUpdatedFlags( + bool hasDuration, + bool requiresText, + bool requiresCategory, + bool isBundle, + TimeProvider timeProvider) + { + return new ProductType( + Id, Nombre, hasDuration, requiresText, requiresCategory, isBundle, + AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight, + IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime); + } + + /// + /// Returns a new ProductType with updated multimedia limits. + /// AllowImages=false normalizes all 4 Max* fields to null. + /// Does NOT mutate the current instance. + /// + public ProductType WithUpdatedMultimedia( + bool allowImages, + int? maxImages, + decimal? maxImageSizeMB, + int? maxImageWidth, + int? maxImageHeight, + TimeProvider timeProvider) + { + var (mi, ms, mw, mh) = NormalizeMultimedia(allowImages, maxImages, maxImageSizeMB, maxImageWidth, maxImageHeight); + + return new ProductType( + Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, + allowImages, mi, ms, mw, mh, + IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime); + } + + /// + /// Returns a new ProductType with IsActive=false and FechaModificacion updated. + /// Does NOT mutate the current instance. + /// + public ProductType WithDeactivated(TimeProvider timeProvider) + { + return new ProductType( + Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, + AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight, + isActive: false, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static (int?, decimal?, int?, int?) NormalizeMultimedia( + bool allow, int? mi, decimal? ms, int? mw, int? mh) + => allow ? (mi, ms, mw, mh) : (null, null, null, null); + + private static void ValidateNombre(string nombre) + { + if (string.IsNullOrWhiteSpace(nombre)) + throw new ArgumentException( + "El nombre del tipo de producto no puede estar vacío o ser solo espacios.", + nameof(nombre)); + + if (nombre.Length > NombreMaxLength) + throw new ArgumentException( + $"El nombre del tipo de producto no puede superar los {NombreMaxLength} caracteres.", + nameof(nombre)); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeEnUsoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeEnUsoException.cs new file mode 100644 index 0000000..b3cf72b --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeEnUsoException.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to deactivate a ProductType that has active products. → HTTP 409 +/// +public sealed class ProductTypeEnUsoException : DomainException +{ + public int ProductTypeId { get; } + public int ProductsActivos { get; } + + public ProductTypeEnUsoException(int id, int productsActivos) + : base($"El tipo de producto con id={id} no puede desactivarse: tiene {productsActivos} producto(s) activo(s) asociados.") + { + ProductTypeId = id; + ProductsActivos = productsActivos; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeFlagsIncoherentesException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeFlagsIncoherentesException.cs new file mode 100644 index 0000000..7901594 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeFlagsIncoherentesException.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a combination of flags/multimedia is logically incoherent. → HTTP 422 +/// Defensive exception — PRD-001 normalizes instead of throwing. +/// Reserved for future rules (e.g., PRD-004 IsBundle+HasDuration constraints). +/// +public sealed class ProductTypeFlagsIncoherentesException : DomainException +{ + public string Reason { get; } + + public ProductTypeFlagsIncoherentesException(string reason) + : base($"Combinación de flags/multimedia inválida: {reason}") + { + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeNombreDuplicadoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNombreDuplicadoException.cs new file mode 100644 index 0000000..79a4914 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNombreDuplicadoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a ProductType with the same active name already exists. → HTTP 409 +/// +public sealed class ProductTypeNombreDuplicadoException : DomainException +{ + public string Nombre { get; } + + public ProductTypeNombreDuplicadoException(string nombre) + : base($"Ya existe un tipo de producto activo con el nombre '{nombre}'.") + { + Nombre = nombre; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNotFoundException.cs new file mode 100644 index 0000000..0507af8 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested ProductType does not exist. → HTTP 404 +/// +public sealed class ProductTypeNotFoundException : DomainException +{ + public int ProductTypeId { get; } + + public ProductTypeNotFoundException(int id) + : base($"El tipo de producto con id={id} no existe.") + { + ProductTypeId = id; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeExceptionsTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeExceptionsTests.cs new file mode 100644 index 0000000..8b1f926 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeExceptionsTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Domain.ProductTypes; + +public class ProductTypeExceptionsTests +{ + // ── ProductTypeNotFoundException ────────────────────────────────────────── + + [Fact] + public void ProductTypeNotFoundException_ContainsIdInMessage() + { + var ex = new ProductTypeNotFoundException(42); + + ex.Message.Should().Contain("42"); + } + + [Fact] + public void ProductTypeNotFoundException_ExposesProductTypeId() + { + var ex = new ProductTypeNotFoundException(99); + + ex.ProductTypeId.Should().Be(99); + } + + [Fact] + public void ProductTypeNotFoundException_IsSubclassOfDomainException() + { + var ex = new ProductTypeNotFoundException(1); + + ex.Should().BeAssignableTo(); + } + + // ── ProductTypeNombreDuplicadoException ─────────────────────────────────── + + [Fact] + public void ProductTypeNombreDuplicadoException_ContainsNombreInMessage() + { + var ex = new ProductTypeNombreDuplicadoException("Clasificados"); + + ex.Message.Should().Contain("Clasificados"); + } + + [Fact] + public void ProductTypeNombreDuplicadoException_ExposesNombre() + { + var ex = new ProductTypeNombreDuplicadoException("Notables"); + + ex.Nombre.Should().Be("Notables"); + } + + [Fact] + public void ProductTypeNombreDuplicadoException_IsSubclassOfDomainException() + { + var ex = new ProductTypeNombreDuplicadoException("x"); + + ex.Should().BeAssignableTo(); + } + + // ── ProductTypeEnUsoException ───────────────────────────────────────────── + + [Fact] + public void ProductTypeEnUsoException_ContainsCountInMessage() + { + var ex = new ProductTypeEnUsoException(id: 5, productsActivos: 7); + + ex.Message.Should().Contain("7"); + } + + [Fact] + public void ProductTypeEnUsoException_ExposesProductTypeIdAndCount() + { + var ex = new ProductTypeEnUsoException(id: 10, productsActivos: 3); + + ex.ProductTypeId.Should().Be(10); + ex.ProductsActivos.Should().Be(3); + } + + [Fact] + public void ProductTypeEnUsoException_IsSubclassOfDomainException() + { + var ex = new ProductTypeEnUsoException(1, 0); + + ex.Should().BeAssignableTo(); + } + + // ── ProductTypeFlagsIncoherentesException ───────────────────────────────── + + [Fact] + public void ProductTypeFlagsIncoherentesException_ContainsReasonInMessage() + { + var ex = new ProductTypeFlagsIncoherentesException("IsBundle sin hijos definidos"); + + ex.Message.Should().Contain("IsBundle sin hijos definidos"); + } + + [Fact] + public void ProductTypeFlagsIncoherentesException_ExposesReason() + { + var ex = new ProductTypeFlagsIncoherentesException("razón técnica"); + + ex.Reason.Should().Be("razón técnica"); + } + + [Fact] + public void ProductTypeFlagsIncoherentesException_IsSubclassOfDomainException() + { + var ex = new ProductTypeFlagsIncoherentesException("x"); + + ex.Should().BeAssignableTo(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeTests.cs new file mode 100644 index 0000000..3332b89 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeTests.cs @@ -0,0 +1,254 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Domain.ProductTypes; + +public class ProductTypeTests +{ + private static readonly FakeTimeProvider FakeTime = + new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero)); + + // ── ForCreation: happy path ────────────────────────────────────────────── + + [Fact] + public void ForCreation_ValidInputs_ReturnsNewInstance_WithActiveTrue() + { + var pt = ProductType.ForCreation( + "Clasificados", + hasDuration: true, requiresText: true, requiresCategory: false, isBundle: false, + allowImages: false, + maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + FakeTime); + + pt.Id.Should().Be(0); + pt.Nombre.Should().Be("Clasificados"); + pt.HasDuration.Should().BeTrue(); + pt.RequiresText.Should().BeTrue(); + pt.RequiresCategory.Should().BeFalse(); + pt.IsBundle.Should().BeFalse(); + pt.AllowImages.Should().BeFalse(); + pt.IsActive.Should().BeTrue(); + pt.FechaCreacion.Should().Be(FakeTime.GetUtcNow().UtcDateTime); + pt.FechaModificacion.Should().BeNull(); + } + + // ── ForCreation: Nombre validations ────────────────────────────────────── + + [Fact] + public void ForCreation_NombreNull_ThrowsArgumentException() + { + var act = () => ProductType.ForCreation( + null!, false, false, false, false, false, + null, null, null, null, FakeTime); + + act.Should().Throw(); + } + + [Fact] + public void ForCreation_NombreWhitespace_ThrowsArgumentException() + { + var act = () => ProductType.ForCreation( + " ", false, false, false, false, false, + null, null, null, null, FakeTime); + + act.Should().Throw(); + } + + [Fact] + public void ForCreation_NombreOver200Chars_ThrowsArgumentException() + { + var nombre = new string('X', 201); + + var act = () => ProductType.ForCreation( + nombre, false, false, false, false, false, + null, null, null, null, FakeTime); + + act.Should().Throw(); + } + + // ── ForCreation: multimedia normalization ──────────────────────────────── + + [Fact] + public void ForCreation_AllowImagesFalse_NormalizesAll4MultimediaFieldsToNull() + { + var pt = ProductType.ForCreation( + "Clasificados", + false, false, false, false, + allowImages: false, + maxImages: 5, maxImageSizeMB: 2.5m, maxImageWidth: 1920, maxImageHeight: 1080, + FakeTime); + + pt.MaxImages.Should().BeNull(); + pt.MaxImageSizeMB.Should().BeNull(); + pt.MaxImageWidth.Should().BeNull(); + pt.MaxImageHeight.Should().BeNull(); + } + + [Fact] + public void ForCreation_AllowImagesTrue_PreservesMultimediaValues() + { + var pt = ProductType.ForCreation( + "Fotos", + false, false, false, false, + allowImages: true, + maxImages: 10, maxImageSizeMB: 5.0m, maxImageWidth: 800, maxImageHeight: 600, + FakeTime); + + pt.AllowImages.Should().BeTrue(); + pt.MaxImages.Should().Be(10); + pt.MaxImageSizeMB.Should().Be(5.0m); + pt.MaxImageWidth.Should().Be(800); + pt.MaxImageHeight.Should().Be(600); + } + + [Fact] + public void ForCreation_AllowImagesTrue_AllMaxNull_IsValid() + { + var pt = ProductType.ForCreation( + "Fotos sin límite", + false, false, false, false, + allowImages: true, + maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + FakeTime); + + pt.AllowImages.Should().BeTrue(); + pt.MaxImages.Should().BeNull(); + pt.MaxImageSizeMB.Should().BeNull(); + pt.MaxImageWidth.Should().BeNull(); + pt.MaxImageHeight.Should().BeNull(); + } + + // ── WithRenamed ────────────────────────────────────────────────────────── + + [Fact] + public void WithRenamed_ValidNombre_ReturnsNewInstance_WithFechaModificacionUpdated() + { + var original = ProductType.ForCreation("Original", false, false, false, false, false, null, null, null, null, FakeTime); + var updated = FakeTimeProvider2(); + var renamed = original.WithRenamed("Nuevo", updated); + + renamed.Nombre.Should().Be("Nuevo"); + renamed.FechaModificacion.Should().Be(updated.GetUtcNow().UtcDateTime); + } + + [Fact] + public void WithRenamed_DoesNotMutateOriginal() + { + var original = ProductType.ForCreation("Original", false, false, false, false, false, null, null, null, null, FakeTime); + _ = original.WithRenamed("Nuevo", FakeTime); + + original.Nombre.Should().Be("Original"); + original.FechaModificacion.Should().BeNull(); + } + + // ── WithUpdatedFlags ───────────────────────────────────────────────────── + + [Fact] + public void WithUpdatedFlags_SetsFlags_PreservesMultimedia() + { + var original = ProductType.ForCreation( + "Tipo", false, false, false, false, + allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: null, maxImageHeight: null, + FakeTime); + var updated = original.WithUpdatedFlags(true, true, true, true, FakeTime); + + updated.HasDuration.Should().BeTrue(); + updated.RequiresText.Should().BeTrue(); + updated.RequiresCategory.Should().BeTrue(); + updated.IsBundle.Should().BeTrue(); + updated.AllowImages.Should().BeTrue(); + updated.MaxImages.Should().Be(5); + updated.MaxImageSizeMB.Should().Be(2.0m); + } + + // ── WithUpdatedMultimedia ───────────────────────────────────────────────── + + [Fact] + public void WithUpdatedMultimedia_AllowImagesFalse_NullifiesAllLimits() + { + var original = ProductType.ForCreation( + "Tipo", false, false, false, false, + allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: 1024, maxImageHeight: 768, + FakeTime); + var updated = original.WithUpdatedMultimedia(false, 3, 1.0m, 800, 600, FakeTime); + + updated.AllowImages.Should().BeFalse(); + updated.MaxImages.Should().BeNull(); + updated.MaxImageSizeMB.Should().BeNull(); + updated.MaxImageWidth.Should().BeNull(); + updated.MaxImageHeight.Should().BeNull(); + } + + [Fact] + public void WithUpdatedMultimedia_AllowImagesTrue_PreservesLimits() + { + var original = ProductType.ForCreation("Tipo", false, false, false, false, false, null, null, null, null, FakeTime); + var updated = original.WithUpdatedMultimedia(true, 8, 3.5m, 1920, 1080, FakeTime); + + updated.AllowImages.Should().BeTrue(); + updated.MaxImages.Should().Be(8); + updated.MaxImageSizeMB.Should().Be(3.5m); + updated.MaxImageWidth.Should().Be(1920); + updated.MaxImageHeight.Should().Be(1080); + } + + // ── WithDeactivated ────────────────────────────────────────────────────── + + [Fact] + public void WithDeactivated_SetsIsActiveFalse_AndFechaModificacion() + { + var original = ProductType.ForCreation("Tipo", false, false, false, false, false, null, null, null, null, FakeTime); + var tp2 = FakeTimeProvider2(); + var deactivated = original.WithDeactivated(tp2); + + deactivated.IsActive.Should().BeFalse(); + deactivated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime); + original.IsActive.Should().BeTrue(); // original not mutated + } + + // ── Hydration ──────────────────────────────────────────────────────────── + + [Fact] + public void Constructor_HydrationRoundtrip_PreservesAllFields() + { + var fechaCreacion = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc); + var fechaModificacion = new DateTime(2026, 3, 10, 9, 30, 0, DateTimeKind.Utc); + + var pt = new ProductType( + id: 42, + nombre: "Bundle Aniversario", + hasDuration: true, + requiresText: false, + requiresCategory: true, + isBundle: true, + allowImages: true, + maxImages: 12, + maxImageSizeMB: 2.75m, + maxImageWidth: 1920, + maxImageHeight: 1080, + isActive: false, + fechaCreacion: fechaCreacion, + fechaModificacion: fechaModificacion); + + pt.Id.Should().Be(42); + pt.Nombre.Should().Be("Bundle Aniversario"); + pt.HasDuration.Should().BeTrue(); + pt.RequiresText.Should().BeFalse(); + pt.RequiresCategory.Should().BeTrue(); + pt.IsBundle.Should().BeTrue(); + pt.AllowImages.Should().BeTrue(); + pt.MaxImages.Should().Be(12); + pt.MaxImageSizeMB.Should().Be(2.75m); + pt.MaxImageWidth.Should().Be(1920); + pt.MaxImageHeight.Should().Be(1080); + pt.IsActive.Should().BeFalse(); + pt.FechaCreacion.Should().Be(fechaCreacion); + pt.FechaModificacion.Should().Be(fechaModificacion); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + + private static FakeTimeProvider FakeTimeProvider2() => + new(new DateTimeOffset(2026, 4, 20, 15, 0, 0, TimeSpan.Zero)); +}