fix(transaction): retain both transaction failures

Keep the original action or commit failure visible alongside a failed rollback and cover owned transaction reuse.

Ref: IT-1588
This commit is contained in:
2026-10-15 14:13:56 +03:00
parent 1da2dd8579
commit 5dd8342bf7
8 changed files with 334 additions and 7 deletions
+213
View File
@@ -0,0 +1,213 @@
namespace HrynCo.DAL.EF.Tests;
using HrynCo.DAL.Abstract;
using Microsoft.EntityFrameworkCore;
using Xunit;
public sealed class EfUnitOfWorkTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ActionFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult)
{
var original = new InvalidOperationException("Action failed.");
var rollbackFailure = new InvalidOperationException("Transaction completed.");
var interceptor = new TransactionFailureInterceptor
{
RollbackException = rollbackFailure
};
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
AggregateException actual = await Assert.ThrowsAsync<AggregateException>(() =>
ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
Assert.Collection(actual.InnerExceptions,
error => Assert.Same(original, error),
error => Assert.Same(rollbackFailure, error));
Assert.Equal(1, interceptor.RollbackAttempts);
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CommitFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult)
{
var original = new InvalidOperationException("Commit failed.");
var rollbackFailure = new InvalidOperationException("Transaction completed.");
var interceptor = new TransactionFailureInterceptor
{
CommitException = original,
RollbackException = rollbackFailure
};
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
AggregateException actual = await Assert.ThrowsAsync<AggregateException>(() =>
ExecuteAsync(unitOfWork, returnsResult, () => Task.CompletedTask));
Assert.Collection(actual.InnerExceptions,
error => Assert.Same(original, error),
error => Assert.Same(rollbackFailure, error));
Assert.Equal(1, interceptor.CommitAttempts);
Assert.Equal(1, interceptor.RollbackAttempts);
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CompletedTransaction_ReportsActionAndRollbackExceptions(bool returnsResult)
{
await using DbContext context = await CreateContextAsync();
var unitOfWork = new TestUnitOfWork(context);
var original = new InvalidOperationException("Action failed after the provider completed the transaction.");
AggregateException actual = await Assert.ThrowsAsync<AggregateException>(() =>
ExecuteAsync(unitOfWork, returnsResult, async () =>
{
await context.Database.CurrentTransaction!.CommitAsync();
throw original;
}));
Assert.Same(original, actual.InnerExceptions[0]);
Assert.IsType<InvalidOperationException>(actual.InnerExceptions[1]);
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ActionFailure_RollsBackWritesAndAllowsNextTransaction(bool returnsResult)
{
await using DbContext context = await CreateContextAsync();
var unitOfWork = new TestUnitOfWork(context);
var original = new InvalidOperationException("Action failed.");
Exception actual = await Assert.ThrowsAsync<InvalidOperationException>(() =>
ExecuteAsync(unitOfWork, returnsResult, async () =>
{
await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)");
throw original;
}));
Assert.Same(original, actual);
Assert.Equal(0, await CountRecordsAsync(context));
Assert.Null(unitOfWork.GetCurrentTransaction());
await ExecuteAsync(unitOfWork, returnsResult,
() => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)"));
Assert.Equal(1, await CountRecordsAsync(context));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Success_CommitsWritesAndCreatesFreshTransactionForNextCall(bool returnsResult)
{
var interceptor = new TransactionFailureInterceptor();
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
ITransaction? first = null;
await ExecuteAsync(unitOfWork, returnsResult, async () =>
{
first = unitOfWork.GetCurrentTransaction();
await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)");
});
Assert.Null(unitOfWork.GetCurrentTransaction());
Assert.Null(context.Database.CurrentTransaction);
await ExecuteAsync(unitOfWork, returnsResult, async () =>
{
Assert.NotSame(first, unitOfWork.GetCurrentTransaction());
await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)");
});
Assert.Equal(2, await CountRecordsAsync(context));
Assert.Equal(2, interceptor.CommitAttempts);
Assert.Equal(0, interceptor.RollbackAttempts);
Assert.Null(unitOfWork.GetCurrentTransaction());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CallerOwnedTransaction_SuccessLeavesLifecycleToCaller(bool returnsResult)
{
var interceptor = new TransactionFailureInterceptor();
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
await using ITransaction transaction = await unitOfWork.BeginTransactionAsync();
await ExecuteAsync(unitOfWork, returnsResult,
() => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)"));
Assert.Same(transaction, unitOfWork.GetCurrentTransaction());
Assert.NotNull(context.Database.CurrentTransaction);
Assert.Equal(0, interceptor.CommitAttempts);
Assert.Equal(0, interceptor.RollbackAttempts);
await transaction.RollbackAsync();
Assert.Equal(0, await CountRecordsAsync(context));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CallerOwnedTransaction_FailureLeavesLifecycleToCaller(bool returnsResult)
{
var interceptor = new TransactionFailureInterceptor();
await using DbContext context = await CreateContextAsync(interceptor);
var unitOfWork = new TestUnitOfWork(context);
await using ITransaction transaction = await unitOfWork.BeginTransactionAsync();
var original = new InvalidOperationException("Action failed.");
Exception actual = await Assert.ThrowsAsync<InvalidOperationException>(() =>
ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
Assert.Same(original, actual);
Assert.Same(transaction, unitOfWork.GetCurrentTransaction());
Assert.NotNull(context.Database.CurrentTransaction);
Assert.Equal(0, interceptor.CommitAttempts);
Assert.Equal(0, interceptor.RollbackAttempts);
await transaction.RollbackAsync();
}
private static async Task ExecuteAsync(TestUnitOfWork unitOfWork, bool returnsResult, Func<Task> action)
{
if (returnsResult)
{
int result = await unitOfWork.ExecuteInTransactionAsync(async () =>
{
await action();
return 42;
});
Assert.Equal(42, result);
return;
}
await unitOfWork.ExecuteInTransactionAsync(action);
}
private static async Task<DbContext> CreateContextAsync(TransactionFailureInterceptor? interceptor = null)
{
var options = new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:");
if (interceptor is not null)
{
options.AddInterceptors(interceptor);
}
var context = new DbContext(options.Options);
await context.Database.OpenConnectionAsync();
await context.Database.ExecuteSqlRawAsync("CREATE TABLE records (id INTEGER NOT NULL)");
return context;
}
private static Task<int> CountRecordsAsync(DbContext context)
{
return context.Database.SqlQueryRaw<int>("SELECT COUNT(*) AS Value FROM records").SingleAsync();
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<ProjectReference Include="..\HrynCo.DAL.EF\HrynCo.DAL.EF.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
namespace HrynCo.DAL.EF.Tests;
using HrynCo.DAL.EF.Core;
using Microsoft.EntityFrameworkCore;
internal sealed class TestUnitOfWork(DbContext context) : EfUnitOfWork<DbContext>(context);
@@ -0,0 +1,42 @@
namespace HrynCo.DAL.EF.Tests;
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
internal sealed class TransactionFailureInterceptor : DbTransactionInterceptor
{
public Exception? CommitException { get; init; }
public Exception? RollbackException { get; init; }
public int CommitAttempts { get; private set; }
public int RollbackAttempts { get; private set; }
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
CommitAttempts++;
if (CommitException is not null)
{
throw CommitException;
}
return ValueTask.FromResult(result);
}
public override ValueTask<InterceptionResult> TransactionRollingBackAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
RollbackAttempts++;
if (RollbackException is not null)
{
throw RollbackException;
}
return ValueTask.FromResult(result);
}
}