Merge pull request 'feat: CAT-002 Regla de Oro Rama vs Hoja + validaciones' (#35) from feature/CAT-002 into main
This commit was merged in pull request #35.
This commit is contained in:
@@ -242,6 +242,31 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// CAT-002: Rubro Regla de Oro (rama vs hoja)
|
||||
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_padre_es_hoja_con_avisos",
|
||||
message = rubroPadreHojaEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_es_rama_con_hijos_activos",
|
||||
message = rubroRamaHijosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-001: Medio exceptions
|
||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Query-only access to Aviso counts by Rubro.
|
||||
/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002
|
||||
/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding.
|
||||
/// </summary>
|
||||
public interface IAvisoQueryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the count of avisos (active, non-archived) assigned to the given rubro.
|
||||
/// </summary>
|
||||
Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a dictionary of { rubroId → count } for the provided ids.
|
||||
/// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node.
|
||||
/// The implementation MUST do a single query; the stub returns an empty dictionary
|
||||
/// (every rubro gets 0 via dictionary.GetValueOrDefault).
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
||||
IReadOnlyCollection<int> rubroIds,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Avisos;
|
||||
|
||||
/// <summary>
|
||||
/// STUB — PRD-002 reemplaza con AvisoQueryRepository contra dbo.Aviso.
|
||||
/// Returns 0 / empty dictionary so every handler guard passes and every tree node shows TieneAvisos=false.
|
||||
/// This is intentional for CAT-002: the mechanism is installed; the data feed arrives in PRD-002.
|
||||
/// </summary>
|
||||
public sealed class NullAvisoQueryRepository : IAvisoQueryRepository
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, int> Empty =
|
||||
new Dictionary<int, int>(capacity: 0);
|
||||
|
||||
public Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
||||
IReadOnlyCollection<int> rubroIds,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(Empty);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ using SIGCM2.Application.Rubros.Move;
|
||||
using SIGCM2.Application.Rubros.GetTree;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Avisos;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -152,7 +154,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
|
||||
|
||||
// Rubros (CAT-001)
|
||||
// Rubros (CAT-001 + CAT-002)
|
||||
// CAT-002: Regla de Oro Rama vs Hoja — stub binding until PRD-002 provides real impl
|
||||
services.AddScoped<IAvisoQueryRepository, NullAvisoQueryRepository>();
|
||||
|
||||
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
||||
|
||||
@@ -12,7 +12,8 @@ public static class RubroTreeBuilder
|
||||
{
|
||||
public static IReadOnlyList<RubroTreeNodeDto> Build(
|
||||
IEnumerable<Rubro> flat,
|
||||
bool incluirInactivos)
|
||||
bool incluirInactivos,
|
||||
IReadOnlyDictionary<int, int> avisoCounts)
|
||||
{
|
||||
var filtered = incluirInactivos
|
||||
? flat.ToList()
|
||||
@@ -36,6 +37,7 @@ public static class RubroTreeBuilder
|
||||
Activo: r.Activo,
|
||||
ParentId: r.ParentId,
|
||||
TarifarioBaseId: r.TarifarioBaseId,
|
||||
TieneAvisos: avisoCounts.GetValueOrDefault(r.Id, 0) > 0,
|
||||
Hijos: children);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,17 +14,20 @@ public sealed class CreateRubroCommandHandler : ICommandHandler<CreateRubroComma
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RubrosOptions _options;
|
||||
private readonly IAvisoQueryRepository _avisoQuery;
|
||||
|
||||
public CreateRubroCommandHandler(
|
||||
IRubroRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<RubrosOptions> options)
|
||||
IOptions<RubrosOptions> options,
|
||||
IAvisoQueryRepository avisoQuery)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_avisoQuery = avisoQuery;
|
||||
}
|
||||
|
||||
public async Task<RubroCreatedDto> Handle(CreateRubroCommand command)
|
||||
@@ -38,6 +41,12 @@ public sealed class CreateRubroCommandHandler : ICommandHandler<CreateRubroComma
|
||||
if (!parent.Activo)
|
||||
throw new RubroPadreInactivoException(command.ParentId.Value);
|
||||
|
||||
// CAT-002: Regla de Oro — padre no puede ser hoja con avisos
|
||||
// CAT-002/PRD-002 TOCTOU — evaluar upgrade a RepeatableRead o constraint DB cuando exista dbo.Aviso
|
||||
var avisosCount = await _avisoQuery.CountAvisosEnRubroAsync(command.ParentId.Value);
|
||||
if (avisosCount > 0)
|
||||
throw new RubroPadreEsHojaConAvisosException(command.ParentId.Value, avisosCount);
|
||||
|
||||
// Depth check: parent's depth + 1 must not exceed MaxDepth
|
||||
var parentDepth = await _repo.GetDepthAsync(command.ParentId);
|
||||
var newDepth = parentDepth + 1;
|
||||
|
||||
@@ -10,4 +10,5 @@ public sealed record RubroTreeNodeDto(
|
||||
bool Activo,
|
||||
int? ParentId,
|
||||
int? TarifarioBaseId,
|
||||
bool TieneAvisos,
|
||||
IReadOnlyList<RubroTreeNodeDto> Hijos);
|
||||
|
||||
@@ -8,15 +8,20 @@ namespace SIGCM2.Application.Rubros.GetTree;
|
||||
public sealed class GetRubroTreeQueryHandler : ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>
|
||||
{
|
||||
private readonly IRubroRepository _repo;
|
||||
private readonly IAvisoQueryRepository _avisoQuery;
|
||||
|
||||
public GetRubroTreeQueryHandler(IRubroRepository repo)
|
||||
public GetRubroTreeQueryHandler(IRubroRepository repo, IAvisoQueryRepository avisoQuery)
|
||||
{
|
||||
_repo = repo;
|
||||
_avisoQuery = avisoQuery;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RubroTreeNodeDto>> Handle(GetRubroTreeQuery query)
|
||||
{
|
||||
var all = await _repo.GetAllAsync(query.IncluirInactivos);
|
||||
return RubroTreeBuilder.Build(all, query.IncluirInactivos);
|
||||
var ids = all.Select(r => r.Id).ToList();
|
||||
// CAT-002: single batch call — avoids N+1 when PRD-002 activates the real implementation
|
||||
var avisoCounts = await _avisoQuery.CountAvisosBatchAsync(ids);
|
||||
return RubroTreeBuilder.Build(all, query.IncluirInactivos, avisoCounts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,20 @@ public sealed class MoveRubroCommandHandler : ICommandHandler<MoveRubroCommand,
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RubrosOptions _options;
|
||||
private readonly IAvisoQueryRepository _avisoQuery;
|
||||
|
||||
public MoveRubroCommandHandler(
|
||||
IRubroRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<RubrosOptions> options)
|
||||
IOptions<RubrosOptions> options,
|
||||
IAvisoQueryRepository avisoQuery)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_avisoQuery = avisoQuery;
|
||||
}
|
||||
|
||||
public async Task<RubroMovedDto> Handle(MoveRubroCommand command)
|
||||
@@ -47,6 +50,12 @@ public sealed class MoveRubroCommandHandler : ICommandHandler<MoveRubroCommand,
|
||||
if (!newParent.Activo)
|
||||
throw new RubroPadreInactivoException(command.NuevoParentId.Value);
|
||||
|
||||
// CAT-002: Regla de Oro — nuevo padre no puede ser hoja con avisos
|
||||
// CAT-002/PRD-002 TOCTOU — evaluar upgrade a RepeatableRead o constraint DB cuando exista dbo.Aviso
|
||||
var avisosCount = await _avisoQuery.CountAvisosEnRubroAsync(command.NuevoParentId.Value);
|
||||
if (avisosCount > 0)
|
||||
throw new RubroPadreEsHojaConAvisosException(command.NuevoParentId.Value, avisosCount);
|
||||
|
||||
// Depth check
|
||||
var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId);
|
||||
var newDepth = parentDepth + 1;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to assign an aviso to a rubro that has active children.
|
||||
/// Invariante: un nodo con hijos activos es RAMA — no puede recibir avisos directos. → HTTP 409
|
||||
/// NOTE: no handler launches this in CAT-002. Consumer: PRD-002 CreateAvisoCommandHandler.
|
||||
/// </summary>
|
||||
public sealed class RubroEsRamaConHijosActivosException : DomainException
|
||||
{
|
||||
public int RubroId { get; }
|
||||
public int CantidadHijos { get; }
|
||||
|
||||
public RubroEsRamaConHijosActivosException(int rubroId, int cantidadHijos)
|
||||
: base($"El destino tiene sub-rubros. No puede contener avisos directos.")
|
||||
{
|
||||
RubroId = rubroId;
|
||||
CantidadHijos = cantidadHijos;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to create or move a child into a rubro that already has avisos assigned.
|
||||
/// Invariante: un nodo con avisos es HOJA — no puede tener hijos. → HTTP 409
|
||||
/// </summary>
|
||||
public sealed class RubroPadreEsHojaConAvisosException : DomainException
|
||||
{
|
||||
public int ParentId { get; }
|
||||
public int CantidadAvisos { get; }
|
||||
|
||||
public RubroPadreEsHojaConAvisosException(int parentId, int cantidadAvisos)
|
||||
: base($"El rubro padre contiene {cantidadAvisos} avisos. Muévalos antes de crear sub-rubros.")
|
||||
{
|
||||
ParentId = parentId;
|
||||
CantidadAvisos = cantidadAvisos;
|
||||
}
|
||||
}
|
||||
@@ -101,11 +101,14 @@ export function CategoryTreeNode({
|
||||
{/* Action buttons — only if canEdit */}
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* CAT-002: disabled when leaf-with-avisos; PRD-002 activates the real data path */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label={`Agregar subrubro en ${node.nombre}`}
|
||||
disabled={node.tieneAvisos === true}
|
||||
title={node.tieneAvisos === true ? 'El rubro contiene avisos asignados. Muévalos antes de agregar sub-rubros.' : undefined}
|
||||
onClick={() => onAddChild(node.id)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface RubroTreeNode {
|
||||
activo: boolean
|
||||
parentId: number | null
|
||||
tarifarioBaseId: number | null
|
||||
// CAT-002: additive field — optional for backward-compat (PRD-002 always sends it)
|
||||
tieneAvisos?: boolean
|
||||
hijos: RubroTreeNode[]
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,64 @@ describe('CategoryTree', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── CAT-002: tieneAvisos disables "Agregar subrubro" button ──────────────────
|
||||
|
||||
describe('CategoryTreeNode tieneAvisos (CAT-002)', () => {
|
||||
it('BotonAgregarSubrubro_Disabled_CuandoTieneAvisosTrue', () => {
|
||||
const nodeConAvisos: RubroTreeNode = {
|
||||
id: 10,
|
||||
nombre: 'ConAvisos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
tieneAvisos: true,
|
||||
hijos: [],
|
||||
}
|
||||
render(
|
||||
<CategoryTree
|
||||
nodes={[nodeConAvisos]}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onAddChild={noop}
|
||||
onMove={noop}
|
||||
canEdit={true}
|
||||
/>,
|
||||
)
|
||||
const addBtn = screen.getByRole('button', { name: /agregar subrubro en conavisos/i })
|
||||
expect(addBtn).toBeDisabled()
|
||||
expect(addBtn).toHaveAttribute('title')
|
||||
expect(addBtn.getAttribute('title')).toBe(
|
||||
'El rubro contiene avisos asignados. Muévalos antes de agregar sub-rubros.',
|
||||
)
|
||||
})
|
||||
|
||||
it('BotonAgregarSubrubro_Enabled_CuandoTieneAvisosFalse', () => {
|
||||
const nodeSinAvisos: RubroTreeNode = {
|
||||
id: 11,
|
||||
nombre: 'SinAvisos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
tieneAvisos: false,
|
||||
hijos: [],
|
||||
}
|
||||
render(
|
||||
<CategoryTree
|
||||
nodes={[nodeSinAvisos]}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onAddChild={noop}
|
||||
onMove={noop}
|
||||
canEdit={true}
|
||||
/>,
|
||||
)
|
||||
const addBtn = screen.getByRole('button', { name: /agregar subrubro en sinavisos/i })
|
||||
expect(addBtn).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CategoryTreeNode depth guard', () => {
|
||||
it('renders depth warning when depth exceeds 10', () => {
|
||||
// Build a deeply nested node at depth 11
|
||||
|
||||
@@ -313,6 +313,35 @@ describe('MoveRubroDialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('displays backend error inline when 409 rubro_padre_es_hoja_con_avisos (CAT-002)', async () => {
|
||||
mockMutateAsync.mockRejectedValue({
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
error: 'rubro_padre_es_hoja_con_avisos',
|
||||
message: 'El rubro padre contiene 3 avisos. Muévalos antes de crear sub-rubros.',
|
||||
},
|
||||
},
|
||||
})
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/el rubro padre contiene 3 avisos/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays backend error inline when 422 depth', async () => {
|
||||
mockMutateAsync.mockRejectedValue({
|
||||
response: {
|
||||
|
||||
177
tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs
Normal file
177
tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
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.Logging.Abstractions;
|
||||
using SIGCM2.Api.Filters;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Rubros;
|
||||
|
||||
/// <summary>
|
||||
/// CAT-002 — Regla de Oro Rama vs Hoja.
|
||||
///
|
||||
/// Unit tests: ExceptionFilter mapping for new 409 cases (no DB needed).
|
||||
/// Integration: GET /arbol returns tieneAvisos field per node (stub = false).
|
||||
///
|
||||
/// Design note: the 409 guard behavior is fully covered by unit tests in
|
||||
/// SIGCM2.Application.Tests (CreateRubroCommandHandlerTests, MoveRubroCommandHandlerTests).
|
||||
/// e2e 409 verification via a separate factory is skipped here because the shared
|
||||
/// ApiIntegration singleton factory cannot be safely augmented with per-test DI overrides
|
||||
/// (RSA key singleton issue documented in ApiIntegrationCollection.cs).
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class RubrosReglaDeOroTests : IAsyncLifetime
|
||||
{
|
||||
private const string AdminEndpoint = "/api/v1/admin/rubros";
|
||||
private const string ReadEndpoint = "/api/v1/rubros";
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public RubrosReglaDeOroTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── 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 HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static async Task DeleteRubroIfExistsAsync(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, """
|
||||
WITH ToDelete AS (
|
||||
SELECT Id FROM dbo.Rubro WHERE Id = @Id
|
||||
UNION ALL
|
||||
SELECT r.Id FROM dbo.Rubro r INNER JOIN ToDelete t ON r.ParentId = t.Id
|
||||
)
|
||||
DELETE r FROM dbo.Rubro r INNER JOIN ToDelete td ON r.Id = td.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))");
|
||||
}
|
||||
|
||||
// ── ExceptionFilter unit tests (no DB, no HTTP) ───────────────────────────
|
||||
|
||||
private static ExceptionContext MakeExceptionContext(Exception exception)
|
||||
{
|
||||
var httpContext = new 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);
|
||||
return new ExceptionContext(actionContext, new List<IFilterMetadata>())
|
||||
{
|
||||
Exception = exception
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409()
|
||||
{
|
||||
var filter = new ExceptionFilter(NullLogger<ExceptionFilter>.Instance);
|
||||
var ctx = MakeExceptionContext(new RubroPadreEsHojaConAvisosException(parentId: 1, cantidadAvisos: 3));
|
||||
|
||||
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_padre_es_hoja_con_avisos", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionFilter_MapsRubroEsRamaConHijosActivos_To409()
|
||||
{
|
||||
var filter = new ExceptionFilter(NullLogger<ExceptionFilter>.Instance);
|
||||
var ctx = MakeExceptionContext(new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 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_es_rama_con_hijos_activos", json);
|
||||
}
|
||||
|
||||
// ── Integration: GET /arbol includes tieneAvisos field (stub = false) ─────
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_ResponseIncludesTieneAvisosField_FalseWithStub()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// Create a root rubro to ensure tree is non-empty
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = "TieneAvisosCheck_CAT002",
|
||||
parentId = (int?)null,
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var rootId = created.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var ourNode = json.EnumerateArray()
|
||||
.FirstOrDefault(n => n.GetProperty("id").GetInt32() == rootId);
|
||||
|
||||
Assert.True(ourNode.ValueKind != JsonValueKind.Undefined, "Our rubro must appear in tree");
|
||||
Assert.True(ourNode.TryGetProperty("tieneAvisos", out var tieneAvisos),
|
||||
"tieneAvisos must be present in every tree node (CAT-002 additive field)");
|
||||
Assert.False(tieneAvisos.GetBoolean(),
|
||||
"Stub (NullAvisoQueryRepository) must always return false");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(rootId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration: POST returns 409 message format (guard path) ─────────────
|
||||
// NOTE: these tests rely on the unit-tested handler behavior. The 409 is proven by:
|
||||
// - CreateRubroCommandHandlerTests.Handle_ParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException
|
||||
// - ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409 (above)
|
||||
// The combined e2e 409 test is omitted here because it requires per-factory DI override
|
||||
// which conflicts with the shared ApiIntegration RSA singleton pattern.
|
||||
// See: ApiIntegrationCollection.cs for the rationale.
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Application.Avisos;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Avisos;
|
||||
|
||||
public class NullAvisoQueryRepositoryTests
|
||||
{
|
||||
private readonly NullAvisoQueryRepository _repo = new();
|
||||
|
||||
[Fact]
|
||||
public async Task CountAvisosEnRubroAsync_Always_ReturnsZero()
|
||||
{
|
||||
var result = await _repo.CountAvisosEnRubroAsync(rubroId: 99);
|
||||
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountAvisosBatchAsync_WithIds_ReturnsDictionaryAllZero()
|
||||
{
|
||||
var result = await _repo.CountAvisosBatchAsync([1, 2, 3]);
|
||||
|
||||
result.GetValueOrDefault(1, 0).Should().Be(0);
|
||||
result.GetValueOrDefault(2, 0).Should().Be(0);
|
||||
result.GetValueOrDefault(3, 0).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountAvisosBatchAsync_EmptyIds_ReturnsEmptyDictionary()
|
||||
{
|
||||
var result = await _repo.CountAvisosBatchAsync([]);
|
||||
|
||||
result.Should().HaveCount(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain.Rubros;
|
||||
|
||||
public class RubroReglaDeOroExceptionsTests
|
||||
{
|
||||
// ── RubroPadreEsHojaConAvisosException ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroPadreEsHojaConAvisosException_Constructor_SetsMessage()
|
||||
{
|
||||
var ex = new RubroPadreEsHojaConAvisosException(parentId: 42, cantidadAvisos: 3);
|
||||
|
||||
ex.Message.Should().Be("El rubro padre contiene 3 avisos. Muévalos antes de crear sub-rubros.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroPadreEsHojaConAvisosException_Constructor_SetsParentId_Y_CantidadAvisos()
|
||||
{
|
||||
var ex = new RubroPadreEsHojaConAvisosException(parentId: 42, cantidadAvisos: 3);
|
||||
|
||||
ex.ParentId.Should().Be(42);
|
||||
ex.CantidadAvisos.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroPadreEsHojaConAvisosException_IsA_DomainException()
|
||||
{
|
||||
var ex = new RubroPadreEsHojaConAvisosException(parentId: 1, cantidadAvisos: 1);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
|
||||
// ── RubroEsRamaConHijosActivosException ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroEsRamaConHijosActivosException_Constructor_SetsMessage()
|
||||
{
|
||||
var ex = new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 2);
|
||||
|
||||
ex.Message.Should().Be("El destino tiene sub-rubros. No puede contener avisos directos.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroEsRamaConHijosActivosException_Constructor_SetsRubroId_Y_CantidadHijos()
|
||||
{
|
||||
var ex = new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 2);
|
||||
|
||||
ex.RubroId.Should().Be(7);
|
||||
ex.CantidadHijos.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroEsRamaConHijosActivosException_IsA_DomainException()
|
||||
{
|
||||
var ex = new RubroEsRamaConHijosActivosException(rubroId: 1, cantidadHijos: 1);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public class CreateRubroCommandHandlerTests
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
||||
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
||||
private readonly CreateRubroCommandHandler _handler;
|
||||
|
||||
public CreateRubroCommandHandlerTests()
|
||||
@@ -30,8 +31,11 @@ public class CreateRubroCommandHandlerTests
|
||||
.Returns(1);
|
||||
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
// Default: no avisos (stub behavior)
|
||||
_avisoQuery.CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
|
||||
_handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options);
|
||||
_handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery);
|
||||
}
|
||||
|
||||
private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null);
|
||||
@@ -173,4 +177,76 @@ public class CreateRubroCommandHandlerTests
|
||||
|
||||
result.Id.Should().Be(99);
|
||||
}
|
||||
|
||||
// ── CAT-002: Guard padre sin avisos ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException()
|
||||
{
|
||||
const int parentId = 5;
|
||||
var parent = new Rubro(parentId, null, "ParentConAvisos", 0, activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(3);
|
||||
|
||||
var act = () => _handler.Handle(ChildCommand(parentId: parentId));
|
||||
|
||||
await act.Should().ThrowAsync<RubroPadreEsHojaConAvisosException>()
|
||||
.Where(ex => ex.ParentId == parentId && ex.CantidadAvisos == 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ParentTieneCeroAvisos_DoesNotThrow()
|
||||
{
|
||||
const int parentId = 5;
|
||||
var parent = new Rubro(parentId, null, "ParentSinAvisos", 0, activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(0);
|
||||
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(10);
|
||||
|
||||
var result = await _handler.Handle(ChildCommand(parentId: parentId));
|
||||
|
||||
result.Id.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ParentNull_SkipsAvisosGuard()
|
||||
{
|
||||
// Root creation — no parent → CountAvisosEnRubroAsync should NOT be called
|
||||
await _handler.Handle(RootCommand());
|
||||
|
||||
await _avisoQuery.DidNotReceive().CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_GuardOrder_ParentInactivo_Wins_OverAvisos()
|
||||
{
|
||||
// Inactive parent with avisos → RubroPadreInactivoException (not avisos exception)
|
||||
const int parentId = 7;
|
||||
var inactiveParent = new Rubro(parentId, null, "InactivoConAvisos", 0, activo: false, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(inactiveParent);
|
||||
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(3);
|
||||
|
||||
var act = () => _handler.Handle(ChildCommand(parentId: parentId));
|
||||
|
||||
await act.Should().ThrowAsync<RubroPadreInactivoException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_GuardOrder_Avisos_Wins_OverDepth()
|
||||
{
|
||||
// Parent at MAX_DEPTH AND has avisos → RubroPadreEsHojaConAvisosException (avisos guard fires first)
|
||||
const int parentId = 5;
|
||||
var parent = new Rubro(parentId, null, "ParentAtMaxDepth", 0, activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(2);
|
||||
_repo.GetDepthAsync(parentId, Arg.Any<CancellationToken>()).Returns(10); // at MaxDepth
|
||||
|
||||
var act = () => _handler.Handle(ChildCommand(parentId: parentId));
|
||||
|
||||
await act.Should().ThrowAsync<RubroPadreEsHojaConAvisosException>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Avisos;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.GetTree;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -13,6 +14,7 @@ public class GetRubroTreeQueryHandlerTests
|
||||
{
|
||||
private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
|
||||
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
||||
|
||||
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
|
||||
=> new(id, parentId, $"Rubro{id}", 0, activo, null, FakeTime.GetUtcNow().UtcDateTime, null);
|
||||
@@ -24,8 +26,10 @@ public class GetRubroTreeQueryHandlerTests
|
||||
{
|
||||
_repo.GetAllAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(1), MakeRubro(2) });
|
||||
_avisoQuery.CountAvisosBatchAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, int>());
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo);
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery);
|
||||
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
@@ -36,8 +40,10 @@ public class GetRubroTreeQueryHandlerTests
|
||||
{
|
||||
_repo.GetAllAsync(true, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(1), MakeRubro(2, activo: false) });
|
||||
_avisoQuery.CountAvisosBatchAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, int>());
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo);
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery);
|
||||
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: true));
|
||||
|
||||
await _repo.Received(1).GetAllAsync(true, Arg.Any<CancellationToken>());
|
||||
@@ -49,13 +55,77 @@ public class GetRubroTreeQueryHandlerTests
|
||||
{
|
||||
_repo.GetAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Rubro>());
|
||||
_avisoQuery.CountAvisosBatchAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, int>());
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo);
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery);
|
||||
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── CAT-002: TieneAvisos populated via batch ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PopulatesTieneAvisos_True_WhenBatchResultContainsCount()
|
||||
{
|
||||
_repo.GetAllAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(1), MakeRubro(2), MakeRubro(3) });
|
||||
_avisoQuery.CountAvisosBatchAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, int> { { 1, 2 } });
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery);
|
||||
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||
|
||||
result.Single(n => n.Id == 1).TieneAvisos.Should().BeTrue();
|
||||
result.Single(n => n.Id == 2).TieneAvisos.Should().BeFalse();
|
||||
result.Single(n => n.Id == 3).TieneAvisos.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_CallsBatchExactlyOnce_WithAllRubroIds()
|
||||
{
|
||||
_repo.GetAllAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(1), MakeRubro(2), MakeRubro(3) });
|
||||
_avisoQuery.CountAvisosBatchAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, int>());
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery);
|
||||
await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||
|
||||
await _avisoQuery.Received(1).CountAvisosBatchAsync(
|
||||
Arg.Any<IReadOnlyCollection<int>>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyTree_CallsBatchWithEmptyList()
|
||||
{
|
||||
_repo.GetAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Rubro>());
|
||||
_avisoQuery.CountAvisosBatchAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<int, int>());
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery);
|
||||
await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||
|
||||
await _avisoQuery.Received(1).CountAvisosBatchAsync(
|
||||
Arg.Is<IReadOnlyCollection<int>>(ids => ids.Count == 0),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_StubBehavior_AllNodesTieneAvisosFalse()
|
||||
{
|
||||
_repo.GetAllAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(1), MakeRubro(2) });
|
||||
|
||||
var handler = new GetRubroTreeQueryHandler(_repo, new NullAvisoQueryRepository());
|
||||
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||
|
||||
result.Should().AllSatisfy(n => n.TieneAvisos.Should().BeFalse());
|
||||
}
|
||||
|
||||
// ── GetRubroByIdQueryHandler ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -17,6 +17,7 @@ public class MoveRubroCommandHandlerTests
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
||||
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
||||
private readonly MoveRubroCommandHandler _handler;
|
||||
|
||||
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
|
||||
@@ -32,8 +33,11 @@ public class MoveRubroCommandHandlerTests
|
||||
.Returns(0);
|
||||
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
// Default: no avisos
|
||||
_avisoQuery.CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
|
||||
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options);
|
||||
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery);
|
||||
}
|
||||
|
||||
// ── Happy path: move to other parent ────────────────────────────────────
|
||||
@@ -173,4 +177,66 @@ public class MoveRubroCommandHandlerTests
|
||||
|
||||
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
|
||||
}
|
||||
|
||||
// ── CAT-002: Guard nuevo padre sin avisos ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NuevoParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException()
|
||||
{
|
||||
const int nuevoParentId = 20;
|
||||
var rubro = MakeRubro(8, parentId: 2);
|
||||
var newParent = MakeRubro(nuevoParentId);
|
||||
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(newParent);
|
||||
_avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(2);
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: nuevoParentId, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroPadreEsHojaConAvisosException>()
|
||||
.Where(ex => ex.ParentId == nuevoParentId && ex.CantidadAvisos == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NuevoParentTieneCeroAvisos_DoesNotThrow()
|
||||
{
|
||||
const int nuevoParentId = 20;
|
||||
var rubro = MakeRubro(8, parentId: 2);
|
||||
var newParent = MakeRubro(nuevoParentId);
|
||||
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(newParent);
|
||||
_avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(0);
|
||||
|
||||
var result = await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: nuevoParentId, NuevoOrden: 0));
|
||||
|
||||
result.Id.Should().Be(8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NuevoParentEsNull_SkipsAvisosGuard()
|
||||
{
|
||||
// Move to root — no parent to check avisos for
|
||||
var rubro = MakeRubro(8, parentId: 2);
|
||||
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: null, NuevoOrden: 0));
|
||||
|
||||
await _avisoQuery.DidNotReceive().CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_CycleCheck_Wins_OverAvisosGuard()
|
||||
{
|
||||
// Cycle check fires before avisos guard
|
||||
const int nuevoParentId = 10;
|
||||
var rubro = MakeRubro(5, parentId: null);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
// nuevoParentId IS a descendant (cycle)
|
||||
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(nuevoParentId, parentId: 5) });
|
||||
_avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(3);
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: nuevoParentId, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroCycleDetectedException>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class RubroTreeBuilderTests
|
||||
[Fact]
|
||||
public void Build_empty_returns_empty_list()
|
||||
{
|
||||
var result = RubroTreeBuilder.Build([], incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build([], incluirInactivos: false, new Dictionary<int, int>());
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public class RubroTreeBuilderTests
|
||||
{
|
||||
var rubros = new[] { MakeRubro(1, null, "Autos", 0) };
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary<int, int>());
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be(1);
|
||||
@@ -50,7 +50,7 @@ public class RubroTreeBuilderTests
|
||||
MakeRubro(2, null, "Camiones", 1)
|
||||
};
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary<int, int>());
|
||||
|
||||
result.Should().HaveCount(3);
|
||||
result[0].Id.Should().Be(1); // Orden=0
|
||||
@@ -70,7 +70,7 @@ public class RubroTreeBuilderTests
|
||||
MakeRubro(3, 2, "Compactos", 0),
|
||||
};
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary<int, int>());
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be(1);
|
||||
@@ -91,7 +91,7 @@ public class RubroTreeBuilderTests
|
||||
MakeRubro(2, null, "Motos", 1, activo: false),
|
||||
};
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary<int, int>());
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be(1);
|
||||
@@ -106,7 +106,7 @@ public class RubroTreeBuilderTests
|
||||
MakeRubro(2, null, "Motos", 1, activo: false),
|
||||
};
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: true);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: true, new Dictionary<int, int>());
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public class RubroTreeBuilderTests
|
||||
MakeRubro(5, 1, "A", 0),
|
||||
};
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary<int, int>());
|
||||
|
||||
var hijos = result[0].Hijos;
|
||||
hijos.Should().HaveCount(4);
|
||||
@@ -135,6 +135,47 @@ public class RubroTreeBuilderTests
|
||||
hijos[3].Nombre.Should().Be("D"); // Orden=3
|
||||
}
|
||||
|
||||
// ── TieneAvisos from avisoCounts dict ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_SetsTieneAvisos_True_WhenCountGreaterThanZero()
|
||||
{
|
||||
var rubros = new[]
|
||||
{
|
||||
MakeRubro(1, null, "Autos", 0),
|
||||
MakeRubro(2, null, "Motos", 1),
|
||||
};
|
||||
var avisoCounts = new Dictionary<int, int> { { 1, 2 }, { 2, 0 } };
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, avisoCounts);
|
||||
|
||||
result.Single(n => n.Id == 1).TieneAvisos.Should().BeTrue();
|
||||
result.Single(n => n.Id == 2).TieneAvisos.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SetsTieneAvisos_False_WhenCountIsZero()
|
||||
{
|
||||
var rubros = new[] { MakeRubro(1, null, "Autos", 0) };
|
||||
var avisoCounts = new Dictionary<int, int> { { 1, 0 } };
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, avisoCounts);
|
||||
|
||||
result[0].TieneAvisos.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SetsTieneAvisos_False_WhenIdMissingFromDict()
|
||||
{
|
||||
// Stub semantics: missing key = 0 = false
|
||||
var rubros = new[] { MakeRubro(1, null, "Autos", 0) };
|
||||
var avisoCounts = new Dictionary<int, int>(); // empty dict
|
||||
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, avisoCounts);
|
||||
|
||||
result[0].TieneAvisos.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── O(n) perf smoke test ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -148,7 +189,7 @@ public class RubroTreeBuilderTests
|
||||
rubros.Add(MakeRubro(i, 1, $"Child{i}", i - 2));
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
|
||||
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary<int, int>());
|
||||
sw.Stop();
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
|
||||
Reference in New Issue
Block a user