feat(infrastructure): ChargeableCharConfigRepository Dapper + SP invocation (PRC-001)
- ChargeableCharConfigRepository implements IChargeableCharConfigRepository via Dapper - InsertWithCloseAsync calls usp_ChargeableCharConfig_InsertWithClose with OUTPUT params; maps SqlException 50409 → ChargeableCharConfigForwardOnlyException, 50404 → ChargeableCharConfigInvalidException - GetActiveForMedioAsync calls usp_ChargeableCharConfig_GetActiveForMedio; returns all rows (global + per-medio) — Application service handles priority resolution - ListAsync / CountAsync use parameterized SQL with OFFSET/FETCH and NULL-aware MedioId filter - GetByIdAsync / DeactivateAsync cover single-entity read and idempotent deactivation - DateOnly mapping: DateTime → DateOnly.FromDateTime() pattern, same as ProductPriceRepository - Registered IChargeableCharConfigRepository → ChargeableCharConfigRepository in DI - 14 integration tests against SIGCM2_Test_App (all GREEN); 1571/1571 total tests pass
This commit is contained in:
@@ -45,6 +45,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
||||||
// PRD-003: ProductPrices históricos
|
// PRD-003: ProductPrices históricos
|
||||||
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
|
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
|
||||||
|
// PRC-001: ChargeableCharConfig — caracteres especiales tasables
|
||||||
|
services.AddScoped<IChargeableCharConfigRepository, ChargeableCharConfigRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
|
||||||
|
///
|
||||||
|
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
|
||||||
|
/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found)
|
||||||
|
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
|
||||||
|
///
|
||||||
|
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio.
|
||||||
|
/// Returns all rows (global + per-medio) — the Application service applies priority.
|
||||||
|
///
|
||||||
|
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
|
||||||
|
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
|
||||||
|
///
|
||||||
|
/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
|
||||||
|
public ChargeableCharConfigRepository(SqlConnectionFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<long> InsertWithCloseAsync(
|
||||||
|
long? medioId,
|
||||||
|
string symbol,
|
||||||
|
string category,
|
||||||
|
decimal price,
|
||||||
|
DateOnly validFrom,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021)
|
||||||
|
p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32);
|
||||||
|
p.Add("@Symbol", symbol, DbType.String, size: 4);
|
||||||
|
p.Add("@Category", category, DbType.String, size: 32);
|
||||||
|
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
|
||||||
|
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||||
|
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||||
|
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
"dbo.usp_ChargeableCharConfig_InsertWithClose",
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50404)
|
||||||
|
{
|
||||||
|
// Medio not found (SP validates MedioId when not null)
|
||||||
|
throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(medioId),
|
||||||
|
$"Medio with Id={medioId} not found.");
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50409)
|
||||||
|
{
|
||||||
|
// Forward-only violation: new ValidFrom <= active.ValidFrom
|
||||||
|
throw new ChargeableCharConfigForwardOnlyException(
|
||||||
|
medioId.HasValue ? (int?)checked((int)medioId.Value) : null,
|
||||||
|
symbol,
|
||||||
|
validFrom,
|
||||||
|
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Get<long>("@NewId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
|
||||||
|
long medioId,
|
||||||
|
DateOnly asOfDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
// SP @MedioId is INT
|
||||||
|
p.Add("@MedioId", checked((int)medioId), DbType.Int32);
|
||||||
|
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(
|
||||||
|
"dbo.usp_ChargeableCharConfig_GetActiveForMedio",
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct));
|
||||||
|
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||||
|
long? medioId,
|
||||||
|
bool activeOnly,
|
||||||
|
int skip,
|
||||||
|
int take,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// NULL-aware MedioId filter:
|
||||||
|
// - medioId provided → filter to that medio only
|
||||||
|
// - medioId null → return all rows regardless of medio
|
||||||
|
// activeOnly filters by IsActive = 1.
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
|
||||||
|
AND (@ActiveOnly = 0 OR IsActive = 1)
|
||||||
|
ORDER BY ValidFrom DESC, Id DESC
|
||||||
|
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
|
||||||
|
ActiveOnly = activeOnly ? 1 : 0,
|
||||||
|
Skip = skip,
|
||||||
|
Take = take
|
||||||
|
},
|
||||||
|
cancellationToken: ct));
|
||||||
|
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> CountAsync(
|
||||||
|
long? medioId,
|
||||||
|
bool activeOnly,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
|
||||||
|
AND (@ActiveOnly = 0 OR IsActive = 1)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
|
||||||
|
ActiveOnly = activeOnly ? 1 : 0
|
||||||
|
},
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<ChargeableCharConfig?> GetByIdAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
|
FROM dbo.ChargeableCharConfig
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||||
|
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task DeactivateAsync(
|
||||||
|
long id,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Idempotent: WHERE ... AND IsActive = 1 — no-op if already inactive.
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.ChargeableCharConfig
|
||||||
|
SET IsActive = 0,
|
||||||
|
ValidTo = @Today
|
||||||
|
WHERE Id = @Id
|
||||||
|
AND IsActive = 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Today = today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
},
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row mapper ────────────────────────────────────────────────────────────
|
||||||
|
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
|
||||||
|
// Same pattern as ProductPriceRepository.
|
||||||
|
|
||||||
|
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
|
||||||
|
=> ChargeableCharConfig.Rehydrate(
|
||||||
|
id: r.Id,
|
||||||
|
medioId: r.MedioId,
|
||||||
|
symbol: r.Symbol,
|
||||||
|
category: r.Category,
|
||||||
|
price: r.PricePerUnit,
|
||||||
|
validFrom: DateOnly.FromDateTime(r.ValidFrom),
|
||||||
|
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
|
||||||
|
isActive: r.IsActive);
|
||||||
|
|
||||||
|
private sealed record ChargeableCharConfigRow(
|
||||||
|
long Id,
|
||||||
|
int? MedioId,
|
||||||
|
string Symbol,
|
||||||
|
string Category,
|
||||||
|
decimal PricePerUnit,
|
||||||
|
DateTime ValidFrom,
|
||||||
|
DateTime? ValidTo,
|
||||||
|
bool IsActive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Integration tests for ChargeableCharConfigRepository (Dapper) against SIGCM2_Test_App.
|
||||||
|
///
|
||||||
|
/// All tests run against the real DB via SqlTestFixture (Database collection).
|
||||||
|
/// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync().
|
||||||
|
/// Tests that mutate specific (MedioId, Symbol) pairs clean their own state before mutating.
|
||||||
|
///
|
||||||
|
/// Spec coverage:
|
||||||
|
/// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id
|
||||||
|
/// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new
|
||||||
|
/// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException
|
||||||
|
/// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation
|
||||||
|
/// T4.5 GetActiveForMedioAsync — medio has override → returns both medio and global rows
|
||||||
|
/// T4.6 GetActiveForMedioAsync — no medio override → returns only global rows
|
||||||
|
/// T4.7 ListAsync — paginates (skip/take)
|
||||||
|
/// T4.8 CountAsync — filters by activeOnly
|
||||||
|
/// T4.9 GetByIdAsync — missing → returns null
|
||||||
|
/// T4.10 GetByIdAsync — exists → returns entity
|
||||||
|
/// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today
|
||||||
|
/// T4.12 DeactivateAsync — already inactive → idempotent (no-op)
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
private int _medioId;
|
||||||
|
|
||||||
|
public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _db.ResetAndSeedAsync();
|
||||||
|
|
||||||
|
// Create a dedicated Medio for per-medio tests
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
_medioId = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES ('REPO_TEST', 'Medio RepoTest', 1, 1)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.1 — InsertWithCloseAsync: first insert for new symbol → row created, returns Id > 0
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId()
|
||||||
|
{
|
||||||
|
// NEW symbol not in canonical seed — use per-medio so it doesn't conflict
|
||||||
|
const string symbol = "@";
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
var newId = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Other",
|
||||||
|
price: 2.5000m,
|
||||||
|
validFrom: new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
newId.Should().BeGreaterThan(0, "first insert must return the new row's Id");
|
||||||
|
|
||||||
|
// Verify the row exists in DB
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"SELECT Id, Symbol, IsActive, ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id",
|
||||||
|
new { Id = newId });
|
||||||
|
|
||||||
|
((object?)row).Should().NotBeNull();
|
||||||
|
((string)row!.Symbol).Should().Be(symbol);
|
||||||
|
((bool)row.IsActive).Should().BeTrue();
|
||||||
|
((object?)row.ValidTo).Should().BeNull("first insert has no ValidTo — still active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.2 — InsertWithCloseAsync: with existing vigente → closes previous, inserts new
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertWithCloseAsync_WithExistingVigente_ClosesPreviousAndInsertsNew()
|
||||||
|
{
|
||||||
|
const string symbol = "#";
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
// First insert — becomes the vigente
|
||||||
|
var firstId = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Other",
|
||||||
|
price: 1.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 3, 1));
|
||||||
|
|
||||||
|
// Second insert (forward) — must close the first
|
||||||
|
var secondValidFrom = new DateOnly(2026, 6, 1);
|
||||||
|
var secondId = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Other",
|
||||||
|
price: 2.0000m,
|
||||||
|
validFrom: secondValidFrom);
|
||||||
|
|
||||||
|
secondId.Should().BeGreaterThan(firstId, "second row must be a new insert with higher Id");
|
||||||
|
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// First row must now have ValidTo = secondValidFrom - 1 day = 2026-05-31
|
||||||
|
var firstRow = await conn.QuerySingleAsync<dynamic>(
|
||||||
|
"SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
((DateTime)firstRow.ValidTo).Date.Should().Be(new DateTime(2026, 5, 31),
|
||||||
|
"SP closes the vigente with ValidTo = new ValidFrom - 1 day");
|
||||||
|
|
||||||
|
// Second row must be the new vigente (ValidTo IS NULL)
|
||||||
|
var secondRow = await conn.QuerySingleAsync<dynamic>(
|
||||||
|
"SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id",
|
||||||
|
new { Id = secondId });
|
||||||
|
|
||||||
|
((object?)secondRow.ValidTo).Should().BeNull("new vigente has ValidTo = NULL");
|
||||||
|
((bool)secondRow.IsActive).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.3 — InsertWithCloseAsync: backdate attempt → ThrowsForwardOnlyException
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertWithCloseAsync_BackdateAttempt_ThrowsChargeableCharConfigForwardOnlyException()
|
||||||
|
{
|
||||||
|
const string symbol = "€";
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
// Establish a vigente at 2026-04-01
|
||||||
|
await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Currency",
|
||||||
|
price: 1.5000m,
|
||||||
|
validFrom: new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Try to insert retroactively — SP will THROW 50409
|
||||||
|
var act = async () => await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Currency",
|
||||||
|
price: 1.2000m,
|
||||||
|
validFrom: new DateOnly(2026, 3, 1));
|
||||||
|
|
||||||
|
await act.Should()
|
||||||
|
.ThrowAsync<ChargeableCharConfigForwardOnlyException>(
|
||||||
|
"SQL THROW 50409 must be mapped to ChargeableCharConfigForwardOnlyException");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.4 — InsertWithCloseAsync: SYSTEM_VERSIONING captures history row after close
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertWithCloseAsync_SystemVersioningCaptures_HistoryHasRowAfterClose()
|
||||||
|
{
|
||||||
|
const string symbol = "£";
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
// Insert the first row
|
||||||
|
var firstId = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Currency",
|
||||||
|
price: 3.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
// Insert a second row — this triggers an UPDATE on the first row → history
|
||||||
|
await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: symbol,
|
||||||
|
category: "Currency",
|
||||||
|
price: 4.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 7, 1));
|
||||||
|
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var histCount = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
histCount.Should().BeGreaterThanOrEqualTo(1,
|
||||||
|
"SYSTEM_VERSIONING must create a history row when the vigente row is closed via UPDATE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.5 — GetActiveForMedioAsync: medio has override → returns both medio and global rows
|
||||||
|
// Note: SP returns ALL rows (global + per-medio); service does priority resolution.
|
||||||
|
// This test verifies the REPOSITORY returns both, not just one.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var asOf = new DateOnly(2026, 6, 1);
|
||||||
|
|
||||||
|
// Add a per-medio override for symbol '$'
|
||||||
|
// Canonical seed already has global '$' from ResetAndSeedAsync
|
||||||
|
await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: "$",
|
||||||
|
category: "Currency",
|
||||||
|
price: 5.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
var rows = await repo.GetActiveForMedioAsync((long)_medioId, asOf);
|
||||||
|
|
||||||
|
// The SP returns both the per-medio '$' AND global rows for other symbols
|
||||||
|
// (at minimum: global '$' was replaced by per-medio; other globals still present)
|
||||||
|
// SP uses ROW_NUMBER to pick 1 row per Symbol, preferring per-medio.
|
||||||
|
// So we should get exactly one row per symbol that is active as of asOf.
|
||||||
|
rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed");
|
||||||
|
|
||||||
|
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
|
||||||
|
dollarRow.Should().NotBeNull("the SP must return a row for '$'");
|
||||||
|
dollarRow!.MedioId.Should().Be(_medioId,
|
||||||
|
"per-medio row takes priority over global in the SP's ROW_NUMBER ordering");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.6 — GetActiveForMedioAsync: no medio override → returns only global rows
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var asOf = new DateOnly(2026, 6, 1);
|
||||||
|
|
||||||
|
// Use a DIFFERENT medioId that has no per-medio rows
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
var otherMedioId = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES ('REPO_NO_OVRD', 'Medio sin override', 1, 1)
|
||||||
|
""");
|
||||||
|
|
||||||
|
var rows = await repo.GetActiveForMedioAsync((long)otherMedioId, asOf);
|
||||||
|
|
||||||
|
rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01");
|
||||||
|
rows.Should().AllSatisfy(r =>
|
||||||
|
r.MedioId.Should().BeNull("all returned rows must be global (MedioId = NULL)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.7 — ListAsync: paginates via skip/take
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListAsync_Paginates_ReturnsCorrectSubset()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
// Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2).
|
||||||
|
var page1 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 0, take: 2);
|
||||||
|
var page2 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 2, take: 2);
|
||||||
|
|
||||||
|
page1.Should().HaveCount(2, "take=2 with at least 4 rows");
|
||||||
|
page2.Should().HaveCount(2, "second page of 4 rows");
|
||||||
|
|
||||||
|
// No overlap
|
||||||
|
page1.Select(r => r.Id).Intersect(page2.Select(r => r.Id))
|
||||||
|
.Should().BeEmpty("two non-overlapping pages must not share any row");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListAsync_PageBeyondTotal_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.ListAsync(medioId: null, activeOnly: false, skip: 1000, take: 10);
|
||||||
|
result.Should().BeEmpty("skip far beyond available data must return empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.8 — CountAsync: filters by activeOnly
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountAsync_FiltersByActiveOnly_CountsOnlyActiveRows()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
// Canonical seed: 4 active global rows
|
||||||
|
var countAll = await repo.CountAsync(medioId: null, activeOnly: false);
|
||||||
|
var countActive = await repo.CountAsync(medioId: null, activeOnly: true);
|
||||||
|
|
||||||
|
countAll.Should().BeGreaterThanOrEqualTo(4,
|
||||||
|
"canonical seed provides at least 4 rows (may have more if other tests ran)");
|
||||||
|
countActive.Should().BeGreaterThanOrEqualTo(4,
|
||||||
|
"all canonical rows are active");
|
||||||
|
countActive.Should().BeLessThanOrEqualTo(countAll,
|
||||||
|
"active-only count must be <= total count");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CountAsync_AfterDeactivation_ActiveCountDecreases()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
// Insert a row, then deactivate it — active count should decrease by 1
|
||||||
|
var id = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: "~",
|
||||||
|
category: "Other",
|
||||||
|
price: 1.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
var today = new DateOnly(2026, 4, 20);
|
||||||
|
var beforeDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true);
|
||||||
|
|
||||||
|
await repo.DeactivateAsync(id, today);
|
||||||
|
|
||||||
|
var afterDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true);
|
||||||
|
|
||||||
|
afterDeactivate.Should().Be(beforeDeactivate - 1,
|
||||||
|
"deactivating one row must decrease the active count by 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.9 — GetByIdAsync: missing → returns null
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_Missing_ReturnsNull()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetByIdAsync(999_999_999L);
|
||||||
|
result.Should().BeNull("non-existent Id must return null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.10 — GetByIdAsync: exists → returns entity with all fields correct
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_Exists_ReturnsEntityWithCorrectFields()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
var expectedValidFrom = new DateOnly(2026, 2, 1);
|
||||||
|
var id = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: "^",
|
||||||
|
category: "Other",
|
||||||
|
price: 7.5000m,
|
||||||
|
validFrom: expectedValidFrom);
|
||||||
|
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
|
||||||
|
entity.Should().NotBeNull();
|
||||||
|
entity!.Id.Should().Be(id);
|
||||||
|
entity.MedioId.Should().Be(_medioId);
|
||||||
|
entity.Symbol.Should().Be("^");
|
||||||
|
entity.Category.Should().Be("Other");
|
||||||
|
entity.PricePerUnit.Should().Be(7.5000m);
|
||||||
|
entity.ValidFrom.Should().Be(expectedValidFrom);
|
||||||
|
entity.ValidTo.Should().BeNull("freshly inserted row has no ValidTo");
|
||||||
|
entity.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.11 — DeactivateAsync: sets IsActive = false and ValidTo = today
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_SetsIsActiveFalseAndValidToToday()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
var id = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: "&",
|
||||||
|
category: "Other",
|
||||||
|
price: 1.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
var today = new DateOnly(2026, 4, 20);
|
||||||
|
await repo.DeactivateAsync(id, today);
|
||||||
|
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
|
||||||
|
entity.Should().NotBeNull();
|
||||||
|
entity!.IsActive.Should().BeFalse("deactivated row must have IsActive = false");
|
||||||
|
entity.ValidTo.Should().Be(today, "ValidTo must be set to the provided today date");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// T4.12 — DeactivateAsync: already inactive → idempotent (no error, row unchanged)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_AlreadyInactive_IsIdempotent()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
|
||||||
|
var id = await repo.InsertWithCloseAsync(
|
||||||
|
medioId: (long?)_medioId,
|
||||||
|
symbol: "*",
|
||||||
|
category: "Other",
|
||||||
|
price: 1.0000m,
|
||||||
|
validFrom: new DateOnly(2026, 1, 1));
|
||||||
|
|
||||||
|
var today = new DateOnly(2026, 4, 20);
|
||||||
|
|
||||||
|
// Deactivate once
|
||||||
|
await repo.DeactivateAsync(id, today);
|
||||||
|
|
||||||
|
// Deactivate again — must be a no-op, no exception
|
||||||
|
var act = async () => await repo.DeactivateAsync(id, today);
|
||||||
|
await act.Should().NotThrowAsync("re-deactivating an already-inactive row must be idempotent");
|
||||||
|
|
||||||
|
// State must remain the same
|
||||||
|
var entity = await repo.GetByIdAsync(id);
|
||||||
|
entity!.IsActive.Should().BeFalse();
|
||||||
|
entity.ValidTo.Should().Be(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static ChargeableCharConfigRepository BuildRepository()
|
||||||
|
=> new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user