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");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 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 ─────────
|
||||
|
||||
private static ProductPriceRepository BuildRepository()
|
||||
|
||||
Reference in New Issue
Block a user