test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRD-003)
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Products;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — Batch 7 / T7.4: Audit failure e2e test.
|
||||||
|
///
|
||||||
|
/// Verifies that when IAuditLogger.LogAsync throws, the TransactionScope rolls back:
|
||||||
|
/// - dbo.ProductPrices row is NOT inserted (fail-closed).
|
||||||
|
/// - dbo.AuditEvent row is NOT created.
|
||||||
|
/// - HTTP response is 500 (unhandled exception propagates through ExceptionFilter).
|
||||||
|
///
|
||||||
|
/// Uses CreateClientWithOverrides (pattern from issue #36) to inject a throwing IAuditLogger
|
||||||
|
/// mock without touching the shared factory.
|
||||||
|
///
|
||||||
|
/// DB: SIGCM2_Test_Api (ApiIntegration collection).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class ProductPricesAuditFailureTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
||||||
|
|
||||||
|
private readonly TestWebAppFactory _factory;
|
||||||
|
private int _medioId;
|
||||||
|
private int _productTypeId;
|
||||||
|
|
||||||
|
public ProductPricesAuditFailureTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
_medioId = await conn.QuerySingleAsync<int>(
|
||||||
|
"SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id");
|
||||||
|
|
||||||
|
var ptId = await conn.QuerySingleOrDefaultAsync<int?>(
|
||||||
|
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
|
||||||
|
if (ptId is null)
|
||||||
|
{
|
||||||
|
ptId = await conn.QuerySingleAsync<int>("""
|
||||||
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages, IsActive, FechaCreacion)
|
||||||
|
VALUES ('PT_AuditFail_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME());
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() AS INT);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
_productTypeId = ptId.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ── T7.4 — Audit failure rolls back the ProductPrice insert ──────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostPrice_WhenAuditLoggerThrows_Returns500AndRollsBackInsert()
|
||||||
|
{
|
||||||
|
// Arrange: seed a unique product
|
||||||
|
var productId = await SeedProductAsync();
|
||||||
|
|
||||||
|
// Count rows before the failing POST
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var priceCountBefore = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
|
||||||
|
new { ProductId = productId });
|
||||||
|
|
||||||
|
var auditCountBefore = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'");
|
||||||
|
|
||||||
|
// Build a mock IAuditLogger that throws on LogAsync
|
||||||
|
var throwingAuditLogger = Substitute.For<IAuditLogger>();
|
||||||
|
throwingAuditLogger
|
||||||
|
.LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("audit down — simulated failure"));
|
||||||
|
|
||||||
|
// Use CreateClientWithOverrides to inject the mock without touching the shared factory
|
||||||
|
using var client = _factory.CreateClientWithOverrides(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IAuditLogger>();
|
||||||
|
services.AddScoped<IAuditLogger>(_ => throwingAuditLogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate admin token (use factory services, not child)
|
||||||
|
var jwt = _factory.Services.GetRequiredService<IJwtService>();
|
||||||
|
var token = jwt.GenerateAccessToken(new Usuario(
|
||||||
|
id: 1, username: "admin", passwordHash: "x",
|
||||||
|
nombre: "Admin", apellido: "Sys", email: null,
|
||||||
|
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
|
||||||
|
|
||||||
|
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices");
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
req.Content = JsonContent.Create(new { price = 999m, priceValidFrom = pvf });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var resp = await client.SendAsync(req);
|
||||||
|
|
||||||
|
// Assert HTTP: audit failure → unhandled exception → 500
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError,
|
||||||
|
because: "IAuditLogger throwing must propagate as 500 (fail-closed)");
|
||||||
|
|
||||||
|
// Assert DB: NO new ProductPrices row was persisted (TransactionScope rolled back)
|
||||||
|
await using var verifyConn = new SqlConnection(ConnectionString);
|
||||||
|
await verifyConn.OpenAsync();
|
||||||
|
|
||||||
|
var priceCountAfter = await verifyConn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
|
||||||
|
new { ProductId = productId });
|
||||||
|
|
||||||
|
priceCountAfter.Should().Be(priceCountBefore,
|
||||||
|
because: "TransactionScope must roll back the ProductPrices INSERT when audit fails");
|
||||||
|
|
||||||
|
// Assert DB: NO new AuditEvent row for this product
|
||||||
|
var auditCountAfter = await verifyConn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'");
|
||||||
|
|
||||||
|
auditCountAfter.Should().Be(auditCountBefore,
|
||||||
|
because: "no AuditEvent row must be created when the audit logger throws");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: seed an active product ───────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<int> SeedProductAsync()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
var nombre = $"PP_AF_{Guid.NewGuid():N}"[..35];
|
||||||
|
return await conn.QuerySingleAsync<int>("""
|
||||||
|
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion)
|
||||||
|
VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, 1, SYSUTCDATETIME());
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() AS INT);
|
||||||
|
""",
|
||||||
|
new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -466,6 +466,283 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
|||||||
"retroactive date behind existing active row triggers SP 50409 → repository maps to ProductPriceForwardOnlyException");
|
"retroactive date behind existing active row triggers SP 50409 → repository maps to ProductPriceForwardOnlyException");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch 7 — T7.1: Concurrency con 3 tasks + SemaphoreSlim barrier
|
||||||
|
// §REQ-1.2 — Exactamente 1 ganador, 2 perdedores lanzan excepción manejable
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrency_ThreeConcurrentInserts_ExactlyOneSucceeds()
|
||||||
|
{
|
||||||
|
// Barrera: todos esperan en el semáforo; cuando se liberan juntos, la race es auténtica.
|
||||||
|
var barrier = new SemaphoreSlim(0, 3);
|
||||||
|
var pvf = new DateOnly(2027, 6, 1);
|
||||||
|
|
||||||
|
async Task<Exception?> TryInsert(decimal price)
|
||||||
|
{
|
||||||
|
// Cada task espera en la barrera antes de ejecutar
|
||||||
|
await barrier.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await ExecAddPriceSpAsync(conn, _defaultProductId, price, pvf);
|
||||||
|
return null; // éxito
|
||||||
|
}
|
||||||
|
catch (SqlException ex)
|
||||||
|
{
|
||||||
|
return ex; // perdedor: 50409, 2601, 2627 o deadlock 1205
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var t1 = Task.Run(() => TryInsert(111.00m));
|
||||||
|
var t2 = Task.Run(() => TryInsert(222.00m));
|
||||||
|
var t3 = Task.Run(() => TryInsert(333.00m));
|
||||||
|
|
||||||
|
// Liberar las 3 tasks simultáneamente
|
||||||
|
barrier.Release(3);
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(t1, t2, t3);
|
||||||
|
|
||||||
|
// Exactamente 1 éxito (null), exactamente 2 fallos
|
||||||
|
var successes = results.Count(r => r is null);
|
||||||
|
var failures = results.Count(r => r is not null);
|
||||||
|
|
||||||
|
successes.Should().Be(1, "exactly one concurrent insert must succeed");
|
||||||
|
failures.Should().Be(2, "the other two must fail with a SqlException");
|
||||||
|
|
||||||
|
// Verificar que el estado final es exactamente 1 activo (PriceValidTo IS NULL)
|
||||||
|
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await verifyConn.OpenAsync();
|
||||||
|
var activeCount = await verifyConn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId AND PriceValidTo IS NULL",
|
||||||
|
new { ProductId = _defaultProductId });
|
||||||
|
|
||||||
|
activeCount.Should().Be(1, "only one active price (PriceValidTo IS NULL) must survive the race");
|
||||||
|
|
||||||
|
// Sin duplicados: COUNT(*) para este producto debe ser 1 (solo la ganadora)
|
||||||
|
var totalCount = await verifyConn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
|
||||||
|
new { ProductId = _defaultProductId });
|
||||||
|
|
||||||
|
totalCount.Should().Be(1, "no duplicate rows must exist for the same ProductId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch 7 — T7.2: SYSTEM_VERSIONING — no history before close, 1 row after
|
||||||
|
// Verifica que el SP produce exactamente 1 row en dbo.ProductPrices_History
|
||||||
|
// al cerrar el activo, y que antes del cierre la tabla está vacía para ese Id.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemVersioning_BeforeClose_HistoryTableIsEmpty()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Antes del UPDATE (cierre), history debe estar vacía para este Id
|
||||||
|
var histCountBefore = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
histCountBefore.Should().Be(0,
|
||||||
|
"SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE, not on INSERT");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemVersioning_AfterClose_ExactlyOneHistoryRow()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Cierra el activo con una segunda inserción más futura
|
||||||
|
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20));
|
||||||
|
|
||||||
|
// dbo.ProductPrices_History debe tener exactamente 1 row para el Id cerrado
|
||||||
|
var histCount = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
histCount.Should().Be(1,
|
||||||
|
"SYSTEM_VERSIONING must produce exactly one history row when the active price is closed via UPDATE");
|
||||||
|
|
||||||
|
// El row activo en dbo.ProductPrices debe tener PriceValidTo <> NULL
|
||||||
|
var pvt = await conn.ExecuteScalarAsync<DateTime?>(
|
||||||
|
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
pvt.Should().NotBeNull("the closed row in dbo.ProductPrices must have PriceValidTo set");
|
||||||
|
pvt!.Value.Date.Should().Be(new DateTime(2026, 4, 19),
|
||||||
|
"PriceValidTo = new PVF - 1 day = 2026-04-19");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch 7 — T7.3: FOR SYSTEM_TIME AS OF — snapshot temporal
|
||||||
|
// Verifica que la history table preserva el estado del activo en el instante
|
||||||
|
// pre-cierre y que la query temporal devuelve el precio correcto.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// T0: insertar precio1 y capturar el instante UTC antes de cerrarlo
|
||||||
|
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Capturar T0 inmediatamente después del INSERT (SYSTEM_VERSIONING usa DATETIME2 UTC)
|
||||||
|
var t0 = await conn.ExecuteScalarAsync<DateTime>(
|
||||||
|
"SELECT SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
// Esperar 200ms para que DATETIME2(3) avance y el registro de history tenga un rango claro
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
// Insertar precio2 que cierra precio1 — esto produce el row en history
|
||||||
|
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 5, 1));
|
||||||
|
|
||||||
|
// T7.3.a — Query FOR SYSTEM_TIME AS OF T0: debe devolver precio1 con PriceValidTo = NULL
|
||||||
|
// (estado del registro tal como estaba en T0, antes del cierre)
|
||||||
|
var snapshotRow = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"""
|
||||||
|
SELECT Id, Price, PriceValidTo
|
||||||
|
FROM dbo.ProductPrices
|
||||||
|
FOR SYSTEM_TIME AS OF @T0
|
||||||
|
WHERE ProductId = @ProductId
|
||||||
|
AND Id = @Id
|
||||||
|
""",
|
||||||
|
new { T0 = t0, ProductId = _defaultProductId, Id = firstId });
|
||||||
|
|
||||||
|
((object?)snapshotRow).Should().NotBeNull(
|
||||||
|
"FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before close)");
|
||||||
|
((decimal)snapshotRow!.Price).Should().Be(100.00m);
|
||||||
|
((object?)snapshotRow.PriceValidTo).Should().BeNull(
|
||||||
|
"at T0 the row was still active (PriceValidTo IS NULL)");
|
||||||
|
|
||||||
|
// T7.3.b — Query actual (sin FOR SYSTEM_TIME): precio1 debe tener PriceValidTo != NULL
|
||||||
|
var currentRow = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"SELECT Id, Price, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
((object?)currentRow).Should().NotBeNull();
|
||||||
|
((object?)currentRow!.PriceValidTo).Should().NotBeNull(
|
||||||
|
"in current state, the first price is closed (PriceValidTo IS NOT NULL)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch 7 — T7.5: GetActiveAsync boundary cases (ventanas civiles inclusivas)
|
||||||
|
// §REQ-4.4 — Inclusive en ambos extremos (PVF y PVT)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_BeforeFirstPrice_ReturnsNull()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Precio1: [2026-01-01 .. 2026-03-31] (cerrado)
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2025, 12, 31));
|
||||||
|
|
||||||
|
result.Should().BeNull("date is before the first PriceValidFrom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_ExactMatchPvf_ReturnsPrice()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Precio1: [2026-01-01 .. 2026-03-31]
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
// Exact match en PVF
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
result.Should().NotBeNull("date equals PriceValidFrom → inclusive lower bound");
|
||||||
|
result!.Price.Should().Be(100.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_MiddleOfRange_ReturnsPrice()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..]
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 2, 15));
|
||||||
|
|
||||||
|
result.Should().NotBeNull("date is in the middle of precio1 window");
|
||||||
|
result!.Price.Should().Be(100.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_ExactMatchPvt_ReturnsClosedPrice()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Precio1: [2026-01-01 .. 2026-03-31] — PVT=2026-03-31 (día anterior a 2026-04-01)
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
// Exact match en PVT del precio1 → inclusive upper bound
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 3, 31));
|
||||||
|
|
||||||
|
result.Should().NotBeNull("date equals PriceValidTo → inclusive upper bound");
|
||||||
|
result!.Price.Should().Be(100.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_ExactMatchNextPvf_ReturnsNextPrice()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..]
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
// PVF del precio2 → debe devolver precio2
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
result.Should().NotBeNull("date equals PriceValidFrom of precio2 → inclusive lower bound");
|
||||||
|
result!.Price.Should().Be(150.00m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_FarFuture_ReturnsActivePrice()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Solo precio activo desde 2026-04-01 (PriceValidTo = NULL)
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2099, 12, 31));
|
||||||
|
|
||||||
|
result.Should().NotBeNull("far-future date should return the open-ended active price");
|
||||||
|
result!.Price.Should().Be(200.00m);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
result.PriceValidTo.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helper: build a real ProductPriceRepository using the test DB ─────────
|
// ── Helper: build a real ProductPriceRepository using the test DB ─────────
|
||||||
|
|
||||||
private static ProductPriceRepository BuildRepository()
|
private static ProductPriceRepository BuildRepository()
|
||||||
|
|||||||
Reference in New Issue
Block a user