feat: PRD-001 ProductType (flags + multimedia) #38

Merged
dmolinari merged 10 commits from feature/PRD-001 into main 2026-04-19 15:18:53 +00:00
7 changed files with 616 additions and 0 deletions
Showing only changes of commit 132d17c99f - Show all commits

View File

@@ -0,0 +1,186 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// 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).
/// </summary>
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; }
/// <summary>
/// Full hydration constructor — used by the repository to reconstruct from DB rows.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Returns a new ProductType with an updated Nombre and FechaModificacion.
/// Does NOT mutate the current instance.
/// </summary>
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);
}
/// <summary>
/// Returns a new ProductType with updated flags, preserving multimedia fields.
/// Does NOT mutate the current instance.
/// </summary>
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);
}
/// <summary>
/// Returns a new ProductType with updated multimedia limits.
/// AllowImages=false normalizes all 4 Max* fields to null.
/// Does NOT mutate the current instance.
/// </summary>
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);
}
/// <summary>
/// Returns a new ProductType with IsActive=false and FechaModificacion updated.
/// Does NOT mutate the current instance.
/// </summary>
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));
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to deactivate a ProductType that has active products. → HTTP 409
/// </summary>
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;
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// 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).
/// </summary>
public sealed class ProductTypeFlagsIncoherentesException : DomainException
{
public string Reason { get; }
public ProductTypeFlagsIncoherentesException(string reason)
: base($"Combinación de flags/multimedia inválida: {reason}")
{
Reason = reason;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a ProductType with the same active name already exists. → HTTP 409
/// </summary>
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;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested ProductType does not exist. → HTTP 404
/// </summary>
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;
}
}

View File

@@ -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<DomainException>();
}
// ── 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<DomainException>();
}
// ── 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<DomainException>();
}
// ── 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<DomainException>();
}
}

View File

@@ -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<ArgumentException>();
}
[Fact]
public void ForCreation_NombreWhitespace_ThrowsArgumentException()
{
var act = () => ProductType.ForCreation(
" ", false, false, false, false, false,
null, null, null, null, FakeTime);
act.Should().Throw<ArgumentException>();
}
[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<ArgumentException>();
}
// ── 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));
}