Files
hrynco-ef/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs
T
agrynco 5dd8342bf7 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
2026-10-15 14:13:56 +03:00

214 lines
8.6 KiB
C#

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();
}
}