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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user