Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions EstateReportingAPI.BusinessLogic.UnitTests/ReportContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using EstateReportingAPI.Models;
using Shouldly;
using Xunit;

namespace EstateReportingAPI.BusinessLogic.UnitTests;

public sealed class ReportContractTests
{
[Fact]
public void PagedRequestDefaultsUseApprovedPageSizes()
{
new TransactionDetailReportRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize);
new TransactionMixSummaryRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize);
new TransactionSummaryByMerchantRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize);
new TransactionSummaryByOperatorRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize);
new GetRecentActivityReceiptReportRequest().PageSize.ShouldBe(ReportQueryLimits.RecentActivityDefaultPageSize);
}

[Fact]
public void PagedResponsesExposeSharedPaginationMetadata()
{
new TransactionDetailReportResponse().Pagination.ShouldNotBeNull();
new TransactionMixSummaryResponse().Pagination.ShouldNotBeNull();
new TransactionSummaryByMerchantResponse().Pagination.ShouldNotBeNull();
new TransactionSummaryByOperatorResponse().Pagination.ShouldNotBeNull();
new ProductPerformanceResponse().Pagination.ShouldNotBeNull();
new GetRecentActivityReceiptReportResponse().Pagination.ShouldNotBeNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using Shouldly;
using Xunit;

namespace EstateReportingAPI.BusinessLogic.UnitTests;

public sealed class ReportQueryValidationTests
{
[Fact]
public void ValidateDateRange_AllowsThirtyInclusiveDays()
{
ReportQueryValidation.ValidateDateRange(
new DateTime(2026, 9, 1),
new DateTime(2026, 9, 30))
.ShouldBeNull();
}

[Fact]
public void ValidateDateRange_RejectsMoreThanThirtyInclusiveDays()
{
ReportQueryValidation.ValidateDateRange(
new DateTime(2026, 9, 1),
new DateTime(2026, 10, 1))
.ShouldBe("date range must not exceed 30 inclusive calendar days.");
}

[Fact]
public void ValidateDateRange_RejectsReversedDates()
{
ReportQueryValidation.ValidateDateRange(
new DateTime(2026, 9, 2),
new DateTime(2026, 9, 1))
.ShouldBe("startDate must be less than or equal to endDate.");
}

[Theory]
[InlineData(1, 1)]
[InlineData(1, 50)]
public void ValidatePaging_AllowsPageNumbersAndSizesWithinBounds(int pageNumber, int pageSize)
{
ReportQueryValidation.ValidatePaging(pageNumber, pageSize).ShouldBeNull();
}

[Theory]
[InlineData(0, 1, "pageNumber must be greater than or equal to 1.")]
[InlineData(1, 0, "pageSize must be between 1 and 50.")]
[InlineData(1, 51, "pageSize must be between 1 and 50.")]
[InlineData(int.MaxValue, 50, "pageNumber is too large for this report.")]
public void ValidatePaging_RejectsInvalidValues(int pageNumber, int pageSize, string expected)
{
ReportQueryValidation.ValidatePaging(pageNumber, pageSize).ShouldBe(expected);
}

[Theory]
[InlineData(1)]
[InlineData(20)]
public void ValidateTopN_AllowsValuesWithinBounds(int topN)
{
ReportQueryValidation.ValidateTopN(topN).ShouldBeNull();
}

[Theory]
[InlineData(0, "topN must be between 1 and 20.")]
[InlineData(21, "topN must be between 1 and 20.")]
public void ValidateTopN_RejectsValuesOutsideBounds(int topN, string expected)
{
ReportQueryValidation.ValidateTopN(topN).ShouldBe(expected);
}

[Fact]
public void ValidateFilterList_AllowsFiftyIds()
{
ReportQueryValidation.ValidateFilterList("merchants", new int[50]).ShouldBeNull();
}

[Fact]
public void ValidateFilterList_RejectsMoreThanFiftyIds()
{
ReportQueryValidation.ValidateFilterList("merchants", new int[51])
.ShouldBe("merchants must contain no more than 50 values.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,54 @@ public async Task GetTransactionDetailReport_AppliesMerchantProductAndOperatorFi
result.Data.Summary.TotalValue.ShouldBe(10m);
}

[Fact]
public async Task GetTransactionDetailReport_PaginatesRowsButKeepsFullSummary()
{
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.TransactionService.GetTransactionDetailReport(
new TransactionQueries.TransactionDetailReportQuery(
EstateId,
new TransactionDetailReportRequest {
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 9, 1),
PageNumber = 2,
PageSize = 2
}),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.Transactions.Count.ShouldBe(1);
result.Data.Summary.TransactionCount.ShouldBe(3);
result.Data.Summary.TotalValue.ShouldBe(60m);
result.Data.Pagination.PageNumber.ShouldBe(2);
result.Data.Pagination.PageSize.ShouldBe(2);
result.Data.Pagination.TotalItems.ShouldBe(3);
result.Data.Pagination.TotalPages.ShouldBe(2);
}

[Fact]
public async Task GetTransactionDetailReport_RejectsDateRangesOverThirtyDays()
{
await using TestDatabase database = await TestDatabase.CreateAsync();

var result = await database.TransactionService.GetTransactionDetailReport(
new TransactionQueries.TransactionDetailReportQuery(
EstateId,
new TransactionDetailReportRequest {
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 10, 1)
}),
CancellationToken.None);

result.IsSuccess.ShouldBeFalse();
result.Message.ShouldBe("date range must not exceed 30 inclusive calendar days.");
}

[Fact]
public async Task GetTransactionSummaryByOperatorReport_AppliesMerchantAndOperatorFilters()
{
Expand Down Expand Up @@ -351,6 +399,60 @@ public async Task GetTransactionSummaryByMerchantReport_WhenNoSales_ReturnsZeroR
result.Data.Summary.TotalValue.ShouldBe(0m);
}

[Fact]
public async Task GetTransactionSummaryByMerchantReport_PaginatesMerchantsButKeepsFullSummary()
{
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.TransactionService.GetTransactionSummaryByMerchantReport(
new TransactionQueries.TransactionSummaryByMerchantQuery(
EstateId,
new TransactionSummaryByMerchantRequest {
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 9, 1),
PageNumber = 2,
PageSize = 1
}),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.Merchants.Count.ShouldBe(1);
result.Data.Summary.TotalCount.ShouldBe(2);
result.Data.Summary.TotalValue.ShouldBe(30m);
result.Data.Pagination.TotalItems.ShouldBe(2);
result.Data.Pagination.TotalPages.ShouldBe(2);
}

[Fact]
public async Task GetTransactionSummaryByOperatorReport_PaginatesOperatorsButKeepsFullSummary()
{
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.TransactionService.GetTransactionSummaryByOperatorReport(
new TransactionQueries.TransactionSummaryByOperatorQuery(
EstateId,
new TransactionSummaryByOperatorRequest {
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 9, 1),
PageNumber = 2,
PageSize = 1
}),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.Operators.Count.ShouldBe(1);
result.Data.Summary.TotalCount.ShouldBe(2);
result.Data.Summary.TotalValue.ShouldBe(30m);
result.Data.Pagination.TotalItems.ShouldBe(2);
result.Data.Pagination.TotalPages.ShouldBe(2);
}

[Fact]
public async Task GetTransactionMixSummary_WhenDateRangeIsInvalid_ReturnsFailure()
{
Expand Down Expand Up @@ -390,7 +492,7 @@ public async Task GetTransactionMixSummary_WhenBreakdownIsUnsupported_ReturnsFai
}

[Fact]
public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndReturnsEmptyResult()
public async Task GetRecentActivityReceiptReport_RejectsInvalidPaging()
{
await using TestDatabase database = await TestDatabase.CreateAsync();

Expand All @@ -404,11 +506,8 @@ public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndRetur
}),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.PageNumber.ShouldBe(1);
result.Data.PageSize.ShouldBe(10);
result.Data.TotalCount.ShouldBe(0);
result.Data.Items.ShouldBeEmpty();
result.IsSuccess.ShouldBeFalse();
result.Message.ShouldBe("pageNumber must be greater than or equal to 1.");
}

