diff --git a/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs b/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs index 6fe5f4a8..6a6ccc97 100644 --- a/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs +++ b/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs @@ -120,11 +120,20 @@ private async Task> LoadHelper(Guid estateId, using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, estateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - MerchantBalanceProjectionState? entity = await context.MerchantBalanceProjectionState.Where(m => m.MerchantId == merchantId).SingleOrDefaultAsync(cancellationToken: cancellationToken); + IQueryable query = context.MerchantBalanceProjectionState.Where(m => m.MerchantId == merchantId); + Result entityResult = await DbQueryHelpers.ExecuteQuerySafeSingleOrDefault(query, + cancellationToken, + $"Error getting merchant balance state for estate {estateId} and merchant {merchantId}"); - if (entity == null) { + if (entityResult.Status == ResultStatus.NotFound) { return Result.Success(new MerchantBalanceState()); } + + if (entityResult.IsFailed) { + return Result.Failure(entityResult.Message); + } + + MerchantBalanceProjectionState entity = entityResult.Data; // We have located a state record so we need to translate to the Model type return Result.Success(new MerchantBalanceState { @@ -159,4 +168,4 @@ private async Task> LoadHelper(Guid estateId, private const String ConnectionStringIdentifier = "EstateReportingReadModel"; #endregion -} \ No newline at end of file +} diff --git a/TransactionProcessor.Tests/RepositoryTests/MerchantBalanceStateRepositoryTests.cs b/TransactionProcessor.Tests/RepositoryTests/MerchantBalanceStateRepositoryTests.cs new file mode 100644 index 00000000..f74bd5a8 --- /dev/null +++ b/TransactionProcessor.Tests/RepositoryTests/MerchantBalanceStateRepositoryTests.cs @@ -0,0 +1,79 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Shared.EntityFramework; +using Shouldly; +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using TransactionProcessor.Database.Contexts; +using TransactionProcessor.ProjectionEngine.Repository; +using Xunit; + +namespace TransactionProcessor.Tests.RepositoryTests; + +public class MerchantBalanceStateRepositoryTests +{ + [Fact] + public async Task Load_ReturnsFailure_WhenQueryThrows() + { + Guid estateId = Guid.NewGuid(); + Guid merchantId = Guid.NewGuid(); + + using SqliteConnection connection = new("DataSource=:memory:"); + await connection.OpenAsync(); + + DbContextOptions setupOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (EstateManagementContext setupContext = new(setupOptions)) + { + await setupContext.Database.EnsureCreatedAsync(); + } + + DbContextOptions failingOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .AddInterceptors(new ThrowingDbCommandInterceptor()) + .Options; + + ServiceProvider serviceProvider = new ServiceCollection() + .AddScoped(_ => new EstateManagementContext(failingOptions)) + .BuildServiceProvider(); + + using IServiceScope scope = serviceProvider.CreateScope(); + + Mock> resolver = new(); + resolver.Setup(r => r.Resolve(It.IsAny(), It.IsAny())) + .Returns(new ResolvedDbContext(scope)); + + MerchantBalanceStateRepository repository = new(resolver.Object); + + var result = await repository.Load(estateId, merchantId, CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + result.Message.ShouldContain("Error getting merchant balance state"); + result.Message.ShouldContain("Simulated transient SQL failure"); + } + + private sealed class ThrowingDbCommandInterceptor : DbCommandInterceptor + { + public override InterceptionResult ReaderExecuting(DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + throw new InvalidOperationException("Simulated transient SQL failure"); + } + + public override ValueTask> ReaderExecutingAsync(DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("Simulated transient SQL failure"); + } + } +} diff --git a/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs b/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs index b0b38247..6f5799f0 100644 --- a/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs +++ b/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs @@ -82,7 +82,9 @@ public RepositoryRegistry() { })); } else { - this.AddDbContext(options => options.UseSqlServer(ConfigurationReader.GetConnectionString("TransactionProcessorReadModel"), retry => { })); + this.AddDbContext(options => options.UseSqlServer(ConfigurationReader.GetConnectionString("TransactionProcessorReadModel"), retry => { + retry.EnableRetryOnFailure(); + })); } } @@ -140,4 +142,4 @@ private void RegisterAggregateCachingFunc() { }); } } -} \ No newline at end of file +}