From c87e01e68f7ca92be58c800a386dfdafb9f3f8f6 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 28 Sep 2026 16:42:11 +0100 Subject: [PATCH 1/2] Increase business logic test coverage --- .codacy.yml | 16 + ...eportingAPI.BusinessLogic.UnitTests.csproj | 2 +- .../ReportingManagerExtensionsTests.cs | 212 +++ .../ReportingManagerReportTests.cs | 1151 +++++++++++++++++ .../RequestHandlerTests.cs | 458 +++++++ .../Queries/Archive/ArchiveCode.cs | 66 - .../QueryTimingInterceptor.cs | 5 + .../ReportingManager.cs | 19 +- 8 files changed, 1855 insertions(+), 74 deletions(-) create mode 100644 .codacy.yml create mode 100644 EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs create mode 100644 EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs create mode 100644 EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs delete mode 100644 EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..ccf59eb --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,16 @@ +exclude_paths: + # Test projects and test infrastructure + - EstateReportingAPI.BusinessLogic.UnitTests/** + - EstateReportingAPI.IntegrationTests/** + - EstateReportingAPI.IntegrationUnitTests/** + - EstateReportingAPI.Tests/** + - EstateReportingAPI.Testing/** + - "**/*Tests/**" + - "**/*Test/**" + + # Build, IDE, coverage, and test-result artifacts + - "**/bin/**" + - "**/obj/**" + - .vs/** + - coverage/** + - TestResults/** diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj b/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj index 65e82f3..9d4b49a 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj +++ b/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj @@ -6,7 +6,7 @@ true enable enable - + None false true diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs new file mode 100644 index 0000000..8fcdc42 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs @@ -0,0 +1,212 @@ +using EstateReportingAPI.BusinessLogic; +using EstateReportingAPI.Models; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using TransactionProcessor.Database.Contexts; +using TransactionProcessor.Database.Entities; +using TransactionProcessor.Database.Entities.Summary; +using Db = TransactionProcessor.Database.Entities; + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class ReportingManagerExtensionsTests +{ + [Fact] + public void TodayTransactionFilters_HandleEmptyAndSelectedValues() + { + IQueryable query = new[] { + new TodayTransaction { MerchantReportingId = 20, OperatorReportingId = 10, ContractProductReportingId = 40 }, + new TodayTransaction { MerchantReportingId = 21, OperatorReportingId = 11, ContractProductReportingId = 41 } + }.AsQueryable(); + + query.ApplyMerchantFilter((List)null!).Count().ShouldBe(2); + query.ApplyMerchantFilter([]).Count().ShouldBe(2); + query.ApplyMerchantFilter(20).Single().MerchantReportingId.ShouldBe(20); + query.ApplyProductFilter([40]).Single().ContractProductReportingId.ShouldBe(40); + query.ApplyProductFilter([]).Count().ShouldBe(2); + query.ApplyOperatorFilter([10]).Single().OperatorReportingId.ShouldBe(10); + query.ApplyOperatorFilter((List)null!).Count().ShouldBe(2); + query.ApplyOperatorFilter(11).Single().OperatorReportingId.ShouldBe(11); + query.ApplyOperatorFilter(0).Count().ShouldBe(2); + } + + [Fact] + public void TransactionHistoryFilters_HandleEmptyAndSelectedValues() + { + IQueryable query = new[] { + new TransactionHistory { MerchantReportingId = 20, OperatorReportingId = 10, ContractProductReportingId = 40 }, + new TransactionHistory { MerchantReportingId = 21, OperatorReportingId = 11, ContractProductReportingId = 41 } + }.AsQueryable(); + + query.ApplyMerchantFilter((List)null!).Count().ShouldBe(2); + query.ApplyMerchantFilter([]).Count().ShouldBe(2); + query.ApplyMerchantFilter(20).Single().MerchantReportingId.ShouldBe(20); + query.ApplyProductFilter([40]).Single().ContractProductReportingId.ShouldBe(40); + query.ApplyProductFilter([]).Count().ShouldBe(2); + query.ApplyOperatorFilter([10]).Single().OperatorReportingId.ShouldBe(10); + query.ApplyOperatorFilter((List)null!).Count().ShouldBe(2); + query.ApplyOperatorFilter(11).Single().OperatorReportingId.ShouldBe(11); + query.ApplyOperatorFilter(0).Count().ShouldBe(2); + } + + [Fact] + public void SettlementFilters_HandleZeroAndSelectedValues() + { + var todayRows = new[] { + new DatabaseProjections.TodaySettlementTransactionProjection { Txn = new TodayTransaction { MerchantReportingId = 20, OperatorReportingId = 10 } }, + new DatabaseProjections.TodaySettlementTransactionProjection { Txn = new TodayTransaction { MerchantReportingId = 21, OperatorReportingId = 11 } } + }.AsQueryable(); + var comparisonRows = new[] { + new DatabaseProjections.ComparisonSettlementTransactionProjection { Txn = new TransactionHistory { MerchantReportingId = 20, OperatorReportingId = 10 } }, + new DatabaseProjections.ComparisonSettlementTransactionProjection { Txn = new TransactionHistory { MerchantReportingId = 21, OperatorReportingId = 11 } } + }.AsQueryable(); + + todayRows.ApplyMerchantFilter(0).Count().ShouldBe(2); + todayRows.ApplyMerchantFilter(20).Single().Txn.MerchantReportingId.ShouldBe(20); + todayRows.ApplyOperatorFilter(0).Count().ShouldBe(2); + todayRows.ApplyOperatorFilter(10).Single().Txn.OperatorReportingId.ShouldBe(10); + comparisonRows.ApplyMerchantFilter(0).Count().ShouldBe(2); + comparisonRows.ApplyMerchantFilter(20).Single().Txn.MerchantReportingId.ShouldBe(20); + comparisonRows.ApplyOperatorFilter(0).Count().ShouldBe(2); + comparisonRows.ApplyOperatorFilter(10).Single().Txn.OperatorReportingId.ShouldBe(10); + + new[] { new TodayTransaction { MerchantReportingId = 20 } }.AsQueryable() + .ApplyMerchantFilter(0).Count().ShouldBe(1); + new[] { new TransactionHistory { MerchantReportingId = 20 } }.AsQueryable() + .ApplyMerchantFilter(0).Count().ShouldBe(1); + } + + [Fact] + public void TransactionSearchFiltersApplyAllPredicates() + { + var matching = new DatabaseProjections.TransactionSearchProjection { + Transaction = new Transaction { + TransactionAmount = 10m, + AuthorisationCode = "AUTH", + ResponseCode = "0000", + TransactionNumber = "TX-1" + }, + Merchant = new Db.Merchant { MerchantReportingId = 20 }, + Operator = new Db.Operator { OperatorReportingId = 10 } + }; + var other = new DatabaseProjections.TransactionSearchProjection { + Transaction = new Transaction { + TransactionAmount = 90m, + AuthorisationCode = "OTHER", + ResponseCode = "1000", + TransactionNumber = "TX-2" + }, + Merchant = new Db.Merchant { MerchantReportingId = 21 }, + Operator = new Db.Operator { OperatorReportingId = 11 } + }; + + var request = new TransactionSearchRequest { + Operators = [10], + Merchants = [20], + ValueRange = new ValueRange { StartValue = 5m, EndValue = 15m }, + AuthCode = "AUTH", + ResponseCode = "0000", + TransactionNumber = "TX-1" + }; + + new[] { matching, other }.AsQueryable().ApplyFilters(request).Single().ShouldBe(matching); + new[] { matching, other }.AsQueryable().ApplyFilters(new TransactionSearchRequest()).Count().ShouldBe(2); + } + + [Fact] + public void PaginationAndSortingCoverAllDirections() + { + var records = new[] { + SearchProjection("Merchant B", "Operator A", 20m), + SearchProjection("Merchant A", "Operator B", 10m), + SearchProjection("Merchant C", "Operator C", 30m) + }.AsQueryable(); + + records.ApplyPagination(new PagingRequest(2, 1)).Single().Transaction.TransactionAmount.ShouldBe(10m); + records.ApplyPagination(new PagingRequest(1, 2)).Count().ShouldBe(2); + + records.ApplySorting(null!).Count().ShouldBe(3); + records.ApplySorting(new SortingRequest(SortField.MerchantName, EstateReportingAPI.Models.SortDirection.Ascending)).First().Merchant.Name.ShouldBe("Merchant A"); + records.ApplySorting(new SortingRequest(SortField.MerchantName, EstateReportingAPI.Models.SortDirection.Descending)).First().Merchant.Name.ShouldBe("Merchant C"); + records.ApplySorting(new SortingRequest(SortField.OperatorName, EstateReportingAPI.Models.SortDirection.Ascending)).First().Operator.Name.ShouldBe("Operator A"); + records.ApplySorting(new SortingRequest(SortField.OperatorName, EstateReportingAPI.Models.SortDirection.Descending)).First().Operator.Name.ShouldBe("Operator C"); + records.ApplySorting(new SortingRequest(SortField.TransactionAmount, EstateReportingAPI.Models.SortDirection.Ascending)).First().Transaction.TransactionAmount.ShouldBe(10m); + records.ApplySorting(new SortingRequest(SortField.TransactionAmount, EstateReportingAPI.Models.SortDirection.Descending)).First().Transaction.TransactionAmount.ShouldBe(30m); + records.ApplySorting(new SortingRequest((SortField)999, (EstateReportingAPI.Models.SortDirection)999)).ShouldBe(records); + } + + [Fact] + public void SettlementFiltersAndFeeGroupingsFilterAndAggregateRows() + { + using EstateManagementContext context = CreateContext(); + Guid merchantId = Guid.NewGuid(); + Guid operatorId = Guid.NewGuid(); + Guid contractId = Guid.NewGuid(); + Guid productId = Guid.NewGuid(); + context.Merchants.Add(new Db.Merchant { MerchantId = merchantId, MerchantReportingId = 20, Name = "Merchant 1" }); + context.Operators.Add(new Db.Operator { OperatorId = operatorId, OperatorReportingId = 10, Name = "Operator 1" }); + context.Contracts.Add(new Db.Contract { ContractId = contractId, OperatorId = operatorId, Description = "Contract 1" }); + context.ContractProducts.Add(new Db.ContractProduct { ContractProductId = productId, ContractId = contractId, ContractProductReportingId = 40, ProductName = "Product 1", DisplayText = "Product 1" }); + context.SaveChanges(); + + Guid transactionId = Guid.NewGuid(); + context.Transactions.Add(new Transaction { + TransactionId = transactionId, + MerchantId = merchantId, + OperatorId = operatorId, + ContractId = contractId, + ContractProductId = productId, + TransactionType = "Sale", + TransactionDate = new DateTime(2026, 9, 1), + TransactionDateTime = new DateTime(2026, 9, 1), + TransactionTime = TimeSpan.Zero, + TransactionAmount = 10m, + IsAuthorised = true, + IsCompleted = true, + ResponseCode = "0000", + TransactionNumber = "TX-1" + }); + context.MerchantSettlementFees.Add(new MerchantSettlementFee { + MerchantId = merchantId, + TransactionId = transactionId, + CalculatedValue = 1.5m, + FeeValue = 1.5m + }); + context.SaveChanges(); + + var feeRows = from t in context.Transactions + join f in context.MerchantSettlementFees on t.TransactionId equals f.TransactionId + select new DatabaseProjections.FeeTransactionProjection { Txn = t, Fee = f }; + + feeRows.ApplyMerchantFilter(context, [20]).Single().ShouldNotBeNull(); + feeRows.ApplyOperatorFilter(context, [10]).Single().ShouldNotBeNull(); + feeRows.ApplyProductFilter(context, [40]).Single().ShouldNotBeNull(); + feeRows.ApplyMerchantFilter(context, []).Count().ShouldBe(1); + feeRows.ApplyOperatorFilter(context, []).Count().ShouldBe(1); + feeRows.ApplyProductFilter(context, []).Count().ShouldBe(1); + + feeRows.ApplyMerchantGrouping(context).Single().ShouldSatisfyAllConditions( + row => row.DimensionName.ShouldBe("Merchant 1"), + row => row.FeesValue.ShouldBe(1.5m), + row => row.FeesCount.ShouldBe(1)); + feeRows.ApplyOperatorGrouping(context).Single().DimensionName.ShouldBe("Operator 1"); + feeRows.ApplyProductGrouping(context).Single().DimensionName.ShouldBe("Operator 1 - Product 1"); + } + + private static EstateManagementContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"extension-tests-{Guid.NewGuid()}") + .Options; + return new EstateManagementContext(options); + } + + private static DatabaseProjections.TransactionSearchProjection SearchProjection(string merchantName, string operatorName, decimal amount) + { + return new DatabaseProjections.TransactionSearchProjection { + Transaction = new Transaction { TransactionAmount = amount }, + Merchant = new Db.Merchant { Name = merchantName }, + Operator = new Db.Operator { Name = operatorName } + }; + } +} diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs new file mode 100644 index 0000000..964f5e1 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs @@ -0,0 +1,1151 @@ +using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.Models; +using Imposter.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Shared.EntityFramework; +using TransactionProcessor.Database.Contexts; +using Db = TransactionProcessor.Database.Entities; +using BalanceState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState; + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class ReportingManagerReportTests +{ + private static readonly Guid EstateId = Guid.Parse("F64241E7-F778-4F77-8A64-099CB51BF4CE"); + + [Fact] + public async Task GetCalendarComparisonDates_ReturnsCalendarRows() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddCalendar(new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.Manager.GetCalendarComparisonDates( + new CalendarQueries.GetComparisonDatesQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(1); + result.Data[0].Date.ShouldBe(new DateTime(2026, 9, 1)); + } + + [Fact] + public async Task GetCalendarDates_ReturnsCalendarRows() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddCalendar(new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.Manager.GetCalendarDates( + new CalendarQueries.GetAllDatesQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(1); + } + + [Fact] + public async Task GetContract_ReturnsContractWithProducts() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddContractFees(); + await database.SaveAsync(); + + var result = await database.Manager.GetContract( + new ContractQueries.GetContractQuery(EstateId, database.ContractId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ContractId.ShouldBe(database.ContractId); + result.Data.Products.Count.ShouldBe(1); + result.Data.Products[0].TransactionFees.Count.ShouldBe(2); + result.Data.Products[0].TransactionFees.Select(fee => fee.Description).ShouldContain("Enabled fee"); + result.Data.Products[0].TransactionFees.Select(fee => fee.Description).ShouldContain("Disabled fee"); + } + + [Fact] + public async Task GetRecentContracts_ReturnsRecentContracts() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetRecentContracts( + new ContractQueries.GetRecentContractsQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(1); + result.Data[0].ContractId.ShouldBe(database.ContractId); + } + + [Fact] + public async Task GetEstate_ReturnsEstateAndRelatedData() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddEstateUser(); + await database.SaveAsync(); + + var result = await database.Manager.GetEstate( + new EstateQueries.GetEstateQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.EstateId.ShouldBe(EstateId); + result.Data.Merchants.Count.ShouldBe(2); + result.Data.Contracts.Count.ShouldBe(1); + result.Data.Users.Count.ShouldBe(1); + } + + [Fact] + public async Task GetEstateOperators_ReturnsEstateOperators() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddEstateOperator(); + await database.SaveAsync(); + + var result = await database.Manager.GetEstateOperators( + new EstateQueries.GetEstateOperatorsQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(1); + result.Data[0].OperatorId.ShouldBe(database.OperatorId); + } + + [Fact] + public async Task GetRecentMerchants_ReturnsMerchantsWithBalances() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddBalance(database.MerchantId, 10m); + database.AddBalance(database.SecondMerchantId, 20m); + await database.SaveAsync(); + + var result = await database.Manager.GetRecentMerchants( + new MerchantQueries.GetRecentMerchantsQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(2); + result.Data[0].Balance.ShouldBe(10m); + } + + [Fact] + public async Task GetMerchantsTransactionKpis_ReturnsKpis() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddBalance(database.MerchantId, 10m); + await database.SaveAsync(); + + var result = await database.Manager.GetMerchantsTransactionKpis( + new MerchantQueries.GetTransactionKpisQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.MerchantsWithNoSaleInLast7Days.ShouldBe(1); + } + + [Fact] + public async Task GetOperators_ReturnsOperators() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetOperators( + new OperatorQueries.GetOperatorsQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(2); + } + + [Fact] + public async Task GetOperator_ReturnsOperator() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetOperator( + new OperatorQueries.GetOperatorQuery(EstateId, database.OperatorId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.OperatorId.ShouldBe(database.OperatorId); + } + + [Fact] + public async Task GetFileImportLogList_WhenNoLogs_ReturnsEmptyList() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetFileImportLogList( + new FileImportLogQueries.GetFileImportLogListQuery(EstateId, null, DateTime.Today.AddDays(-1), DateTime.Today), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ShouldBeEmpty(); + } + + [Fact] + public async Task GetFileImportLogList_WithFileDataBuildsHierarchicalResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + Guid logId = database.AddFileImportData(); + await database.SaveAsync(); + + var result = await database.Manager.GetFileImportLogList( + new FileImportLogQueries.GetFileImportLogListQuery(EstateId, null, DateTime.Today.AddDays(-1), DateTime.Today), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(1); + result.Data[0].FileImportLogId.ShouldBe(logId); + result.Data[0].FileDetailsList.Count.ShouldBe(1); + result.Data[0].FileDetailsList[0].FileLines.Count.ShouldBe(1); + } + + [Fact] + public async Task GetFileImportLog_WhenLogDoesNotExist_ReturnsNotFound() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetFileImportLog( + new FileImportLogQueries.GetFileImportLogQuery(EstateId, null, Guid.NewGuid()), CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + } + + [Fact] + public async Task GetFileImportLog_WithFileDataBuildsHierarchicalResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + Guid logId = database.AddFileImportData(); + await database.SaveAsync(); + + var result = await database.Manager.GetFileImportLog( + new FileImportLogQueries.GetFileImportLogQuery(EstateId, null, logId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.FileImportLogId.ShouldBe(logId); + result.Data.FileDetailsList.Count.ShouldBe(1); + result.Data.FileDetailsList[0].FileLines.Count.ShouldBe(1); + } + + [Fact] + public async Task GetFileProfileConfigurationList_WhenNoConfigurations_ReturnsEmptyList() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetFileProfileConfigurationList( + new FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery(EstateId), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ShouldBeEmpty(); + } + + [Fact] + public async Task GetTransactionSummaryByMerchantReport_AverageUsesTransactionCount() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1)); + database.AddSale(database.MerchantId, database.SecondOperatorId, 30m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionSummaryByMerchantReport( + new TransactionQueries.TransactionSummaryByMerchantQuery( + EstateId, + new TransactionSummaryByMerchantRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Merchants.Single().AverageValue.ShouldBe(20m); + result.Data.Summary.AverageValue.ShouldBe(20m); + } + + [Fact] + public async Task GetTransactionSummaryByOperatorReport_SeparatesAuthorisedAndDeclinedTotals() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1), authorised: true); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1), authorised: false); + database.AddSale(database.SecondMerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1), authorised: true); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionSummaryByOperatorReport( + new TransactionQueries.TransactionSummaryByOperatorQuery( + EstateId, + new TransactionSummaryByOperatorRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var summary = result.Data.Operators.Single(); + summary.TotalCount.ShouldBe(3); + summary.TotalValue.ShouldBe(60m); + summary.AuthorisedCount.ShouldBe(2); + summary.DeclinedCount.ShouldBe(1); + summary.AuthorisedPercentage.ShouldBe(2m / 3m); + summary.AverageValue.ShouldBe(20m); + } + + [Fact] + public async Task GetTransactionDetailReport_AppliesMerchantProductAndOperatorFilters() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionDetailReport( + new TransactionQueries.TransactionDetailReportQuery( + EstateId, + new TransactionDetailReportRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + Merchants = [20], + Products = [40], + Operators = [10] + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Summary.TransactionCount.ShouldBe(1); + result.Data.Summary.TotalValue.ShouldBe(10m); + } + + [Fact] + public async Task GetTransactionSummaryByOperatorReport_AppliesMerchantAndOperatorFilters() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionSummaryByOperatorReport( + new TransactionQueries.TransactionSummaryByOperatorQuery( + EstateId, + new TransactionSummaryByOperatorRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + Merchants = [20], + Operators = [10] + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Operators.Single().TotalValue.ShouldBe(10m); + } + + [Fact] + public async Task GetTransactionSummaryByMerchantReport_WhenNoSales_ReturnsZeroResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetTransactionSummaryByMerchantReport( + new TransactionQueries.TransactionSummaryByMerchantQuery( + EstateId, + new TransactionSummaryByMerchantRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Merchants.ShouldBeEmpty(); + result.Data.Summary.TotalCount.ShouldBe(0); + result.Data.Summary.TotalValue.ShouldBe(0m); + } + + [Fact] + public async Task GetTransactionMixSummary_WhenDateRangeIsInvalid_ReturnsFailure() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetTransactionMixSummary( + new TransactionQueries.TransactionMixSummaryQuery( + EstateId, + new TransactionMixSummaryRequest { + StartDate = new DateTime(2026, 9, 2), + EndDate = new DateTime(2026, 9, 1), + Breakdown = TransactionMixBreakdown.Product, + Measure = TransactionMixMeasure.Count + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + } + + [Fact] + public async Task GetTransactionMixSummary_WhenBreakdownIsUnsupported_ReturnsFailure() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetTransactionMixSummary( + new TransactionQueries.TransactionMixSummaryQuery( + EstateId, + new TransactionMixSummaryRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + Breakdown = (TransactionMixBreakdown)999, + Measure = TransactionMixMeasure.Count + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + } + + [Fact] + public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndReturnsEmptyResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetRecentActivityReceiptReport( + new TransactionQueries.GetRecentActivityReceiptReportQuery( + EstateId, + new GetRecentActivityReceiptReportRequest { + ReportDate = new DateTime(2026, 9, 1), + PageNumber = 0, + PageSize = 0 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.PageNumber.ShouldBe(1); + result.Data.PageSize.ShouldBe(10); + result.Data.TotalCount.ShouldBe(0); + result.Data.Items.ShouldBeEmpty(); + } + + [Fact] + public async Task GetRecentActivityReceiptReport_PaginatesOrderedRowsAndPreservesTotalCount() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0)); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0)); + database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1, 11, 0, 0)); + await database.SaveAsync(); + + var result = await database.Manager.GetRecentActivityReceiptReport( + new TransactionQueries.GetRecentActivityReceiptReportQuery( + EstateId, + new GetRecentActivityReceiptReportRequest { + ReportDate = new DateTime(2026, 9, 1), + PageNumber = 2, + PageSize = 2 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TotalCount.ShouldBe(3); + result.Data.Items.Count.ShouldBe(1); + result.Data.Items.Single().Amount.ShouldBe(10m); + } + + [Fact] + public async Task GetTransactionDetailReport_IncludesFeesAndZeroFeeForUnsettledRows() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + Guid transactionWithFee = database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1)); + database.AddSettlementFee(transactionWithFee, 0.5m); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionDetailReport( + new TransactionQueries.TransactionDetailReportQuery( + EstateId, + new TransactionDetailReportRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Summary.TransactionCount.ShouldBe(2); + result.Data.Summary.TotalValue.ShouldBe(30m); + result.Data.Summary.TotalFees.ShouldBe(0.5m); + result.Data.Transactions.Sum(t => t.TotalFees).ShouldBe(0.5m); + result.Data.Transactions.ShouldContain(t => t.TotalFees == 0m); + } + + [Fact] + public async Task GetTransactionDetailReport_IncludesBothDateBoundaries() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 2)); + database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 8, 31)); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionDetailReport( + new TransactionQueries.TransactionDetailReportQuery( + EstateId, + new TransactionDetailReportRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 2) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Summary.TransactionCount.ShouldBe(2); + result.Data.Summary.TotalValue.ShouldBe(30m); + } + + [Fact] + public async Task GetTodaysSettlement_WhenNoSettlementRows_ReturnsZeroTotals() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetTodaysSettlement( + new SettlementQueries.TodaysSettlementQuery(EstateId, DateTime.Today.AddDays(-1)), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TodaysSettlementCount.ShouldBe(0); + result.Data.TodaysPendingSettlementCount.ShouldBe(0); + result.Data.ComparisonSettlementCount.ShouldBe(0); + result.Data.ComparisonPendingSettlementCount.ShouldBe(0); + result.Data.TodaysSettlementValue.ShouldBe(0m); + result.Data.ComparisonSettlementValue.ShouldBe(0m); + } + + [Fact] + public async Task GetTodaysSales_WhenReadModelsAreEmpty_ReturnsZeroTotals() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + DateTime comparisonDate = DateTime.Today.AddDays(-1); + + var result = await database.Manager.GetTodaysSales( + new TransactionQueries.TodaysSalesQuery(EstateId, 20, 10, comparisonDate), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TodaysSalesCount.ShouldBe(0); + result.Data.TodaysSalesValue.ShouldBe(0m); + result.Data.TodaysAverageSalesValue.ShouldBe(0m); + result.Data.ComparisonSalesCount.ShouldBe(0); + result.Data.ComparisonSalesValue.ShouldBe(0m); + result.Data.ComparisonAverageSalesValue.ShouldBe(0m); + } + + [Fact] + public async Task GetTodaysFailedSales_WhenReadModelsAreEmpty_ReturnsZeroTotals() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + DateTime comparisonDate = DateTime.Today.AddDays(-1); + + var result = await database.Manager.GetTodaysFailedSales( + new TransactionQueries.TodaysFailedSales(EstateId, comparisonDate, "1000"), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TodaysSalesCount.ShouldBe(0); + result.Data.TodaysSalesValue.ShouldBe(0m); + result.Data.TodaysAverageSalesValue.ShouldBe(0m); + result.Data.ComparisonSalesCount.ShouldBe(0); + result.Data.ComparisonSalesValue.ShouldBe(0m); + result.Data.ComparisonAverageSalesValue.ShouldBe(0m); + } + + [Fact] + public async Task GetMerchantDailyPerformanceSummary_ReturnsMetricsTopProductAndRecentSales() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0), authorised: true); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0), authorised: false); + database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 2, 11, 0, 0), authorised: true); + await database.SaveAsync(); + + var result = await database.Manager.GetMerchantDailyPerformanceSummary( + new TransactionQueries.MerchantDailyPerformanceSummaryQuery( + EstateId, + new MerchantDailyPerformanceSummaryRequest { + MerchantReportingId = 20, + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 2) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Metrics.Count.ShouldBe(9); + result.Data.Metrics.Single(m => m.Title == "Total Sales Count").Value.ShouldBe(3); + result.Data.Metrics.Single(m => m.Title == "Total Sales Value").Value.ShouldBe(60m); + result.Data.Metrics.Single(m => m.Title == "Successful Sales Count").Value.ShouldBe(2); + result.Data.Metrics.Single(m => m.Title == "Failed Sales Count").Value.ShouldBe(1); + result.Data.Metrics.Single(m => m.Title == "Average Sales Count").Value.ShouldBe(1.5m); + result.Data.Metrics.Single(m => m.Title == "Average Sales Value").Value.ShouldBe(20m); + result.Data.Metrics.Single(m => m.Title == "Top Product Sales Count").Value.ShouldBe(3); + result.Data.DrillDownTransactions.Count.ShouldBe(3); + result.Data.DrillDownTransactions.First().Amount.ShouldBe(30m); + result.Data.DrillDownTransactions.First().Status.ShouldBe("Successful"); + } + + [Fact] + public async Task GetMerchantDailyPerformanceSummary_WhenNoTransactions_ReturnsBaseMetrics() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetMerchantDailyPerformanceSummary( + new TransactionQueries.MerchantDailyPerformanceSummaryQuery( + EstateId, + new MerchantDailyPerformanceSummaryRequest { + MerchantReportingId = 20, + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 2) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Metrics.Count.ShouldBe(8); + result.Data.Metrics.ShouldAllBe(metric => metric.Value == 0); + result.Data.DrillDownTransactions.ShouldBeEmpty(); + } + + [Fact] + public async Task GetMerchants_AppliesAllQueryFilters() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddBalance(database.MerchantId, 10m); + database.AddBalance(database.SecondMerchantId, 20m); + await database.SaveAsync(); + + var result = await database.Manager.GetMerchants( + new MerchantQueries.GetMerchantsQuery( + EstateId, + new MerchantQueries.MerchantQueryOptions("Merchant 1", "M1", 2, "Region", "P1")), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Count.ShouldBe(1); + result.Data.Single().Name.ShouldBe("Merchant 1"); + } + + [Fact] + public async Task GetMerchant_MapsAddressContactOpeningHoursAndBalance() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddBalance(database.MerchantId, 123.45m); + database.AddMerchantRelatedData(); + await database.SaveAsync(); + + var result = await database.Manager.GetMerchant( + new MerchantQueries.GetMerchantQuery(EstateId, database.MerchantId), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Name.ShouldBe("Merchant 1"); + result.Data.Reference.ShouldBe("M1"); + result.Data.Balance.ShouldBe(123.45m); + result.Data.AddressLine1.ShouldBe("Address 1"); + result.Data.ContactName.ShouldBe("Contact 1"); + result.Data.ContactEmail.ShouldBe("one@example.test"); + result.Data.SettlementSchedule.ShouldBe(2); + } + + [Fact] + public async Task GetProductPerformanceReport_WithSalesMapsDetailsAndSummary() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.Manager.GetProductPerformanceReport( + new TransactionQueries.ProductPerformanceQuery(EstateId, new DateTime(2026, 9, 1), new DateTime(2026, 9, 1)), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ProductDetails.Count.ShouldBe(1); + result.Data.ProductDetails.Single().ProductName.ShouldBe("Product 1"); + result.Data.ProductDetails.Single().TransactionCount.ShouldBe(2); + result.Data.ProductDetails.Single().TransactionValue.ShouldBe(40m); + result.Data.Summary.TotalCount.ShouldBe(2); + result.Data.Summary.TotalValue.ShouldBe(40m); + result.Data.Summary.AveragePerProduct.ShouldBe(40m); + result.Data.Summary.TotalProducts.ShouldBe(1); + } + + [Fact] + public async Task GetMerchantOperators_MapsConfiguredRows() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddMerchantRelatedData(); + await database.SaveAsync(); + + var operators = await database.Manager.GetMerchantOperators( + new MerchantQueries.GetMerchantOperatorsQuery(EstateId, database.MerchantId), CancellationToken.None); + + operators.IsSuccess.ShouldBeTrue(); + operators.Data.Single().OperatorName.ShouldBe("Merchant Operator"); + } + + [Fact] + public async Task GetMerchantContracts_MapsConfiguredRows() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddMerchantRelatedData(); + await database.SaveAsync(); + + var contracts = await database.Manager.GetMerchantContracts( + new MerchantQueries.GetMerchantContractsQuery(EstateId, database.MerchantId), CancellationToken.None); + + contracts.IsSuccess.ShouldBeTrue(); + contracts.Data.Single().ContractName.ShouldBe("Contract 1"); + contracts.Data.Single().ContractProducts.Single().ProductName.ShouldBe("Product 1"); + } + + [Fact] + public async Task GetMerchantDevices_MapsConfiguredRows() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddMerchantRelatedData(); + await database.SaveAsync(); + + var devices = await database.Manager.GetMerchantDevices( + new MerchantQueries.GetMerchantDevicesQuery(EstateId, database.MerchantId), CancellationToken.None); + + devices.IsSuccess.ShouldBeTrue(); + devices.Data.Single().DeviceIdentifier.ShouldBe("device-1"); + } + + [Fact] + public async Task GetMerchantOpeningHours_MapsAllDays() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddMerchantRelatedData(); + await database.SaveAsync(); + + var openingHours = await database.Manager.GetMerchantOpeningHours( + new MerchantQueries.GetMerchantOpeningHoursQuery(EstateId, database.MerchantId), CancellationToken.None); + + openingHours.IsSuccess.ShouldBeTrue(); + openingHours.Data.Count.ShouldBe(7); + openingHours.Data.Single(x => x.DayOfWeek == DayOfWeek.Monday).OpeningTime.ShouldBe("08:00"); + } + + [Fact] + public async Task GetMerchantSchedule_ParsesClosedDays() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddMerchantRelatedData(); + await database.SaveAsync(); + + var schedule = await database.Manager.GetMerchantSchedule( + new MerchantQueries.GetMerchantScheduleQuery(EstateId, database.MerchantId, 2026), CancellationToken.None); + + schedule.IsSuccess.ShouldBeTrue(); + schedule.Data.Year.ShouldBe(2026); + schedule.Data.Months.Single().ClosedDays.ShouldBe([1, 15]); + } + + [Fact] + public async Task GetTodaysSalesByHour_WhenReadModelsAreEmpty_ReturnsEmptyResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetTodaysSalesByHour( + new TransactionQueries.TodaysSalesByHour(EstateId, DateTime.Today.AddDays(-1)), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContracts_MapsProductsAndOnlyEnabledFees() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddContractFees(); + await database.SaveAsync(); + + var result = await database.Manager.GetContracts( + new ContractQueries.GetContractsQuery(EstateId), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var contract = result.Data.Single(); + contract.OperatorName.ShouldBe("Operator 1"); + contract.Products.Single().ProductName.ShouldBe("Product 1"); + contract.Products.Single().TransactionFees.Count.ShouldBe(1); + contract.Products.Single().TransactionFees.Single().Description.ShouldBe("Enabled fee"); + } + + [Fact] + public async Task GetTransactionMixSummary_GroupsByStatusAndOrdersByValue() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1), authorised: true); + database.AddSale(database.MerchantId, database.OperatorId, 25m, new DateTime(2026, 9, 1), authorised: false); + await database.SaveAsync(); + + var result = await database.Manager.GetTransactionMixSummary( + new TransactionQueries.TransactionMixSummaryQuery( + EstateId, + new TransactionMixSummaryRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + Breakdown = TransactionMixBreakdown.Status, + Measure = TransactionMixMeasure.Value, + TopN = 1 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TotalCount.ShouldBe(2); + result.Data.TotalValue.ShouldBe(35m); + result.Data.Groups.Count.ShouldBe(1); + result.Data.Groups.Single().GroupName.ShouldBe("Declined"); + result.Data.Groups.Single().TransactionValue.ShouldBe(25m); + result.Data.Transactions.Count.ShouldBe(2); + result.Data.Transactions.First().Value.ShouldBe(25m); + } + + [Fact] + public async Task GetRecentActivityReceiptReport_AppliesMerchantAndSearchFilters() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0)); + database.AddSale(database.SecondMerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0)); + await database.SaveAsync(); + + var result = await database.Manager.GetRecentActivityReceiptReport( + new TransactionQueries.GetRecentActivityReceiptReportQuery( + EstateId, + new GetRecentActivityReceiptReportRequest { + ReportDate = new DateTime(2026, 9, 1), + MerchantReportingId = 20, + SearchText = "Product 1" + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TotalCount.ShouldBe(1); + result.Data.Items.Single().Amount.ShouldBe(10m); + } + + [Fact] + public async Task GetProductPerformanceReport_WhenNoSales_ReturnsZeroSummary() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetProductPerformanceReport( + new TransactionQueries.ProductPerformanceQuery(EstateId, new DateTime(2026, 9, 1), new DateTime(2026, 9, 1)), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ProductDetails.ShouldBeEmpty(); + result.Data.Summary.TotalCount.ShouldBe(0); + result.Data.Summary.TotalValue.ShouldBe(0m); + } + + [Fact] + public async Task GetMerchants_WhenBalanceIsMissing_ReturnsFailureResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.Manager.GetMerchants( + new MerchantQueries.GetMerchantsQuery(EstateId, new MerchantQueries.MerchantQueryOptions("", "", null, "", "")), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + } + + [Fact] + public async Task GetMerchants_WhenBalanceIsDuplicated_ReturnsFailureResult() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddBalance(database.MerchantId, 10m); + database.AddBalance(database.MerchantId, 20m); + await database.SaveAsync(); + + var result = await database.Manager.GetMerchants( + new MerchantQueries.GetMerchantsQuery(EstateId, new MerchantQueries.MerchantQueryOptions("", "", null, "", "")), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + } + + private sealed class TestDatabase : IAsyncDisposable + { + private readonly ServiceProvider services; + private readonly EstateManagementContext seedContext; + + private TestDatabase(ServiceProvider services, EstateManagementContext seedContext, ReportingManager manager, + Guid merchantId, Guid secondMerchantId, Guid operatorId, Guid secondOperatorId, Guid contractId, Guid productId) + { + this.services = services; + this.seedContext = seedContext; + Manager = manager; + MerchantId = merchantId; + SecondMerchantId = secondMerchantId; + OperatorId = operatorId; + SecondOperatorId = secondOperatorId; + ContractId = contractId; + ProductId = productId; + } + + public ReportingManager Manager { get; } + public Guid MerchantId { get; } + public Guid SecondMerchantId { get; } + public Guid OperatorId { get; } + public Guid SecondOperatorId { get; } + public Guid ContractId { get; } + public Guid ProductId { get; } + + public static async Task CreateAsync() + { + string databaseName = $"reporting-tests-{Guid.NewGuid()}"; + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + var seedContext = new EstateManagementContext(options); + + Guid estateId = EstateId; + Guid merchantId = Guid.NewGuid(); + Guid secondMerchantId = Guid.NewGuid(); + Guid operatorId = Guid.NewGuid(); + Guid secondOperatorId = Guid.NewGuid(); + Guid contractId = Guid.NewGuid(); + Guid productId = Guid.NewGuid(); + + seedContext.Estates.Add(new Db.Estate { EstateId = estateId, EstateReportingId = 1, Name = "Test Estate" }); + seedContext.Operators.AddRange( + new Db.Operator { OperatorId = operatorId, EstateId = estateId, OperatorReportingId = 10, Name = "Operator 1" }, + new Db.Operator { OperatorId = secondOperatorId, EstateId = estateId, OperatorReportingId = 11, Name = "Operator 2" }); + seedContext.Merchants.Add(new Db.Merchant { MerchantId = merchantId, EstateId = estateId, MerchantReportingId = 20, Name = "Merchant 1", Reference = "M1", SettlementSchedule = 2 }); + seedContext.Merchants.Add(new Db.Merchant { MerchantId = secondMerchantId, EstateId = estateId, MerchantReportingId = 21, Name = "Merchant 2", Reference = "M2", SettlementSchedule = 3 }); + seedContext.MerchantAddresses.AddRange( + new Db.MerchantAddress { AddressId = Guid.NewGuid(), MerchantId = merchantId, AddressLine1 = "Address 1", Region = "Region", PostalCode = "P1" }, + new Db.MerchantAddress { AddressId = Guid.NewGuid(), MerchantId = secondMerchantId, AddressLine1 = "Address 2", Region = "Region", PostalCode = "P2" }); + seedContext.MerchantContacts.AddRange( + new Db.MerchantContact { ContactId = Guid.NewGuid(), MerchantId = merchantId, Name = "Contact 1", EmailAddress = "one@example.test" }, + new Db.MerchantContact { ContactId = Guid.NewGuid(), MerchantId = secondMerchantId, Name = "Contact 2", EmailAddress = "two@example.test" }); + seedContext.Contracts.Add(new Db.Contract { ContractId = contractId, EstateId = estateId, OperatorId = operatorId, ContractReportingId = 30, Description = "Contract 1" }); + seedContext.ContractProducts.Add(new Db.ContractProduct { + ContractProductId = productId, + ContractId = contractId, + ContractProductReportingId = 40, + ProductName = "Product 1", + DisplayText = "Product 1", + ProductType = 1, + Value = 1m + }); + await seedContext.SaveChangesAsync(); + + var services = new ServiceCollection() + .AddDbContext(builder => builder.UseInMemoryDatabase(databaseName)) + .BuildServiceProvider(); + var resolver = new IDbContextResolverImposter(); + resolver.Resolve(Arg.Any(), Arg.Any()) + .Returns(new ResolvedDbContext(services.CreateScope())); + + return new TestDatabase(services, seedContext, new ReportingManager(resolver.Instance()), merchantId, secondMerchantId, operatorId, + secondOperatorId, contractId, productId); + } + + public Guid AddSale(Guid merchantId, Guid operatorId, decimal amount, DateTime date, bool authorised = true) + { + Guid transactionId = Guid.NewGuid(); + seedContext.Transactions.Add(new Db.Transaction { + TransactionId = transactionId, + MerchantId = merchantId, + OperatorId = operatorId, + ContractId = ContractId, + ContractProductId = ProductId, + TransactionType = "Sale", + TransactionDate = date.Date, + TransactionDateTime = date, + TransactionTime = date.TimeOfDay, + TransactionAmount = amount, + IsAuthorised = authorised, + IsCompleted = true, + ResponseCode = authorised ? "0000" : "1000", + TransactionNumber = "0001" + }); + return transactionId; + } + + public void AddContractFees() + { + seedContext.ContractProductTransactionFees.AddRange( + new Db.ContractProductTransactionFee { + ContractProductTransactionFeeId = Guid.NewGuid(), + ContractProductTransactionFeeReportingId = 50, + ContractProductId = ProductId, + Description = "Enabled fee", + Value = 0.5m, + CalculationType = 1, + FeeType = 1, + IsEnabled = true + }, + new Db.ContractProductTransactionFee { + ContractProductTransactionFeeId = Guid.NewGuid(), + ContractProductTransactionFeeReportingId = 51, + ContractProductId = ProductId, + Description = "Disabled fee", + Value = 0.25m, + CalculationType = 1, + FeeType = 1, + IsEnabled = false + }); + } + + public void AddMerchantRelatedData() + { + seedContext.MerchantOperators.Add(new Db.MerchantOperator { + MerchantId = MerchantId, + OperatorId = OperatorId, + MerchantNumber = "merchant-number", + Name = "Merchant Operator", + TerminalNumber = "terminal-1", + IsDeleted = false + }); + seedContext.MerchantContracts.Add(new Db.MerchantContract { + MerchantId = MerchantId, + ContractId = ContractId, + IsDeleted = false + }); + seedContext.MerchantDevices.Add(new Db.MerchantDevice { + MerchantId = MerchantId, + DeviceId = Guid.NewGuid(), + DeviceIdentifier = "device-1", + CreatedDateTime = new DateTime(2026, 9, 1), + IsEnabled = true + }); + seedContext.MerchantOpeningHours.Add(new Db.MerchantOpeningHours { + MerchantId = MerchantId, + MondayOpening = "08:00", + MondayClosing = "17:00", + TuesdayOpening = "08:00", + TuesdayClosing = "17:00", + WednesdayOpening = "08:00", + WednesdayClosing = "17:00", + ThursdayOpening = "08:00", + ThursdayClosing = "17:00", + FridayOpening = "08:00", + FridayClosing = "17:00", + SaturdayOpening = "09:00", + SaturdayClosing = "13:00", + SundayOpening = "Closed", + SundayClosing = "Closed" + }); + Guid scheduleId = Guid.NewGuid(); + seedContext.MerchantSchedules.Add(new Db.MerchantSchedule { + MerchantScheduleId = scheduleId, + EstateId = EstateId, + MerchantId = MerchantId, + Year = 2026 + }); + seedContext.MerchantScheduleMonths.Add(new Db.MerchantScheduleMonth { + MerchantScheduleId = scheduleId, + Month = 9, + ClosedDays = "1,15,invalid" + }); + } + + public void AddBalance(Guid merchantId, decimal balance) + { + seedContext.MerchantBalanceProjectionState.Add(new BalanceState { + EstateId = Guid.NewGuid(), + MerchantId = merchantId, + MerchantName = merchantId == MerchantId ? "Merchant 1" : "Merchant 2", + Balance = balance, + LastSale = new DateTime(2026, 9, 1), + Timestamp = Array.Empty() + }); + } + + public void AddCalendar(DateTime date) + { + seedContext.Calendar.Add(new Db.Calendar { + Date = date, + DayOfWeek = date.DayOfWeek.ToString(), + DayOfWeekShort = date.DayOfWeek.ToString()[..3], + MonthNameLong = date.ToString("MMMM"), + MonthNameShort = date.ToString("MMM"), + MonthNumber = date.Month, + WeekNumberString = "35", + YearWeekNumber = $"{date.Year}35", + Year = date.Year + }); + } + + public void AddEstateOperator() + { + seedContext.EstateOperators.Add(new Db.EstateOperator { + EstateId = EstateId, + OperatorId = OperatorId + }); + } + + public void AddEstateUser() + { + seedContext.EstateSecurityUsers.Add(new Db.EstateSecurityUser { + EstateId = EstateId, + SecurityUserId = Guid.NewGuid(), + EmailAddress = "user@example.test" + }); + } + + public Guid AddFileImportData() + { + Guid logId = Guid.NewGuid(); + Guid fileId = Guid.NewGuid(); + Guid userId = Guid.NewGuid(); + Guid profileId = Guid.NewGuid(); + + seedContext.FileImportLogs.Add(new Db.FileImportLog { + FileImportLogId = logId, + EstateId = EstateId, + ImportLogDate = DateTime.Today, + ImportLogDateTime = DateTime.Today.AddHours(1) + }); + seedContext.EstateSecurityUsers.Add(new Db.EstateSecurityUser { + EstateId = EstateId, + SecurityUserId = userId, + EmailAddress = "uploader@example.test" + }); + seedContext.Files.Add(new Db.File { + FileId = fileId, + FileImportLogId = logId, + FileLocation = "import.csv", + FileProfileId = profileId, + FileReceivedDateTime = DateTime.Today, + UserId = userId, + MerchantId = MerchantId + }); + seedContext.FileLines.Add(new Db.FileLine { + FileId = fileId, + LineNumber = 1, + FileLineData = "line-data", + Status = "OK" + }); + + return logId; + } + + public void AddSettlementFee(Guid transactionId, decimal feeValue) + { + Guid settlementId = Guid.NewGuid(); + seedContext.Settlements.Add(new Db.Settlement { + SettlementId = settlementId, + EstateId = EstateId, + MerchantId = MerchantId, + SettlementDate = new DateTime(2026, 9, 1), + ProcessingStarted = true, + IsCompleted = true, + ProcessingStartedDateTIme = new DateTime(2026, 9, 1) + }); + seedContext.MerchantSettlementFees.Add(new Db.MerchantSettlementFee { + SettlementId = settlementId, + TransactionId = transactionId, + MerchantId = MerchantId, + FeeValue = feeValue, + CalculatedValue = feeValue, + IsSettled = true, + FeeCalculatedDateTime = new DateTime(2026, 9, 1) + }); + } + + public Task SaveAsync() => seedContext.SaveChangesAsync(); + + public async ValueTask DisposeAsync() + { + await seedContext.DisposeAsync(); + await services.DisposeAsync(); + } + } +} diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs new file mode 100644 index 0000000..1202750 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs @@ -0,0 +1,458 @@ +using EstateReportingAPI.BusinessLogic; +using EstateReportingAPI.BusinessLogic.Queries; +using EstateReportingAPI.BusinessLogic.RequestHandlers; +using EstateReportingAPI.Models; +using Imposter.Abstractions; +using Shouldly; +using SimpleResults; + +[assembly: GenerateImposter(typeof(IReportingManager))] + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class RequestHandlerTests +{ + [Fact] + public async Task SettlementRequestHandler_ForwardsRequestAndCancellationToken() + { + var manager = new IReportingManagerImposter(); + var expected = Result.Success(new TodaysSettlement()); + var request = new SettlementQueries.TodaysSettlementQuery(Guid.NewGuid(), DateTime.Today); + var cancellationToken = new CancellationTokenSource().Token; + manager.GetTodaysSettlement(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(expected)); + + var actual = await new SettlementRequestHandler(manager.Instance()).Handle(request, cancellationToken); + + actual.ShouldBe(expected); + } + + [Fact] + public async Task CalendarRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new CalendarQueries.GetYearsQuery(Guid.NewGuid()); + manager.GetCalendarYears(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task CalendarRequestHandler_ForwardsAllDatesRequest() + { + var manager = new IReportingManagerImposter(); + var request = new CalendarQueries.GetAllDatesQuery(Guid.NewGuid()); + manager.GetCalendarDates(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task CalendarRequestHandler_ForwardsComparisonDatesRequest() + { + var manager = new IReportingManagerImposter(); + var request = new CalendarQueries.GetComparisonDatesQuery(Guid.NewGuid()); + manager.GetCalendarComparisonDates(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task CalendarRequestHandler_WhenComparisonDatesFails_ReturnsFailure() + { + var manager = new IReportingManagerImposter(); + var request = new CalendarQueries.GetComparisonDatesQuery(Guid.NewGuid()); + manager.GetCalendarComparisonDates(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>>(Result.Failure("Calendar lookup failed."))); + + var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task ContractRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new ContractQueries.GetContractsQuery(Guid.NewGuid()); + manager.GetContracts(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new ContractRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task ContractRequestHandler_ForwardsRecentContractsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new ContractQueries.GetRecentContractsQuery(Guid.NewGuid()); + manager.GetRecentContracts(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new ContractRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task ContractRequestHandler_ForwardsContractRequest() + { + var manager = new IReportingManagerImposter(); + var request = new ContractQueries.GetContractQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetContract(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new Contract()))); + + var actual = await new ContractRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task EstateRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new EstateQueries.GetEstateQuery(Guid.NewGuid()); + manager.GetEstate(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new Estate()))); + + var actual = await new EstateRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task EstateRequestHandler_ForwardsEstateOperatorsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new EstateQueries.GetEstateOperatorsQuery(Guid.NewGuid()); + manager.GetEstateOperators(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new EstateRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task FileImportLogRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new FileImportLogQueries.GetFileImportLogListQuery(Guid.NewGuid(), null, DateTime.Today, DateTime.Today); + manager.GetFileImportLogList(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new FileImportLogRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task FileImportLogRequestHandler_ForwardsSingleLogRequest() + { + var manager = new IReportingManagerImposter(); + var request = new FileImportLogQueries.GetFileImportLogQuery(Guid.NewGuid(), null, Guid.NewGuid()); + manager.GetFileImportLog(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new FileImportLog()))); + + var actual = await new FileImportLogRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task FileProfileConfigurationRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery(Guid.NewGuid()); + manager.GetFileProfileConfigurationList(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new FileProfileConfigurationRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetMerchant(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new Merchant()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsRecentMerchantsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetRecentMerchantsQuery(Guid.NewGuid()); + manager.GetRecentMerchants(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsTransactionKpisRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetTransactionKpisQuery(Guid.NewGuid()); + manager.GetMerchantsTransactionKpis(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new MerchantKpi()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsMerchantsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantsQuery(Guid.NewGuid(), new MerchantQueries.MerchantQueryOptions("", "", null, "", "")); + manager.GetMerchants(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsMerchantOperatorsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantOperatorsQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetMerchantOperators(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsMerchantContractsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantContractsQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetMerchantContracts(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsMerchantDevicesRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantDevicesQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetMerchantDevices(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsMerchantOpeningHoursRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantOpeningHoursQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetMerchantOpeningHours(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantRequestHandler_ForwardsMerchantScheduleRequest() + { + var manager = new IReportingManagerImposter(); + var request = new MerchantQueries.GetMerchantScheduleQuery(Guid.NewGuid(), Guid.NewGuid(), 2026); + manager.GetMerchantSchedule(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new MerchantScheduleResponse()))); + + var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task OperatorRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new OperatorQueries.GetOperatorsQuery(Guid.NewGuid()); + manager.GetOperators(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new OperatorRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task OperatorRequestHandler_ForwardsOperatorRequest() + { + var manager = new IReportingManagerImposter(); + var request = new OperatorQueries.GetOperatorQuery(Guid.NewGuid(), Guid.NewGuid()); + manager.GetOperator(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new Operator()))); + + var actual = await new OperatorRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.ProductPerformanceQuery(Guid.NewGuid(), DateTime.Today, DateTime.Today); + manager.GetProductPerformanceReport(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new ProductPerformanceResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsFailedSalesRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TodaysFailedSales(Guid.NewGuid(), DateTime.Today, "1000"); + manager.GetTodaysFailedSales(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new TodaysSales()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsTodaysSalesRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TodaysSalesQuery(Guid.NewGuid(), 1, 2, DateTime.Today); + manager.GetTodaysSales(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new TodaysSales()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsTransactionDetailRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TransactionDetailReportQuery(Guid.NewGuid(), new TransactionDetailReportRequest()); + manager.GetTransactionDetailReport(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new TransactionDetailReportResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsSummaryByMerchantRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TransactionSummaryByMerchantQuery(Guid.NewGuid(), new TransactionSummaryByMerchantRequest()); + manager.GetTransactionSummaryByMerchantReport(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new TransactionSummaryByMerchantResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsSummaryByOperatorRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TransactionSummaryByOperatorQuery(Guid.NewGuid(), new TransactionSummaryByOperatorRequest()); + manager.GetTransactionSummaryByOperatorReport(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new TransactionSummaryByOperatorResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsTransactionMixRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TransactionMixSummaryQuery(Guid.NewGuid(), new TransactionMixSummaryRequest()); + manager.GetTransactionMixSummary(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new TransactionMixSummaryResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsRecentActivityRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.GetRecentActivityReceiptReportQuery(Guid.NewGuid(), new GetRecentActivityReceiptReportRequest()); + manager.GetRecentActivityReceiptReport(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new GetRecentActivityReceiptReportResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsSalesByHourRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.TodaysSalesByHour(Guid.NewGuid(), DateTime.Today); + manager.GetTodaysSalesByHour(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new List()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task TransactionRequestHandler_ForwardsMerchantDailyPerformanceRequest() + { + var manager = new IReportingManagerImposter(); + var request = new TransactionQueries.MerchantDailyPerformanceSummaryQuery(Guid.NewGuid(), new MerchantDailyPerformanceSummaryRequest()); + manager.GetMerchantDailyPerformanceSummary(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success(new MerchantDailyPerformanceSummaryResponse()))); + + var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None); + + actual.IsSuccess.ShouldBeTrue(); + } +} diff --git a/EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs b/EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs deleted file mode 100644 index e4d6f2e..0000000 --- a/EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs +++ /dev/null @@ -1,66 +0,0 @@ -using EstateReportingAPI.Models; -using MediatR; -using SimpleResults; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text; - -namespace EstateReportingAPI.BusinessLogic.Queries.Archive -{ - public record MerchantQueries - { - public record GetMerchantsQuery(Guid EstateId) : IRequest>>; - - public record GetByLastSaleQuery(Guid EstateId, DateTime StartDateTime, DateTime EndDateTime) : IRequest>>; - public record GetTopMerchantsBySalesValueQuery(Guid EstateId, Int32 numberOfMerchants) : IRequest>>; - public record GetBottomMerchantsBySalesValueQuery(Guid EstateId, Int32 numberOfMerchants) : IRequest>>; - public record GetMerchantPerformanceQuery(Guid EstateId, DateTime comparisonDate, List merchantReportingIds) : IRequest>; - } - - - [ExcludeFromCodeCoverage] - public record OperatorQueries - { - public record GetOperatorsQuery(Guid EstateId) : IRequest>>; - public record GetOperatorPerformanceQuery(Guid EstateId, DateTime comparisonDate, List operatorReportingIds) : IRequest>; - public record GetTopOperatorsBySalesValueQuery(Guid EstateId, Int32 numberOfOperators) : IRequest>>; - public record GetBottomOperatorsBySalesValueQuery(Guid EstateId, Int32 numberOfOperators) : IRequest>>; - } - - [ExcludeFromCodeCoverage] - public record ProductQueries - { - public record GetProductPerformanceQuery(Guid EstateId, DateTime comparisonDate, List productReportingIds) : IRequest>; - public record GetTopProductsBySalesValueQuery(Guid EstateId, Int32 numberOfProducts) : IRequest>>; - public record GetBottomProductsBySalesValueQuery(Guid EstateId, Int32 numberOfProducts) : IRequest>>; - } - - [ExcludeFromCodeCoverage] - public record ResponseCodeQueries - { - public record GetResponseCodesQuery(Guid EstateId) : IRequest>>; - } - - [ExcludeFromCodeCoverage] - public record SettlementQueries - { - public record GetTodaysSettlementQuery(Guid EstateId, Int32 MerchantReportingId, Int32 OperatorReportingId, DateTime ComparisonDate) : IRequest>; - - public record GetLastSettlementQuery(Guid EstateId) : IRequest>; - - public record GetUnsettledFeesQuery(Guid EstateId, DateTime StartDate, DateTime EndDate, List MerchantIdFilter, List OperatorIdFilter, List ProductIdFilter, GroupByOption GroupByOption) : IRequest>>; - } - - [ExcludeFromCodeCoverage] - public record TransactionQueries - { - public record TodaysSalesQuery(Guid estateId, Int32 merchantReportingId, Int32 operatorReportingId, DateTime comparisonDate) : IRequest>; - public record TodaysFailedSales(Guid estateId, DateTime comparisonDate, String responseCode) : IRequest>; - - public record TodaysSalesCountByHour(Guid estateId, Int32 merchantReportingId, Int32 operatorReportingId, DateTime comparisonDate) : IRequest>>; - public record TodaysSalesValueByHour(Guid estateId, Int32 merchantReportingId, Int32 operatorReportingId, DateTime comparisonDate) : IRequest>>; - - public record TransactionSearchQuery(Guid estateId, TransactionSearchRequest request, PagingRequest pagingRequest, Models.SortingRequest sortingRequest) : IRequest>>; - } -} diff --git a/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs b/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs index cdbb0ff..0e47a0e 100644 --- a/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs +++ b/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs @@ -11,11 +11,14 @@ using System.Collections.Generic; using System.Data.Common; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text; using TransactionProcessor.Database.Contexts; namespace EstateReportingAPI.BusinessLogic; +// Infrastructure component currently scheduled for generalisation and relocation to Shared; exclude until that work is completed. +[ExcludeFromCodeCoverage] public class QueryTimingInterceptor : DbCommandInterceptor { internal void LogIfRequired(DbCommand command, @@ -46,6 +49,8 @@ public override async ValueTask ReaderExecutedAsync(DbCommand comm } +// Infrastructure component currently scheduled for generalisation and relocation to Shared; exclude until that work is completed. +[ExcludeFromCodeCoverage] public class DbContextResolverX : IDbContextResolver where TContext : DbContext { private readonly IServiceProvider _rootProvider; diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index ff086b2..38d3606 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -923,7 +923,7 @@ into g MerchantName = g.Key.MerchantName, TotalCount = g.Sum(x => x.TotalCount), TotalValue = g.Sum(x => x.TotalValue), - AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, + AverageValue = g.Sum(x => x.TotalCount) > 0 ? g.Sum(x => x.TotalValue) / g.Sum(x => x.TotalCount) : 0m, AuthorisedCount = g.Sum(x => x.AuthorisedCount), DeclinedCount = g.Sum(x => x.DeclinedCount), AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m @@ -1003,7 +1003,7 @@ into g OperatorName = g.Key.OperatorName, TotalCount = g.Sum(x => x.TotalCount), TotalValue = g.Sum(x => x.TotalValue), - AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, + AverageValue = g.Sum(x => x.TotalCount) > 0 ? g.Sum(x => x.TotalValue) / g.Sum(x => x.TotalCount) : 0m, AuthorisedCount = g.Sum(x => x.AuthorisedCount), DeclinedCount = g.Sum(x => x.DeclinedCount), AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m @@ -1053,12 +1053,17 @@ public async Task>> GetMerchants(MerchantQueries.GetMercha if (balanceQueryResults.IsFailed) return ResultHelpers.CreateFailure(balanceQueryResults); - Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data); - List response = merchants.Select(merchant => - ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId))) - .ToList(); + try { + Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data); + List response = merchants.Select(merchant => + ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId))) + .ToList(); - return Result.Success(response); + return Result.Success(response); + } + catch (InvalidOperationException ex) { + return Result.Failure(ex.Message); + } } public async Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) { From b902078858af5c4c31bed94bb0b1d27d6d89f831 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 28 Sep 2026 17:23:50 +0100 Subject: [PATCH 2/2] Restore transaction summary average behaviour --- .../ReportingManagerReportTests.cs | 6 +++--- EstateReportingAPI.BusinessLogic/ReportingManager.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs index 964f5e1..93d3410 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs @@ -232,7 +232,7 @@ public async Task GetFileProfileConfigurationList_WhenNoConfigurations_ReturnsEm } [Fact] - public async Task GetTransactionSummaryByMerchantReport_AverageUsesTransactionCount() + public async Task GetTransactionSummaryByMerchantReport_AverageUsesGroupedTotals() { await using TestDatabase database = await TestDatabase.CreateAsync(); database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); @@ -250,7 +250,7 @@ public async Task GetTransactionSummaryByMerchantReport_AverageUsesTransactionCo CancellationToken.None); result.IsSuccess.ShouldBeTrue(); - result.Data.Merchants.Single().AverageValue.ShouldBe(20m); + result.Data.Merchants.Single().AverageValue.ShouldBe(30m); result.Data.Summary.AverageValue.ShouldBe(20m); } @@ -279,7 +279,7 @@ public async Task GetTransactionSummaryByOperatorReport_SeparatesAuthorisedAndDe summary.AuthorisedCount.ShouldBe(2); summary.DeclinedCount.ShouldBe(1); summary.AuthorisedPercentage.ShouldBe(2m / 3m); - summary.AverageValue.ShouldBe(20m); + summary.AverageValue.ShouldBe(30m); } [Fact] diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 38d3606..3a41d53 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -923,7 +923,7 @@ into g MerchantName = g.Key.MerchantName, TotalCount = g.Sum(x => x.TotalCount), TotalValue = g.Sum(x => x.TotalValue), - AverageValue = g.Sum(x => x.TotalCount) > 0 ? g.Sum(x => x.TotalValue) / g.Sum(x => x.TotalCount) : 0m, + AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, AuthorisedCount = g.Sum(x => x.AuthorisedCount), DeclinedCount = g.Sum(x => x.DeclinedCount), AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m @@ -1003,7 +1003,7 @@ into g OperatorName = g.Key.OperatorName, TotalCount = g.Sum(x => x.TotalCount), TotalValue = g.Sum(x => x.TotalValue), - AverageValue = g.Sum(x => x.TotalCount) > 0 ? g.Sum(x => x.TotalValue) / g.Sum(x => x.TotalCount) : 0m, + AverageValue = g.Count() > 0 ? g.Sum(x => x.TotalValue) / g.Count() : 0m, AuthorisedCount = g.Sum(x => x.AuthorisedCount), DeclinedCount = g.Sum(x => x.DeclinedCount), AuthorisedPercentage = g.Sum(x => x.TotalCount) > 0 ? (decimal)g.Sum(x => x.AuthorisedCount) / (decimal)g.Sum(x => x.TotalCount) : 0m