[Fact]
Expand All @@ -431,7 +530,7 @@ public async Task GetRecentActivityReceiptReport_PaginatesOrderedRowsAndPreserve
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.TotalCount.ShouldBe(3);
result.Data.Pagination.TotalItems.ShouldBe(3);
result.Data.Items.Count.ShouldBe(1);
result.Data.Items.Single().Amount.ShouldBe(10m);
}
Expand Down Expand Up @@ -595,6 +694,25 @@ public async Task GetMerchantDailyPerformanceSummary_WhenNoTransactions_ReturnsB
result.Data.DrillDownTransactions.ShouldBeEmpty();
}

[Fact]
public async Task GetMerchantDailyPerformanceSummary_RejectsDateRangesOverThirtyDays()
{
await using TestDatabase database = await TestDatabase.CreateAsync();

var result = await database.TransactionService.GetMerchantDailyPerformanceSummary(
new TransactionQueries.MerchantDailyPerformanceSummaryQuery(
EstateId,
new MerchantDailyPerformanceSummaryRequest {
MerchantReportingId = 20,
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 10, 1)
}),
CancellationToken.None);

result.IsSuccess.ShouldBeFalse();
result.Message.ShouldBe("date range must not exceed 30 inclusive calendar days.");
}

[Fact]
public async Task GetMerchants_AppliesAllQueryFilters()
{
Expand Down Expand Up @@ -791,7 +909,60 @@ public async Task GetTransactionMixSummary_GroupsByStatusAndOrdersByValue()
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);
result.Data.Transactions.Select(t => t.Value).ShouldContain(25m);
}

