Compare commits

..

10 Commits

22 changed files with 776 additions and 19 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>();

View File

@@ -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);
} }

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
} }
} }

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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" />

View File

@@ -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[]
} }

View File

@@ -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

View File

@@ -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: {

View 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.
}

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View File

@@ -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>();
}
} }

View File

@@ -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]

View File

@@ -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>();
}
} }

View File

@@ -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);