feat(domain): RubroConProductosActivosException + guard en DeactivateRubro (closes #41) #44
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
160
tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs
Normal file
160
tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user