[Fact]
public async Task GetTransactionMixSummary_PaginatesTransactionsAndLimitsGroups()
{
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, 1, 11, 0, 0), authorised: false);
await database.SaveAsync();

var result = await database.TransactionService.GetTransactionMixSummary(
new TransactionQueries.TransactionMixSummaryQuery(
EstateId,
new TransactionMixSummaryRequest {
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 9, 1),
Breakdown = TransactionMixBreakdown.Status,
Measure = TransactionMixMeasure.Count,
TopN = 1,
PageNumber = 2,
PageSize = 2
}),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.TotalCount.ShouldBe(3);
result.Data.TotalValue.ShouldBe(60m);
result.Data.Groups.Count.ShouldBe(1);
result.Data.Transactions.Count.ShouldBe(1);
result.Data.Pagination.TotalItems.ShouldBe(3);
result.Data.Pagination.TotalPages.ShouldBe(2);
}

[Fact]
public async Task GetTransactionMixSummary_RejectsTopNAboveTwenty()
{
await using TestDatabase database = await TestDatabase.CreateAsync();

var result = await database.TransactionService.GetTransactionMixSummary(
new TransactionQueries.TransactionMixSummaryQuery(
EstateId,
new TransactionMixSummaryRequest {
StartDate = new DateTime(2026, 9, 1),
EndDate = new DateTime(2026, 9, 1),
Breakdown = TransactionMixBreakdown.Status,
Measure = TransactionMixMeasure.Count,
TopN = 21
}),
CancellationToken.None);

result.IsSuccess.ShouldBeFalse();
result.Message.ShouldBe("topN must be between 1 and 20.");
}

[Fact]
Expand All @@ -813,7 +984,7 @@ public async Task GetRecentActivityReceiptReport_AppliesMerchantAndSearchFilters
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.TotalCount.ShouldBe(1);
result.Data.Pagination.TotalItems.ShouldBe(1);
result.Data.Items.Single().Amount.ShouldBe(10m);
}

Expand All @@ -832,6 +1003,31 @@ public async Task GetProductPerformanceReport_WhenNoSales_ReturnsZeroSummary()
result.Data.Summary.TotalValue.ShouldBe(0m);
}

[Fact]
public async Task GetProductPerformanceReport_ReturnsPagedProductsAndFullSummary()
{
await using TestDatabase database = await TestDatabase.CreateAsync();
database.AddSale(database.MerchantId, database.OperatorId, 25m, new DateTime(2026, 9, 1));
await database.SaveAsync();

var result = await database.TransactionService.GetProductPerformanceReport(
new TransactionQueries.ProductPerformanceQuery(
EstateId,
new DateTime(2026, 9, 1),
new DateTime(2026, 9, 1),
1,
1),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Data.ProductDetails.Count.ShouldBe(1);
result.Data.Summary.TotalProducts.ShouldBe(1);
result.Data.Summary.TotalCount.ShouldBe(1);
result.Data.Summary.TotalValue.ShouldBe(25m);
result.Data.Pagination.TotalItems.ShouldBe(1);
result.Data.Pagination.TotalPages.ShouldBe(1);
}

[Fact]
public async Task GetMerchants_WhenBalanceIsMissing_ReturnsFailureResult()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public record TodaysFailedSales(Guid EstateId, DateTime ComparisonDate, String R
public record TransactionDetailReportQuery(Guid EstateId, TransactionDetailReportRequest Request) : IRequest<Result<TransactionDetailReportResponse>>;
public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummaryByMerchantRequest Request) : IRequest<Result<TransactionSummaryByMerchantResponse>>;
public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest<Result<TransactionSummaryByOperatorResponse>>;
public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : IRequest<Result<ProductPerformanceResponse>>;
public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate, int PageNumber = 1, int PageSize = 50) : IRequest<Result<ProductPerformanceResponse>>;
public record TransactionMixSummaryQuery(Guid EstateId, TransactionMixSummaryRequest Request) : IRequest<Result<TransactionMixSummaryResponse>>;
public record GetRecentActivityReceiptReportQuery(Guid EstateId, GetRecentActivityReceiptReportRequest Request) : IRequest<Result<GetRecentActivityReceiptReportResponse>>;
public record TodaysSalesByHour(Guid estateId, DateTime comparisonDate) : IRequest<Result<List<Models.TodaysSalesByHour>>>;
Expand Down
Loading
Loading