From be6f76d107606be473d3d07777d65774f1d7a3ef Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:38:55 -0300
Subject: [PATCH 01/23] test(udt-011): V015 migration tests for timezone views
(Red)
---
.../Admin/V015MigrationTests.cs | 262 ++++++++++++++++++
1 file changed, 262 insertions(+)
create mode 100644 tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs
diff --git a/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs b/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs
new file mode 100644
index 0000000..ad9990d
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs
@@ -0,0 +1,262 @@
+using Dapper;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using Xunit;
+
+namespace SIGCM2.Api.Tests.Admin;
+
+///
+/// UDT-011 Batch 2 — V015 migration integration tests.
+/// Validates:
+/// REQ-DB-VIEWS-001 : Vista dbo.v_AuditEvent_Local existe tras aplicar V015.
+/// REQ-DB-VIEWS-002 : Vista dbo.v_SecurityEvent_Local existe tras aplicar V015.
+/// REQ-DB-VIEWS-003 : OccurredAtLocal retorna offset -03:00 (Argentina Standard Time).
+/// REQ-DB-VIEWS-004 : V015 es idempotente (re-ejecución no falla ni duplica vistas).
+/// REQ-DB-VIEWS-005 : V015_ROLLBACK elimina ambas vistas.
+///
+/// NOTA: Esta suite opera directamente sobre SIGCM2_Test con Dapper.
+/// NO usa WebApplicationFactory (es test de migración pura, no API).
+/// La migración se aplica via SqlTestFixture.EnsureV015SchemaAsync().
+///
+[Collection("ApiIntegration")]
+public sealed class V015MigrationTests : IClassFixture
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ public V015MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
+ {
+ // Depend on the factory so SqlTestFixture.InitializeAsync runs
+ // (ensures V015 schema is present via EnsureV015SchemaAsync).
+ }
+
+ // ── REQ-DB-VIEWS-001 ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task V015_DebeCrearVistaAuditEventLocal()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var objectId = await conn.ExecuteScalarAsync("""
+ SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V')
+ """);
+
+ objectId.Should().NotBeNull("dbo.v_AuditEvent_Local debe existir tras aplicar V015");
+ }
+
+ // ── REQ-DB-VIEWS-002 ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task V015_DebeCrearVistaSecurityEventLocal()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var objectId = await conn.ExecuteScalarAsync("""
+ SELECT OBJECT_ID('dbo.v_SecurityEvent_Local', 'V')
+ """);
+
+ objectId.Should().NotBeNull("dbo.v_SecurityEvent_Local debe existir tras aplicar V015");
+ }
+
+ // ── REQ-DB-VIEWS-003 ─────────────────────────────────────────────────────
+ // Inserta 1 fila con OccurredAt = 2026-05-01 01:30:00 UTC
+ // Espera OccurredAtLocal con offset -03:00 → 2026-04-30 22:30:00-03:00
+
+ [Fact]
+ public async Task v_AuditEvent_Local_RetornaOccurredAtLocalConOffsetArgentina()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ // Insert a test row with known OccurredAt UTC value.
+ // OccurredAt = 2026-05-01 01:30:00 UTC → Argentina (UTC-3) = 2026-04-30 22:30:00
+ var occurredAt = new DateTime(2026, 5, 1, 1, 30, 0, DateTimeKind.Utc);
+
+ long insertedId;
+ try
+ {
+ insertedId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.AuditEvent
+ (OccurredAt, ActorUserId, ActorRoleId, Action, TargetType, TargetId, CorrelationId, IpAddress, UserAgent, Metadata)
+ VALUES
+ (@OccurredAt, NULL, NULL, 'test.v015', 'Test', '0', NULL, '127.0.0.1', NULL, NULL);
+ SELECT SCOPE_IDENTITY();
+ """, new { OccurredAt = occurredAt });
+ }
+ catch
+ {
+ // If insert fails (e.g. CHECK constraint on Action), skip — vista test only
+ throw;
+ }
+
+ var result = await conn.QuerySingleOrDefaultAsync<(DateTimeOffset OccurredAtLocal, DateTime OccurredAt)>("""
+ SELECT TOP 1 OccurredAtLocal, OccurredAt
+ FROM dbo.v_AuditEvent_Local
+ WHERE Action = 'test.v015'
+ AND OccurredAt = @OccurredAt
+ """, new { OccurredAt = occurredAt });
+
+ result.OccurredAt.Should().Be(occurredAt, "OccurredAt debe ser el UTC insertado");
+
+ // Argentina Standard Time = UTC-3 (sin DST desde 2009). Offset fijo = -03:00.
+ result.OccurredAtLocal.Offset.TotalHours.Should().Be(-3,
+ "OccurredAtLocal debe tener offset -03:00 (Argentina Standard Time)");
+
+ // 2026-05-01 01:30 UTC → 2026-04-30 22:30 ART
+ result.OccurredAtLocal.DateTime.Date.Should().Be(new DateTime(2026, 4, 30),
+ "La fecha local Argentina debe ser 2026-04-30 (día anterior al UTC)");
+ result.OccurredAtLocal.Hour.Should().Be(22, "La hora local Argentina debe ser 22");
+ result.OccurredAtLocal.Minute.Should().Be(30, "Los minutos deben ser 30");
+ }
+
+ // ── REQ-DB-VIEWS-004 — Idempotencia ──────────────────────────────────────
+
+ [Fact]
+ public async Task V015_EsIdempotente()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ // Re-ejecutar el DDL idempotente de V015 por segunda vez — NO debe fallar.
+ // La vista ya existe (creada en EnsureV015SchemaAsync), el guard IF OBJECT_ID IS NULL
+ // debe cortocircuitar sin error.
+ await conn.ExecuteAsync("""
+ IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
+ BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_AuditEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ ActorRoleId,
+ Action,
+ TargetType,
+ TargetId,
+ CorrelationId,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.AuditEvent;
+ ');
+ END
+ """);
+
+ await conn.ExecuteAsync("""
+ IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
+ BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_SecurityEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ AttemptedUsername,
+ SessionId,
+ Action,
+ Result,
+ FailureReason,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.SecurityEvent;
+ ');
+ END
+ """);
+
+ // Ambas vistas deben seguir existiendo.
+ var auditExists = await conn.ExecuteScalarAsync("""
+ SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V')
+ """);
+
+ var secExists = await conn.ExecuteScalarAsync("""
+ SELECT OBJECT_ID('dbo.v_SecurityEvent_Local', 'V')
+ """);
+
+ auditExists.Should().NotBeNull("v_AuditEvent_Local debe seguir existiendo tras re-ejecución idempotente");
+ secExists.Should().NotBeNull("v_SecurityEvent_Local debe seguir existiendo tras re-ejecución idempotente");
+ }
+
+ // ── REQ-DB-VIEWS-005 — Rollback ───────────────────────────────────────────
+
+ [Fact]
+ public async Task V015_ROLLBACK_EliminaVistas()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ // Apply rollback DDL (simula V015_ROLLBACK.sql).
+ await conn.ExecuteAsync("""
+ IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NOT NULL
+ DROP VIEW dbo.v_AuditEvent_Local;
+ """);
+
+ await conn.ExecuteAsync("""
+ IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NOT NULL
+ DROP VIEW dbo.v_SecurityEvent_Local;
+ """);
+
+ var auditExists = await conn.ExecuteScalarAsync("""
+ SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V')
+ """);
+
+ var secExists = await conn.ExecuteScalarAsync("""
+ SELECT OBJECT_ID('dbo.v_SecurityEvent_Local', 'V')
+ """);
+
+ auditExists.Should().BeNull("v_AuditEvent_Local debe ser NULL tras el rollback");
+ secExists.Should().BeNull("v_SecurityEvent_Local debe ser NULL tras el rollback");
+
+ // IMPORTANT: Re-create views after rollback test so remaining tests still pass.
+ // (This test tears down — re-apply V015 DDL to restore the fixture state.)
+ await conn.ExecuteAsync("""
+ IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
+ BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_AuditEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ ActorRoleId,
+ Action,
+ TargetType,
+ TargetId,
+ CorrelationId,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.AuditEvent;
+ ');
+ END
+ """);
+
+ await conn.ExecuteAsync("""
+ IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
+ BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_SecurityEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ AttemptedUsername,
+ SessionId,
+ Action,
+ Result,
+ FailureReason,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.SecurityEvent;
+ ');
+ END
+ """);
+ }
+}
--
2.49.1
From a51a7bc07e94fa10aae0a5b47cf58d937b4c7544 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:39:00 -0300
Subject: [PATCH 02/23] feat(udt-011): V015 create v_AuditEvent_Local +
v_SecurityEvent_Local views
---
.../V015__create_local_timezone_views.sql | 88 +++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 database/migrations/V015__create_local_timezone_views.sql
diff --git a/database/migrations/V015__create_local_timezone_views.sql b/database/migrations/V015__create_local_timezone_views.sql
new file mode 100644
index 0000000..fa483a2
--- /dev/null
+++ b/database/migrations/V015__create_local_timezone_views.sql
@@ -0,0 +1,88 @@
+-- V015__create_local_timezone_views.sql
+-- UDT-011: Vistas admin con OccurredAt convertido a hora Argentina.
+--
+-- Crea:
+-- dbo.v_AuditEvent_Local — AuditEvent con OccurredAtLocal (offset -03:00)
+-- dbo.v_SecurityEvent_Local — SecurityEvent con OccurredAtLocal (offset -03:00)
+--
+-- Conversión: OccurredAt AT TIME ZONE 'UTC' AT TIME ZONE 'Argentina Standard Time'
+-- → offset fijo -03:00, sin DST (Argentina dejó el horario de verano en 2009).
+-- → Nombre 'Argentina Standard Time' es portable: Windows + SQL Server Linux 2022+ (via ICU).
+--
+-- Idempotente: re-ejecutable. Guard IF OBJECT_ID IS NULL en cada vista.
+-- No altera tablas base — rollback seguro sin pérdida de datos.
+-- Reversa: V015_ROLLBACK.sql.
+-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
+--
+-- Covers: REQ-DB-VIEWS-001, REQ-DB-VIEWS-002, REQ-DB-VIEWS-003, REQ-DB-VIEWS-004
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+SET NOCOUNT ON;
+GO
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 1. dbo.v_AuditEvent_Local
+-- ═══════════════════════════════════════════════════════════════════════
+-- Nota: CREATE VIEW no permite IF...BEGIN...END directo — se usa EXEC('CREATE VIEW ...').
+
+IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
+BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_AuditEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ ActorRoleId,
+ Action,
+ TargetType,
+ TargetId,
+ CorrelationId,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.AuditEvent;
+ ');
+ PRINT 'View dbo.v_AuditEvent_Local created.';
+END
+ELSE
+ PRINT 'View dbo.v_AuditEvent_Local already exists — skip.';
+GO
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 2. dbo.v_SecurityEvent_Local
+-- ═══════════════════════════════════════════════════════════════════════
+
+IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
+BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_SecurityEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ AttemptedUsername,
+ SessionId,
+ Action,
+ Result,
+ FailureReason,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.SecurityEvent;
+ ');
+ PRINT 'View dbo.v_SecurityEvent_Local created.';
+END
+ELSE
+ PRINT 'View dbo.v_SecurityEvent_Local already exists — skip.';
+GO
+
+PRINT '';
+PRINT 'V015 applied successfully.';
+PRINT ' - dbo.v_AuditEvent_Local (AuditEvent + OccurredAtLocal offset -03:00)';
+PRINT ' - dbo.v_SecurityEvent_Local (SecurityEvent + OccurredAtLocal offset -03:00)';
+PRINT ' - Argentina Standard Time = UTC-3 (fixed offset, no DST since 2009)';
+GO
--
2.49.1
From 7913dd8bb90774fa7ca783e5ae66ba10edd55fb6 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:39:00 -0300
Subject: [PATCH 03/23] chore(udt-011): V015_ROLLBACK script for timezone views
---
database/migrations/V015_ROLLBACK.sql | 37 +++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 database/migrations/V015_ROLLBACK.sql
diff --git a/database/migrations/V015_ROLLBACK.sql b/database/migrations/V015_ROLLBACK.sql
new file mode 100644
index 0000000..a0e5546
--- /dev/null
+++ b/database/migrations/V015_ROLLBACK.sql
@@ -0,0 +1,37 @@
+-- V015_ROLLBACK.sql
+-- Reversa de V015__create_local_timezone_views.sql.
+--
+-- Elimina: dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local
+-- No toca datos: las tablas base AuditEvent y SecurityEvent no se modifican.
+--
+-- Idempotente: seguro para re-ejecutar.
+-- Prerequisito: ningún objeto dependa de estas vistas (funciones, SPs, otras vistas).
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+SET NOCOUNT ON;
+GO
+
+IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NOT NULL
+BEGIN
+ DROP VIEW dbo.v_AuditEvent_Local;
+ PRINT 'View dbo.v_AuditEvent_Local dropped.';
+END
+ELSE
+ PRINT 'View dbo.v_AuditEvent_Local does not exist — skip.';
+GO
+
+IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NOT NULL
+BEGIN
+ DROP VIEW dbo.v_SecurityEvent_Local;
+ PRINT 'View dbo.v_SecurityEvent_Local dropped.';
+END
+ELSE
+ PRINT 'View dbo.v_SecurityEvent_Local does not exist — skip.';
+GO
+
+PRINT '';
+PRINT 'V015 rolled back.';
+PRINT ' - dbo.v_AuditEvent_Local removed.';
+PRINT ' - dbo.v_SecurityEvent_Local removed.';
+GO
--
2.49.1
From cc4efe9ef2bcb6e59d04562b868927f8bb04ff4a Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:39:04 -0300
Subject: [PATCH 04/23] chore(udt-011): SqlTestFixture.EnsureV015SchemaAsync
for timezone views
---
tests/SIGCM2.TestSupport/SqlTestFixture.cs | 61 ++++++++++++++++++++++
1 file changed, 61 insertions(+)
diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
index 411a78b..828bd42 100644
--- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs
+++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
@@ -47,6 +47,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V014 (ADM-009): ensure dbo.TipoDeIva + dbo.IngresosBrutos + temporal + seed + permiso fiscal.
await EnsureV014SchemaAsync();
+ // V015 (UDT-011): ensure dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views exist.
+ await EnsureV015SchemaAsync();
+
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -774,4 +777,62 @@ public sealed class SqlTestFixture : IAsyncLifetime
// Permiso 'administracion:fiscal:gestionar' y asignacion a admin se siembran
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
+
+ ///
+ /// UDT-011 (V015): applies dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views
+ /// idempotently to the test database. Mirrors V015__create_local_timezone_views.sql.
+ /// Views expose OccurredAtLocal (DateTimeOffset, offset -03:00 Argentina Standard Time).
+ /// Note: CREATE VIEW cannot be inside IF...BEGIN...END directly — uses EXEC('CREATE VIEW ...').
+ ///
+ private async Task EnsureV015SchemaAsync()
+ {
+ const string createAuditEventLocal = """
+ IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
+ BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_AuditEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ ActorRoleId,
+ Action,
+ TargetType,
+ TargetId,
+ CorrelationId,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.AuditEvent;
+ ');
+ END
+ """;
+
+ const string createSecurityEventLocal = """
+ IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
+ BEGIN
+ EXEC('
+ CREATE VIEW dbo.v_SecurityEvent_Local AS
+ SELECT
+ Id,
+ OccurredAt,
+ OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
+ ActorUserId,
+ AttemptedUsername,
+ SessionId,
+ Action,
+ Result,
+ FailureReason,
+ IpAddress,
+ UserAgent,
+ Metadata
+ FROM dbo.SecurityEvent;
+ ');
+ END
+ """;
+
+ await _connection.ExecuteAsync(createAuditEventLocal);
+ await _connection.ExecuteAsync(createSecurityEventLocal);
+ }
}
--
2.49.1
From 7e4a096f246ee151752ff8b9ccd7204f5bd49b35 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:43:28 -0300
Subject: [PATCH 05/23] test(udt-011): TimeProvider Argentina extension tests
with FakeTimeProvider (Red)
---
.../TimeProviderArgentinaExtensionsTests.cs | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 tests/SIGCM2.Application.Tests/Common/TimeProviderArgentinaExtensionsTests.cs
diff --git a/tests/SIGCM2.Application.Tests/Common/TimeProviderArgentinaExtensionsTests.cs b/tests/SIGCM2.Application.Tests/Common/TimeProviderArgentinaExtensionsTests.cs
new file mode 100644
index 0000000..8fb8261
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Common/TimeProviderArgentinaExtensionsTests.cs
@@ -0,0 +1,77 @@
+using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Tests.Common;
+
+///
+/// SUITE-BE-CLOCK — UDT-011 T300.10
+/// Unit tests for TimeProviderArgentinaExtensions.GetArgentinaToday.
+/// Uses FakeTimeProvider for deterministic UTC control.
+///
+public sealed class TimeProviderArgentinaExtensionsTests
+{
+ ///
+ /// 22:30 ART del 30/04 = 01:30 UTC del 01/05.
+ /// UTC lleva al día siguiente, ART debe ser el día anterior.
+ ///
+ [Fact]
+ public void GetArgentinaToday_A22_30UTCdel01Mayo_Retorna30AbrilArgentina()
+ {
+ // 22:30 ART del 30/04 = 01:30 UTC del 01/05
+ var fake = new FakeTimeProvider();
+ fake.SetUtcNow(new DateTimeOffset(2026, 5, 1, 1, 30, 0, TimeSpan.Zero));
+
+ var result = fake.GetArgentinaToday();
+
+ result.Should().Be(new DateOnly(2026, 4, 30));
+ }
+
+ ///
+ /// 00:30 ART del 01/05 = 03:30 UTC del 01/05.
+ /// Ambos UTC y ART son el mismo día civil.
+ ///
+ [Fact]
+ public void GetArgentinaToday_A00_30ARTdel01Mayo_Retorna01MayoArgentina()
+ {
+ // 00:30 ART del 01/05 = 03:30 UTC del 01/05
+ var fake = new FakeTimeProvider();
+ fake.SetUtcNow(new DateTimeOffset(2026, 5, 1, 3, 30, 0, TimeSpan.Zero));
+
+ var result = fake.GetArgentinaToday();
+
+ result.Should().Be(new DateOnly(2026, 5, 1));
+ }
+
+ ///
+ /// Fin de mes en año bisiesto: 22:30 ART del 28/02/2024 = 01:30 UTC del 29/02/2024.
+ /// UTC cae en día bisiesto 29/02, ART debe devolver 28/02.
+ ///
+ [Fact]
+ public void GetArgentinaToday_BisiestoEnFinDeMesArgentina_RetornaCorrectoAR()
+ {
+ // 22:30 ART del 28/02/2024 (año bisiesto) = 01:30 UTC del 29/02/2024
+ var fake = new FakeTimeProvider();
+ fake.SetUtcNow(new DateTimeOffset(2024, 2, 29, 1, 30, 0, TimeSpan.Zero));
+
+ var result = fake.GetArgentinaToday();
+
+ result.Should().Be(new DateOnly(2024, 2, 28));
+ }
+
+ ///
+ /// Cruce de año: 22:30 ART del 31/12/2026 = 01:30 UTC del 01/01/2027.
+ /// UTC es año nuevo, ART debe ser el 31/12 del año anterior.
+ ///
+ [Fact]
+ public void GetArgentinaToday_FinDeAnio_22_30ARTdel31Dic_Retorna31Dic()
+ {
+ // 22:30 ART del 31/12/2026 = 01:30 UTC del 01/01/2027
+ var fake = new FakeTimeProvider();
+ fake.SetUtcNow(new DateTimeOffset(2027, 1, 1, 1, 30, 0, TimeSpan.Zero));
+
+ var result = fake.GetArgentinaToday();
+
+ result.Should().Be(new DateOnly(2026, 12, 31));
+ }
+}
--
2.49.1
From 03d51d4310a1450480e7299b97e32e387a8fc390 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:43:31 -0300
Subject: [PATCH 06/23] chore(udt-011): add
Microsoft.Extensions.TimeProvider.Testing NuGet
---
Directory.Packages.props | 1 +
tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj | 1 +
2 files changed, 2 insertions(+)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d3074f0..27f85c8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,6 +19,7 @@
+
diff --git a/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj b/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj
index ceb4501..d94bcf5 100644
--- a/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj
+++ b/tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj
@@ -10,6 +10,7 @@
+
--
2.49.1
From 4e70b0f847204d5040b5550977e7d15d17c75d06 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:43:35 -0300
Subject: [PATCH 07/23] feat(udt-011):
TimeProviderArgentinaExtensions.GetArgentinaToday cross-platform
---
.../Common/TimeProviderArgentinaExtensions.cs | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 src/api/SIGCM2.Application/Common/TimeProviderArgentinaExtensions.cs
diff --git a/src/api/SIGCM2.Application/Common/TimeProviderArgentinaExtensions.cs b/src/api/SIGCM2.Application/Common/TimeProviderArgentinaExtensions.cs
new file mode 100644
index 0000000..efed334
--- /dev/null
+++ b/src/api/SIGCM2.Application/Common/TimeProviderArgentinaExtensions.cs
@@ -0,0 +1,45 @@
+namespace SIGCM2.Application.Common;
+
+///
+/// Extension methods for that expose Argentina-localized
+/// date helpers. Handles the UTC-3 offset cross-platform (IANA / Windows TZ IDs).
+///
+/// UDT-011: Cat2 fields (VigenciaDesde, etc.) must use civil Argentine date, never
+/// raw UTC, to avoid date-creep during the 22:00–23:59 window.
+///
+public static class TimeProviderArgentinaExtensions
+{
+ // IANA TZ id — Linux / macOS / .NET 8+ on Windows with ICU
+ public const string ArgentinaTimeZoneId = "America/Argentina/Buenos_Aires";
+
+ // Windows built-in TZ id — fallback for environments without ICU
+ public const string ArgentinaTimeZoneIdWindows = "Argentina Standard Time";
+
+ private static readonly TimeZoneInfo ArgentinaTz = LoadArgentinaTz();
+
+ ///
+ /// Returns today's civil date in Argentina timezone, computed from the
+ /// UTC clock.
+ /// Safe in tests via FakeTimeProvider; safe in production via
+ /// TimeProvider.System.
+ ///
+ public static DateOnly GetArgentinaToday(this TimeProvider timeProvider)
+ {
+ var utcNow = timeProvider.GetUtcNow();
+ var argentinaNow = TimeZoneInfo.ConvertTime(utcNow, ArgentinaTz);
+ return DateOnly.FromDateTime(argentinaNow.DateTime);
+ }
+
+ private static TimeZoneInfo LoadArgentinaTz()
+ {
+ try
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneId);
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ // Windows without ICU: fall back to built-in Windows TZ name
+ return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneIdWindows);
+ }
+ }
+}
--
2.49.1
From 54d2340bb949ddf79aeecd590243b8285410d7f3 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:44:21 -0300
Subject: [PATCH 08/23] feat(udt-011): register TimeProvider.System in
AddApplication DI
---
.../SIGCM2.Application/DependencyInjection.cs | 3 ++
.../AddApplicationDependencyInjectionTests.cs | 30 +++++++++++++++++++
2 files changed, 33 insertions(+)
create mode 100644 tests/SIGCM2.Application.Tests/Common/AddApplicationDependencyInjectionTests.cs
diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs
index c0e29e9..171721a 100644
--- a/src/api/SIGCM2.Application/DependencyInjection.cs
+++ b/src/api/SIGCM2.Application/DependencyInjection.cs
@@ -67,6 +67,9 @@ public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
+ // UDT-011: TimeProvider singleton — available to all handlers for Cat2 date computation
+ services.AddSingleton(TimeProvider.System);
+
// Command handlers
services.AddScoped, LoginCommandHandler>();
services.AddScoped, RefreshCommandHandler>();
diff --git a/tests/SIGCM2.Application.Tests/Common/AddApplicationDependencyInjectionTests.cs b/tests/SIGCM2.Application.Tests/Common/AddApplicationDependencyInjectionTests.cs
new file mode 100644
index 0000000..3a27cd5
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Common/AddApplicationDependencyInjectionTests.cs
@@ -0,0 +1,30 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using SIGCM2.Application;
+
+namespace SIGCM2.Application.Tests.Common;
+
+///
+/// UDT-011 T300.20.2 — DI registration smoke tests for AddApplication.
+/// Pure unit: no DB, no HTTP — just verifies the service container.
+///
+public sealed class AddApplicationDependencyInjectionTests
+{
+ ///
+ /// [REQ-BE-CLOCK-004] TimeProvider.System must be registered as singleton
+ /// so all command handlers can inject it without a concrete coupling.
+ ///
+ [Fact]
+ public void AddApplication_Registers_TimeProvider_System()
+ {
+ var services = new ServiceCollection();
+ // AddApplication requires some infrastructure; provide minimal stubs
+ // by only testing the DI graph without building the full host.
+ services.AddApplication();
+ using var provider = services.BuildServiceProvider();
+
+ var timeProvider = provider.GetRequiredService();
+
+ timeProvider.Should().BeSameAs(TimeProvider.System);
+ }
+}
--
2.49.1
From 8dd668d5c5a2a5933630eb24c82c2ea413eb4c7a Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:47:13 -0300
Subject: [PATCH 09/23] test(udt-011): DateOnlyJsonConverter serialization
tests (Red)
---
.../Json/DateOnlyJsonConverterTests.cs | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 tests/SIGCM2.Api.Tests/Json/DateOnlyJsonConverterTests.cs
diff --git a/tests/SIGCM2.Api.Tests/Json/DateOnlyJsonConverterTests.cs b/tests/SIGCM2.Api.Tests/Json/DateOnlyJsonConverterTests.cs
new file mode 100644
index 0000000..51fb8bd
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Json/DateOnlyJsonConverterTests.cs
@@ -0,0 +1,54 @@
+using System.Text.Json;
+using FluentAssertions;
+using SIGCM2.Api.Json;
+
+namespace SIGCM2.Api.Tests.Json;
+
+///
+/// UDT-011 T300.30 — Unit tests for DateOnlyJsonConverter.
+/// Verifies round-trip serialization as "yyyy-MM-dd" ISO string.
+/// No DB, no HTTP.
+///
+public sealed class DateOnlyJsonConverterTests
+{
+ private readonly JsonSerializerOptions _options = new()
+ {
+ Converters = { new DateOnlyJsonConverter() }
+ };
+
+ /// [REQ-BE-JSON-001] DateOnly serializes as "yyyy-MM-dd" string.
+ [Fact]
+ public void Serialize_DateOnly_WritesYyyyMmDdString()
+ {
+ var date = new DateOnly(2026, 5, 1);
+ var json = JsonSerializer.Serialize(date, _options);
+ json.Should().Be("\"2026-05-01\"");
+ }
+
+ /// [REQ-BE-JSON-002] "yyyy-MM-dd" string deserializes back to DateOnly.
+ [Fact]
+ public void Deserialize_YyyyMmDdString_ReturnsDateOnly()
+ {
+ var json = "\"2026-05-01\"";
+ var date = JsonSerializer.Deserialize(json, _options);
+ date.Should().Be(new DateOnly(2026, 5, 1));
+ }
+
+ /// [REQ-BE-JSON-003] Invalid date format (dd/MM/yyyy) throws on deserialize.
+ [Fact]
+ public void Deserialize_InvalidFormat_ThrowsFormatException()
+ {
+ var json = "\"01/05/2026\""; // formato dd/MM/yyyy — inválido
+ var act = () => JsonSerializer.Deserialize(json, _options);
+ act.Should().Throw();
+ }
+
+ /// JSON null is not valid for non-nullable DateOnly — must throw.
+ [Fact]
+ public void Deserialize_Null_ThrowsException()
+ {
+ var json = "null";
+ var act = () => JsonSerializer.Deserialize(json, _options);
+ act.Should().Throw(); // JsonException desde GetString() null path
+ }
+}
--
2.49.1
From a75d2f75a0d4ba711449c1707e96ca6ea3e90c77 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:47:16 -0300
Subject: [PATCH 10/23] feat(udt-011): DateOnlyJsonConverter as yyyy-MM-dd
---
.../SIGCM2.Api/Json/DateOnlyJsonConverter.cs | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 src/api/SIGCM2.Api/Json/DateOnlyJsonConverter.cs
diff --git a/src/api/SIGCM2.Api/Json/DateOnlyJsonConverter.cs b/src/api/SIGCM2.Api/Json/DateOnlyJsonConverter.cs
new file mode 100644
index 0000000..ca1b1e1
--- /dev/null
+++ b/src/api/SIGCM2.Api/Json/DateOnlyJsonConverter.cs
@@ -0,0 +1,31 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SIGCM2.Api.Json;
+
+///
+/// JSON converter for that uses the "yyyy-MM-dd" ISO format.
+///
+/// UDT-011: Ensures Cat2 date fields (VigenciaDesde, etc.) never serialize as
+/// "2026-05-01T00:00:00" or with a UTC suffix "Z", which would mislead consumers
+/// into treating civil Argentine dates as absolute UTC instants.
+///
+public sealed class DateOnlyJsonConverter : JsonConverter
+{
+ private const string DateFormat = "yyyy-MM-dd";
+
+ public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var str = reader.GetString();
+ if (str is null)
+ throw new JsonException("DateOnly value cannot be null.");
+
+ return DateOnly.ParseExact(str, DateFormat, CultureInfo.InvariantCulture);
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString(DateFormat, CultureInfo.InvariantCulture));
+ }
+}
--
2.49.1
From 3c264aa7a1db9b3f41ae37b5c1eed993990e7fe7 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 09:47:19 -0300
Subject: [PATCH 11/23] chore(udt-011): register DateOnlyJsonConverter in
Program.cs AddJsonOptions
---
src/api/SIGCM2.Api/Program.cs | 10 ++++-
.../Admin/FiscalControllerTests.cs | 42 +++++++++++++++++++
2 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs
index 92ab9f8..1000af3 100644
--- a/src/api/SIGCM2.Api/Program.cs
+++ b/src/api/SIGCM2.Api/Program.cs
@@ -2,12 +2,13 @@ using Microsoft.AspNetCore.Authorization;
using Serilog;
using Scalar.AspNetCore;
using SIGCM2.Api.Authorization;
+using SIGCM2.Api.Filters;
using SIGCM2.Api.HealthChecks;
+using SIGCM2.Api.Json;
using SIGCM2.Api.Middleware;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
using SIGCM2.Infrastructure.Audit.Jobs;
-using SIGCM2.Api.Filters;
// Bootstrap logger — before DI is built
Log.Logger = new LoggerConfiguration()
@@ -36,10 +37,15 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped();
builder.Services.AddSingleton();
-// Controllers with exception filter
+// Controllers with exception filter + JSON options
+// UDT-011: DateOnlyJsonConverter ensures Cat2 date fields serialize as "yyyy-MM-dd"
+// and never as "2026-05-01T00:00:00" or with a UTC "Z" suffix.
builder.Services.AddControllers(opts =>
{
opts.Filters.Add();
+}).AddJsonOptions(jsonOpts =>
+{
+ jsonOpts.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
});
// OpenAPI / Scalar
diff --git a/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs
index 704b380..9c7ed2b 100644
--- a/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs
+++ b/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs
@@ -693,4 +693,46 @@ public sealed class FiscalControllerTests : IAsyncLifetime
Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'");
Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'");
}
+
+ // ── UDT-011: DateOnly serialization format ────────────────────────────────
+
+ ///
+ /// [UDT-011 REQ-BE-JSON-001] POST /iva → vigenciaDesde en respuesta debe ser
+ /// "yyyy-MM-dd" (e.g. "2025-01-01"), no "2025-01-01T00:00:00" ni con sufijo "Z".
+ /// Valida que DateOnlyJsonConverter está activo en el pipeline de controllers.
+ ///
+ [Fact]
+ public async Task CreateIva_VigenciaDesde_SerializesAsDateOnlyString()
+ {
+ const string codigo = "IVA_9999";
+ var token = await GetAdminTokenAsync();
+ try
+ {
+ using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new
+ {
+ codigo,
+ descripcion = "IVA DateOnly Format Test",
+ porcentaje = 5.0m,
+ aplicaIVA = true,
+ vigenciaDesde = "2025-01-01"
+ }, token);
+ var resp = await _client.SendAsync(req);
+
+ Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
+
+ // Read raw JSON string to inspect format (not deserialized)
+ var rawJson = await resp.Content.ReadAsStringAsync();
+
+ // vigenciaDesde MUST be "2025-01-01" — short date format
+ Assert.Contains("\"2025-01-01\"", rawJson);
+
+ // Must NOT contain datetime format or UTC suffix
+ Assert.DoesNotContain("T00:00:00", rawJson);
+ Assert.DoesNotContain("\"2025-01-01Z\"", rawJson);
+ }
+ finally
+ {
+ await DeleteTipoDeIvaByCodigoAsync(codigo);
+ }
+ }
}
--
2.49.1
From 4e1d8f69abc8eb3f4dc92c060ea9fc2114cb6c4a Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:12:03 -0300
Subject: [PATCH 12/23] =?UTF-8?q?feat(udt-011):=20T400.20=20=E2=80=94=20do?=
=?UTF-8?q?main=20mutators=20accept=20explicit=20DateTime=20now=20param?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove DateTime.UtcNow calls from all With*/Deactivate/Reactivate/
CerrarVigencia/NuevaVersion domain methods. Caller (Application layer)
is now responsible for passing the UTC timestamp obtained via
_timeProvider.GetUtcNow().UtcDateTime.
---
.../SIGCM2.Domain/Entities/IngresosBrutos.cs | 24 ++++++++------
src/api/SIGCM2.Domain/Entities/Medio.cs | 10 +++---
.../SIGCM2.Domain/Entities/PuntoDeVenta.cs | 9 ++---
src/api/SIGCM2.Domain/Entities/Seccion.cs | 10 +++---
src/api/SIGCM2.Domain/Entities/TipoDeIva.cs | 33 +++++++++++--------
src/api/SIGCM2.Domain/Entities/Usuario.cs | 25 +++++++-------
6 files changed, 61 insertions(+), 50 deletions(-)
diff --git a/src/api/SIGCM2.Domain/Entities/IngresosBrutos.cs b/src/api/SIGCM2.Domain/Entities/IngresosBrutos.cs
index db35ce6..3a5e2db 100644
--- a/src/api/SIGCM2.Domain/Entities/IngresosBrutos.cs
+++ b/src/api/SIGCM2.Domain/Entities/IngresosBrutos.cs
@@ -95,9 +95,13 @@ public sealed class IngresosBrutos
///
/// Si la predecesora ya está cerrada (VigenciaHasta != null).
/// Si vigenciaDesde no es posterior a la predecesora, o nuevaAlicuota fuera de rango.
+ ///
+ /// Timestamp UTC provisto por el caller (Application layer via TimeProvider).
+ ///
public (IngresosBrutos predecesoraCerrada, IngresosBrutos nuevaVersion) NuevaVersion(
decimal nuevaAlicuota,
- DateOnly vigenciaDesde)
+ DateOnly vigenciaDesde,
+ DateTime now)
{
if (VigenciaHasta is not null)
throw new InvalidOperationException(
@@ -120,7 +124,7 @@ public sealed class IngresosBrutos
vigenciaHasta: vigenciaDesde.AddDays(-1),
predecesorId: PredecesorId,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
var nueva = ForCreation(
provincia: Provincia,
@@ -136,26 +140,26 @@ public sealed class IngresosBrutos
// ── Cosmetic mutators (NO WithAlicuota, NO WithProvincia) ─────────────────
/// Actualiza la descripción. Alicuota y Provincia permanecen inmutables.
- public IngresosBrutos WithDescripcion(string descripcion)
+ public IngresosBrutos WithDescripcion(string descripcion, DateTime now)
=> new(Id, Provincia, descripcion, Alicuota, Activo,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
/// Retorna instancia con Activo=false.
- public IngresosBrutos Deactivate()
+ public IngresosBrutos Deactivate(DateTime now)
=> new(Id, Provincia, Descripcion, Alicuota, false,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
/// Retorna instancia con Activo=true.
- public IngresosBrutos Reactivate()
+ public IngresosBrutos Reactivate(DateTime now)
=> new(Id, Provincia, Descripcion, Alicuota, true,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
///
/// Cierra la vigencia seteando VigenciaHasta. Usado por el handler de NuevaVersion.
///
- public IngresosBrutos CerrarVigencia(DateOnly vigenciaHasta)
+ public IngresosBrutos CerrarVigencia(DateOnly vigenciaHasta, DateTime now)
=> new(Id, Provincia, Descripcion, Alicuota, Activo,
- VigenciaDesde, vigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, vigenciaHasta, PredecesorId, FechaCreacion, now);
// ── Private helpers ───────────────────────────────────────────────────────
diff --git a/src/api/SIGCM2.Domain/Entities/Medio.cs b/src/api/SIGCM2.Domain/Entities/Medio.cs
index 6c7df79..25d3004 100644
--- a/src/api/SIGCM2.Domain/Entities/Medio.cs
+++ b/src/api/SIGCM2.Domain/Entities/Medio.cs
@@ -49,9 +49,9 @@ public sealed class Medio
///
/// Returns a new instance with updated fields. Codigo is immutable (use BD UQ to enforce).
- /// Sets FechaModificacion = UtcNow.
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
///
- public Medio WithUpdatedProfile(string nombre, TipoMedio tipo, int? plataformaEmpresaId)
+ public Medio WithUpdatedProfile(string nombre, TipoMedio tipo, int? plataformaEmpresaId, DateTime now)
=> new(
id: Id,
codigo: Codigo,
@@ -60,9 +60,9 @@ public sealed class Medio
plataformaEmpresaId: plataformaEmpresaId,
activo: Activo,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
- public Medio WithActivo(bool activo)
+ public Medio WithActivo(bool activo, DateTime now)
=> new(
id: Id,
codigo: Codigo,
@@ -71,5 +71,5 @@ public sealed class Medio
plataformaEmpresaId: PlataformaEmpresaId,
activo: activo,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
}
diff --git a/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs b/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs
index 87ec486..ba7572b 100644
--- a/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs
+++ b/src/api/SIGCM2.Domain/Entities/PuntoDeVenta.cs
@@ -52,8 +52,9 @@ public sealed class PuntoDeVenta
///
/// Retorna una nueva instancia con nombre, numeroAFIP y descripcion actualizados.
/// MedioId es inmutable (enforce en BD).
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
///
- public PuntoDeVenta WithUpdatedProfile(string nombre, short numeroAFIP, string? descripcion)
+ public PuntoDeVenta WithUpdatedProfile(string nombre, short numeroAFIP, string? descripcion, DateTime now)
=> new(
id: Id,
medioId: MedioId,
@@ -62,9 +63,9 @@ public sealed class PuntoDeVenta
descripcion: descripcion,
activo: Activo,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
- public PuntoDeVenta WithActivo(bool activo)
+ public PuntoDeVenta WithActivo(bool activo, DateTime now)
=> new(
id: Id,
medioId: MedioId,
@@ -73,5 +74,5 @@ public sealed class PuntoDeVenta
descripcion: Descripcion,
activo: activo,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
}
diff --git a/src/api/SIGCM2.Domain/Entities/Seccion.cs b/src/api/SIGCM2.Domain/Entities/Seccion.cs
index f7d3e2f..115757b 100644
--- a/src/api/SIGCM2.Domain/Entities/Seccion.cs
+++ b/src/api/SIGCM2.Domain/Entities/Seccion.cs
@@ -46,9 +46,9 @@ public sealed class Seccion
///
/// Returns a new instance with updated fields. MedioId and Codigo are immutable.
- /// Sets FechaModificacion = UtcNow.
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
///
- public Seccion WithUpdatedProfile(string nombre, string tipo)
+ public Seccion WithUpdatedProfile(string nombre, string tipo, DateTime now)
=> new(
id: Id,
medioId: MedioId,
@@ -57,9 +57,9 @@ public sealed class Seccion
tipo: tipo,
activo: Activo,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
- public Seccion WithActivo(bool activo)
+ public Seccion WithActivo(bool activo, DateTime now)
=> new(
id: Id,
medioId: MedioId,
@@ -68,5 +68,5 @@ public sealed class Seccion
tipo: Tipo,
activo: activo,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
}
diff --git a/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs b/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs
index 3514bdc..9aab66f 100644
--- a/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs
+++ b/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs
@@ -106,9 +106,14 @@ public sealed class TipoDeIva
///
/// Si la predecesora ya está cerrada (VigenciaHasta != null).
/// Si vigenciaDesde no es posterior a la predecesora, o nuevoPorcentaje fuera de rango.
+ ///
+ /// Crea una nueva versión con el porcentaje actualizado.
+ /// Timestamp UTC provisto por el caller (Application layer via TimeProvider).
+ ///
public (TipoDeIva predecesoraCerrada, TipoDeIva nuevaVersion) NuevaVersion(
decimal nuevoPorcentaje,
- DateOnly vigenciaDesde)
+ DateOnly vigenciaDesde,
+ DateTime now)
{
if (VigenciaHasta is not null)
throw new InvalidOperationException(
@@ -132,7 +137,7 @@ public sealed class TipoDeIva
vigenciaHasta: vigenciaDesde.AddDays(-1),
predecesorId: PredecesorId,
fechaCreacion: FechaCreacion,
- fechaModificacion: DateTime.UtcNow);
+ fechaModificacion: now);
var nueva = ForCreation(
codigo: Codigo,
@@ -149,36 +154,36 @@ public sealed class TipoDeIva
// ── Cosmetic mutators (sealed With* — NOT WithPorcentaje) ─────────────────
/// Actualiza la descripción. Porcentaje y vigencias permanecen inmutables.
- public TipoDeIva WithDescripcion(string descripcion)
+ public TipoDeIva WithDescripcion(string descripcion, DateTime now)
=> new(Id, Codigo, descripcion, Porcentaje, AplicaIVA, Activo,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
/// Actualiza el código. Porcentaje y vigencias permanecen inmutables.
- public TipoDeIva WithCodigo(string codigo)
+ public TipoDeIva WithCodigo(string codigo, DateTime now)
=> new(Id, codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
/// Actualiza la bandera AplicaIVA. Porcentaje permanece inmutable.
- public TipoDeIva WithAplicaIVA(bool aplicaIVA)
+ public TipoDeIva WithAplicaIVA(bool aplicaIVA, DateTime now)
=> new(Id, Codigo, Descripcion, Porcentaje, aplicaIVA, Activo,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
/// Retorna instancia con Activo=false.
- public TipoDeIva Deactivate()
+ public TipoDeIva Deactivate(DateTime now)
=> new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, false,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
/// Retorna instancia con Activo=true.
- public TipoDeIva Reactivate()
+ public TipoDeIva Reactivate(DateTime now)
=> new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, true,
- VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, now);
///
/// Cierra la vigencia seteando VigenciaHasta. Usado por el handler de NuevaVersion.
///
- public TipoDeIva CerrarVigencia(DateOnly vigenciaHasta)
+ public TipoDeIva CerrarVigencia(DateOnly vigenciaHasta, DateTime now)
=> new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
- VigenciaDesde, vigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
+ VigenciaDesde, vigenciaHasta, PredecesorId, FechaCreacion, now);
// ── Private helpers ───────────────────────────────────────────────────────
diff --git a/src/api/SIGCM2.Domain/Entities/Usuario.cs b/src/api/SIGCM2.Domain/Entities/Usuario.cs
index deefe66..08d5966 100644
--- a/src/api/SIGCM2.Domain/Entities/Usuario.cs
+++ b/src/api/SIGCM2.Domain/Entities/Usuario.cs
@@ -76,9 +76,10 @@ public sealed class Usuario
///
/// Returns a new instance with updated profile fields.
- /// Sets FechaModificacion = UtcNow. Username and PasswordHash are immutable.
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
+ /// Username and PasswordHash are immutable.
///
- public Usuario WithUpdatedProfile(string nombre, string apellido, string? email, string rol, bool activo)
+ public Usuario WithUpdatedProfile(string nombre, string apellido, string? email, string rol, bool activo, DateTime now)
=> new(
id: Id,
username: Username,
@@ -89,15 +90,15 @@ public sealed class Usuario
rol: rol,
permisosJson: PermisosJson,
activo: activo,
- fechaModificacion: DateTime.UtcNow,
+ fechaModificacion: now,
ultimoLogin: UltimoLogin,
mustChangePassword: MustChangePassword);
///
/// Returns a new instance with a new password hash and mustChangePassword flag.
- /// Sets FechaModificacion = UtcNow.
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
///
- public Usuario WithNewPasswordHash(string hash, bool mustChangePassword)
+ public Usuario WithNewPasswordHash(string hash, bool mustChangePassword, DateTime now)
=> new(
id: Id,
username: Username,
@@ -108,15 +109,15 @@ public sealed class Usuario
rol: Rol,
permisosJson: PermisosJson,
activo: Activo,
- fechaModificacion: DateTime.UtcNow,
+ fechaModificacion: now,
ultimoLogin: UltimoLogin,
mustChangePassword: mustChangePassword);
///
/// Returns a new instance with only the MustChangePassword flag changed.
- /// Sets FechaModificacion = UtcNow.
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
///
- public Usuario WithMustChangePassword(bool value)
+ public Usuario WithMustChangePassword(bool value, DateTime now)
=> new(
id: Id,
username: Username,
@@ -127,16 +128,16 @@ public sealed class Usuario
rol: Rol,
permisosJson: PermisosJson,
activo: Activo,
- fechaModificacion: DateTime.UtcNow,
+ fechaModificacion: now,
ultimoLogin: UltimoLogin,
mustChangePassword: value);
///
/// UDT-009: Returns a new instance with PermisosJson replaced.
- /// Sets FechaModificacion = UtcNow.
+ /// Caller is responsible for passing the current UTC timestamp via TimeProvider.GetUtcNow().UtcDateTime.
/// Accepts raw JSON string so Domain stays free of Application dependencies.
///
- public Usuario WithPermisosJson(string permisosJson)
+ public Usuario WithPermisosJson(string permisosJson, DateTime now)
=> new(
id: Id,
username: Username,
@@ -147,7 +148,7 @@ public sealed class Usuario
rol: Rol,
permisosJson: permisosJson,
activo: Activo,
- fechaModificacion: DateTime.UtcNow,
+ fechaModificacion: now,
ultimoLogin: UltimoLogin,
mustChangePassword: MustChangePassword);
--
2.49.1
From d69da5ff4c068a49410af0bc0841e9bdbdc66dba Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:12:17 -0300
Subject: [PATCH 13/23] =?UTF-8?q?feat(udt-011):=20T400.10=20=E2=80=94=20in?=
=?UTF-8?q?ject=20TimeProvider=20into=20all=20Application=20handlers?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All command handlers that call domain mutators now inject TimeProvider
via constructor and use _timeProvider.GetUtcNow().UtcDateTime as the
explicit 'now' argument. Replaces previous direct DateTime.UtcNow usage.
---
.../Auth/Login/LoginCommandHandler.cs | 7 +++++--
.../Auth/Logout/LogoutCommandHandler.cs | 10 ++++++++--
.../Auth/Refresh/RefreshCommandHandler.cs | 7 +++++--
.../Create/CreateIngresosBrutosCommandHandler.cs | 6 ++++--
.../DeactivateIngresosBrutosCommandHandler.cs | 7 +++++--
.../NuevaVersionIngresosBrutosCommandHandler.cs | 9 +++++++--
.../ReactivateIngresosBrutosCommandHandler.cs | 7 +++++--
.../Update/UpdateIngresosBrutosCommandHandler.cs | 9 ++++++---
.../Deactivate/DeactivateMedioCommandHandler.cs | 7 +++++--
.../Reactivate/ReactivateMedioCommandHandler.cs | 7 +++++--
.../Medios/Update/UpdateMedioCommandHandler.cs | 7 +++++--
.../DeactivatePuntoDeVentaCommandHandler.cs | 8 ++++++--
.../ReactivatePuntoDeVentaCommandHandler.cs | 8 ++++++--
.../Update/UpdatePuntoDeVentaCommandHandler.cs | 8 ++++++--
.../Deactivate/DeactivateSeccionCommandHandler.cs | 11 +++++++++--
.../Reactivate/ReactivateSeccionCommandHandler.cs | 11 +++++++++--
.../Update/UpdateSeccionCommandHandler.cs | 11 +++++++++--
.../Create/CreateTipoDeIvaCommandHandler.cs | 6 ++++--
.../DeactivateTipoDeIvaCommandHandler.cs | 7 +++++--
.../NuevaVersionTipoDeIvaCommandHandler.cs | 9 +++++++--
.../ReactivateTipoDeIvaCommandHandler.cs | 7 +++++--
.../Update/UpdateTipoDeIvaCommandHandler.cs | 15 +++++++++------
.../Deactivate/DeactivateUsuarioCommandHandler.cs | 7 +++++--
...pdateUsuarioPermisosOverridesCommandHandler.cs | 8 ++++++--
.../Reactivate/ReactivateUsuarioCommandHandler.cs | 9 +++++++--
.../ResetUsuarioPasswordCommandHandler.cs | 8 ++++++--
.../Update/UpdateUsuarioCommandHandler.cs | 7 +++++--
27 files changed, 164 insertions(+), 59 deletions(-)
diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs
index e74a07f..d1e8459 100644
--- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs
@@ -22,6 +22,7 @@ public sealed class LoginCommandHandler : ICommandHandler _logger;
+ private readonly TimeProvider _timeProvider;
public LoginCommandHandler(
IUsuarioRepository repository,
@@ -33,7 +34,8 @@ public sealed class LoginCommandHandler : ICommandHandler logger)
+ ILogger logger,
+ TimeProvider timeProvider)
{
_repository = repository;
_hasher = hasher;
@@ -45,6 +47,7 @@ public sealed class LoginCommandHandler : ICommandHandler Handle(LoginCommand command)
@@ -81,7 +84,7 @@ public sealed class LoginCommandHandler : ICommandHandler Handle(LogoutCommand command)
{
// Revoke all active tokens for the user across all families.
// Idempotent: 0 rows affected is not an error.
- await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, now);
await _security.LogAsync("logout", "success", actorUserId: command.UsuarioId);
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
}
diff --git a/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs
index b9dbbaa..b9d56e5 100644
--- a/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Auth/Refresh/RefreshCommandHandler.cs
@@ -17,6 +17,7 @@ public sealed class RefreshCommandHandler : ICommandHandler Handle(RefreshCommand command)
@@ -60,7 +63,7 @@ public sealed class RefreshCommandHandler : ICommandHandler Handle(CreateIngresosBrutosCommand command)
@@ -51,7 +53,7 @@ public sealed class CreateIngresosBrutosCommandHandler
vigenciaDesde: entity.VigenciaDesde,
vigenciaHasta: entity.VigenciaHasta,
predecesorId: entity.PredecesorId,
- fechaCreacion: DateTime.UtcNow,
+ fechaCreacion: _timeProvider.GetUtcNow().UtcDateTime,
fechaModificacion: null));
}
}
diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs
index d78b769..b21b179 100644
--- a/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs
+++ b/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs
@@ -12,11 +12,13 @@ public sealed class DeactivateIngresosBrutosCommandHandler
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
- public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
+ public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
+ _timeProvider = timeProvider;
}
public async Task Handle(DeactivateIngresosBrutosCommand command)
@@ -41,6 +43,7 @@ public sealed class DeactivateIngresosBrutosCommandHandler
tx.Complete();
- return IngresosBrutosMapper.ToDto(entity.Deactivate());
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ return IngresosBrutosMapper.ToDto(entity.Deactivate(now));
}
}
diff --git a/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs
index 741b93b..6131c1c 100644
--- a/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs
+++ b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs
@@ -12,11 +12,13 @@ public sealed class NuevaVersionIngresosBrutosCommandHandler
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
- public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
+ public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
+ _timeProvider = timeProvider;
}
public async Task Handle(NuevaVersionIngresosBrutosCommand command)
@@ -29,10 +31,13 @@ public sealed class NuevaVersionIngresosBrutosCommandHandler
if (!predecesora.Activo || predecesora.VigenciaHasta is not null)
throw new PredecesorYaCerradoException(command.PredecesoraId);
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+
// Steps 3–4: domain validation + tuple creation (throws ArgumentException if vigencia invalid)
var (predecesoraCerrada, nuevaVersion) = predecesora.NuevaVersion(
command.NuevaAlicuota,
- command.VigenciaDesde);
+ command.VigenciaDesde,
+ now);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs
index 4b96bd3..c203c7e 100644
--- a/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs
+++ b/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs
@@ -12,11 +12,13 @@ public sealed class ReactivateIngresosBrutosCommandHandler
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
- public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
+ public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
+ _timeProvider = timeProvider;
}
public async Task Handle(ReactivateIngresosBrutosCommand command)
@@ -41,6 +43,7 @@ public sealed class ReactivateIngresosBrutosCommandHandler
tx.Complete();
- return IngresosBrutosMapper.ToDto(entity.Reactivate());
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ return IngresosBrutosMapper.ToDto(entity.Reactivate(now));
}
}
diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs
index d87e4c3..4b0ef12 100644
--- a/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs
+++ b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs
@@ -12,11 +12,13 @@ public sealed class UpdateIngresosBrutosCommandHandler
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
- public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
+ public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
+ _timeProvider = timeProvider;
}
public async Task Handle(UpdateIngresosBrutosCommand command)
@@ -24,8 +26,9 @@ public sealed class UpdateIngresosBrutosCommandHandler
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new IngresosBrutosNotFoundException(command.Id);
- var updated = entity.WithDescripcion(command.Descripcion);
- updated = command.Activo ? updated.Reactivate() : updated.Deactivate();
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ var updated = entity.WithDescripcion(command.Descripcion, now);
+ updated = command.Activo ? updated.Reactivate(now) : updated.Deactivate(now);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs
index 7b87564..92dacc1 100644
--- a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs
@@ -11,11 +11,13 @@ public sealed class DeactivateMedioCommandHandler : ICommandHandler Handle(DeactivateMedioCommand command)
@@ -27,7 +29,8 @@ public sealed class DeactivateMedioCommandHandler : ICommandHandler Handle(ReactivateMedioCommand command)
@@ -28,7 +30,8 @@ public sealed class ReactivateMedioCommandHandler : ICommandHandler Handle(UpdateMedioCommand command)
@@ -23,7 +25,8 @@ public sealed class UpdateMedioCommandHandler : ICommandHandler Handle(DeactivatePuntoDeVentaCommand command)
@@ -32,7 +35,8 @@ public sealed class DeactivatePuntoDeVentaCommandHandler : ICommandHandler Handle(ReactivatePuntoDeVentaCommand command)
@@ -39,7 +42,8 @@ public sealed class ReactivatePuntoDeVentaCommandHandler : ICommandHandler Handle(UpdatePuntoDeVentaCommand command)
@@ -39,7 +42,8 @@ public sealed class UpdatePuntoDeVentaCommandHandler : ICommandHandler Handle(DeactivateSeccionCommand command)
@@ -35,7 +41,8 @@ public sealed class DeactivateSeccionCommandHandler : ICommandHandler Handle(ReactivateSeccionCommand command)
@@ -36,7 +42,8 @@ public sealed class ReactivateSeccionCommandHandler : ICommandHandler Handle(UpdateSeccionCommand command)
@@ -31,7 +37,8 @@ public sealed class UpdateSeccionCommandHandler : ICommandHandler Handle(CreateTipoDeIvaCommand command)
@@ -55,7 +57,7 @@ public sealed class CreateTipoDeIvaCommandHandler : ICommandHandler Handle(DeactivateTipoDeIvaCommand command)
@@ -41,6 +43,7 @@ public sealed class DeactivateTipoDeIvaCommandHandler : ICommandHandler Handle(NuevaVersionTipoDeIvaCommand command)
@@ -29,10 +31,13 @@ public sealed class NuevaVersionTipoDeIvaCommandHandler
if (!predecesora.Activo || predecesora.VigenciaHasta is not null)
throw new PredecesorYaCerradoException(command.PredecesoraId);
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+
// Steps 3–4: delegate validation + tuple creation to domain (throws ArgumentException on invalid vigencia)
var (predecesoraCerrada, nuevaVersion) = predecesora.NuevaVersion(
command.NuevoPorcentaje,
- command.VigenciaDesde);
+ command.VigenciaDesde,
+ now);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
diff --git a/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs
index 2a2ab9a..7a617b7 100644
--- a/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs
+++ b/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs
@@ -11,11 +11,13 @@ public sealed class ReactivateTipoDeIvaCommandHandler : ICommandHandler Handle(ReactivateTipoDeIvaCommand command)
@@ -41,6 +43,7 @@ public sealed class ReactivateTipoDeIvaCommandHandler : ICommandHandler Handle(UpdateTipoDeIvaCommand command)
@@ -23,15 +25,16 @@ public sealed class UpdateTipoDeIvaCommandHandler : ICommandHandler Handle(DeactivateUsuarioCommand cmd)
@@ -43,7 +46,7 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler Handle(UpdateUsuarioPermisosOverridesCommand command)
@@ -59,7 +62,8 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
// 4. Persist — use WithPermisosJson to get updated FechaModificacion
var newOverrides = new PermisosOverride(grant, deny);
var previousOverrides = PermisosOverride.FromJson(usuario.PermisosJson);
- var updated = usuario.WithPermisosJson(newOverrides.ToJson());
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
+ var updated = usuario.WithPermisosJson(newOverrides.ToJson(), now);
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs
index c89a473..4c73270 100644
--- a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs
@@ -12,11 +12,16 @@ public sealed class ReactivateUsuarioCommandHandler : ICommandHandler Handle(ReactivateUsuarioCommand cmd)
@@ -34,7 +39,7 @@ public sealed class ReactivateUsuarioCommandHandler : ICommandHandler Handle(ResetUsuarioPasswordCommand cmd)
@@ -45,8 +48,9 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler Handle(UpdateUsuarioCommand cmd)
@@ -52,7 +55,7 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler
Date: Sat, 18 Apr 2026 10:12:24 -0300
Subject: [PATCH 14/23] =?UTF-8?q?feat(udt-011):=20T400.30=20=E2=80=94=20in?=
=?UTF-8?q?ject=20TimeProvider=20into=20Infrastructure=20critical=20servic?=
=?UTF-8?q?es?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AuditLogger, SecurityEventLogger: inject TimeProvider and use
_timeProvider.GetUtcNow().UtcDateTime for occurredAt timestamps.
JwtService: inject TimeProvider; use GetUtcNow() for token IssuedAt/Expires.
DI: update JwtService factory to pass sp.GetRequiredService().
Repositories: remove ?? DateTime.UtcNow fallback in UpdateAsync since callers
always provide FechaModificacion via domain mutators.
---
src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs | 7 +++++--
src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs | 7 +++++--
src/api/SIGCM2.Infrastructure/DependencyInjection.cs | 5 ++++-
.../SIGCM2.Infrastructure/Persistence/MedioRepository.cs | 2 +-
.../Persistence/PuntoDeVentaRepository.cs | 2 +-
.../SIGCM2.Infrastructure/Persistence/SeccionRepository.cs | 2 +-
src/api/SIGCM2.Infrastructure/Security/JwtService.cs | 6 ++++--
7 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
index 6376ec1..4794726 100644
--- a/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
+++ b/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
@@ -12,15 +12,18 @@ public sealed class AuditLogger : IAuditLogger
private readonly IAuditContext _context;
private readonly IAuditEventRepository _repo;
private readonly IOptions _options;
+ private readonly TimeProvider _timeProvider;
public AuditLogger(
IAuditContext context,
IAuditEventRepository repo,
- IOptions options)
+ IOptions options,
+ TimeProvider timeProvider)
{
_context = context;
_repo = repo;
_options = options;
+ _timeProvider = timeProvider;
}
public async Task LogAsync(
@@ -42,7 +45,7 @@ public sealed class AuditLogger : IAuditLogger
: _context.CorrelationId;
await _repo.InsertAsync(
- occurredAt: DateTime.UtcNow,
+ occurredAt: _timeProvider.GetUtcNow().UtcDateTime,
actorUserId: _context.ActorUserId,
actorRoleId: _context.ActorRoleId,
action: action,
diff --git a/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
index 3b3a94e..b3398ca 100644
--- a/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
+++ b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
@@ -11,15 +11,18 @@ public sealed class SecurityEventLogger : ISecurityEventLogger
private readonly ISecurityEventRepository _repo;
private readonly IAuditContext _context;
private readonly IOptions _options;
+ private readonly TimeProvider _timeProvider;
public SecurityEventLogger(
ISecurityEventRepository repo,
IAuditContext context,
- IOptions options)
+ IOptions options,
+ TimeProvider timeProvider)
{
_repo = repo;
_context = context;
_options = options;
+ _timeProvider = timeProvider;
}
public async Task LogAsync(
@@ -37,7 +40,7 @@ public sealed class SecurityEventLogger : ISecurityEventLogger
: JsonSanitizer.Sanitize(metadata, _options.Value.SanitizedKeys);
await _repo.InsertAsync(
- occurredAt: DateTime.UtcNow,
+ occurredAt: _timeProvider.GetUtcNow().UtcDateTime,
actorUserId: actorUserId,
attemptedUsername: attemptedUsername,
sessionId: sessionId,
diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs
index a638f1f..0931998 100644
--- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs
+++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs
@@ -68,7 +68,10 @@ public static class DependencyInjection
});
services.AddScoped(sp =>
- new JwtService(sp.GetRequiredService(), sp.GetRequiredService()));
+ new JwtService(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
services.AddScoped();
services.AddSingleton();
services.AddHttpContextAccessor();
diff --git a/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs
index 99dc1ca..8c197ef 100644
--- a/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs
+++ b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs
@@ -85,7 +85,7 @@ public sealed class MedioRepository : IMedioRepository
Tipo = (int)m.Tipo,
m.PlataformaEmpresaId,
m.Activo,
- FechaModificacion = m.FechaModificacion ?? DateTime.UtcNow,
+ FechaModificacion = m.FechaModificacion,
m.Id,
});
}
diff --git a/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs
index d276536..5749ead 100644
--- a/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs
+++ b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs
@@ -96,7 +96,7 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
pdv.Nombre,
pdv.Descripcion,
pdv.Activo,
- FechaModificacion = pdv.FechaModificacion ?? DateTime.UtcNow,
+ FechaModificacion = pdv.FechaModificacion,
pdv.Id,
});
}
diff --git a/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs
index 6ec75eb..327c7cc 100644
--- a/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs
+++ b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs
@@ -84,7 +84,7 @@ public sealed class SeccionRepository : ISeccionRepository
s.Nombre,
s.Tipo,
s.Activo,
- FechaModificacion = s.FechaModificacion ?? DateTime.UtcNow,
+ FechaModificacion = s.FechaModificacion,
s.Id,
});
}
diff --git a/src/api/SIGCM2.Infrastructure/Security/JwtService.cs b/src/api/SIGCM2.Infrastructure/Security/JwtService.cs
index 85ab73e..e52c2a7 100644
--- a/src/api/SIGCM2.Infrastructure/Security/JwtService.cs
+++ b/src/api/SIGCM2.Infrastructure/Security/JwtService.cs
@@ -11,11 +11,13 @@ public sealed class JwtService : IJwtService
{
private readonly RSA _rsa;
private readonly JwtOptions _options;
+ private readonly TimeProvider _timeProvider;
- public JwtService(RSA rsa, JwtOptions options)
+ public JwtService(RSA rsa, JwtOptions options, TimeProvider timeProvider)
{
_rsa = rsa;
_options = options;
+ _timeProvider = timeProvider;
}
///
@@ -62,7 +64,7 @@ public sealed class JwtService : IJwtService
new("rol", usuario.Rol),
};
- var now = DateTime.UtcNow;
+ var now = _timeProvider.GetUtcNow().UtcDateTime;
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
--
2.49.1
From 9bc191c3ae3201954a95a89383b7feb54cb5f7d9 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:12:32 -0300
Subject: [PATCH 15/23] =?UTF-8?q?test(udt-011):=20T400.40=20=E2=80=94=20up?=
=?UTF-8?q?date=20tests=20for=20TimeProvider=20injection=20and=20explicit?=
=?UTF-8?q?=20now=20params?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix all test compilation errors caused by T400.10/T400.20/T400.30:
- Handler constructors: add TimeProvider.System as last argument
- Domain mutator calls: add DateTime.UtcNow as explicit 'now' argument
- AuditLogger/SecurityEventLogger Build() helpers: add TimeProvider.System
- JwtService test constructors: add TimeProvider.System
Cat2 coverage already present in TimeProviderArgentinaExtensionsTests.cs:
FakeTimeProvider proves GetArgentinaToday() returns ART civil date, not UTC.
---
.../Auth/Login/LoginCommandHandlerTests.cs | 2 +-
.../Auth/Logout/LogoutCommandHandlerTests.cs | 2 +-
.../Refresh/RefreshCommandHandlerTests.cs | 3 +-
.../Domain/Fiscal/IngresosBrutosTests.cs | 18 ++++++------
.../Domain/Fiscal/TipoDeIvaTests.cs | 28 +++++++++----------
.../Domain/PuntoDeVentaTests.cs | 12 ++++----
.../Domain/UsuarioTests.cs | 23 ++++++++-------
.../Infrastructure/Audit/AuditLoggerTests.cs | 2 +-
.../Audit/SecurityEventLoggerTests.cs | 2 +-
.../Infrastructure/JwtServiceTests.cs | 4 +--
...CreateIngresosBrutosCommandHandlerTests.cs | 2 +-
...tivateIngresosBrutosCommandHandlerTests.cs | 2 +-
...ersionIngresosBrutosCommandHandlerTests.cs | 2 +-
...tivateIngresosBrutosCommandHandlerTests.cs | 2 +-
...UpdateIngresosBrutosCommandHandlerTests.cs | 2 +-
.../DeactivateMedioCommandHandlerTests.cs | 2 +-
.../Medios/MedioRepositoryTests.cs | 6 ++--
.../ReactivateMedioCommandHandlerTests.cs | 2 +-
.../Update/UpdateMedioCommandHandlerTests.cs | 2 +-
...activatePuntoDeVentaCommandHandlerTests.cs | 2 +-
...activatePuntoDeVentaCommandHandlerTests.cs | 2 +-
.../UpdatePuntoDeVentaCommandHandlerTests.cs | 2 +-
.../DeactivateSeccionCommandHandlerTests.cs | 2 +-
.../ReactivateSeccionCommandHandlerTests.cs | 2 +-
.../Secciones/SeccionRepositoryTests.cs | 6 ++--
.../UpdateSeccionCommandHandlerTests.cs | 2 +-
.../CreateTipoDeIvaCommandHandlerTests.cs | 2 +-
.../DeactivateTipoDeIvaCommandHandlerTests.cs | 2 +-
...uevaVersionTipoDeIvaCommandHandlerTests.cs | 2 +-
.../ReactivateTipoDeIvaCommandHandlerTests.cs | 2 +-
.../UpdateTipoDeIvaCommandHandlerTests.cs | 2 +-
.../DeactivateUsuarioCommandHandlerTests.cs | 2 +-
.../ReactivateUsuarioCommandHandlerTests.cs | 2 +-
...ResetUsuarioPasswordCommandHandlerTests.cs | 2 +-
.../UpdateUsuarioCommandHandlerTests.cs | 2 +-
35 files changed, 79 insertions(+), 75 deletions(-)
diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
index 103300b..ef202a0 100644
--- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
@@ -44,7 +44,7 @@ public class LoginCommandHandlerTests
_handler = new LoginCommandHandler(
_repository, _hasher, _jwtService,
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
- _rolPermisoRepo, _security, _logger);
+ _rolPermisoRepo, _security, _logger, TimeProvider.System);
}
// Scenario: valid credentials → returns token response with usuario populated
diff --git a/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs
index ed07064..dcba3c0 100644
--- a/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs
@@ -13,7 +13,7 @@ public class LogoutCommandHandlerTests
public LogoutCommandHandlerTests()
{
- _handler = new LogoutCommandHandler(_refreshRepo, _security);
+ _handler = new LogoutCommandHandler(_refreshRepo, _security, TimeProvider.System);
}
[Fact]
diff --git a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs
index 7423cf0..5dba28c 100644
--- a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs
@@ -36,7 +36,8 @@ public class RefreshCommandHandlerTests
_generator.Generate().Returns("new_raw_token_value_xyz");
_handler = new RefreshCommandHandler(
- _refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security);
+ _refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security,
+ TimeProvider.System);
}
// Helper: build an active stored RefreshToken with a matching principal
diff --git a/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs b/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs
index e3602c8..122529f 100644
--- a/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs
+++ b/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs
@@ -95,7 +95,7 @@ public class IngresosBrutosTests
{
var original = MakeIIBB(descripcion: "Original");
- var updated = original.WithDescripcion("Actualizado");
+ var updated = original.WithDescripcion("Actualizado", DateTime.UtcNow);
updated.Should().NotBeSameAs(original);
updated.Descripcion.Should().Be("Actualizado");
@@ -107,7 +107,7 @@ public class IngresosBrutosTests
{
var original = MakeIIBB(activo: true);
- var deactivated = original.Deactivate();
+ var deactivated = original.Deactivate(DateTime.UtcNow);
deactivated.Activo.Should().BeFalse();
deactivated.Alicuota.Should().Be(original.Alicuota);
@@ -119,7 +119,7 @@ public class IngresosBrutosTests
{
var original = MakeIIBB(activo: false);
- var reactivated = original.Reactivate();
+ var reactivated = original.Reactivate(DateTime.UtcNow);
reactivated.Activo.Should().BeTrue();
}
@@ -130,7 +130,7 @@ public class IngresosBrutosTests
var original = MakeIIBB(vigenciaHasta: null);
var hasta = new DateOnly(2026, 5, 31);
- var cerrado = original.CerrarVigencia(hasta);
+ var cerrado = original.CerrarVigencia(hasta, DateTime.UtcNow);
cerrado.VigenciaHasta.Should().Be(hasta);
cerrado.Alicuota.Should().Be(original.Alicuota);
@@ -143,7 +143,7 @@ public class IngresosBrutosTests
{
var predecesora = MakeIIBB(id: 5, alicuota: 2.5m, vigenciaDesde: Desde2020, vigenciaHasta: null);
- var (cerrada, nueva) = predecesora.NuevaVersion(3.0m, Desde2026);
+ var (cerrada, nueva) = predecesora.NuevaVersion(3.0m, Desde2026, DateTime.UtcNow);
cerrada.Id.Should().Be(5);
cerrada.VigenciaHasta.Should().Be(Desde2026.AddDays(-1));
@@ -166,7 +166,7 @@ public class IngresosBrutosTests
vigenciaDesde: Desde2020,
vigenciaHasta: new DateOnly(2025, 12, 31));
- var act = () => predecesora.NuevaVersion(4.0m, Desde2026);
+ var act = () => predecesora.NuevaVersion(4.0m, Desde2026, DateTime.UtcNow);
act.Should().Throw();
}
@@ -176,7 +176,7 @@ public class IngresosBrutosTests
{
var predecesora = MakeIIBB(vigenciaDesde: Desde2020, vigenciaHasta: null);
- var act = () => predecesora.NuevaVersion(4.0m, Desde2020);
+ var act = () => predecesora.NuevaVersion(4.0m, Desde2020, DateTime.UtcNow);
act.Should().Throw()
.WithParameterName("vigenciaDesde");
@@ -187,7 +187,7 @@ public class IngresosBrutosTests
{
var predecesora = MakeIIBB(vigenciaDesde: Desde2020, vigenciaHasta: null);
- var act = () => predecesora.NuevaVersion(-1m, Desde2026);
+ var act = () => predecesora.NuevaVersion(-1m, Desde2026, DateTime.UtcNow);
act.Should().Throw()
.WithParameterName("nuevaAlicuota");
@@ -198,7 +198,7 @@ public class IngresosBrutosTests
{
var predecesora = MakeIIBB(vigenciaDesde: Desde2020, vigenciaHasta: null);
- var act = () => predecesora.NuevaVersion(101m, Desde2026);
+ var act = () => predecesora.NuevaVersion(101m, Desde2026, DateTime.UtcNow);
act.Should().Throw()
.WithParameterName("nuevaAlicuota");
diff --git a/tests/SIGCM2.Application.Tests/Domain/Fiscal/TipoDeIvaTests.cs b/tests/SIGCM2.Application.Tests/Domain/Fiscal/TipoDeIvaTests.cs
index b5d700f..a33d951 100644
--- a/tests/SIGCM2.Application.Tests/Domain/Fiscal/TipoDeIvaTests.cs
+++ b/tests/SIGCM2.Application.Tests/Domain/Fiscal/TipoDeIvaTests.cs
@@ -150,7 +150,7 @@ public class TipoDeIvaTests
{
var original = MakeTipoDeIva(descripcion: "Original");
- var updated = original.WithDescripcion("Nueva descripcion");
+ var updated = original.WithDescripcion("Nueva descripcion", DateTime.UtcNow);
updated.Should().NotBeSameAs(original);
updated.Descripcion.Should().Be("Nueva descripcion");
@@ -162,7 +162,7 @@ public class TipoDeIvaTests
{
var original = MakeTipoDeIva(codigo: "IVA_21");
- var updated = original.WithCodigo("NO_GRAVADO");
+ var updated = original.WithCodigo("NO_GRAVADO", DateTime.UtcNow);
updated.Codigo.Should().Be("NO_GRAVADO");
updated.Porcentaje.Should().Be(original.Porcentaje);
@@ -174,7 +174,7 @@ public class TipoDeIvaTests
{
var original = MakeTipoDeIva(aplicaIVA: true);
- var updated = original.WithAplicaIVA(false);
+ var updated = original.WithAplicaIVA(false, DateTime.UtcNow);
updated.AplicaIVA.Should().BeFalse();
updated.Porcentaje.Should().Be(original.Porcentaje);
@@ -185,7 +185,7 @@ public class TipoDeIvaTests
{
var original = MakeTipoDeIva(activo: true);
- var deactivated = original.Deactivate();
+ var deactivated = original.Deactivate(DateTime.UtcNow);
deactivated.Activo.Should().BeFalse();
deactivated.Porcentaje.Should().Be(original.Porcentaje);
@@ -197,7 +197,7 @@ public class TipoDeIvaTests
{
var original = MakeTipoDeIva(activo: false);
- var reactivated = original.Reactivate();
+ var reactivated = original.Reactivate(DateTime.UtcNow);
reactivated.Activo.Should().BeTrue();
}
@@ -208,7 +208,7 @@ public class TipoDeIvaTests
var original = MakeTipoDeIva(vigenciaHasta: null);
var hasta = new DateOnly(2026, 5, 31);
- var cerrado = original.CerrarVigencia(hasta);
+ var cerrado = original.CerrarVigencia(hasta, DateTime.UtcNow);
cerrado.VigenciaHasta.Should().Be(hasta);
cerrado.Porcentaje.Should().Be(original.Porcentaje);
@@ -222,7 +222,7 @@ public class TipoDeIvaTests
{
var predecesora = MakeTipoDeIva(id: 5, porcentaje: 21m, vigenciaDesde: Desde2020, vigenciaHasta: null);
- var (cerrada, nueva) = predecesora.NuevaVersion(23.5m, Desde2026);
+ var (cerrada, nueva) = predecesora.NuevaVersion(23.5m, Desde2026, DateTime.UtcNow);
cerrada.Id.Should().Be(5);
cerrada.VigenciaHasta.Should().Be(Desde2026.AddDays(-1), "predecesora queda cerrada el día anterior");
@@ -244,7 +244,7 @@ public class TipoDeIvaTests
var predecesora = MakeTipoDeIva(porcentaje: 10.5m, vigenciaDesde: Desde2020);
var nuevaVigencia = new DateOnly(2025, 1, 1);
- var (_, nueva) = predecesora.NuevaVersion(21m, nuevaVigencia);
+ var (_, nueva) = predecesora.NuevaVersion(21m, nuevaVigencia, DateTime.UtcNow);
nueva.Porcentaje.Should().Be(21m);
predecesora.Porcentaje.Should().Be(10.5m, "predecesora no muta");
@@ -259,7 +259,7 @@ public class TipoDeIvaTests
vigenciaDesde: Desde2020,
vigenciaHasta: new DateOnly(2025, 12, 31)); // ya cerrada
- var act = () => predecesora.NuevaVersion(23.5m, Desde2026);
+ var act = () => predecesora.NuevaVersion(23.5m, Desde2026, DateTime.UtcNow);
act.Should().Throw();
}
@@ -269,7 +269,7 @@ public class TipoDeIvaTests
{
var predecesora = MakeTipoDeIva(vigenciaDesde: Desde2020, vigenciaHasta: null);
- var act = () => predecesora.NuevaVersion(23.5m, Desde2020); // igual a VigenciaDesde predecesora
+ var act = () => predecesora.NuevaVersion(23.5m, Desde2020, DateTime.UtcNow); // igual a VigenciaDesde predecesora
act.Should().Throw()
.WithParameterName("vigenciaDesde");
@@ -281,7 +281,7 @@ public class TipoDeIvaTests
var predecesora = MakeTipoDeIva(vigenciaDesde: Desde2026, vigenciaHasta: null);
var vigenciaAnterior = new DateOnly(2020, 1, 1);
- var act = () => predecesora.NuevaVersion(23.5m, vigenciaAnterior);
+ var act = () => predecesora.NuevaVersion(23.5m, vigenciaAnterior, DateTime.UtcNow);
act.Should().Throw()
.WithParameterName("vigenciaDesde");
@@ -292,7 +292,7 @@ public class TipoDeIvaTests
{
var predecesora = MakeTipoDeIva(vigenciaDesde: Desde2020, vigenciaHasta: null);
- var act = () => predecesora.NuevaVersion(-1m, Desde2026);
+ var act = () => predecesora.NuevaVersion(-1m, Desde2026, DateTime.UtcNow);
act.Should().Throw()
.WithParameterName("nuevoPorcentaje");
@@ -303,7 +303,7 @@ public class TipoDeIvaTests
{
var predecesora = MakeTipoDeIva(vigenciaDesde: Desde2020, vigenciaHasta: null);
- var act = () => predecesora.NuevaVersion(101m, Desde2026);
+ var act = () => predecesora.NuevaVersion(101m, Desde2026, DateTime.UtcNow);
act.Should().Throw()
.WithParameterName("nuevoPorcentaje");
@@ -326,7 +326,7 @@ public class TipoDeIvaTests
{
var original = MakeTipoDeIva(id: 99, porcentaje: 21m, vigenciaDesde: Desde2020);
- var updated = original.WithDescripcion("Nueva");
+ var updated = original.WithDescripcion("Nueva", DateTime.UtcNow);
updated.Id.Should().Be(99);
updated.Porcentaje.Should().Be(21m);
diff --git a/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs b/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs
index 57409cf..9061427 100644
--- a/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs
+++ b/tests/SIGCM2.Application.Tests/Domain/PuntoDeVentaTests.cs
@@ -43,7 +43,7 @@ public class PuntoDeVentaTests
{
var original = MakePdv(id: 10, nombre: "Original");
- var updated = original.WithUpdatedProfile(nombre: "Actualizado", numeroAFIP: 7, descripcion: "Desc");
+ var updated = original.WithUpdatedProfile(nombre: "Actualizado", numeroAFIP: 7, descripcion: "Desc", now: DateTime.UtcNow);
Assert.NotSame(original, updated);
Assert.Equal("Actualizado", updated.Nombre);
@@ -56,7 +56,7 @@ public class PuntoDeVentaTests
{
var original = MakePdv(id: 10, medioId: 5);
- var updated = original.WithUpdatedProfile("Nuevo", 2, null);
+ var updated = original.WithUpdatedProfile("Nuevo", 2, null, DateTime.UtcNow);
Assert.Equal(10, updated.Id);
Assert.Equal(5, updated.MedioId);
@@ -69,7 +69,7 @@ public class PuntoDeVentaTests
{
var original = MakePdv();
- var updated = original.WithUpdatedProfile("Nuevo", 2, null);
+ var updated = original.WithUpdatedProfile("Nuevo", 2, null, DateTime.UtcNow);
Assert.NotNull(updated.FechaModificacion);
}
@@ -81,7 +81,7 @@ public class PuntoDeVentaTests
{
var pdv = MakePdv(activo: true);
- var deactivated = pdv.WithActivo(false);
+ var deactivated = pdv.WithActivo(false, DateTime.UtcNow);
Assert.False(deactivated.Activo);
Assert.NotSame(pdv, deactivated);
@@ -92,7 +92,7 @@ public class PuntoDeVentaTests
{
var pdv = MakePdv(activo: false);
- var reactivated = pdv.WithActivo(true);
+ var reactivated = pdv.WithActivo(true, DateTime.UtcNow);
Assert.True(reactivated.Activo);
}
@@ -102,7 +102,7 @@ public class PuntoDeVentaTests
{
var pdv = MakePdv(id: 99, medioId: 3);
- var toggled = pdv.WithActivo(false);
+ var toggled = pdv.WithActivo(false, DateTime.UtcNow);
Assert.Equal(99, toggled.Id);
Assert.Equal(3, toggled.MedioId);
diff --git a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs
index ee28ce7..eb1c2c0 100644
--- a/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs
+++ b/tests/SIGCM2.Application.Tests/Domain/UsuarioTests.cs
@@ -101,7 +101,7 @@ public class UsuarioTests
public void WithUpdatedProfile_Returns_NewInstance()
{
var u = MakeUsuario();
- var updated = u.WithUpdatedProfile("NuevoNombre", "NuevoApellido", "new@x.com", "cajero", true);
+ var updated = u.WithUpdatedProfile("NuevoNombre", "NuevoApellido", "new@x.com", "cajero", true, DateTime.UtcNow);
Assert.NotSame(u, updated);
}
@@ -109,7 +109,7 @@ public class UsuarioTests
public void WithUpdatedProfile_Sets_Fields_Correctly()
{
var u = MakeUsuario();
- var updated = u.WithUpdatedProfile("Pedro", "Gómez", "p@g.com", "cajero", false);
+ var updated = u.WithUpdatedProfile("Pedro", "Gómez", "p@g.com", "cajero", false, DateTime.UtcNow);
Assert.Equal("Pedro", updated.Nombre);
Assert.Equal("Gómez", updated.Apellido);
Assert.Equal("p@g.com", updated.Email);
@@ -121,8 +121,9 @@ public class UsuarioTests
public void WithUpdatedProfile_Sets_FechaModificacion_To_UtcNow()
{
var before = DateTime.UtcNow.AddSeconds(-1);
+ var now = DateTime.UtcNow;
var u = MakeUsuario();
- var updated = u.WithUpdatedProfile("A", "B", null, "admin", true);
+ var updated = u.WithUpdatedProfile("A", "B", null, "admin", true, now);
Assert.NotNull(updated.FechaModificacion);
Assert.True(updated.FechaModificacion >= before);
}
@@ -131,7 +132,7 @@ public class UsuarioTests
public void WithUpdatedProfile_Preserves_Immutable_Fields()
{
var u = MakeUsuario();
- var updated = u.WithUpdatedProfile("X", "Y", null, "cajero", true);
+ var updated = u.WithUpdatedProfile("X", "Y", null, "cajero", true, DateTime.UtcNow);
Assert.Equal(u.Id, updated.Id);
Assert.Equal(u.Username, updated.Username);
Assert.Equal(u.PasswordHash, updated.PasswordHash);
@@ -143,7 +144,7 @@ public class UsuarioTests
public void WithNewPasswordHash_Returns_NewInstance()
{
var u = MakeUsuario();
- var updated = u.WithNewPasswordHash("newhash", mustChangePassword: false);
+ var updated = u.WithNewPasswordHash("newhash", mustChangePassword: false, DateTime.UtcNow);
Assert.NotSame(u, updated);
}
@@ -151,7 +152,7 @@ public class UsuarioTests
public void WithNewPasswordHash_Sets_Hash_And_MustChange()
{
var u = MakeUsuario();
- var updated = u.WithNewPasswordHash("newhash", mustChangePassword: true);
+ var updated = u.WithNewPasswordHash("newhash", mustChangePassword: true, DateTime.UtcNow);
Assert.Equal("newhash", updated.PasswordHash);
Assert.True(updated.MustChangePassword);
}
@@ -160,7 +161,7 @@ public class UsuarioTests
public void WithNewPasswordHash_Clears_MustChange_When_False()
{
var u = MakeUsuario(mustChangePassword: true);
- var updated = u.WithNewPasswordHash("hash2", mustChangePassword: false);
+ var updated = u.WithNewPasswordHash("hash2", mustChangePassword: false, DateTime.UtcNow);
Assert.False(updated.MustChangePassword);
}
@@ -168,8 +169,9 @@ public class UsuarioTests
public void WithNewPasswordHash_Sets_FechaModificacion()
{
var before = DateTime.UtcNow.AddSeconds(-1);
+ var now = DateTime.UtcNow;
var u = MakeUsuario();
- var updated = u.WithNewPasswordHash("hash2", false);
+ var updated = u.WithNewPasswordHash("hash2", false, now);
Assert.NotNull(updated.FechaModificacion);
Assert.True(updated.FechaModificacion >= before);
}
@@ -208,7 +210,7 @@ public class UsuarioTests
public void WithMustChangePassword_Sets_Value_True()
{
var u = MakeUsuario(mustChangePassword: false);
- var updated = u.WithMustChangePassword(true);
+ var updated = u.WithMustChangePassword(true, DateTime.UtcNow);
Assert.True(updated.MustChangePassword);
}
@@ -216,8 +218,9 @@ public class UsuarioTests
public void WithMustChangePassword_Sets_FechaModificacion()
{
var before = DateTime.UtcNow.AddSeconds(-1);
+ var now = DateTime.UtcNow;
var u = MakeUsuario();
- var updated = u.WithMustChangePassword(true);
+ var updated = u.WithMustChangePassword(true, now);
Assert.NotNull(updated.FechaModificacion);
Assert.True(updated.FechaModificacion >= before);
}
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs
index 6dc1615..a0f52d7 100644
--- a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs
@@ -22,7 +22,7 @@ public sealed class AuditLoggerTests
repo ??= Substitute.For();
options ??= new AuditOptions();
var optsWrapper = Options.Create(options);
- return new AuditLogger(context, repo, optsWrapper);
+ return new AuditLogger(context, repo, optsWrapper, TimeProvider.System);
}
[Fact]
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs
index 45ca6fb..455e873 100644
--- a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs
@@ -20,7 +20,7 @@ public sealed class SecurityEventLoggerTests
repo ??= Substitute.For();
context ??= Substitute.For();
options ??= new AuditOptions();
- return new SecurityEventLogger(repo, context, Options.Create(options));
+ return new SecurityEventLogger(repo, context, Options.Create(options), TimeProvider.System);
}
[Fact]
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
index c080c8f..620996d 100644
--- a/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs
@@ -22,7 +22,7 @@ public class JwtServiceTests : IDisposable
Audience = "sigcm2.web",
AccessTokenMinutes = 60
};
- _jwtService = new JwtService(_rsa, _options);
+ _jwtService = new JwtService(_rsa, _options, TimeProvider.System);
}
public void Dispose() => _rsa.Dispose();
@@ -219,7 +219,7 @@ public class JwtServiceTests : IDisposable
// Sign with a different RSA key
using var otherRsa = System.Security.Cryptography.RSA.Create(2048);
var otherOptions = new JwtOptions { Issuer = "sigcm2.api", Audience = "sigcm2.web", AccessTokenMinutes = 60 };
- var otherService = new JwtService(otherRsa, otherOptions);
+ var otherService = new JwtService(otherRsa, otherOptions, TimeProvider.System);
var tokenFromOtherKey = otherService.GenerateAccessToken(MakeUsuario());
// Validating with the correct key should throw
diff --git a/tests/SIGCM2.Application.Tests/IngresosBrutos/Create/CreateIngresosBrutosCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/IngresosBrutos/Create/CreateIngresosBrutosCommandHandlerTests.cs
index 437f152..0ce53ed 100644
--- a/tests/SIGCM2.Application.Tests/IngresosBrutos/Create/CreateIngresosBrutosCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/IngresosBrutos/Create/CreateIngresosBrutosCommandHandlerTests.cs
@@ -21,7 +21,7 @@ public class CreateIngresosBrutosCommandHandlerTests
public CreateIngresosBrutosCommandHandlerTests()
{
- _handler = new CreateIngresosBrutosCommandHandler(_repo, _audit);
+ _handler = new CreateIngresosBrutosCommandHandler(_repo, _audit, TimeProvider.System);
_repo.InsertAsync(Arg.Any(), Arg.Any()).Returns(55);
}
diff --git a/tests/SIGCM2.Application.Tests/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandlerTests.cs
index 7e70669..30d88bf 100644
--- a/tests/SIGCM2.Application.Tests/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandlerTests.cs
@@ -24,7 +24,7 @@ public class DeactivateIngresosBrutosCommandHandlerTests
public DeactivateIngresosBrutosCommandHandlerTests()
{
- _handler = new DeactivateIngresosBrutosCommandHandler(_repo, _audit);
+ _handler = new DeactivateIngresosBrutosCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity());
_repo.SetActivoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
}
diff --git a/tests/SIGCM2.Application.Tests/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandlerTests.cs
index 15570a5..3bdab42 100644
--- a/tests/SIGCM2.Application.Tests/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandlerTests.cs
@@ -31,7 +31,7 @@ public class NuevaVersionIngresosBrutosCommandHandlerTests
public NuevaVersionIngresosBrutosCommandHandlerTests()
{
- _handler = new NuevaVersionIngresosBrutosCommandHandler(_repo, _audit);
+ _handler = new NuevaVersionIngresosBrutosCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakePredecesora());
_repo.UpdateCierreVigenciaAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
_repo.InsertAsync(Arg.Any(), Arg.Any()).Returns(88);
diff --git a/tests/SIGCM2.Application.Tests/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandlerTests.cs
index f291764..3cac630 100644
--- a/tests/SIGCM2.Application.Tests/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandlerTests.cs
@@ -24,7 +24,7 @@ public class ReactivateIngresosBrutosCommandHandlerTests
public ReactivateIngresosBrutosCommandHandlerTests()
{
- _handler = new ReactivateIngresosBrutosCommandHandler(_repo, _audit);
+ _handler = new ReactivateIngresosBrutosCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity());
_repo.SetActivoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
}
diff --git a/tests/SIGCM2.Application.Tests/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandlerTests.cs
index a072775..bdf407c 100644
--- a/tests/SIGCM2.Application.Tests/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandlerTests.cs
@@ -27,7 +27,7 @@ public class UpdateIngresosBrutosCommandHandlerTests
public UpdateIngresosBrutosCommandHandlerTests()
{
- _handler = new UpdateIngresosBrutosCommandHandler(_repo, _audit);
+ _handler = new UpdateIngresosBrutosCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity());
_repo.UpdateCosmeticoAsync(Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any()).Returns(true);
diff --git a/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs
index ff1c94f..2a0cf8c 100644
--- a/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs
@@ -18,7 +18,7 @@ public class DeactivateMedioCommandHandlerTests
public DeactivateMedioCommandHandlerTests()
{
- _handler = new DeactivateMedioCommandHandler(_repo, _audit);
+ _handler = new DeactivateMedioCommandHandler(_repo, _audit, TimeProvider.System);
}
// ── not found → throws ──────────────────────────────────────────────────
diff --git a/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs
index 3a7dafd..80c9fdf 100644
--- a/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs
+++ b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs
@@ -148,7 +148,7 @@ public class MedioRepositoryTests : IAsyncLifetime
var id = await _repository.AddAsync(Medio.ForCreation("UPD01", "Original", TipoMedio.Diario, null));
var original = await _repository.GetByIdAsync(id);
- var updated = original!.WithUpdatedProfile("Actualizado", TipoMedio.Radio, 7);
+ var updated = original!.WithUpdatedProfile("Actualizado", TipoMedio.Radio, 7, DateTime.UtcNow);
await _repository.UpdateAsync(updated);
var result = await _repository.GetByIdAsync(id);
@@ -167,7 +167,7 @@ public class MedioRepositoryTests : IAsyncLifetime
var id = await _repository.AddAsync(Medio.ForCreation("HIST01", "Historial", TipoMedio.Diario, null));
var original = await _repository.GetByIdAsync(id);
- var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null);
+ var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null, DateTime.UtcNow);
await _repository.UpdateAsync(updated);
var historyCount = await _connection.ExecuteScalarAsync(
@@ -186,7 +186,7 @@ public class MedioRepositoryTests : IAsyncLifetime
// Deactivate second medio
var inact = await _repository.GetByIdAsync(idInact);
- await _repository.UpdateAsync(inact!.WithActivo(false));
+ await _repository.UpdateAsync(inact!.WithActivo(false, DateTime.UtcNow));
var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: true, Tipo: null, Search: null));
diff --git a/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs
index ac7dd12..5267b79 100644
--- a/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs
@@ -19,7 +19,7 @@ public class ReactivateMedioCommandHandlerTests
public ReactivateMedioCommandHandlerTests()
{
- _handler = new ReactivateMedioCommandHandler(_repo, _audit);
+ _handler = new ReactivateMedioCommandHandler(_repo, _audit, TimeProvider.System);
}
// ── not found → throws ──────────────────────────────────────────────────
diff --git a/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs
index e96aa4c..90c8316 100644
--- a/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs
@@ -24,7 +24,7 @@ public class UpdateMedioCommandHandlerTests
public UpdateMedioCommandHandlerTests()
{
- _handler = new UpdateMedioCommandHandler(_repo, _audit);
+ _handler = new UpdateMedioCommandHandler(_repo, _audit, TimeProvider.System);
}
// ── not found → throws ──────────────────────────────────────────────────
diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs
index 5ac8a93..9308898 100644
--- a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Deactivate/DeactivatePuntoDeVentaCommandHandlerTests.cs
@@ -22,7 +22,7 @@ public class DeactivatePuntoDeVentaCommandHandlerTests
public DeactivatePuntoDeVentaCommandHandlerTests()
{
- _handler = new DeactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
+ _handler = new DeactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit, TimeProvider.System);
_medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(5, true));
}
diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs
index b810d82..6eca8b1 100644
--- a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Reactivate/ReactivatePuntoDeVentaCommandHandlerTests.cs
@@ -23,7 +23,7 @@ public class ReactivatePuntoDeVentaCommandHandlerTests
public ReactivatePuntoDeVentaCommandHandlerTests()
{
- _handler = new ReactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
+ _handler = new ReactivatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit, TimeProvider.System);
_medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(5, true));
}
diff --git a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs
index 1724e35..22d9d2a 100644
--- a/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/PuntosDeVenta/Update/UpdatePuntoDeVentaCommandHandlerTests.cs
@@ -25,7 +25,7 @@ public class UpdatePuntoDeVentaCommandHandlerTests
public UpdatePuntoDeVentaCommandHandlerTests()
{
- _handler = new UpdatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit);
+ _handler = new UpdatePuntoDeVentaCommandHandler(_repo, _medioRepo, _audit, TimeProvider.System);
_repo.GetByIdAsync(10, Arg.Any()).Returns(MakePdv(10));
_medioRepo.GetByIdAsync(5, Arg.Any()).Returns(MakeMedio(5));
_repo.ExistsByNumeroAFIPInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false);
diff --git a/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs
index 3b355c7..6d0a50d 100644
--- a/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs
@@ -22,7 +22,7 @@ public class DeactivateSeccionCommandHandlerTests
public DeactivateSeccionCommandHandlerTests()
{
- _handler = new DeactivateSeccionCommandHandler(_repo, _medioRepo, _audit);
+ _handler = new DeactivateSeccionCommandHandler(_repo, _medioRepo, _audit, TimeProvider.System);
// Default: medio is active
_medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(1, true));
}
diff --git a/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs
index 1c8f978..84e4b12 100644
--- a/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs
@@ -23,7 +23,7 @@ public class ReactivateSeccionCommandHandlerTests
public ReactivateSeccionCommandHandlerTests()
{
- _handler = new ReactivateSeccionCommandHandler(_repo, _medioRepo, _audit);
+ _handler = new ReactivateSeccionCommandHandler(_repo, _medioRepo, _audit, TimeProvider.System);
// Default: medio is active
_medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(1, true));
}
diff --git a/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs
index 5f7a000..541fb13 100644
--- a/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs
+++ b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs
@@ -150,7 +150,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
var id = await _repository.AddAsync(Seccion.ForCreation(_medioId, "UPD01", "Original", "clasificados"));
var original = await _repository.GetByIdAsync(id);
- var updated = original!.WithUpdatedProfile("Actualizado", "notables");
+ var updated = original!.WithUpdatedProfile("Actualizado", "notables", DateTime.UtcNow);
await _repository.UpdateAsync(updated);
var result = await _repository.GetByIdAsync(id);
@@ -168,7 +168,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
var id = await _repository.AddAsync(Seccion.ForCreation(_medioId, "HIST01", "Historial", "clasificados"));
var original = await _repository.GetByIdAsync(id);
- var updated = original!.WithUpdatedProfile("Historial v2", "suplementos");
+ var updated = original!.WithUpdatedProfile("Historial v2", "suplementos", DateTime.UtcNow);
await _repository.UpdateAsync(updated);
var historyCount = await _connection.ExecuteScalarAsync(
@@ -213,7 +213,7 @@ public class SeccionRepositoryTests : IAsyncLifetime
var inactId = await _repository.AddAsync(Seccion.ForCreation(_medioId, "INACT01", "Inactiva", "clasificados"));
var inact = await _repository.GetByIdAsync(inactId);
- await _repository.UpdateAsync(inact!.WithActivo(false));
+ await _repository.UpdateAsync(inact!.WithActivo(false, DateTime.UtcNow));
var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: _medioId, Tipo: null, Activo: true, Search: null));
diff --git a/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs
index 1f8764e..458b02b 100644
--- a/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs
@@ -27,7 +27,7 @@ public class UpdateSeccionCommandHandlerTests
public UpdateSeccionCommandHandlerTests()
{
- _handler = new UpdateSeccionCommandHandler(_repo, _medioRepo, _audit);
+ _handler = new UpdateSeccionCommandHandler(_repo, _medioRepo, _audit, TimeProvider.System);
// Default: medio is active
_medioRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(MakeMedio(1, true));
}
diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs
index 4eb8259..667ffce 100644
--- a/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs
@@ -22,7 +22,7 @@ public class CreateTipoDeIvaCommandHandlerTests
public CreateTipoDeIvaCommandHandlerTests()
{
- _handler = new CreateTipoDeIvaCommandHandler(_repo, _audit);
+ _handler = new CreateTipoDeIvaCommandHandler(_repo, _audit, TimeProvider.System);
_repo.InsertAsync(Arg.Any(), Arg.Any()).Returns(42);
}
diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs
index 5c1e887..aebce83 100644
--- a/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs
@@ -20,7 +20,7 @@ public class DeactivateTipoDeIvaCommandHandlerTests
public DeactivateTipoDeIvaCommandHandlerTests()
{
- _handler = new DeactivateTipoDeIvaCommandHandler(_repo, _audit);
+ _handler = new DeactivateTipoDeIvaCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity());
_repo.SetActivoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
}
diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs
index f569d7f..96bdb97 100644
--- a/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs
@@ -34,7 +34,7 @@ public class NuevaVersionTipoDeIvaCommandHandlerTests
public NuevaVersionTipoDeIvaCommandHandlerTests()
{
- _handler = new NuevaVersionTipoDeIvaCommandHandler(_repo, _audit);
+ _handler = new NuevaVersionTipoDeIvaCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakePredecesora());
_repo.UpdateCierreVigenciaAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(true);
diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs
index a930e54..1bb5634 100644
--- a/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs
@@ -20,7 +20,7 @@ public class ReactivateTipoDeIvaCommandHandlerTests
public ReactivateTipoDeIvaCommandHandlerTests()
{
- _handler = new ReactivateTipoDeIvaCommandHandler(_repo, _audit);
+ _handler = new ReactivateTipoDeIvaCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity());
_repo.SetActivoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true);
}
diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs
index 240a8ae..3b99d2f 100644
--- a/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs
@@ -35,7 +35,7 @@ public class UpdateTipoDeIvaCommandHandlerTests
public UpdateTipoDeIvaCommandHandlerTests()
{
- _handler = new UpdateTipoDeIvaCommandHandler(_repo, _audit);
+ _handler = new UpdateTipoDeIvaCommandHandler(_repo, _audit, TimeProvider.System);
_repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity());
_repo.UpdateCosmeticoAsync(Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
diff --git a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs
index 7d7a69c..5b35819 100644
--- a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs
@@ -17,7 +17,7 @@ public class DeactivateUsuarioCommandHandlerTests
public DeactivateUsuarioCommandHandlerTests()
{
- _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit);
+ _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit, TimeProvider.System);
_repo.CountActiveAdminsAsync(Arg.Any()).Returns(2);
}
diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs
index bceb7cd..a0cd42f 100644
--- a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs
@@ -16,7 +16,7 @@ public class ReactivateUsuarioCommandHandlerTests
public ReactivateUsuarioCommandHandlerTests()
{
- _handler = new ReactivateUsuarioCommandHandler(_repo, _audit);
+ _handler = new ReactivateUsuarioCommandHandler(_repo, _audit, TimeProvider.System);
}
private static Usuario MakeUser(int id = 5, bool activo = false)
diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs
index d0b0ab2..dc807e9 100644
--- a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs
@@ -18,7 +18,7 @@ public class ResetUsuarioPasswordCommandHandlerTests
public ResetUsuarioPasswordCommandHandlerTests()
{
- _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo, _audit);
+ _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo, _audit, TimeProvider.System);
_hasher.Hash(Arg.Any()).Returns(args => "$2a$12$hashof_" + args[0]);
}
diff --git a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs
index b634c72..fc5a6ab 100644
--- a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs
@@ -20,7 +20,7 @@ public class UpdateUsuarioCommandHandlerTests
public UpdateUsuarioCommandHandlerTests()
{
- _handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo, _audit);
+ _handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo, _audit, TimeProvider.System);
// Default: rol exists and is active
_rolRepo.ExistsActiveByCodigoAsync(Arg.Any(), Arg.Any()).Returns(true);
--
2.49.1
From bc3e5d99a1000cdc10e545c164ccad5810c8bbfe Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:17:43 -0300
Subject: [PATCH 16/23] =?UTF-8?q?test(web/udt-011):=20dateFormat.ts=20util?=
=?UTF-8?q?ity=20tests=20(Red=20=E2=80=94=206=20funciones=20+=20edge=20cas?=
=?UTF-8?q?es)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/web/src/tests/lib/dateFormat.test.ts | 111 +++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 src/web/src/tests/lib/dateFormat.test.ts
diff --git a/src/web/src/tests/lib/dateFormat.test.ts b/src/web/src/tests/lib/dateFormat.test.ts
new file mode 100644
index 0000000..c7f1934
--- /dev/null
+++ b/src/web/src/tests/lib/dateFormat.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import {
+ AR_TZ,
+ formatInstant,
+ formatCivilDate,
+ formatCivilDateRange,
+ todayArgentina,
+ parseCivilDate,
+} from '@/lib/dateFormat';
+
+describe('dateFormat.ts', () => {
+ describe('AR_TZ constant', () => {
+ it('is America/Argentina/Buenos_Aires', () => {
+ expect(AR_TZ).toBe('America/Argentina/Buenos_Aires');
+ });
+ });
+
+ describe('formatInstant (Cat1 — UTC → AR display)', () => {
+ it('converts 01:30 UTC to 22:30 ART previous day', () => {
+ const iso = '2026-05-01T01:30:00.000Z';
+ const result = formatInstant(iso);
+ // format es-AR short + medium: "30/4/2026, 22:30:00" o similar
+ expect(result).toMatch(/30\/0?4\/2026/);
+ expect(result).toMatch(/22:30/);
+ });
+
+ it('uses AR timezone regardless of browser TZ', () => {
+ const iso = '2026-05-01T12:00:00.000Z'; // 09:00 ART
+ const result = formatInstant(iso);
+ expect(result).toMatch(/01\/0?5\/2026/);
+ expect(result).toMatch(/09:00/);
+ });
+ });
+
+ describe('formatCivilDate (Cat2 — yyyy-MM-dd → dd/MM/yyyy)', () => {
+ it('splits manually without new Date()', () => {
+ expect(formatCivilDate('2026-05-01')).toBe('01/05/2026');
+ });
+
+ it('preserves day for early-year dates', () => {
+ expect(formatCivilDate('2026-01-01')).toBe('01/01/2026');
+ });
+
+ it('preserves day for end-of-year dates', () => {
+ expect(formatCivilDate('2026-12-31')).toBe('31/12/2026');
+ });
+ });
+
+ describe('formatCivilDateRange', () => {
+ it('renders full range with arrow', () => {
+ expect(formatCivilDateRange('2026-05-01', '2026-12-31'))
+ .toBe('01/05/2026 → 31/12/2026');
+ });
+
+ it('renders open range when hasta is null', () => {
+ expect(formatCivilDateRange('2026-05-01', null))
+ .toBe('desde 01/05/2026');
+ });
+ });
+
+ describe('todayArgentina (fix BUG-FE-03)', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns 2026-04-30 at 22:30 ART of 30/04 (BUG-FE-03 regression)', () => {
+ // 22:30 ART = 01:30 UTC del día siguiente
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-01T01:30:00.000Z'));
+
+ expect(todayArgentina()).toBe('2026-04-30');
+ });
+
+ it('returns 2026-05-01 at 00:30 ART of 01/05', () => {
+ // 00:30 ART = 03:30 UTC
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-01T03:30:00.000Z'));
+
+ expect(todayArgentina()).toBe('2026-05-01');
+ });
+
+ it('returns 2024-02-28 at 22:30 ART of 28/02/2024 (bisiesto)', () => {
+ // 22:30 ART del 28/02/2024 = 01:30 UTC del 29/02/2024
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-02-29T01:30:00.000Z'));
+
+ expect(todayArgentina()).toBe('2024-02-28');
+ });
+
+ it('returns 2026-12-31 at 22:30 ART of 31/12 (end of year)', () => {
+ // 22:30 ART del 31/12/2026 = 01:30 UTC del 01/01/2027
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2027-01-01T01:30:00.000Z'));
+
+ expect(todayArgentina()).toBe('2026-12-31');
+ });
+ });
+
+ describe('parseCivilDate', () => {
+ it('splits "2026-05-01" into {year, month, day}', () => {
+ expect(parseCivilDate('2026-05-01')).toEqual({ year: 2026, month: 5, day: 1 });
+ });
+
+ it('does not use new Date() (no UTC creep)', () => {
+ const result = parseCivilDate('2026-01-01');
+ expect(result.year).toBe(2026);
+ expect(result.month).toBe(1);
+ expect(result.day).toBe(1);
+ });
+ });
+});
--
2.49.1
From 2ea76781294dcc4d457daece0f2be313689362cc Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:17:47 -0300
Subject: [PATCH 17/23] feat(web/udt-011): dateFormat.ts utility
(formatInstant, formatCivilDate, todayArgentina, etc.)
---
src/web/src/lib/dateFormat.ts | 99 +++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
create mode 100644 src/web/src/lib/dateFormat.ts
diff --git a/src/web/src/lib/dateFormat.ts b/src/web/src/lib/dateFormat.ts
new file mode 100644
index 0000000..39b931a
--- /dev/null
+++ b/src/web/src/lib/dateFormat.ts
@@ -0,0 +1,99 @@
+/**
+ * Localización temporal Argentina — utility centralizada.
+ * Ver: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.17 ⏰ Localización Temporal Argentina.md
+ * Engram topic_key: sig-cm2/conventions/fechas-timezones
+ *
+ * REGLAS PROHIBIDAS (no usar fuera de este módulo):
+ * - new Date(civilDateString) → aplica UTC, pierde días
+ * - toISOString().slice(0, 10) → UTC creep
+ * - toLocaleString('es-AR', {...}) sin timeZone → depende del navegador
+ */
+
+export const AR_TZ = 'America/Argentina/Buenos_Aires';
+
+type FormatStyle = 'short' | 'medium' | 'long' | 'full';
+
+interface FormatInstantOptions {
+ dateStyle?: FormatStyle;
+ timeStyle?: FormatStyle;
+}
+
+/**
+ * Formatea un instante UTC (Cat1) como string legible en zona horaria Argentina.
+ * Usa partes explícitas para garantizar año 4 dígitos y hora 24h independientemente del entorno.
+ * Output: "dd/MM/yyyy, HH:mm:ss"
+ */
+export function formatInstant(
+ iso: string,
+ _opts: FormatInstantOptions = { dateStyle: 'short', timeStyle: 'medium' }
+): string {
+ const parts = new Intl.DateTimeFormat('es-AR', {
+ timeZone: AR_TZ,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ }).formatToParts(new Date(iso));
+
+ const get = (type: string): string => parts.find(p => p.type === type)!.value;
+ return `${get('day')}/${get('month')}/${get('year')}, ${get('hour')}:${get('minute')}:${get('second')}`;
+}
+
+/**
+ * Formatea una fecha civil Argentina (Cat2, formato "yyyy-MM-dd") a "dd/MM/yyyy".
+ * Split manual — NO usa new Date() para evitar UTC creep.
+ */
+export function formatCivilDate(yyyyMmDd: string): string {
+ const [y, m, d] = yyyyMmDd.split('-');
+ return `${d}/${m}/${y}`;
+}
+
+/**
+ * Formatea un rango de fechas civiles Argentinas.
+ */
+export function formatCivilDateRange(from: string, to: string | null): string {
+ return to ? `${formatCivilDate(from)} → ${formatCivilDate(to)}` : `desde ${formatCivilDate(from)}`;
+}
+
+/**
+ * Retorna la fecha civil Argentina de hoy en formato "yyyy-MM-dd".
+ * Fix de BUG-FE-03: usa Intl.DateTimeFormat con timeZone, NO toISOString().slice(0, 10).
+ */
+export function todayArgentina(): string {
+ const now = new Date();
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: AR_TZ,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(now);
+
+ const y = parts.find(p => p.type === 'year')!.value;
+ const m = parts.find(p => p.type === 'month')!.value;
+ const d = parts.find(p => p.type === 'day')!.value;
+
+ return `${y}-${m}-${d}`;
+}
+
+/**
+ * Parsea una fecha civil Argentina ("yyyy-MM-dd") a partes numéricas.
+ * Split manual — NO usa new Date().
+ */
+export function parseCivilDate(yyyyMmDd: string): { year: number; month: number; day: number } {
+ const [y, m, d] = yyyyMmDd.split('-').map(Number);
+ return { year: y, month: m, day: d };
+}
+
+/**
+ * Convierte un valor de (ART implícito) a ISO UTC.
+ * Input: "2026-05-01T22:30" (sin TZ) → interpretado como ART → "2026-05-02T01:30:00.000Z".
+ *
+ * Útil para AuditFilters que envía filtros de rango al backend.
+ */
+export function parseArgentinaDateTimeToUtc(localDateTime: string): string {
+ // Parse "yyyy-MM-ddTHH:mm" como ART (offset -03:00) y convertir a ISO UTC
+ return new Date(`${localDateTime}:00-03:00`).toISOString();
+}
--
2.49.1
From 7e23a160629e8ac748d35c5368a2197ea67f2e7a Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:22:43 -0300
Subject: [PATCH 18/23] fix(web/udt-011): TipoDeIvaFormModal default
vigenciaDesde usa todayArgentina (fix BUG-FE-03)
---
.../iva/components/TipoDeIvaFormModal.tsx | 7 ++++---
.../fiscal/iva/TipoDeIvaFormModal.test.tsx | 19 +++++++++++++++++++
2 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx b/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx
index 048a364..d1fccac 100644
--- a/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx
+++ b/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx
@@ -2,6 +2,7 @@
// Modal de edición / creación de TipoDeIva
// CRÍTICO: NO incluye campo Porcentaje (inmutable, cambiar via NuevaVersion)
import { useEffect } from 'react'
+import { todayArgentina } from '@/lib/dateFormat'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -98,7 +99,7 @@ export function TipoDeIvaFormModal({
aplicaIVA: true,
activo: true,
porcentajeCreate: undefined,
- vigenciaDesde: '',
+ vigenciaDesde: todayArgentina(),
},
})
@@ -119,7 +120,7 @@ export function TipoDeIvaFormModal({
aplicaIVA: true,
activo: true,
porcentajeCreate: undefined,
- vigenciaDesde: '',
+ vigenciaDesde: todayArgentina(),
})
}
createMutation.reset()
@@ -158,7 +159,7 @@ export function TipoDeIvaFormModal({
codigo: values.codigo,
descripcion: values.descripcion,
porcentaje: values.porcentajeCreate,
- vigenciaDesde: values.vigenciaDesde ?? new Date().toISOString().slice(0, 10),
+ vigenciaDesde: values.vigenciaDesde ?? todayArgentina(),
aplicaIVA: values.aplicaIVA,
},
{
diff --git a/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx b/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx
index 53edf89..6e78a22 100644
--- a/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx
+++ b/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx
@@ -1,5 +1,6 @@
// T600.5 — TDD: TipoDeIvaFormModal
// CRÍTICO: verifica que el modal de Editar NO tiene campo Porcentaje [REQ-UI-003]
+// T600.10 — BUG-FE-03 regression: default vigenciaDesde usa todayArgentina (no UTC)
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -131,3 +132,21 @@ describe('TipoDeIvaFormModal — validación', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
+
+describe('TipoDeIvaFormModal — BUG-FE-03 regression: vigenciaDesde usa todayArgentina', () => {
+ // A las 22:30 ART del 30/04, UTC ya es 01:30 del 01/05.
+ // new Date().toISOString().slice(0,10) devolvería "2026-05-01" (UTC) — INCORRECTO.
+ // todayArgentina() debe devolver "2026-04-30" — CORRECTO.
+ it('modo create: campo vigenciaDesde refleja fecha ART, no UTC, a las 22:30 ART del 30/04', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-05-01T01:30:00.000Z')) // 22:30 ART del 30/04
+
+ renderModal({ item: null })
+
+ const vigenciaInput = screen.getByLabelText(/vigencia desde/i) as HTMLInputElement
+ // El input debe tener value="2026-04-30" (fecha ART), no "2026-05-01" (UTC)
+ expect(vigenciaInput.value).toBe('2026-04-30')
+
+ vi.useRealTimers()
+ })
+})
--
2.49.1
From 20b58639082e4819ab5da1f14763d13093b6dbac Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:22:47 -0300
Subject: [PATCH 19/23] fix(web/udt-011): IngresosBrutosFormModal default
vigenciaDesde usa todayArgentina
---
.../components/IngresosBrutosFormModal.tsx | 7 ++++---
.../iibb/IngresosBrutosFormModal.test.tsx | 19 +++++++++++++++++++
2 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx b/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx
index 22ed9c4..851ad29 100644
--- a/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx
+++ b/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx
@@ -2,6 +2,7 @@
// Modal de edición / creación de IngresosBrutos
// CRÍTICO: NO incluye campo Alícuota en modo edit (inmutable, cambiar via NuevaVersion)
import { useEffect } from 'react'
+import { todayArgentina } from '@/lib/dateFormat'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -96,7 +97,7 @@ export function IngresosBrutosFormModal({
descripcion: '',
activo: true,
alicuotaCreate: undefined,
- vigenciaDesde: '',
+ vigenciaDesde: todayArgentina(),
},
})
@@ -115,7 +116,7 @@ export function IngresosBrutosFormModal({
descripcion: '',
activo: true,
alicuotaCreate: undefined,
- vigenciaDesde: '',
+ vigenciaDesde: todayArgentina(),
})
}
createMutation.reset()
@@ -152,7 +153,7 @@ export function IngresosBrutosFormModal({
provincia: values.provincia as ProvinciaArgentina,
descripcion: values.descripcion,
alicuota: values.alicuotaCreate,
- vigenciaDesde: values.vigenciaDesde ?? new Date().toISOString().slice(0, 10),
+ vigenciaDesde: values.vigenciaDesde ?? todayArgentina(),
},
{
onSuccess: () => {
diff --git a/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx b/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx
index f1ed0c0..a089165 100644
--- a/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx
+++ b/src/web/src/tests/features/fiscal/iibb/IngresosBrutosFormModal.test.tsx
@@ -1,5 +1,6 @@
// T600.20-T600.29 (IIBB) — TDD: IngresosBrutosFormModal
// CRÍTICO: verifica que el modal de Editar NO tiene campo Alícuota [REQ-UI-007]
+// T600.10 — BUG-FE-03 regression: default vigenciaDesde usa todayArgentina (no UTC)
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -91,3 +92,21 @@ describe('IngresosBrutosFormModal — CRÍTICO: sin campo Alícuota en modo EDIT
expect(onClose).toHaveBeenCalledTimes(1)
})
})
+
+describe('IngresosBrutosFormModal — BUG-FE-03 regression: vigenciaDesde usa todayArgentina', () => {
+ // A las 22:30 ART del 30/04, UTC ya es 01:30 del 01/05.
+ // new Date().toISOString().slice(0,10) devolvería "2026-05-01" (UTC) — INCORRECTO.
+ // todayArgentina() debe devolver "2026-04-30" — CORRECTO.
+ it('modo create: campo vigenciaDesde refleja fecha ART, no UTC, a las 22:30 ART del 30/04', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-05-01T01:30:00.000Z')) // 22:30 ART del 30/04
+
+ renderModal({ item: null })
+
+ const vigenciaInput = screen.getByLabelText(/vigencia desde/i) as HTMLInputElement
+ // El input debe tener value="2026-04-30" (fecha ART), no "2026-05-01" (UTC)
+ expect(vigenciaInput.value).toBe('2026-04-30')
+
+ vi.useRealTimers()
+ })
+})
--
2.49.1
From 71d092838957a1a0d922c9408842284f0d64aa2c Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:24:15 -0300
Subject: [PATCH 20/23] fix(web/udt-011): NuevaVigenciaModal preview usa
prevCivilDate+formatCivilDate sin Date() (fix BUG-FE-04)
---
.../components/NuevaVigenciaIibbModal.tsx | 12 +++++++-----
.../iva/components/NuevaVigenciaModal.tsx | 13 +++++++------
src/web/src/lib/dateFormat.ts | 14 ++++++++++++++
.../fiscal/iva/NuevaVigenciaModal.test.tsx | 6 +++---
src/web/src/tests/lib/dateFormat.test.ts | 19 +++++++++++++++++++
5 files changed, 50 insertions(+), 14 deletions(-)
diff --git a/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx b/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx
index f435922..1c30bcc 100644
--- a/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx
+++ b/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx
@@ -27,6 +27,7 @@ import {
import { useNuevaVersionIngresosBrutos } from '../hooks/useIngresosBrutos'
import type { IngresosBrutos } from '../types/ingresosBrutos.types'
import { toast } from 'sonner'
+import { prevCivilDate, formatCivilDate } from '@/lib/dateFormat'
const formSchema = z.object({
alicuota: z.coerce
@@ -48,11 +49,12 @@ interface NuevaVigenciaIibbModalProps {
onSuccess: () => void
}
-function fechaCierre(vigenciaDesde: string): string {
+/** Formatea la fecha de cierre (vigenciaDesde - 1 día) para display "dd/MM/yyyy".
+ * Usa prevCivilDate (Date.UTC pura, sin TZ) + formatCivilDate (split manual).
+ */
+function fechaCierreDisplay(vigenciaDesde: string): string {
if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—'
- const d = new Date(vigenciaDesde + 'T00:00:00')
- d.setDate(d.getDate() - 1)
- return d.toISOString().slice(0, 10)
+ return formatCivilDate(prevCivilDate(vigenciaDesde))
}
function resolveBackendError(err: unknown): string | null {
@@ -212,7 +214,7 @@ export function NuevaVigenciaIibbModal({
Versión actual ({item.alicuota}%) quedará cerrada el{' '}
- {fechaCierre(watchedVigencia)}.
+ {fechaCierreDisplay(watchedVigencia)}.
Esta acción no se puede deshacer.
diff --git a/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx b/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx
index 010f2b6..c132447 100644
--- a/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx
+++ b/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx
@@ -28,6 +28,7 @@ import {
import { useNuevaVersionTipoDeIva } from '../hooks/useTiposDeIva'
import type { TipoDeIva } from '../types/tipoDeIva.types'
import { toast } from 'sonner'
+import { prevCivilDate, formatCivilDate } from '@/lib/dateFormat'
const formSchema = z.object({
porcentaje: z.coerce
@@ -49,12 +50,12 @@ interface NuevaVigenciaModalProps {
onSuccess: () => void
}
-/** Devuelve la fecha anterior (vigenciaDesde - 1 día) como string "yyyy-MM-dd" */
-function fechaCierre(vigenciaDesde: string): string {
+/** Formatea la fecha de cierre (vigenciaDesde - 1 día) para display "dd/MM/yyyy".
+ * Usa prevCivilDate (Date.UTC pura, sin TZ) + formatCivilDate (split manual).
+ */
+function fechaCierreDisplay(vigenciaDesde: string): string {
if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—'
- const d = new Date(vigenciaDesde + 'T00:00:00')
- d.setDate(d.getDate() - 1)
- return d.toISOString().slice(0, 10)
+ return formatCivilDate(prevCivilDate(vigenciaDesde))
}
function resolveBackendError(err: unknown): string | null {
@@ -218,7 +219,7 @@ export function NuevaVigenciaModal({
Versión actual ({item.porcentaje}%) quedará cerrada el{' '}
- {fechaCierre(watchedVigencia)}.
+ {fechaCierreDisplay(watchedVigencia)}.
Esta acción no se puede deshacer.
diff --git a/src/web/src/lib/dateFormat.ts b/src/web/src/lib/dateFormat.ts
index 39b931a..d757788 100644
--- a/src/web/src/lib/dateFormat.ts
+++ b/src/web/src/lib/dateFormat.ts
@@ -87,6 +87,20 @@ export function parseCivilDate(yyyyMmDd: string): { year: number; month: number;
return { year: y, month: m, day: d };
}
+/**
+ * Retorna el día anterior a una fecha civil Argentina en formato "yyyy-MM-dd".
+ * Usa Date.UTC para aritmética pura — sin conversión de timezone en ningún momento.
+ * Output: "yyyy-MM-dd"
+ */
+export function prevCivilDate(yyyyMmDd: string): string {
+ const [y, m, d] = yyyyMmDd.split('-').map(Number);
+ const prevDay = new Date(Date.UTC(y, m - 1, d - 1));
+ const yy = prevDay.getUTCFullYear();
+ const mm = String(prevDay.getUTCMonth() + 1).padStart(2, '0');
+ const dd = String(prevDay.getUTCDate()).padStart(2, '0');
+ return `${yy}-${mm}-${dd}`;
+}
+
/**
* Convierte un valor de (ART implícito) a ISO UTC.
* Input: "2026-05-01T22:30" (sin TZ) → interpretado como ART → "2026-05-02T01:30:00.000Z".
diff --git a/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx b/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx
index ccc688f..30ff538 100644
--- a/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx
+++ b/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx
@@ -122,7 +122,7 @@ describe('NuevaVigenciaModal — preview con fechas correctas [REQ-UI-004]', ()
)
})
- it('preview muestra fecha de cierre = vigenciaDesde - 1 día', async () => {
+ it('preview muestra fecha de cierre = vigenciaDesde - 1 día (formato dd/MM/yyyy, BUG-FE-04)', async () => {
renderModal()
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
@@ -132,9 +132,9 @@ describe('NuevaVigenciaModal — preview con fechas correctas [REQ-UI-004]', ()
const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
await userEvent.type(vigenciaInput, '2026-05-01')
- // La versión anterior cierra el día anterior → 2026-04-30
+ // La versión anterior cierra el día anterior → 30/04/2026 (formato AR civil)
await waitFor(() =>
- expect(screen.getByText(/2026-04-30/)).toBeInTheDocument(),
+ expect(screen.getByText(/30\/04\/2026/)).toBeInTheDocument(),
)
})
})
diff --git a/src/web/src/tests/lib/dateFormat.test.ts b/src/web/src/tests/lib/dateFormat.test.ts
index c7f1934..a7e4624 100644
--- a/src/web/src/tests/lib/dateFormat.test.ts
+++ b/src/web/src/tests/lib/dateFormat.test.ts
@@ -6,6 +6,7 @@ import {
formatCivilDateRange,
todayArgentina,
parseCivilDate,
+ prevCivilDate,
} from '@/lib/dateFormat';
describe('dateFormat.ts', () => {
@@ -108,4 +109,22 @@ describe('dateFormat.ts', () => {
expect(result.day).toBe(1);
});
});
+
+ describe('prevCivilDate (BUG-FE-04 — fecha cierre NuevaVigencia)', () => {
+ it('returns day before (2026-05-01 → 2026-04-30)', () => {
+ expect(prevCivilDate('2026-05-01')).toBe('2026-04-30');
+ });
+
+ it('crosses month boundary (2026-05-31 → 2026-05-30)', () => {
+ expect(prevCivilDate('2026-06-01')).toBe('2026-05-31');
+ });
+
+ it('crosses year boundary (2026-01-01 → 2025-12-31)', () => {
+ expect(prevCivilDate('2026-01-01')).toBe('2025-12-31');
+ });
+
+ it('handles leap year (2024-03-01 → 2024-02-29)', () => {
+ expect(prevCivilDate('2024-03-01')).toBe('2024-02-29');
+ });
+ });
});
--
2.49.1
From 03a02c63d5b92b4d88a0f13abfac09aa63ff2c35 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:26:29 -0300
Subject: [PATCH 21/23] refactor(web/udt-011): eliminar 4 funciones formatDate
duplicadas y formatOccurredAt, usar dateFormat utility (fix BUG-FE-01,
BUG-FE-02)
---
.../features/medios/pages/MedioDetailPage.tsx | 14 +++-----------
.../pages/PuntoDeVentaDetailPage.tsx | 14 +++-----------
.../secciones/pages/SeccionDetailPage.tsx | 14 +++-----------
.../features/users/components/UsersTable.tsx | 12 ++----------
src/web/src/lib/dateFormat.ts | 9 +++++++++
src/web/src/pages/admin/audit/AuditPage.tsx | 17 ++---------------
src/web/src/tests/lib/dateFormat.test.ts | 16 ++++++++++++++++
7 files changed, 38 insertions(+), 58 deletions(-)
diff --git a/src/web/src/features/medios/pages/MedioDetailPage.tsx b/src/web/src/features/medios/pages/MedioDetailPage.tsx
index 2992e68..468f201 100644
--- a/src/web/src/features/medios/pages/MedioDetailPage.tsx
+++ b/src/web/src/features/medios/pages/MedioDetailPage.tsx
@@ -5,15 +5,7 @@ import { CanPerform } from '@/components/auth/CanPerform'
import { useMedio } from '../hooks/useMedio'
import { DeactivateMedioModal } from '../components/DeactivateMedioModal'
import { tipoMedioLabel } from '../tipoMedio'
-
-function formatDate(iso: string | null): string {
- if (!iso) return '—'
- return new Date(iso).toLocaleDateString('es-AR', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })
-}
+import { formatInstantOrDash } from '@/lib/dateFormat'
export function MedioDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -69,11 +61,11 @@ export function MedioDetailPage() {
Creado
- {formatDate(medio.fechaCreacion)}
+ {formatInstantOrDash(medio.fechaCreacion)}
Modificado
- {formatDate(medio.fechaModificacion)}
+ {formatInstantOrDash(medio.fechaModificacion)}
diff --git a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx
index f948c47..ec805f4 100644
--- a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx
+++ b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx
@@ -7,15 +7,7 @@ import { useMedio } from '../../medios/hooks/useMedio'
import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal'
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
import { PdvInactivoBanner } from '../components/PdvInactivoBanner'
-
-function formatDate(iso: string | null): string {
- if (!iso) return '—'
- return new Date(iso).toLocaleDateString('es-AR', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })
-}
+import { formatInstantOrDash } from '@/lib/dateFormat'
export function PuntoDeVentaDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -70,11 +62,11 @@ export function PuntoDeVentaDetailPage() {
Creado
- {formatDate(pdv.fechaCreacion)}
+ {formatInstantOrDash(pdv.fechaCreacion)}
Modificado
- {formatDate(pdv.fechaModificacion)}
+ {formatInstantOrDash(pdv.fechaModificacion)}
diff --git a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx
index 5a5e448..7c3f846 100644
--- a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx
+++ b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx
@@ -7,15 +7,7 @@ import { DeactivateSeccionModal } from '../components/DeactivateSeccionModal'
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
import { tipoSeccionLabel } from '../tipoSeccion'
import { useMedio } from '../../medios/hooks/useMedio'
-
-function formatDate(iso: string | null): string {
- if (!iso) return '—'
- return new Date(iso).toLocaleDateString('es-AR', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })
-}
+import { formatInstantOrDash } from '@/lib/dateFormat'
export function SeccionDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -75,11 +67,11 @@ export function SeccionDetailPage() {
Creado
- {formatDate(seccion.fechaCreacion)}
+ {formatInstantOrDash(seccion.fechaCreacion)}
Modificado
- {formatDate(seccion.fechaModificacion)}
+ {formatInstantOrDash(seccion.fechaModificacion)}
diff --git a/src/web/src/features/users/components/UsersTable.tsx b/src/web/src/features/users/components/UsersTable.tsx
index 059e137..a9c0378 100644
--- a/src/web/src/features/users/components/UsersTable.tsx
+++ b/src/web/src/features/users/components/UsersTable.tsx
@@ -3,21 +3,13 @@ import type { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { DataTable } from '@/components/ui/data-table'
import type { UserListItem } from '../types'
+import { formatInstantOrDash } from '@/lib/dateFormat'
interface UsersTableProps {
rows: UserListItem[]
onRowClick: (user: UserListItem) => void
}
-function formatDate(iso: string | null): string {
- if (!iso) return '—'
- return new Date(iso).toLocaleDateString('es-AR', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })
-}
-
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
const columns = useMemo[]>(
() => [
@@ -81,7 +73,7 @@ export function UsersTable({ rows, onRowClick }: UsersTableProps) {
header: 'Último login',
cell: ({ row }) => (
- {formatDate(row.original.ultimoLogin)}
+ {formatInstantOrDash(row.original.ultimoLogin)}
),
meta: { priority: 'low' },
diff --git a/src/web/src/lib/dateFormat.ts b/src/web/src/lib/dateFormat.ts
index d757788..e436dd6 100644
--- a/src/web/src/lib/dateFormat.ts
+++ b/src/web/src/lib/dateFormat.ts
@@ -87,6 +87,15 @@ export function parseCivilDate(yyyyMmDd: string): { year: number; month: number;
return { year: y, month: m, day: d };
}
+/**
+ * Formatea un instante UTC (Cat1) nullable a string legible en zona horaria Argentina.
+ * Retorna '—' cuando el valor es null o undefined.
+ */
+export function formatInstantOrDash(iso: string | null | undefined): string {
+ if (!iso) return '—';
+ return formatInstant(iso);
+}
+
/**
* Retorna el día anterior a una fecha civil Argentina en formato "yyyy-MM-dd".
* Usa Date.UTC para aritmética pura — sin conversión de timezone en ningún momento.
diff --git a/src/web/src/pages/admin/audit/AuditPage.tsx b/src/web/src/pages/admin/audit/AuditPage.tsx
index b16e960..7979ac4 100644
--- a/src/web/src/pages/admin/audit/AuditPage.tsx
+++ b/src/web/src/pages/admin/audit/AuditPage.tsx
@@ -18,20 +18,7 @@ import {
toApiFilter,
type AuditFiltersValue,
} from './AuditFilters'
-
-/** Formatea un ISO datetime a hora local AR (dd/mm/yyyy HH:mm:ss). */
-function formatOccurredAt(iso: string): string {
- const d = new Date(iso)
- if (Number.isNaN(d.getTime())) return iso
- return d.toLocaleString('es-AR', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- })
-}
+import { formatInstant } from '@/lib/dateFormat'
/** Copia texto al clipboard con fallback + toast. */
async function copyToClipboard(text: string, label: string): Promise {
@@ -123,7 +110,7 @@ export function AuditPage() {
header: 'Fecha',
cell: ({ row }) => (
- {formatOccurredAt(row.original.occurredAt)}
+ {formatInstant(row.original.occurredAt)}
),
meta: { priority: 'high' },
diff --git a/src/web/src/tests/lib/dateFormat.test.ts b/src/web/src/tests/lib/dateFormat.test.ts
index a7e4624..f428760 100644
--- a/src/web/src/tests/lib/dateFormat.test.ts
+++ b/src/web/src/tests/lib/dateFormat.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
import {
AR_TZ,
formatInstant,
+ formatInstantOrDash,
formatCivilDate,
formatCivilDateRange,
todayArgentina,
@@ -33,6 +34,21 @@ describe('dateFormat.ts', () => {
});
});
+ describe('formatInstantOrDash (Cat1 nullable)', () => {
+ it('returns "—" for null', () => {
+ expect(formatInstantOrDash(null)).toBe('—');
+ });
+
+ it('returns "—" for undefined', () => {
+ expect(formatInstantOrDash(undefined)).toBe('—');
+ });
+
+ it('delegates to formatInstant for valid ISO', () => {
+ const iso = '2026-05-01T01:30:00.000Z';
+ expect(formatInstantOrDash(iso)).toBe(formatInstant(iso));
+ });
+ });
+
describe('formatCivilDate (Cat2 — yyyy-MM-dd → dd/MM/yyyy)', () => {
it('splits manually without new Date()', () => {
expect(formatCivilDate('2026-05-01')).toBe('01/05/2026');
--
2.49.1
From ef4b02be3ba10901c54147ed55d3399b86763421 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:26:56 -0300
Subject: [PATCH 22/23] fix(web/udt-011): AuditFilters datetime-local usa
parseArgentinaDateTimeToUtc (fix BUG-FE-05)
---
src/web/src/pages/admin/audit/AuditFilters.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/web/src/pages/admin/audit/AuditFilters.tsx b/src/web/src/pages/admin/audit/AuditFilters.tsx
index f55cc9f..79e2f56 100644
--- a/src/web/src/pages/admin/audit/AuditFilters.tsx
+++ b/src/web/src/pages/admin/audit/AuditFilters.tsx
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
+import { parseArgentinaDateTimeToUtc } from '@/lib/dateFormat'
/** Filtros crudos del form (todos strings para binding directo del input). */
export interface AuditFiltersValue {
@@ -132,8 +133,8 @@ export function AuditFilters({
* que espera el cliente de API.
*
* - `actor` vacío o NaN → omitido
- * - `from`/`to` vienen del `datetime-local` (local time, sin timezone).
- * Los convertimos a ISO UTC vía `new Date(...).toISOString()`.
+ * - `from`/`to` vienen del `datetime-local` (hora ART implícita, sin timezone).
+ * Los convertimos a ISO UTC vía `parseArgentinaDateTimeToUtc()` (fix BUG-FE-05).
* - Strings vacíos → omitidos.
*/
export function toApiFilter(
@@ -148,12 +149,11 @@ export function toApiFilter(
if (value.targetType.trim() !== '') out.targetType = value.targetType.trim()
if (value.targetId.trim() !== '') out.targetId = value.targetId.trim()
if (value.from.trim() !== '') {
- const d = new Date(value.from)
- if (!Number.isNaN(d.getTime())) out.from = d.toISOString()
+ // BUG-FE-05: interpretar el string como ART (-03:00), no como local del browser
+ try { out.from = parseArgentinaDateTimeToUtc(value.from) } catch { /* invalid input */ }
}
if (value.to.trim() !== '') {
- const d = new Date(value.to)
- if (!Number.isNaN(d.getTime())) out.to = d.toISOString()
+ try { out.to = parseArgentinaDateTimeToUtc(value.to) } catch { /* invalid input */ }
}
return out
--
2.49.1
From 408c97559b4732a45d60ea141faae93108b7f390 Mon Sep 17 00:00:00 2001
From: dmolinari
Date: Sat, 18 Apr 2026 10:27:13 -0300
Subject: [PATCH 23/23] chore(web/udt-011): grep final confirma 0 anti-patterns
en src/web/src fuera de dateFormat.ts
--
2.49.1