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 + """); + } +} 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 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 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); + } } 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)); + } +} 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 @@ + 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); + } + } +} 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); + } +} 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 + } +} 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)); + } +} 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); + } + } } 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); 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), 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); 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); + }); + }); +}); 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(); +} 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() + }) +}) 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() + }) +}) 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'); + }); + }); }); 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'); 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 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