diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index decc84d..8fc1884 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -267,6 +267,18 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; 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 case MedioCodigoDuplicadoException medioCodDupEx: context.Result = new ObjectResult(new diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs index e4418ba..53bee93 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs @@ -12,4 +12,10 @@ public interface IProductQueryRepository /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products. /// Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default); + + /// + /// Returns the count of active Products where RubroId = rubroId. + /// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41) + /// + Task CountActiveByRubroAsync(int rubroId, CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs index 0099b13..1a15bf0 100644 --- a/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs @@ -10,15 +10,18 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler 0) 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); using var tx = new TransactionScope( diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroConProductosActivosException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroConProductosActivosException.cs new file mode 100644 index 0000000..e55fb34 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RubroConProductosActivosException.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to soft-delete a Rubro that still has active Products referencing it via RubroId. → HTTP 409 +/// +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; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs index 4ee619b..0615f39 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs @@ -34,4 +34,16 @@ public sealed class ProductQueryRepository : IProductQueryRepository var result = await connection.ExecuteScalarAsync(sql, new { ProductTypeId = productTypeId }); return result == 1; } + + public async Task 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(sql, new { RubroId = rubroId }); + } } diff --git a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs index 76010e6..aa1890b 100644 --- a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs @@ -327,4 +327,7 @@ file sealed class AlwaysInUseProductQueryRepository : IProductQueryRepository { public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) => Task.FromResult(true); + + public Task CountActiveByRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(0); } diff --git a/tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs b/tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs new file mode 100644 index 0000000..82b158f --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs @@ -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; + +/// +/// 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. +/// +[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.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()) + { + Exception = new RubroConProductosActivosException(rubroId: 5, productosActivosCount: 2) + }; + + filter.OnException(ctx); + + var result = Assert.IsType(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 ───────────────── + + /// + /// 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). + /// + [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(); + 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(); + services.AddScoped(_ => 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(); + Assert.Equal("rubro_con_productos_activos", body.GetProperty("error").GetString()); + } + finally + { + await DeleteRubroDirectAsync(rubroId); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + 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))"); + } +} + +/// +/// 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. +/// +file sealed class StubProductQueryRepository : IProductQueryRepository +{ + private readonly int _activeCount; + + public StubProductQueryRepository(int activeCount) + { + _activeCount = activeCount; + } + + public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + => Task.FromResult(false); + + public Task CountActiveByRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(_activeCount); +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Rubros/RubroConProductosActivosExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/Rubros/RubroConProductosActivosExceptionTests.cs new file mode 100644 index 0000000..b8c433a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Rubros/RubroConProductosActivosExceptionTests.cs @@ -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(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs index 309e8e3..e557810 100644 --- a/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs @@ -8,6 +8,7 @@ namespace SIGCM2.Application.Tests.Products.Repository; /// /// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App. /// These tests verify the real Dapper implementation replaces NullProductQueryRepository. +/// Issue #41: CountActiveByRubroAsync tests added here (same class, same DB, same fixture). /// [Collection("Database")] public class ProductQueryRepositoryTests : IAsyncLifetime @@ -44,7 +45,7 @@ public class ProductQueryRepositoryTests : IAsyncLifetime { // Arrange: insert a ProductType and an active Product referencing it var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); - await InsertActiveProductAsync(medioId, productTypeId); + await InsertActiveProductAsync(medioId, productTypeId, rubroId: null); var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); @@ -56,7 +57,7 @@ public class ProductQueryRepositoryTests : IAsyncLifetime { // Arrange: insert an inactive product var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); - await InsertInactiveProductAsync(medioId, productTypeId); + await InsertInactiveProductAsync(medioId, productTypeId, rubroId: null); 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 var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); - await InsertActiveProductAsync(medioId, productTypeId); + await InsertActiveProductAsync(medioId, productTypeId, rubroId: null); var otherProductTypeId = productTypeId + 100; var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId); @@ -76,8 +77,65 @@ public class ProductQueryRepositoryTests : IAsyncLifetime 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 ─────────────────────────────────────────────────────────────── + private async Task InsertRubroAsync(string nombre) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + return await conn.ExecuteScalarAsync(""" + 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() { await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); @@ -98,23 +156,25 @@ public class ProductQueryRepositoryTests : IAsyncLifetime 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 conn.OpenAsync(); await conn.ExecuteAsync(""" - INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) - VALUES ('Producto Activo', @MedioId, @ProductTypeId, 100, 1, SYSUTCDATETIME()) - """, new { MedioId = medioId, ProductTypeId = productTypeId }); + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, IsActive, FechaCreacion) + VALUES (@Nombre, @MedioId, @ProductTypeId, @RubroId, 100, 1, SYSUTCDATETIME()) + """, 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 conn.OpenAsync(); await conn.ExecuteAsync(""" - INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) - VALUES ('Producto Inactivo', @MedioId, @ProductTypeId, 100, 0, SYSUTCDATETIME()) - """, new { MedioId = medioId, ProductTypeId = productTypeId }); + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, IsActive, FechaCreacion) + VALUES (@Nombre, @MedioId, @ProductTypeId, @RubroId, 100, 0, SYSUTCDATETIME()) + """, new { Nombre = nombre, MedioId = medioId, ProductTypeId = productTypeId, RubroId = rubroId }); } } diff --git a/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs index 68b5ddd..2ca3040 100644 --- a/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs @@ -13,6 +13,7 @@ public class DeactivateRubroCommandHandlerTests { private readonly IRubroRepository _repo = Substitute.For(); private readonly IAuditLogger _audit = Substitute.For(); + private readonly IProductQueryRepository _productQuery = Substitute.For(); private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); private readonly DeactivateRubroCommandHandler _handler; @@ -22,7 +23,8 @@ public class DeactivateRubroCommandHandlerTests public DeactivateRubroCommandHandlerTests() { _repo.CountActiveChildrenAsync(Arg.Any(), Arg.Any()).Returns(0); - _handler = new DeactivateRubroCommandHandler(_repo, _audit, _timeProvider); + _productQuery.CountActiveByRubroAsync(Arg.Any(), Arg.Any()).Returns(0); + _handler = new DeactivateRubroCommandHandler(_repo, _audit, _productQuery, _timeProvider); } // ── Happy path: leaf soft-delete ───────────────────────────────────────── @@ -103,4 +105,43 @@ public class DeactivateRubroCommandHandlerTests await act.Should().ThrowAsync(); } + + // ── Active Products guard (issue #41) ──────────────────────────────────── + + [Fact] + public async Task Deactivate_WithActiveProducts_ThrowsRubroConProductosActivosException() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(LeafRubro(10)); + _productQuery.CountActiveByRubroAsync(10, Arg.Any()).Returns(3); + + var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 10)); + + await act.Should().ThrowAsync() + .Where(ex => ex.RubroId == 10 && ex.ProductosActivosCount == 3); + } + + [Fact] + public async Task Deactivate_WithZeroActiveProducts_Succeeds() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(LeafRubro(10)); + _productQuery.CountActiveByRubroAsync(10, Arg.Any()).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()).Returns(LeafRubro(5)); + _repo.CountActiveChildrenAsync(5, Arg.Any()).Returns(2); + _productQuery.CountActiveByRubroAsync(5, Arg.Any()).Returns(3); + + var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 5)); + + // Children exception must win — products guard is never reached + await act.Should().ThrowAsync(); + } }