Compare commits

...

5 Commits

Author SHA1 Message Date
e735afb5b4 Merge pull request 'feat(domain): RubroConProductosActivosException + guard en DeactivateRubro (closes #41)' (#44) from fix/issue-41-rubro-deactivation-guard into main 2026-04-19 20:09:38 +00:00
50a5118a78 feat(api): ExceptionFilter + e2e 409 para RubroConProductosActivos (closes #41)
Mapea RubroConProductosActivosException → HTTP 409 con error code
rubro_con_productos_activos. Test e2e usa DI override (patrón issue #36)
para stub IProductQueryRepository sin sembrar Products reales en DB.
2026-04-19 17:08:42 -03:00
c974e824e0 feat(infrastructure): ProductQueryRepository.CountActiveByRubroAsync + integration test
Implementa SELECT COUNT(1) FROM dbo.Product WHERE RubroId = @RubroId AND IsActive = 1.
Tests de integración verifican: 0 sin productos, count correcto con mix
activos/inactivos/otro rubro, y solo inactivos retorna 0.
2026-04-19 17:08:35 -03:00
900fd5e975 feat(application): DeactivateRubroCommandHandler guard contra Products activos
Extiende IProductQueryRepository con CountActiveByRubroAsync, inyecta
el repositorio en el handler e intercala el chequeo después del guard
de hijos activos. Tests de unidad cubren: throw, success con 0 productos,
y estabilidad del orden de guardas (hijos primero).
2026-04-19 17:08:30 -03:00
e9d1e3237d feat(domain): RubroConProductosActivosException + test (closes #41)
Co-authored-by: fix/issue-41-rubro-deactivation-guard
2026-04-19 17:08:23 -03:00
10 changed files with 357 additions and 12 deletions

View File

@@ -267,6 +267,18 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
case RubroConProductosActivosException rubroProductosEx:
context.Result = new ObjectResult(new
{
error = "rubro_con_productos_activos",
message = rubroProductosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-001: Medio exceptions // ADM-001: Medio exceptions
case MedioCodigoDuplicadoException medioCodDupEx: case MedioCodigoDuplicadoException medioCodDupEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new

View File

@@ -12,4 +12,10 @@ public interface IProductQueryRepository
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products. /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
/// </summary> /// </summary>
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default); Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
/// <summary>
/// Returns the count of active Products where RubroId = rubroId.
/// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41)
/// </summary>
Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default);
} }

View File

@@ -10,15 +10,18 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
{ {
private readonly IRubroRepository _repo; private readonly IRubroRepository _repo;
private readonly IAuditLogger _audit; private readonly IAuditLogger _audit;
private readonly IProductQueryRepository _productQuery;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
public DeactivateRubroCommandHandler( public DeactivateRubroCommandHandler(
IRubroRepository repo, IRubroRepository repo,
IAuditLogger audit, IAuditLogger audit,
IProductQueryRepository productQuery,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
_repo = repo; _repo = repo;
_audit = audit; _audit = audit;
_productQuery = productQuery;
_timeProvider = timeProvider; _timeProvider = timeProvider;
} }
@@ -31,6 +34,10 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
if (activeChildren > 0) if (activeChildren > 0)
throw new RubroTieneHijosActivosException(command.Id, activeChildren); throw new RubroTieneHijosActivosException(command.Id, activeChildren);
var productosActivos = await _productQuery.CountActiveByRubroAsync(command.Id);
if (productosActivos > 0)
throw new RubroConProductosActivosException(command.Id, productosActivos);
var deactivated = target.WithActivo(false, _timeProvider); var deactivated = target.WithActivo(false, _timeProvider);
using var tx = new TransactionScope( using var tx = new TransactionScope(

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to soft-delete a Rubro that still has active Products referencing it via RubroId. → HTTP 409
/// </summary>
public sealed class RubroConProductosActivosException : DomainException
{
public int RubroId { get; }
public int ProductosActivosCount { get; }
public RubroConProductosActivosException(int rubroId, int productosActivosCount)
: base($"No se puede desactivar el rubro {rubroId}: tiene {productosActivosCount} producto(s) activo(s) referenciándolo.")
{
RubroId = rubroId;
ProductosActivosCount = productosActivosCount;
}
}

View File

@@ -34,4 +34,16 @@ public sealed class ProductQueryRepository : IProductQueryRepository
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId }); var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
return result == 1; return result == 1;
} }
public async Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1) FROM dbo.Product WHERE RubroId = @RubroId AND IsActive = 1
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(sql, new { RubroId = rubroId });
}
} }

View File

