diff --git a/TransactionProcessor.Tests/HandlerTests/TransactionHandlersTests.cs b/TransactionProcessor.Tests/HandlerTests/TransactionHandlersTests.cs new file mode 100644 index 00000000..4a77b885 --- /dev/null +++ b/TransactionProcessor.Tests/HandlerTests/TransactionHandlersTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.AspNetCore.Http; +using Moq; +using Newtonsoft.Json; +using Shared.General; +using Shouldly; +using SimpleResults; +using TransactionProcessor.BusinessLogic.Requests; +using TransactionProcessor.DataTransferObjects; +using TransactionProcessor.Handlers; +using TransactionProcessor.Models; +using Xunit; + +namespace TransactionProcessor.Tests.HandlerTests +{ + public class TransactionHandlersTests + { + [Fact] + public async Task PerformTransaction_LogonPayloadWithoutTypeMetadata_SendsLogonCommand() + { + Mock mediator = new Mock(MockBehavior.Strict); + LogonTransactionRequest request = new LogonTransactionRequest + { + DeviceIdentifier = "device-1", + TransactionDateTime = DateTime.SpecifyKind(new DateTime(2024, 1, 2, 3, 4, 5), DateTimeKind.Utc), + TransactionNumber = "000001", + TransactionType = "Logon" + }; + + mediator.Setup(m => m.Send(It.Is(command => + command.EstateId == TestData.EstateId && + command.MerchantId == TestData.MerchantId && + command.DeviceIdentifier == request.DeviceIdentifier && + command.TransactionNumber == request.TransactionNumber && + command.TransactionType == request.TransactionType && + command.TransactionDateTime.Kind == DateTimeKind.Unspecified && + command.TransactionDateTime.Ticks == request.TransactionDateTime.Ticks), + It.IsAny())) + .ReturnsAsync(Result.Success(new ProcessLogonTransactionResponse + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + ResponseCode = "0000", + ResponseMessage = "SUCCESS", + TransactionId = Guid.NewGuid() + })); + + IResult result = await TransactionHandlers.PerformTransaction(mediator.Object, + new DefaultHttpContext(), + CreateSerialisedMessage(request), + CancellationToken.None); + + result.ShouldNotBeNull(); + mediator.VerifyAll(); + } + + [Fact] + public async Task PerformTransaction_SalePayloadWithoutTypeMetadata_SendsSaleCommand() + { + Mock mediator = new Mock(MockBehavior.Strict); + SaleTransactionRequest request = new SaleTransactionRequest + { + AdditionalTransactionMetadata = new Dictionary { { "amount", "12.34" } }, + ContractId = Guid.NewGuid(), + CustomerEmailAddress = "customer@test.local", + DeviceIdentifier = "device-1", + OperatorId = Guid.NewGuid(), + ProductId = Guid.NewGuid(), + TransactionDateTime = new DateTime(2024, 1, 2, 3, 4, 5), + TransactionNumber = "000002", + TransactionSource = 2, + TransactionType = "Sale" + }; + + mediator.Setup(m => m.Send(It.Is(command => + command.EstateId == TestData.EstateId && + command.MerchantId == TestData.MerchantId && + command.DeviceIdentifier == request.DeviceIdentifier && + command.TransactionNumber == request.TransactionNumber && + command.TransactionType == request.TransactionType && + command.OperatorId == request.OperatorId && + command.CustomerEmailAddress == request.CustomerEmailAddress && + command.ContractId == request.ContractId && + command.ProductId == request.ProductId && + command.TransactionSource == request.TransactionSource && + command.AdditionalTransactionMetadata["amount"] == "12.34"), + It.IsAny())) + .ReturnsAsync(Result.Success(new ProcessSaleTransactionResponse + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + ResponseCode = "0000", + ResponseMessage = "SUCCESS", + TransactionId = Guid.NewGuid() + })); + + IResult result = await TransactionHandlers.PerformTransaction(mediator.Object, + new DefaultHttpContext(), + CreateSerialisedMessage(request), + CancellationToken.None); + + result.ShouldNotBeNull(); + mediator.VerifyAll(); + } + + [Fact] + public async Task PerformTransaction_ReconciliationPayloadWithoutTypeMetadata_SendsReconciliationCommand() + { + Mock mediator = new Mock(MockBehavior.Strict); + ReconciliationRequest request = new ReconciliationRequest + { + DeviceIdentifier = "device-1", + OperatorTotals = new List(), + TransactionCount = 4, + TransactionDateTime = new DateTime(2024, 1, 2, 3, 4, 5), + TransactionValue = 42.50m + }; + + mediator.Setup(m => m.Send(It.Is(command => + command.EstateId == TestData.EstateId && + command.MerchantId == TestData.MerchantId && + command.DeviceIdentifier == request.DeviceIdentifier && + command.TransactionCount == request.TransactionCount && + command.TransactionValue == request.TransactionValue), + It.IsAny())) + .ReturnsAsync(Result.Success(new ProcessReconciliationTransactionResponse + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + ResponseCode = "0000", + ResponseMessage = "SUCCESS", + TransactionId = Guid.NewGuid() + })); + + IResult result = await TransactionHandlers.PerformTransaction(mediator.Object, + new DefaultHttpContext(), + CreateSerialisedMessage(request), + CancellationToken.None); + + result.ShouldNotBeNull(); + mediator.VerifyAll(); + } + + [Fact] + public async Task PerformTransaction_UnsupportedPayload_ReturnsBadRequest() + { + Mock mediator = new Mock(MockBehavior.Strict); + + IResult result = await TransactionHandlers.PerformTransaction(mediator.Object, + new DefaultHttpContext(), + CreateSerialisedMessage(new + { + device_identifier = "device-1", + transaction_type = "Unknown" + }), + CancellationToken.None); + + IStatusCodeHttpResult statusCodeResult = result.ShouldBeAssignableTo(); + statusCodeResult.StatusCode.ShouldBe(StatusCodes.Status400BadRequest); + mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + private static SerialisedMessage CreateSerialisedMessage(Object request) + { + return new SerialisedMessage + { + Metadata = new Dictionary + { + { MetadataContants.KeyNameEstateId, TestData.EstateId.ToString() }, + { MetadataContants.KeyNameMerchantId, TestData.MerchantId.ToString() } + }, + SerialisedData = JsonConvert.SerializeObject(request) + }; + } + + private static class TestData + { + public static Guid EstateId => Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static Guid MerchantId => Guid.Parse("22222222-2222-2222-2222-222222222222"); + } + } +} diff --git a/TransactionProcessor/Handlers/TransactionHandlers.cs b/TransactionProcessor/Handlers/TransactionHandlers.cs index 7534d6d7..cac6e7b6 100644 --- a/TransactionProcessor/Handlers/TransactionHandlers.cs +++ b/TransactionProcessor/Handlers/TransactionHandlers.cs @@ -5,6 +5,7 @@ using MediatR; using Microsoft.AspNetCore.Http; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using SimpleResults; using Shared.Results.Web; using Shared.General; @@ -25,8 +26,13 @@ public static async Task PerformTransaction(IMediator mediator, HttpCon Guid estateId = Guid.Parse(transactionRequest.Metadata[MetadataContants.KeyNameEstateId]); Guid merchantId = Guid.Parse(transactionRequest.Metadata[MetadataContants.KeyNameMerchantId]); - DataTransferObject dto = JsonConvert.DeserializeObject(transactionRequest.SerialisedData, - new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto }); + Result deserialiseResult = DeserializeTransactionRequest(transactionRequest.SerialisedData); + if (deserialiseResult.IsFailed || deserialiseResult.Data == null) + { + return ResponseFactory.FromResult(Result.Invalid(deserialiseResult.Message)); + } + + DataTransferObject dto = deserialiseResult.Data; dto.MerchantId = merchantId; dto.EstateId = estateId; @@ -40,12 +46,102 @@ public static async Task PerformTransaction(IMediator mediator, HttpCon LogonTransactionRequest ltr => await ProcessSpecificMessage(mediator, ltr, transactionReceivedDateTime, cancellationToken), SaleTransactionRequest str => await ProcessSpecificMessage(mediator, str, transactionReceivedDateTime, cancellationToken), ReconciliationRequest rr => await ProcessSpecificMessage(mediator, rr, cancellationToken), - _ => Result.Invalid($"DTO Type {dto.GetType().Name} not supported)") + _ => Result.Invalid($"DTO Type {dto.GetType().Name} not supported") }; return ResponseFactory.FromResult(transactionResult, message => message); } + private static Result DeserializeTransactionRequest(String serialisedData) + { + try { + JObject jsonObject = JObject.Parse(serialisedData); + + if (IsReconciliationRequest(jsonObject)) { + return DeserializeKnownType(jsonObject); + } + + if (IsSaleRequest(jsonObject)) { + return DeserializeKnownType(jsonObject); + } + + if (IsLogonRequest(jsonObject)) { + return DeserializeKnownType(jsonObject); + } + + return Result.Invalid("DTO Type could not be determined"); + } + catch (JsonException ex) { + return Result.Invalid($"Invalid transaction request payload: {ex.Message}"); + } + } + + private static Result DeserializeKnownType(JObject jsonObject) where T : DataTransferObject + { + try + { + JsonSerializer serializer = JsonSerializer.Create(new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + MetadataPropertyHandling = MetadataPropertyHandling.Ignore + }); + + T dto = jsonObject.ToObject(serializer); + + if (dto == null) + { + return Result.Invalid($"Failed to deserialize transaction request as {typeof(T).Name}: deserialized payload was null"); + } + + return Result.Success(dto); + } + catch (JsonException ex) + { + return Result.Invalid($"Failed to deserialize transaction request as {typeof(T).Name}: {ex.Message}"); + } + } + + private static Boolean IsLogonRequest(JObject jsonObject) { + if (TryGetTransactionType(jsonObject, out string transactionType)) { + return String.Equals(transactionType, "Logon", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static Boolean IsSaleRequest(JObject jsonObject) { + if (TryGetTransactionType(jsonObject, out string transactionType)) { + return String.Equals(transactionType, "Sale", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static Boolean IsReconciliationRequest(JObject jsonObject) { + if (TryGetTransactionType(jsonObject, out string _)) { + return false; + } + + return true; + } + + private static bool TryGetTransactionType(JObject jsonObject, + out string transactionType) { + transactionType = null; + + if (!HasProperty(jsonObject, "transaction_type")) + return false; + + transactionType = jsonObject.GetValue("transaction_type", StringComparison.OrdinalIgnoreCase)?.Value(); + + return transactionType != null; + } + + private static Boolean HasProperty(JObject jsonObject, + String propertyName) { + return jsonObject.GetValue(propertyName, StringComparison.OrdinalIgnoreCase) != null; + } + public static async Task ResendTransactionReceipt(IMediator mediator, HttpContext ctx, Guid estateId, Guid transactionId, CancellationToken cancellationToken) { TransactionCommands.ResendTransactionReceiptCommand command = new(transactionId, estateId); @@ -125,4 +221,4 @@ private static async Task> ProcessSpecificMessage(IMed return ModelFactory.ConvertFrom(result.Data); } } -} \ No newline at end of file +}