feat: CAT-002 Regla de Oro Rama vs Hoja + validaciones #35
@@ -242,6 +242,31 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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
|
// ADM-001: Medio exceptions
|
||||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||||
context.Result = new ObjectResult(new
|
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.GetTree;
|
||||||
using SIGCM2.Application.Rubros.GetById;
|
using SIGCM2.Application.Rubros.GetById;
|
||||||
using SIGCM2.Application.Rubros.Dtos;
|
using SIGCM2.Application.Rubros.Dtos;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Avisos;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -152,7 +154,10 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
|
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<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public static class RubroTreeBuilder
|
|||||||
{
|
{
|
||||||
public static IReadOnlyList<RubroTreeNodeDto> Build(
|
public static IReadOnlyList<RubroTreeNodeDto> Build(
|
||||||
IEnumerable<Rubro> flat,
|
IEnumerable<Rubro> flat,
|
||||||
bool incluirInactivos)
|
bool incluirInactivos,
|
||||||
|
IReadOnlyDictionary<int, int> avisoCounts)
|
||||||
{
|
{
|
||||||
var filtered = incluirInactivos
|
var filtered = incluirInactivos
|
||||||
? flat.ToList()
|
? flat.ToList()
|
||||||
@@ -36,6 +37,7 @@ public static class RubroTreeBuilder
|
|||||||
Activo: r.Activo,
|
Activo: r.Activo,
|
||||||
ParentId: r.ParentId,
|
ParentId: r.ParentId,
|
||||||
TarifarioBaseId: r.TarifarioBaseId,
|
TarifarioBaseId: r.TarifarioBaseId,
|
||||||
|
TieneAvisos: avisoCounts.GetValueOrDefault(r.Id, 0) > 0,
|
||||||
Hijos: children);
|
Hijos: children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ public sealed class CreateRubroCommandHandler : ICommandHandler<CreateRubroComma
|
|||||||
private readonly IAuditLogger _audit;
|
private readonly IAuditLogger _audit;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly RubrosOptions _options;
|
private readonly RubrosOptions _options;
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery;
|
||||||
|
|
||||||
public CreateRubroCommandHandler(
|
public CreateRubroCommandHandler(
|
||||||
IRubroRepository repo,
|
IRubroRepository repo,
|
||||||
IAuditLogger audit,
|
IAuditLogger audit,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<RubrosOptions> options)
|
IOptions<RubrosOptions> options,
|
||||||
|
IAvisoQueryRepository avisoQuery)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_audit = audit;
|
_audit = audit;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_avisoQuery = avisoQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RubroCreatedDto> Handle(CreateRubroCommand command)
|
public async Task<RubroCreatedDto> Handle(CreateRubroCommand command)
|
||||||
@@ -38,6 +41,12 @@ public sealed class CreateRubroCommandHandler : ICommandHandler<CreateRubroComma
|
|||||||
if (!parent.Activo)
|
if (!parent.Activo)
|
||||||
throw new RubroPadreInactivoException(command.ParentId.Value);
|
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
|
// Depth check: parent's depth + 1 must not exceed MaxDepth
|
||||||
var parentDepth = await _repo.GetDepthAsync(command.ParentId);
|
var parentDepth = await _repo.GetDepthAsync(command.ParentId);
|
||||||
var newDepth = parentDepth + 1;
|
var newDepth = parentDepth + 1;
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ public sealed record RubroTreeNodeDto(
|
|||||||
bool Activo,
|
bool Activo,
|
||||||
int? ParentId,
|
int? ParentId,
|
||||||
int? TarifarioBaseId,
|
int? TarifarioBaseId,
|
||||||
|
bool TieneAvisos,
|
||||||
IReadOnlyList<RubroTreeNodeDto> Hijos);
|
IReadOnlyList<RubroTreeNodeDto> Hijos);
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ namespace SIGCM2.Application.Rubros.GetTree;
|
|||||||
public sealed class GetRubroTreeQueryHandler : ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>
|
public sealed class GetRubroTreeQueryHandler : ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>
|
||||||
{
|
{
|
||||||
private readonly IRubroRepository _repo;
|
private readonly IRubroRepository _repo;
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery;
|
||||||
|
|
||||||
public GetRubroTreeQueryHandler(IRubroRepository repo)
|
public GetRubroTreeQueryHandler(IRubroRepository repo, IAvisoQueryRepository avisoQuery)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
|
_avisoQuery = avisoQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<RubroTreeNodeDto>> Handle(GetRubroTreeQuery query)
|
public async Task<IReadOnlyList<RubroTreeNodeDto>> Handle(GetRubroTreeQuery query)
|
||||||
{
|
{
|
||||||
var all = await _repo.GetAllAsync(query.IncluirInactivos);
|
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 IAuditLogger _audit;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly RubrosOptions _options;
|
private readonly RubrosOptions _options;
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery;
|
||||||
|
|
||||||
public MoveRubroCommandHandler(
|
public MoveRubroCommandHandler(
|
||||||
IRubroRepository repo,
|
IRubroRepository repo,
|
||||||
IAuditLogger audit,
|
IAuditLogger audit,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<RubrosOptions> options)
|
IOptions<RubrosOptions> options,
|
||||||
|
IAvisoQueryRepository avisoQuery)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_audit = audit;
|
_audit = audit;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_avisoQuery = avisoQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RubroMovedDto> Handle(MoveRubroCommand command)
|
public async Task<RubroMovedDto> Handle(MoveRubroCommand command)
|
||||||
@@ -47,6 +50,12 @@ public sealed class MoveRubroCommandHandler : ICommandHandler<MoveRubroCommand,
|
|||||||
if (!newParent.Activo)
|
if (!newParent.Activo)
|
||||||
throw new RubroPadreInactivoException(command.NuevoParentId.Value);
|
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
|
// Depth check
|
||||||
var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId);
|
var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId);
|
||||||
var newDepth = parentDepth + 1;
|
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 */}
|
{/* Action buttons — only if canEdit */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0"
|
className="h-6 w-6 p-0"
|
||||||
aria-label={`Agregar subrubro en ${node.nombre}`}
|
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)}
|
onClick={() => onAddChild(node.id)}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface RubroTreeNode {
|
|||||||
activo: boolean
|
activo: boolean
|
||||||
parentId: number | null
|
parentId: number | null
|
||||||
tarifarioBaseId: number | null
|
tarifarioBaseId: number | null
|
||||||
|
// CAT-002: additive field — optional for backward-compat (PRD-002 always sends it)
|
||||||
|
tieneAvisos?: boolean
|
||||||
hijos: RubroTreeNode[]
|
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', () => {
|
describe('CategoryTreeNode depth guard', () => {
|
||||||
it('renders depth warning when depth exceeds 10', () => {
|
it('renders depth warning when depth exceeds 10', () => {
|
||||||
// Build a deeply nested node at depth 11
|
// 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 () => {
|
it('displays backend error inline when 422 depth', async () => {
|
||||||
mockMutateAsync.mockRejectedValue({
|
mockMutateAsync.mockRejectedValue({
|
||||||
response: {
|
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 IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
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 IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
||||||
private readonly CreateRubroCommandHandler _handler;
|
private readonly CreateRubroCommandHandler _handler;
|
||||||
|
|
||||||
public CreateRubroCommandHandlerTests()
|
public CreateRubroCommandHandlerTests()
|
||||||
@@ -30,8 +31,11 @@ public class CreateRubroCommandHandlerTests
|
|||||||
.Returns(1);
|
.Returns(1);
|
||||||
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(0);
|
.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);
|
private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null);
|
||||||
@@ -173,4 +177,76 @@ public class CreateRubroCommandHandlerTests
|
|||||||
|
|
||||||
result.Id.Should().Be(99);
|
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 Microsoft.Extensions.Time.Testing;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Avisos;
|
||||||
using SIGCM2.Application.Rubros.GetById;
|
using SIGCM2.Application.Rubros.GetById;
|
||||||
using SIGCM2.Application.Rubros.GetTree;
|
using SIGCM2.Application.Rubros.GetTree;
|
||||||
using SIGCM2.Domain.Entities;
|
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 static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||||
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
|
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)
|
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
|
||||||
=> new(id, parentId, $"Rubro{id}", 0, activo, null, FakeTime.GetUtcNow().UtcDateTime, null);
|
=> 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>())
|
_repo.GetAllAsync(false, Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { MakeRubro(1), MakeRubro(2) });
|
.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));
|
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||||
|
|
||||||
result.Should().HaveCount(2);
|
result.Should().HaveCount(2);
|
||||||
@@ -36,8 +40,10 @@ public class GetRubroTreeQueryHandlerTests
|
|||||||
{
|
{
|
||||||
_repo.GetAllAsync(true, Arg.Any<CancellationToken>())
|
_repo.GetAllAsync(true, Arg.Any<CancellationToken>())
|
||||||
.Returns(new[] { MakeRubro(1), MakeRubro(2, activo: false) });
|
.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));
|
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: true));
|
||||||
|
|
||||||
await _repo.Received(1).GetAllAsync(true, Arg.Any<CancellationToken>());
|
await _repo.Received(1).GetAllAsync(true, Arg.Any<CancellationToken>());
|
||||||
@@ -49,13 +55,77 @@ public class GetRubroTreeQueryHandlerTests
|
|||||||
{
|
{
|
||||||
_repo.GetAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
_repo.GetAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Array.Empty<Rubro>());
|
.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));
|
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
|
||||||
|
|
||||||
result.Should().BeEmpty();
|
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 ─────────────────────────────────────────────
|
// ── GetRubroByIdQueryHandler ─────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class MoveRubroCommandHandlerTests
|
|||||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
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 IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
||||||
private readonly MoveRubroCommandHandler _handler;
|
private readonly MoveRubroCommandHandler _handler;
|
||||||
|
|
||||||
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
|
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
|
||||||
@@ -32,8 +33,11 @@ public class MoveRubroCommandHandlerTests
|
|||||||
.Returns(0);
|
.Returns(0);
|
||||||
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(0);
|
.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 ────────────────────────────────────
|
// ── Happy path: move to other parent ────────────────────────────────────
|
||||||
@@ -173,4 +177,66 @@ public class MoveRubroCommandHandlerTests
|
|||||||
|
|
||||||
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
|
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]
|
[Fact]
|
||||||
public void Build_empty_returns_empty_list()
|
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();
|
result.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ public class RubroTreeBuilderTests
|
|||||||
{
|
{
|
||||||
var rubros = new[] { MakeRubro(1, null, "Autos", 0) };
|
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.Should().HaveCount(1);
|
||||||
result[0].Id.Should().Be(1);
|
result[0].Id.Should().Be(1);
|
||||||
@@ -50,7 +50,7 @@ public class RubroTreeBuilderTests
|
|||||||
MakeRubro(2, null, "Camiones", 1)
|
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.Should().HaveCount(3);
|
||||||
result[0].Id.Should().Be(1); // Orden=0
|
result[0].Id.Should().Be(1); // Orden=0
|
||||||
@@ -70,7 +70,7 @@ public class RubroTreeBuilderTests
|
|||||||
MakeRubro(3, 2, "Compactos", 0),
|
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.Should().HaveCount(1);
|
||||||
result[0].Id.Should().Be(1);
|
result[0].Id.Should().Be(1);
|
||||||
@@ -91,7 +91,7 @@ public class RubroTreeBuilderTests
|
|||||||
MakeRubro(2, null, "Motos", 1, activo: false),
|
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.Should().HaveCount(1);
|
||||||
result[0].Id.Should().Be(1);
|
result[0].Id.Should().Be(1);
|
||||||
@@ -106,7 +106,7 @@ public class RubroTreeBuilderTests
|
|||||||
MakeRubro(2, null, "Motos", 1, activo: false),
|
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);
|
result.Should().HaveCount(2);
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ public class RubroTreeBuilderTests
|
|||||||
MakeRubro(5, 1, "A", 0),
|
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;
|
var hijos = result[0].Hijos;
|
||||||
hijos.Should().HaveCount(4);
|
hijos.Should().HaveCount(4);
|
||||||
@@ -135,6 +135,47 @@ public class RubroTreeBuilderTests
|
|||||||
hijos[3].Nombre.Should().Be("D"); // Orden=3
|
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 ──────────────────────────────────────────────────
|
// ── O(n) perf smoke test ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -148,7 +189,7 @@ public class RubroTreeBuilderTests
|
|||||||
rubros.Add(MakeRubro(i, 1, $"Child{i}", i - 2));
|
rubros.Add(MakeRubro(i, 1, $"Child{i}", i - 2));
|
||||||
|
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
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();
|
sw.Stop();
|
||||||
|
|
||||||
result.Should().HaveCount(1);
|
result.Should().HaveCount(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user