@@ -327,4 +327,7 @@ file sealed class AlwaysInUseProductQueryRepository : IProductQueryRepository
{ {
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
=> Task.FromResult(true); => Task.FromResult(true);
public Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default)
=> Task.FromResult(0);
} }

View File

@@ -0,0 +1,160 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions;
using SIGCM2.Api.Filters;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Rubros;
/// <summary>
/// Issue #41 — DeactivateRubroCommandHandler guard contra Products activos.
///
/// Unit tests: ExceptionFilter mapping for RubroConProductosActivosException → 409.
/// E2E: DELETE /api/v1/admin/rubros/{id} with stub IProductQueryRepository returning count > 0 → 409.
/// </summary>
[Collection("ApiIntegration")]
public sealed class RubrosDeactivateGuardTests : IAsyncLifetime
{
private const string AdminEndpoint = "/api/v1/admin/rubros";
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly TestWebAppFactory _factory;
private readonly HttpClient _client;
public RubrosDeactivateGuardTests(TestWebAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
// ── ExceptionFilter unit test (no DB, no HTTP) ────────────────────────────
[Fact]
public void ExceptionFilter_MapsRubroConProductosActivos_To409()
{
var filter = new ExceptionFilter(NullLogger<ExceptionFilter>.Instance);
var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext();
var routeData = new Microsoft.AspNetCore.Routing.RouteData();
var actionDescriptor = new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor();
var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary();
var actionContext = new ActionContext(httpContext, routeData, actionDescriptor, modelState);
var ctx = new ExceptionContext(actionContext, new List<IFilterMetadata>())
{
Exception = new RubroConProductosActivosException(rubroId: 5, productosActivosCount: 2)
};
filter.OnException(ctx);
var result = Assert.IsType<ObjectResult>(ctx.Result);
Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode);
var json = System.Text.Json.JsonSerializer.Serialize(result.Value);
Assert.Contains("rubro_con_productos_activos", json);
}
// ── E2E: DELETE with stub reporting active products → 409 ─────────────────
/// <summary>
/// Verifies the 409 guard path end-to-end via a per-test DI override: injects a stub
/// IProductQueryRepository that reports the Rubro has 2 active products,
/// so no real Product row needs to exist in the database (issue #36 DI override pattern).
/// </summary>
[Fact]
public async Task DeactivateRubro_WhenHasActiveProducts_Returns409WithErrorCode()
{
// Arrange: get admin token from shared client (same DB)
var token = await GetAdminTokenAsync();
// Create a real Rubro to have a valid id to deactivate
using var createReq = new HttpRequestMessage(HttpMethod.Post, AdminEndpoint);
createReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
createReq.Content = JsonContent.Create(new
{
nombre = $"Rubro_Guard41_{Guid.NewGuid():N}"[..30],
parentId = (int?)null,
});
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var rubroId = created.GetProperty("id").GetInt32();
try
{
// Override IProductQueryRepository to report 2 active products for any rubroId
using var overrideClient = _factory.CreateClientWithOverrides(services =>
{
services.RemoveAll<IProductQueryRepository>();
services.AddScoped<IProductQueryRepository>(_ => new StubProductQueryRepository(activeCount: 2));
});
var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"{AdminEndpoint}/{rubroId}");
deleteReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var resp = await overrideClient.SendAsync(deleteReq);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("rubro_con_productos_activos", body.GetProperty("error").GetString());
}
finally
{
await DeleteRubroDirectAsync(rubroId);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = AdminUsername,
password = AdminPassword
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private static async Task DeleteRubroDirectAsync(int id)
{
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.ApiTestDb);
await conn.OpenAsync();
await Dapper.SqlMapper.ExecuteAsync(conn, "ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF)");
await Dapper.SqlMapper.ExecuteAsync(conn, "DELETE FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
await Dapper.SqlMapper.ExecuteAsync(conn, "DELETE FROM dbo.Rubro WHERE Id = @Id", new { Id = id });
await Dapper.SqlMapper.ExecuteAsync(conn,
"ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Rubro_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
}
}
/// <summary>
/// Stub: always reports a fixed count of active products for any rubroId.
/// Used by RubrosDeactivateGuardTests to verify the 409 guard without seeding real Product rows.
/// </summary>
file sealed class StubProductQueryRepository : IProductQueryRepository
{
private readonly int _activeCount;
public StubProductQueryRepository(int activeCount)
{
_activeCount = activeCount;
}
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default)
=> Task.FromResult(_activeCount);
}

View File

@@ -0,0 +1,27 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Domain.Rubros;
public class RubroConProductosActivosExceptionTests
{
[Fact]
public void Constructor_SetsPropertiesAndMessage()
{
var ex = new RubroConProductosActivosException(rubroId: 7, productosActivosCount: 3);
ex.RubroId.Should().Be(7);
ex.ProductosActivosCount.Should().Be(3);
ex.Message.Should().Contain("7");
ex.Message.Should().Contain("3");
ex.Message.Should().Contain("producto");
}
[Fact]
public void Constructor_InheritsFromDomainException()
{
var ex = new RubroConProductosActivosException(1, 2);
ex.Should().BeAssignableTo<DomainException>();
}
}

View File

@@ -8,6 +8,7 @@ namespace SIGCM2.Application.Tests.Products.Repository;
/// <summary> /// <summary>
/// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App. /// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App.
/// These tests verify the real Dapper implementation replaces NullProductQueryRepository. /// These tests verify the real Dapper implementation replaces NullProductQueryRepository.
/// Issue #41: CountActiveByRubroAsync tests added here (same class, same DB, same fixture).
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class ProductQueryRepositoryTests : IAsyncLifetime public class ProductQueryRepositoryTests : IAsyncLifetime
@@ -44,7 +45,7 @@ public class ProductQueryRepositoryTests : IAsyncLifetime
{ {
// Arrange: insert a ProductType and an active Product referencing it // Arrange: insert a ProductType and an active Product referencing it
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertActiveProductAsync(medioId, productTypeId); await InsertActiveProductAsync(medioId, productTypeId, rubroId: null);
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId);
@@ -56,7 +57,7 @@ public class ProductQueryRepositoryTests : IAsyncLifetime
{ {
// Arrange: insert an inactive product // Arrange: insert an inactive product
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertInactiveProductAsync(medioId, productTypeId); await InsertInactiveProductAsync(medioId, productTypeId, rubroId: null);
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId);
@@ -68,7 +69,7 @@ public class ProductQueryRepositoryTests : IAsyncLifetime
{ {
// Arrange: insert active product for productTypeId=A, query for productTypeId=B // Arrange: insert active product for productTypeId=A, query for productTypeId=B
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertActiveProductAsync(medioId, productTypeId); await InsertActiveProductAsync(medioId, productTypeId, rubroId: null);
var otherProductTypeId = productTypeId + 100; var otherProductTypeId = productTypeId + 100;
var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId); var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId);
@@ -76,8 +77,65 @@ public class ProductQueryRepositoryTests : IAsyncLifetime
result.Should().BeFalse(); result.Should().BeFalse();
} }
// ── CountActiveByRubroAsync (issue #41) ──────────────────────────────────
[Fact]
public async Task CountActiveByRubroAsync_NoProducts_ReturnsZero()
{
var result = await _repository.CountActiveByRubroAsync(rubroId: 99999);
result.Should().Be(0);
}
[Fact]
public async Task CountActiveByRubroAsync_Returns_CorrectCount()
{
// Arrange: insert a Rubro and products in various states
var rubroId = await InsertRubroAsync("Rubro Clasificados");
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
// 2 active products referencing the rubro
await InsertActiveProductAsync(medioId, productTypeId, rubroId: rubroId);
await InsertActiveProductAsync(medioId, productTypeId, rubroId: rubroId);
// 1 inactive product referencing the same rubro (should NOT count)
await InsertInactiveProductAsync(medioId, productTypeId, rubroId: rubroId);
// 1 active product with a DIFFERENT rubroId (should NOT count)
var otherRubroId = await InsertRubroAsync("Otro Rubro");
await InsertActiveProductAsync(medioId, productTypeId, rubroId: otherRubroId);
var result = await _repository.CountActiveByRubroAsync(rubroId);
result.Should().Be(2);
}
[Fact]
public async Task CountActiveByRubroAsync_WithOnlyInactiveProducts_ReturnsZero()
{
var rubroId = await InsertRubroAsync("Rubro Solo Inactivos");
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertInactiveProductAsync(medioId, productTypeId, rubroId: rubroId);
var result = await _repository.CountActiveByRubroAsync(rubroId);
result.Should().Be(0);
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private async Task<int> InsertRubroAsync(string nombre)
{
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
return await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Rubro (Nombre, ParentId, Orden, Activo, TarifarioBaseId, FechaCreacion)
OUTPUT INSERTED.Id
VALUES (@Nombre, NULL, 0, 1, NULL, SYSUTCDATETIME())
""", new { Nombre = nombre });
}
private async Task<(int MedioId, int ProductTypeId)> InsertMedioAndProductTypeAsync() private async Task<(int MedioId, int ProductTypeId)> InsertMedioAndProductTypeAsync()
{ {
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
@@ -98,23 +156,25 @@ public class ProductQueryRepositoryTests : IAsyncLifetime
return (medioId, productTypeId); return (medioId, productTypeId);
} }
private async Task InsertActiveProductAsync(int medioId, int productTypeId) private async Task InsertActiveProductAsync(int medioId, int productTypeId, int? rubroId)
{ {
var nombre = $"ProdActivo-{Guid.NewGuid():N}";
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync(); await conn.OpenAsync();
await conn.ExecuteAsync(""" await conn.ExecuteAsync("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, IsActive, FechaCreacion)
VALUES ('Producto Activo', @MedioId, @ProductTypeId, 100, 1, SYSUTCDATETIME()) VALUES (@Nombre, @MedioId, @ProductTypeId, @RubroId, 100, 1, SYSUTCDATETIME())
""", new { MedioId = medioId, ProductTypeId = productTypeId }); """, new { Nombre = nombre, MedioId = medioId, ProductTypeId = productTypeId, RubroId = rubroId });
} }
private async Task InsertInactiveProductAsync(int medioId, int productTypeId) private async Task InsertInactiveProductAsync(int medioId, int productTypeId, int? rubroId)
{ {
var nombre = $"ProdInactivo-{Guid.NewGuid():N}";
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync(); await conn.OpenAsync();
await conn.ExecuteAsync(""" await conn.ExecuteAsync("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, IsActive, FechaCreacion)
VALUES ('Producto Inactivo', @MedioId, @ProductTypeId, 100, 0, SYSUTCDATETIME()) VALUES (@Nombre, @MedioId, @ProductTypeId, @RubroId, 100, 0, SYSUTCDATETIME())
""", new { MedioId = medioId, ProductTypeId = productTypeId }); """, new { Nombre = nombre, MedioId = medioId, ProductTypeId = productTypeId, RubroId = rubroId });
} }
} }

View File

@@ -13,6 +13,7 @@ public class DeactivateRubroCommandHandlerTests
{ {
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>(); private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>(); private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly IProductQueryRepository _productQuery = Substitute.For<IProductQueryRepository>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly DeactivateRubroCommandHandler _handler; private readonly DeactivateRubroCommandHandler _handler;
@@ -22,7 +23,8 @@ public class DeactivateRubroCommandHandlerTests
public DeactivateRubroCommandHandlerTests() public DeactivateRubroCommandHandlerTests()
{ {
_repo.CountActiveChildrenAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0); _repo.CountActiveChildrenAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0);
_handler = new DeactivateRubroCommandHandler(_repo, _audit, _timeProvider); _productQuery.CountActiveByRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0);
_handler = new DeactivateRubroCommandHandler(_repo, _audit, _productQuery, _timeProvider);
} }
// ── Happy path: leaf soft-delete ───────────────────────────────────────── // ── Happy path: leaf soft-delete ─────────────────────────────────────────
@@ -103,4 +105,43 @@ public class DeactivateRubroCommandHandlerTests
await act.Should().ThrowAsync<RubroNotFoundException>(); await act.Should().ThrowAsync<RubroNotFoundException>();
} }
// ── Active Products guard (issue #41) ────────────────────────────────────
[Fact]
public async Task Deactivate_WithActiveProducts_ThrowsRubroConProductosActivosException()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro(10));
_productQuery.CountActiveByRubroAsync(10, Arg.Any<CancellationToken>()).Returns(3);
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 10));
await act.Should().ThrowAsync<RubroConProductosActivosException>()
.Where(ex => ex.RubroId == 10 && ex.ProductosActivosCount == 3);
}
[Fact]
public async Task Deactivate_WithZeroActiveProducts_Succeeds()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro(10));
_productQuery.CountActiveByRubroAsync(10, Arg.Any<CancellationToken>()).Returns(0);
var result = await _handler.Handle(new DeactivateRubroCommand(Id: 10));
result.Activo.Should().BeFalse();
}
[Fact]
public async Task Deactivate_WithActiveChildrenAndProducts_ThrowsChildrenFirst()
{
// Children guard fires BEFORE products guard — order of checks must be stable.
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(LeafRubro(5));
_repo.CountActiveChildrenAsync(5, Arg.Any<CancellationToken>()).Returns(2);
_productQuery.CountActiveByRubroAsync(5, Arg.Any<CancellationToken>()).Returns(3);
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 5));
// Children exception must win — products guard is never reached
await act.Should().ThrowAsync<RubroTieneHijosActivosException>();
}
} }