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(() => 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(() => 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(() => ExecuteAsync(unitOfWork, returnsResult, async () => { await context.Database.CurrentTransaction!.CommitAsync(); throw original; })); Assert.Same(original, actual.InnerExceptions[0]); Assert.IsType(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(() => 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(() => 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 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 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 CountRecordsAsync(DbContext context) { return context.Database.SqlQueryRaw("SELECT COUNT(*) AS Value FROM records").SingleAsync(); } }