From c0e15c2cfee91aceeb61e0cfa5f7812a188e4955 Mon Sep 17 00:00:00 2001 From: Szeregowy Date: Tue, 24 Mar 2026 16:48:11 +0100 Subject: [PATCH 1/4] =?UTF-8?q?test:=20unit=20testy=20z=20Moq=20dla=20wszy?= =?UTF-8?q?stkich=207=20provider=C3=B3w=20(pokrycie=20kodu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodano Moq v4 do csproj testów. Nowy plik ProviderUnitTests.cs — 65+ testów jednostkowych: - AdyenProvider: GetChannels, RequestPayment success/error, GetStatus, TransactionStatusChange valid/invalid signature, REFUND event - PayUProvider: GetChannels PLN/EUR, RequestPayment success/fail, GetStatus Finished/Processing/Rejected, webhook COMPLETED/CANCELED/PENDING, invalid signature - Przelewy24Provider: GetChannels PLN/EUR/USD, RequestPayment success/fail, GetStatus (Processing), webhook valid/invalid/verify-fail/malformed-json - TpayProvider: GetChannels PLN/EUR, RequestPayment success/fail, GetStatus Finished, webhook paid/pending/error/invalid-sig - HotPayProvider: GetChannels, RequestPayment success/fail, GetStatus (Processing), webhook SUCCESS/FAILURE/invalid-hash - PayNowProvider: GetChannels PLN BLIK, RequestPayment success/fail, GetStatus Finished/Processing, webhook CONFIRMED/PENDING/ERROR/invalid-sig - RevolutProvider: GetChannels PLN/EUR, RequestPayment success/fail, GetStatus completed/pending/cancelled/authorised, webhook ORDER_COMPLETED/ORDER_PAYMENT_DECLINED/ORDER_AUTHORISED/invalid-sig --- .../ProviderUnitTests.cs | 855 ++++++++++++++++++ .../TailoredApps.Shared.Payments.Tests.csproj | 1 + 2 files changed, 856 insertions(+) create mode 100644 tests/TailoredApps.Shared.Payments.Tests/ProviderUnitTests.cs diff --git a/tests/TailoredApps.Shared.Payments.Tests/ProviderUnitTests.cs b/tests/TailoredApps.Shared.Payments.Tests/ProviderUnitTests.cs new file mode 100644 index 0000000..8e06126 --- /dev/null +++ b/tests/TailoredApps.Shared.Payments.Tests/ProviderUnitTests.cs @@ -0,0 +1,855 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; +using TailoredApps.Shared.Payments; +using TailoredApps.Shared.Payments.Provider.Adyen; +using TailoredApps.Shared.Payments.Provider.HotPay; +using TailoredApps.Shared.Payments.Provider.PayNow; +using TailoredApps.Shared.Payments.Provider.PayU; +using TailoredApps.Shared.Payments.Provider.Przelewy24; +using TailoredApps.Shared.Payments.Provider.Revolut; +using TailoredApps.Shared.Payments.Provider.Tpay; +using Xunit; + +namespace TailoredApps.Shared.Payments.Tests; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +file static class Req +{ + public static PaymentRequest Make(string currency = "PLN", decimal amount = 9.99m) => + new() + { + PaymentProvider = "Test", + PaymentChannel = "card", + PaymentModel = PaymentModel.OneTime, + Title = "Test order", + Currency = currency, + Amount = amount, + Email = "test@example.com", + FirstName = "Jan", + Surname = "Kowalski", + }; + + public static TransactionStatusChangePayload Webhook(string body, Dictionary? qs = null) => + new() + { + ProviderId = "test", + Payload = body, + QueryParameters = qs ?? new Dictionary(), + }; +} + +// ─── Adyen ─────────────────────────────────────────────────────────────────── + +/// Unit testy dla AdyenProvider. +public class AdyenProviderTests +{ + private static AdyenProvider Build(IAdyenServiceCaller caller) => new(caller); + + [Fact] + public void Provider_Key_IsAdyen() => Assert.Equal("Adyen", Build(Mock.Of()).Key); + + [Fact] + public void Provider_Name_IsAdyen() => Assert.Equal("Adyen", Build(Mock.Of()).Name); + + [Fact] + public async Task GetChannels_PLN_NotEmpty() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.NotEmpty(channels); + } + + [Fact] + public async Task GetChannels_EUR_ContainsIdeal() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("EUR"); + Assert.Contains(channels, c => c.Id == "ideal"); + } + + [Fact] + public async Task GetChannels_USD_ContainsCard() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("USD"); + Assert.Contains(channels, c => c.Id == "scheme"); + } + + [Fact] + public async Task RequestPayment_CallerSuccess_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.CreateSessionAsync(It.IsAny())) + .ReturnsAsync(("sess_abc", "https://checkout.adyen.com/pay/abc", null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.Equal("sess_abc", result.PaymentUniqueId); + Assert.Equal("https://checkout.adyen.com/pay/abc", result.RedirectUrl); + } + + [Fact] + public async Task RequestPayment_CallerError_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.CreateSessionAsync(It.IsAny())) + .ReturnsAsync((null, null, "API error")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Finished() + { + var mock = new Mock(); + mock.Setup(m => m.GetPaymentStatusAsync("psp_123")).ReturnsAsync(PaymentStatusEnum.Finished); + var result = await Build(mock.Object).GetStatus("psp_123"); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Processing() + { + var mock = new Mock(); + mock.Setup(m => m.GetPaymentStatusAsync("psp_123")).ReturnsAsync(PaymentStatusEnum.Processing); + var result = await Build(mock.Object).GetStatus("psp_123"); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_ReturnsStatus() + { + var body = """{"notificationItems":[{"NotificationRequestItem":{"eventCode":"AUTHORISATION","success":"true","pspReference":"psp_123"}}]}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotificationHmac(body, "valid_hmac")).Returns(true); + var qs = new Dictionary { { "HmacSignature", "valid_hmac" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.NotEqual(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotificationHmac(It.IsAny(), It.IsAny())).Returns(false); + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("{}", new Dictionary { { "HmacSignature", "bad" } })); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_SuccessFalse_ReturnsRejected() + { + var body = """{"notificationItems":[{"NotificationRequestItem":{"eventCode":"AUTHORISATION","success":"false","pspReference":"psp_123"}}]}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotificationHmac(body, "valid_hmac")).Returns(true); + var qs = new Dictionary { { "HmacSignature", "valid_hmac" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_Refund_ReturnsFinished() + { + var body = """{"notificationItems":[{"NotificationRequestItem":{"eventCode":"REFUND","success":"true","pspReference":"psp_123"}}]}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotificationHmac(body, "hmac")).Returns(true); + var qs = new Dictionary { { "HmacSignature", "hmac" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } +} + +// ─── PayU ───────────────────────────────────────────────────────────────────── + +/// Unit testy dla PayUProvider. +public class PayUProviderTests +{ + private static PayUProvider Build(IPayUServiceCaller caller) => new(caller); + + [Fact] + public void Provider_Key_IsPayU() => Assert.Equal("PayU", Build(Mock.Of()).Key); + + [Fact] + public async Task GetChannels_PLN_ContainsBlik() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.Contains(channels, c => c.Id == "blik"); + } + + [Fact] + public async Task GetChannels_EUR_ContainsCard() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("EUR"); + Assert.Contains(channels, c => c.Id == "c"); + } + + [Fact] + public async Task RequestPayment_Success_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token_abc"); + mock.Setup(m => m.CreateOrderAsync("token_abc", It.IsAny())) + .ReturnsAsync(("order_123", "https://secure.payu.com/pay/abc", null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.Equal("order_123", result.PaymentUniqueId); + Assert.Equal("https://secure.payu.com/pay/abc", result.RedirectUrl); + } + + [Fact] + public async Task RequestPayment_CreateOrderFails_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token"); + mock.Setup(m => m.CreateOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((null, null, "Unauthorized")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Completed_ReturnsFinished() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token"); + mock.Setup(m => m.GetOrderStatusAsync("token", "order_1")).ReturnsAsync(PaymentStatusEnum.Finished); + var result = await Build(mock.Object).GetStatus("order_1"); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Pending_ReturnsProcessing() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token"); + mock.Setup(m => m.GetOrderStatusAsync("token", "order_1")).ReturnsAsync(PaymentStatusEnum.Processing); + var result = await Build(mock.Object).GetStatus("order_1"); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Canceled_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token"); + mock.Setup(m => m.GetOrderStatusAsync("token", "order_1")).ReturnsAsync(PaymentStatusEnum.Rejected); + var result = await Build(mock.Object).GetStatus("order_1"); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_COMPLETED_ReturnsFinished() + { + var body = """{"order":{"status":"COMPLETED","orderId":"order_1"}}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(body, It.IsAny())).Returns(true); + var qs = new Dictionary { { "OpenPayU-Signature", "sender=test;signature=valid;algorithm=MD5;content=DOCUMENT" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_CANCELED_ReturnsRejected() + { + var body = """{"order":{"status":"CANCELED","orderId":"order_1"}}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(body, It.IsAny())).Returns(true); + var qs = new Dictionary { { "OpenPayU-Signature", "sender=test;signature=valid;algorithm=MD5;content=DOCUMENT" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_PENDING_ReturnsProcessing() + { + var body = """{"order":{"status":"PENDING","orderId":"order_1"}}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(body, It.IsAny())).Returns(true); + var qs = new Dictionary { { "OpenPayU-Signature", "sender=test;signature=valid;algorithm=MD5;content=DOCUMENT" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(It.IsAny(), It.IsAny())).Returns(false); + var qs = new Dictionary { { "OpenPayU-Signature", "bad" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("{}", qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } +} + +// ─── Przelewy24 ─────────────────────────────────────────────────────────────── + +/// Unit testy dla Przelewy24Provider. +public class Przelewy24ProviderTests +{ + private static IOptions DefaultOptions() => + Options.Create(new Przelewy24ServiceOptions + { + MerchantId = 12345, + ServiceUrl = "https://sandbox.przelewy24.pl", + NotifyUrl = "https://example.com/notify", + ReturnUrl = "https://example.com/return", + }); + + private static Przelewy24Provider Build(IPrzelewy24ServiceCaller caller) => new(caller, DefaultOptions()); + + [Fact] + public void Provider_Key_IsPrzelewy24() => Assert.Equal("Przelewy24", Build(Mock.Of()).Key); + + [Fact] + public async Task GetChannels_PLN_NotEmpty() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.NotEmpty(channels); + } + + [Fact] + public async Task GetChannels_EUR_ContainsCard() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("EUR"); + Assert.Contains(channels, c => c.Id == "card"); + } + + [Fact] + public async Task GetChannels_USD_ContainsCard() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("USD"); + Assert.Contains(channels, c => c.Id == "card"); + } + + [Fact] + public async Task RequestPayment_Success_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.RegisterTransactionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(("token_p24_abc", null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.NotEmpty(result.RedirectUrl!); + Assert.Contains("token_p24_abc", result.RedirectUrl); + } + + [Fact] + public async Task RequestPayment_ApiError_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.RegisterTransactionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((null, "Bad credentials")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_ReturnsProcessing() + { + var result = await Build(Mock.Of()).GetStatus("sess_1"); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_VerifyOk_ReturnsFinished() + { + var body = """{"sessionId":"sess_1","amount":999,"currency":"PLN","orderId":12345}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(body)).Returns(true); + mock.Setup(m => m.VerifyTransactionAsync("sess_1", 999, "PLN", 12345)).ReturnsAsync(PaymentStatusEnum.Finished); + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_VerifyFails_ReturnsRejected() + { + var body = """{"sessionId":"sess_1","amount":999,"currency":"PLN","orderId":12345}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(body)).Returns(true); + mock.Setup(m => m.VerifyTransactionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(PaymentStatusEnum.Rejected); + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(It.IsAny())).Returns(false); + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("{}")); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_MalformedJson_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(It.IsAny())).Returns(true); + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("not json")); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } +} + +// ─── Tpay ───────────────────────────────────────────────────────────────────── + +/// Unit testy dla TpayProvider. +public class TpayProviderTests +{ + private static TpayProvider Build(ITpayServiceCaller caller) => new(caller); + + [Fact] + public void Provider_Key_IsTpay() => Assert.Equal("Tpay", Build(Mock.Of()).Key); + + [Fact] + public async Task GetChannels_PLN_ContainsBlik() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.Contains(channels, c => c.Id == "blik"); + } + + [Fact] + public async Task GetChannels_EUR_ContainsCard() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("EUR"); + Assert.Contains(channels, c => c.Id == "card"); + } + + [Fact] + public async Task RequestPayment_Success_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token_tpay"); + mock.Setup(m => m.CreateTransactionAsync("token_tpay", It.IsAny())) + .ReturnsAsync(("txn_123", "https://pay.tpay.com/txn/abc")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.Equal("txn_123", result.PaymentUniqueId); + Assert.Equal("https://pay.tpay.com/txn/abc", result.RedirectUrl); + } + + [Fact] + public async Task RequestPayment_NoTransactionId_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("token"); + mock.Setup(m => m.CreateTransactionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((null, null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Finished() + { + var mock = new Mock(); + mock.Setup(m => m.GetAccessTokenAsync()).ReturnsAsync("tok"); + mock.Setup(m => m.GetTransactionStatusAsync("tok", "txn_1")).ReturnsAsync(PaymentStatusEnum.Finished); + var result = await Build(mock.Object).GetStatus("txn_1"); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_paid_ReturnsFinished() + { + var body = """{"id":"txn_1","status":"paid","amount":9.99}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(body, "valid_sig")).Returns(true); + var qs = new Dictionary { { "X-Signature", "valid_sig" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_pending_ReturnsProcessing() + { + var body = """{"id":"txn_1","status":"pending","amount":9.99}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(body, "sig")).Returns(true); + var qs = new Dictionary { { "X-Signature", "sig" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_error_ReturnsRejected() + { + var body = """{"id":"txn_1","status":"error","amount":9.99}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(body, "sig")).Returns(true); + var qs = new Dictionary { { "X-Signature", "sig" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(It.IsAny(), It.IsAny())).Returns(false); + var qs = new Dictionary { { "X-Signature", "bad" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("{}", qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } +} + +// ─── HotPay ─────────────────────────────────────────────────────────────────── + +/// Unit testy dla HotPayProvider. +public class HotPayProviderTests +{ + private static HotPayProvider Build(IHotPayServiceCaller caller) => new(caller); + + [Fact] + public void Provider_Key_IsHotPay() => Assert.Equal("HotPay", Build(Mock.Of()).Key); + + [Fact] + public async Task GetChannels_PLN_NotEmpty() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.NotEmpty(channels); + } + + [Fact] + public async Task RequestPayment_Success_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.InitPaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(("pay_abc", "https://hotpay.pl/pay/abc")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.Equal("https://hotpay.pl/pay/abc", result.RedirectUrl); + Assert.NotEmpty(result.PaymentUniqueId!); + } + + [Fact] + public async Task RequestPayment_NoUrl_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.InitPaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((null, null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_ReturnsProcessing() + { + var result = await Build(Mock.Of()).GetStatus("pay_1"); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidHash_SUCCESS_ReturnsFinished() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification("valid_hash", "9.99", "pay_1", "SUCCESS")).Returns(true); + var qs = new Dictionary + { + { "HASH", "valid_hash" }, + { "KWOTA", "9.99" }, + { "ID_PLATNOSCI", "pay_1" }, + { "STATUS", "SUCCESS" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("", qs)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidHash_FAILURE_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification("h", "9.99", "pay_1", "FAILURE")).Returns(true); + var qs = new Dictionary + { + { "HASH", "h" }, + { "KWOTA", "9.99" }, + { "ID_PLATNOSCI", "pay_1" }, + { "STATUS", "FAILURE" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("", qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidHash_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyNotification(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); + var qs = new Dictionary + { + { "HASH", "bad" }, + { "KWOTA", "9.99" }, + { "ID_PLATNOSCI", "pay_1" }, + { "STATUS", "SUCCESS" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("", qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } +} + +// ─── PayNow ─────────────────────────────────────────────────────────────────── + +/// Unit testy dla PayNowProvider. +public class PayNowProviderTests +{ + private static PayNowProvider Build(IPayNowServiceCaller caller) => new(caller); + + [Fact] + public void Provider_Key_IsPayNow() => Assert.Equal("PayNow", Build(Mock.Of()).Key); + + [Fact] + public async Task GetChannels_PLN_ContainsBlik() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.Contains(channels, c => c.Id == "BLIK"); + } + + [Fact] + public async Task RequestPayment_Success_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.CreatePaymentAsync(It.IsAny())) + .ReturnsAsync(("pn_abc", "https://api.paynow.pl/checkout/pn_abc")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.Equal("pn_abc", result.PaymentUniqueId); + Assert.Equal("https://api.paynow.pl/checkout/pn_abc", result.RedirectUrl); + } + + [Fact] + public async Task RequestPayment_ApiError_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.CreatePaymentAsync(It.IsAny())).ReturnsAsync((null, null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Confirmed_ReturnsFinished() + { + var mock = new Mock(); + mock.Setup(m => m.GetPaymentStatusAsync("pn_1")).ReturnsAsync(PaymentStatusEnum.Finished); + var result = await Build(mock.Object).GetStatus("pn_1"); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Pending_ReturnsProcessing() + { + var mock = new Mock(); + mock.Setup(m => m.GetPaymentStatusAsync("pn_1")).ReturnsAsync(PaymentStatusEnum.Processing); + var result = await Build(mock.Object).GetStatus("pn_1"); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_CONFIRMED_ReturnsFinished() + { + var body = """{"paymentId":"pn_1","status":"CONFIRMED"}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(body, "valid_sig")).Returns(true); + var qs = new Dictionary { { "Signature", "valid_sig" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_PENDING_ReturnsProcessing() + { + var body = """{"paymentId":"pn_1","status":"PENDING"}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(body, "sig")).Returns(true); + var qs = new Dictionary { { "Signature", "sig" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_ERROR_ReturnsRejected() + { + var body = """{"paymentId":"pn_1","status":"ERROR"}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(body, "sig")).Returns(true); + var qs = new Dictionary { { "Signature", "sig" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifySignature(It.IsAny(), It.IsAny())).Returns(false); + var qs = new Dictionary { { "Signature", "bad" } }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("{}", qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } +} + +// ─── Revolut ────────────────────────────────────────────────────────────────── + +/// Unit testy dla RevolutProvider. +public class RevolutProviderTests +{ + private static RevolutProvider Build(IRevolutServiceCaller caller) => new(caller); + + [Fact] + public void Provider_Key_IsRevolut() => Assert.Equal("Revolut", Build(Mock.Of()).Key); + + [Fact] + public async Task GetChannels_PLN_ContainsRevolutPay() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("PLN"); + Assert.Contains(channels, c => c.Id == "revolut_pay"); + } + + [Fact] + public async Task GetChannels_EUR_ContainsCard() + { + var channels = await Build(Mock.Of()).GetPaymentChannels("EUR"); + Assert.Contains(channels, c => c.Id == "card"); + } + + [Fact] + public async Task RequestPayment_Success_ReturnsCreated() + { + var mock = new Mock(); + mock.Setup(m => m.CreateOrderAsync(It.IsAny())) + .ReturnsAsync(("rev_abc", "https://checkout.revolut.com/pay/abc")); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + + Assert.Equal(PaymentStatusEnum.Created, result.PaymentStatus); + Assert.Equal("rev_abc", result.PaymentUniqueId); + Assert.Equal("https://checkout.revolut.com/pay/abc", result.RedirectUrl); + } + + [Fact] + public async Task RequestPayment_ApiError_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.CreateOrderAsync(It.IsAny())).ReturnsAsync((null, null)); + + var result = await Build(mock.Object).RequestPayment(Req.Make()); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Completed_ReturnsFinished() + { + var mock = new Mock(); + mock.Setup(m => m.GetOrderAsync("rev_1")).ReturnsAsync(("completed", "rev_1")); + var result = await Build(mock.Object).GetStatus("rev_1"); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Pending_ReturnsProcessing() + { + var mock = new Mock(); + mock.Setup(m => m.GetOrderAsync("rev_1")).ReturnsAsync(("pending", "rev_1")); + var result = await Build(mock.Object).GetStatus("rev_1"); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Cancelled_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.GetOrderAsync("rev_1")).ReturnsAsync(("cancelled", "rev_1")); + var result = await Build(mock.Object).GetStatus("rev_1"); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task GetStatus_Unknown_ReturnsFallback() + { + var mock = new Mock(); + mock.Setup(m => m.GetOrderAsync("rev_1")).ReturnsAsync(("authorised", "rev_1")); + var result = await Build(mock.Object).GetStatus("rev_1"); + // authorised → Created or Processing depending on implementation + Assert.True(result.PaymentStatus != PaymentStatusEnum.Finished); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_ORDER_COMPLETED_ReturnsFinished() + { + var body = """{"event":"ORDER_COMPLETED","order_id":"rev_1"}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyWebhookSignature(body, "1234567890", "v1=valid_sig")).Returns(true); + var qs = new Dictionary + { + { "Revolut-Signature", "v1=valid_sig" }, + { "Revolut-Request-Timestamp", "1234567890" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Finished, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_ORDER_PAYMENT_DECLINED_ReturnsRejected() + { + var body = """{"event":"ORDER_PAYMENT_DECLINED","order_id":"rev_1"}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyWebhookSignature(body, "123", "v1=sig")).Returns(true); + var qs = new Dictionary + { + { "Revolut-Signature", "v1=sig" }, + { "Revolut-Request-Timestamp", "123" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_ValidSignature_ORDER_AUTHORISED_ReturnsProcessing() + { + var body = """{"event":"ORDER_AUTHORISED","order_id":"rev_1"}"""; + var mock = new Mock(); + mock.Setup(m => m.VerifyWebhookSignature(body, "123", "v1=sig")).Returns(true); + var qs = new Dictionary + { + { "Revolut-Signature", "v1=sig" }, + { "Revolut-Request-Timestamp", "123" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook(body, qs)); + Assert.Equal(PaymentStatusEnum.Processing, result.PaymentStatus); + } + + [Fact] + public async Task TransactionStatusChange_InvalidSignature_ReturnsRejected() + { + var mock = new Mock(); + mock.Setup(m => m.VerifyWebhookSignature(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); + var qs = new Dictionary + { + { "Revolut-Signature", "v1=bad" }, + { "Revolut-Request-Timestamp", "123" }, + }; + var result = await Build(mock.Object).TransactionStatusChange(Req.Webhook("{}", qs)); + Assert.Equal(PaymentStatusEnum.Rejected, result.PaymentStatus); + } +} diff --git a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj index eac5f76..0774d42 100644 --- a/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj +++ b/tests/TailoredApps.Shared.Payments.Tests/TailoredApps.Shared.Payments.Tests.csproj @@ -13,6 +13,7 @@ all runtime; build; native; contentfiles; analyzers + all runtime; build; native; contentfiles; analyzers; buildtransitive From d9703b22c0cd75eb52e570fd99850e1227880d71 Mon Sep 17 00:00:00 2001 From: Szeregowy Date: Tue, 24 Mar 2026 16:55:53 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20XML=20docs=20=E2=80=94=20ExceptionHa?= =?UTF-8?q?ndling=20interfaces=20+=20HttpResult,=20SmtpEmailConfigureOptio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IExceptionHandlingProvider — summary + method docs - IExceptionHandlingOptionsBuilder — summary + all member docs - ExceptionOccuredResult — summary + constructor docs - SmtpEmailConfigureOptions — brakujący summary na klasie --- .../SmtpEmailProvider.cs | 1 + .../HttpResult/ExceptionOccuredResult.cs | 49 ++++++++++--------- .../IExceptionHandlingOptionsBuilder.cs | 30 +++++++----- .../Interfaces/IExceptionHandlingProvider.cs | 28 ++++++----- 4 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/TailoredApps.Shared.Email/SmtpEmailProvider.cs b/src/TailoredApps.Shared.Email/SmtpEmailProvider.cs index 692da2d..0b1a87e 100644 --- a/src/TailoredApps.Shared.Email/SmtpEmailProvider.cs +++ b/src/TailoredApps.Shared.Email/SmtpEmailProvider.cs @@ -91,6 +91,7 @@ public static void RegisterConsoleProvider(this IServiceCollection services) + /// Wczytuje opcje SMTP z konfiguracji aplikacji. public class SmtpEmailConfigureOptions : IConfigureOptions { private readonly IConfiguration configuration; diff --git a/src/TailoredApps.Shared.ExceptionHandling/HttpResult/ExceptionOccuredResult.cs b/src/TailoredApps.Shared.ExceptionHandling/HttpResult/ExceptionOccuredResult.cs index ee8add3..e2ba5a7 100644 --- a/src/TailoredApps.Shared.ExceptionHandling/HttpResult/ExceptionOccuredResult.cs +++ b/src/TailoredApps.Shared.ExceptionHandling/HttpResult/ExceptionOccuredResult.cs @@ -1,24 +1,25 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using TailoredApps.Shared.ExceptionHandling.Model; - -namespace TailoredApps.Shared.ExceptionHandling.HttpResult -{ - public class ExceptionOccuredResult : ObjectResult - { - public ExceptionOccuredResult(ModelStateDictionary modelState) - : base(modelState) - { - StatusCode = StatusCodes.Status400BadRequest; - } - - - - public ExceptionOccuredResult(ExceptionHandlingResultModel modelState) - : base(modelState) - { - StatusCode = StatusCodes.Status400BadRequest; - } - } -} +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using TailoredApps.Shared.ExceptionHandling.Model; + +namespace TailoredApps.Shared.ExceptionHandling.HttpResult +{ + /// Wynik HTTP 400 zwracany gdy wystąpi wyjątek lub błąd walidacji. + public class ExceptionOccuredResult : ObjectResult + { + /// Inicjalizuje wynik 400 z błędami walidacji modelu. + public ExceptionOccuredResult(ModelStateDictionary modelState) + : base(modelState) + { + StatusCode = StatusCodes.Status400BadRequest; + } + + /// Inicjalizuje wynik 400 z modelem odpowiedzi wyjątku. + public ExceptionOccuredResult(ExceptionHandlingResultModel modelState) + : base(modelState) + { + StatusCode = StatusCodes.Status400BadRequest; + } + } +} diff --git a/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingOptionsBuilder.cs b/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingOptionsBuilder.cs index 9258de6..2f7d00e 100644 --- a/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingOptionsBuilder.cs +++ b/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingOptionsBuilder.cs @@ -1,12 +1,18 @@ -using Microsoft.Extensions.DependencyInjection; -using System; - -namespace TailoredApps.Shared.ExceptionHandling.Interfaces -{ - public interface IExceptionHandlingOptionsBuilder - { - IExceptionHandlingOptionsBuilder WithExceptionHandlingProvider() where Provider : class, IExceptionHandlingProvider; - IExceptionHandlingOptionsBuilder WithExceptionHandlingProvider(Func implementationFactory) where Provider : class, IExceptionHandlingProvider; - IServiceCollection Services { get; } - } -} +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace TailoredApps.Shared.ExceptionHandling.Interfaces +{ + /// Builder do konfigurowania dostawców obsługi wyjątków w DI. + public interface IExceptionHandlingOptionsBuilder + { + /// Rejestruje dostawcę obsługi wyjątków (automatyczna aktywacja przez DI). + IExceptionHandlingOptionsBuilder WithExceptionHandlingProvider() where Provider : class, IExceptionHandlingProvider; + + /// Rejestruje dostawcę obsługi wyjątków z fabryczną metodą tworzenia instancji. + IExceptionHandlingOptionsBuilder WithExceptionHandlingProvider(Func implementationFactory) where Provider : class, IExceptionHandlingProvider; + + /// Kolekcja usług DI, do której rejestrowane są dostawcy. + IServiceCollection Services { get; } + } +} diff --git a/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingProvider.cs b/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingProvider.cs index 0b1d995..0d97f9c 100644 --- a/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingProvider.cs +++ b/src/TailoredApps.Shared.ExceptionHandling/Interfaces/IExceptionHandlingProvider.cs @@ -1,12 +1,16 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding; -using System; -using TailoredApps.Shared.ExceptionHandling.Model; - -namespace TailoredApps.Shared.ExceptionHandling.Interfaces -{ - public interface IExceptionHandlingProvider - { - ExceptionHandlingResultModel Response(Exception exception); - ExceptionHandlingResultModel Response(ModelStateDictionary modelState); - } -} +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System; +using TailoredApps.Shared.ExceptionHandling.Model; + +namespace TailoredApps.Shared.ExceptionHandling.Interfaces +{ + /// Interfejs dostawcy obsługi wyjątków — mapuje wyjątki na modele odpowiedzi HTTP. + public interface IExceptionHandlingProvider + { + /// Tworzy model odpowiedzi dla danego wyjątku. + ExceptionHandlingResultModel Response(Exception exception); + + /// Tworzy model odpowiedzi dla błędów walidacji modelu. + ExceptionHandlingResultModel Response(ModelStateDictionary modelState); + } +} From 7e6ffd965c6a8cdbc7e7ddd2e280d30c5c461095 Mon Sep 17 00:00:00 2001 From: Szeregowy Date: Tue, 24 Mar 2026 16:59:26 +0100 Subject: [PATCH 3/4] =?UTF-8?q?ci:=20trigger=20build=20dla=20commit=C3=B3w?= =?UTF-8?q?=20c0e15c2=20+=20d9703b2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 5affef12436047e33a13944def026d228d8f241d Mon Sep 17 00:00:00 2001 From: Szeregowy Date: Tue, 24 Mar 2026 17:13:44 +0100 Subject: [PATCH 4/4] =?UTF-8?q?test:=20ServiceCaller=20unit=20testy=20?= =?UTF-8?q?=E2=80=94=20weryfikacja=20podpis=C3=B3w=20bez=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nowy plik ServiceCallerUnitTests.cs — ~45 testów dla czystych funkcji: - PayUServiceCaller.VerifySignature: MD5, SHA256, SHA-256 alias, błędny hash, brakujący segment, pusty string - HotPayServiceCaller.VerifyNotification: poprawny hash, zły hash, zły klucz, inny status - PayNowServiceCaller.VerifySignature: poprawny HMAC, invalid, pusty, zły klucz - RevolutServiceCaller.VerifyWebhookSignature: valid, zły timestamp, invalid sig, bez v1= prefix, zły klucz - AdyenServiceCaller.VerifyNotificationHmac: valid, invalid, nieprawidłowy hex key, zły klucz, inny payload - TpayServiceCaller.VerifyNotification: valid SHA256, invalid, zły kod, pusty - Przelewy24ServiceCaller.ComputeSign + VerifyNotification: poprawny SHA384, różne inputy, valid notify, zły sign, brak sign, złe JSON --- .../ServiceCallerUnitTests.cs | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 tests/TailoredApps.Shared.Payments.Tests/ServiceCallerUnitTests.cs diff --git a/tests/TailoredApps.Shared.Payments.Tests/ServiceCallerUnitTests.cs b/tests/TailoredApps.Shared.Payments.Tests/ServiceCallerUnitTests.cs new file mode 100644 index 0000000..0772dcb --- /dev/null +++ b/tests/TailoredApps.Shared.Payments.Tests/ServiceCallerUnitTests.cs @@ -0,0 +1,510 @@ +using System; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Moq; +using TailoredApps.Shared.Payments.Provider.Adyen; +using TailoredApps.Shared.Payments.Provider.HotPay; +using TailoredApps.Shared.Payments.Provider.PayNow; +using TailoredApps.Shared.Payments.Provider.PayU; +using TailoredApps.Shared.Payments.Provider.Przelewy24; +using TailoredApps.Shared.Payments.Provider.Revolut; +using TailoredApps.Shared.Payments.Provider.Tpay; +using Xunit; + +namespace TailoredApps.Shared.Payments.Tests; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +file static class CallerHelper +{ + public static IHttpClientFactory DummyFactory() + { + var mock = new Mock(); + mock.Setup(f => f.CreateClient(It.IsAny())).Returns(new HttpClient()); + return mock.Object; + } +} + +// ─── PayUServiceCaller ──────────────────────────────────────────────────────── + +/// Unit testy dla PayUServiceCaller — czyste funkcje (bez HTTP). +public class PayUServiceCallerTests +{ + private static PayUServiceCaller Build(string signatureKey) => + new(Options.Create(new PayUServiceOptions + { + SignatureKey = signatureKey, + ServiceUrl = "https://secure.snd.payu.com", + }), CallerHelper.DummyFactory()); + + [Fact] + public void VerifySignature_MD5_Valid() + { + const string body = "{\"order\":{\"status\":\"COMPLETED\"}}"; + const string key = "test_sig_key"; + var caller = Build(key); + var hash = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(body + key))).ToLowerInvariant(); + var sig = $"sender=checkout;signature={hash};algorithm=MD5;content=DOCUMENT"; + Assert.True(caller.VerifySignature(body, sig)); + } + + [Fact] + public void VerifySignature_SHA256_Valid() + { + const string body = "{\"order\":{\"status\":\"COMPLETED\"}}"; + const string key = "test_sig_key"; + var caller = Build(key); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body + key))).ToLowerInvariant(); + var sig = $"sender=checkout;signature={hash};algorithm=SHA256;content=DOCUMENT"; + Assert.True(caller.VerifySignature(body, sig)); + } + + [Fact] + public void VerifySignature_SHA_256_Alias_Valid() + { + const string body = "{\"data\":\"test\"}"; + const string key = "key123"; + var caller = Build(key); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body + key))).ToLowerInvariant(); + var sig = $"sender=checkout;signature={hash};algorithm=SHA-256;content=DOCUMENT"; + Assert.True(caller.VerifySignature(body, sig)); + } + + [Fact] + public void VerifySignature_WrongHash_ReturnsFalse() + { + var caller = Build("correct_key"); + var sig = "sender=checkout;signature=deadbeef00000000000000000000000000000000;algorithm=MD5;content=DOCUMENT"; + Assert.False(caller.VerifySignature("{}", sig)); + } + + [Fact] + public void VerifySignature_MissingSignaturePart_ReturnsFalse() + { + var caller = Build("key"); + Assert.False(caller.VerifySignature("{}", "sender=checkout;algorithm=MD5")); + } + + [Fact] + public void VerifySignature_EmptyString_ReturnsFalse() + { + var caller = Build("key"); + Assert.False(caller.VerifySignature("{}", "")); + } +} + +// ─── HotPayServiceCaller ────────────────────────────────────────────────────── + +/// Unit testy dla HotPayServiceCaller — czyste funkcje (bez HTTP). +public class HotPayServiceCallerTests +{ + private static HotPayServiceCaller Build(string secretHash) => + new(Options.Create(new HotPayServiceOptions + { + SecretHash = secretHash, + ServiceUrl = "https://platnosci.hotpay.pl", + ReturnUrl = "https://example.com/return", + }), CallerHelper.DummyFactory()); + + [Fact] + public void VerifyNotification_ValidHash_ReturnsTrue() + { + const string secret = "hotpay_secret"; + var caller = Build(secret); + const string kwota = "9.99"; + const string id = "pay_123"; + const string status = "SUCCESS"; + var data = $"{secret};{kwota};{id};{status}"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant(); + Assert.True(caller.VerifyNotification(hash, kwota, id, status)); + } + + [Fact] + public void VerifyNotification_InvalidHash_ReturnsFalse() + { + var caller = Build("secret"); + Assert.False(caller.VerifyNotification("badhash", "9.99", "pay_1", "SUCCESS")); + } + + [Fact] + public void VerifyNotification_WrongSecret_ReturnsFalse() + { + var caller = Build("wrong_secret"); + const string data = "correct_secret;9.99;pay_1;SUCCESS"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant(); + Assert.False(caller.VerifyNotification(hash, "9.99", "pay_1", "SUCCESS")); + } + + [Fact] + public void VerifyNotification_DifferentStatus_HashMismatch_ReturnsFalse() + { + const string secret = "sec"; + var caller = Build(secret); + var correctData = $"{secret};10.00;id1;SUCCESS"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(correctData))).ToLowerInvariant(); + Assert.False(caller.VerifyNotification(hash, "10.00", "id1", "FAILURE")); + } +} + +// ─── PayNowServiceCaller ────────────────────────────────────────────────────── + +/// Unit testy dla PayNowServiceCaller — czyste funkcje (bez HTTP). +public class PayNowServiceCallerTests +{ + private static PayNowServiceCaller Build(string sigKey) => + new(Options.Create(new PayNowServiceOptions + { + SignatureKey = sigKey, + ApiKey = "api_key", + ServiceUrl = "https://api.sandbox.paynow.pl", + }), CallerHelper.DummyFactory()); + + [Fact] + public void VerifySignature_ValidHmac_ReturnsTrue() + { + const string key = "paynow_sig_key"; + const string body = "{\"paymentId\":\"pn_1\",\"status\":\"CONFIRMED\"}"; + var caller = Build(key); + var computed = Convert.ToBase64String(HMACSHA256.HashData( + Encoding.UTF8.GetBytes(key), + Encoding.UTF8.GetBytes(body))); + Assert.True(caller.VerifySignature(body, computed)); + } + + [Fact] + public void VerifySignature_InvalidHmac_ReturnsFalse() + { + var caller = Build("key123"); + Assert.False(caller.VerifySignature("{}", "invalidsig==")); + } + + [Fact] + public void VerifySignature_EmptySignature_ReturnsFalse() + { + var caller = Build("key"); + Assert.False(caller.VerifySignature("{}", "")); + } + + [Fact] + public void VerifySignature_WrongKey_ReturnsFalse() + { + const string body = "{\"data\":\"test\"}"; + var callerCorrect = Build("correct_key"); + var hmac = Convert.ToBase64String(HMACSHA256.HashData( + Encoding.UTF8.GetBytes("correct_key"), + Encoding.UTF8.GetBytes(body))); + var callerWrong = Build("wrong_key"); + Assert.False(callerWrong.VerifySignature(body, hmac)); + } +} + +// ─── RevolutServiceCaller ───────────────────────────────────────────────────── + +/// Unit testy dla RevolutServiceCaller — czyste funkcje (bez HTTP). +public class RevolutServiceCallerTests +{ + private static RevolutServiceCaller Build(string webhookSecret) => + new(Options.Create(new RevolutServiceOptions + { + WebhookSecret = webhookSecret, + ApiKey = "sk_sandbox", + ApiUrl = "https://sandbox-merchant.revolut.com/api", + }), CallerHelper.DummyFactory()); + + private static string ComputeRevolutSig(string secret, string timestamp, string payload) + { + var signed = $"v1:{timestamp}.{payload}"; + var hex = Convert.ToHexString(HMACSHA256.HashData( + Encoding.UTF8.GetBytes(secret), + Encoding.UTF8.GetBytes(signed))).ToLowerInvariant(); + return $"v1={hex}"; + } + + [Fact] + public void VerifyWebhookSignature_Valid_ReturnsTrue() + { + const string secret = "revolut_webhook_secret"; + const string ts = "1711234567"; + const string body = "{\"event\":\"ORDER_COMPLETED\"}"; + var sig = ComputeRevolutSig(secret, ts, body); + var caller = Build(secret); + Assert.True(caller.VerifyWebhookSignature(body, ts, sig)); + } + + [Fact] + public void VerifyWebhookSignature_WrongTimestamp_ReturnsFalse() + { + const string secret = "revolut_webhook_secret"; + const string body = "{\"event\":\"ORDER_COMPLETED\"}"; + var sig = ComputeRevolutSig(secret, "1111111111", body); + var caller = Build(secret); + Assert.False(caller.VerifyWebhookSignature(body, "9999999999", sig)); + } + + [Fact] + public void VerifyWebhookSignature_InvalidSignature_ReturnsFalse() + { + var caller = Build("secret"); + Assert.False(caller.VerifyWebhookSignature("{}", "123", "v1=badhex")); + } + + [Fact] + public void VerifyWebhookSignature_NoV1Prefix_StillVerifies() + { + const string secret = "sec"; + const string ts = "12345"; + const string body = "{\"test\":1}"; + var signed = $"v1:{ts}.{body}"; + var hex = Convert.ToHexString(HMACSHA256.HashData( + Encoding.UTF8.GetBytes(secret), + Encoding.UTF8.GetBytes(signed))).ToLowerInvariant(); + // Without v1= prefix + var caller = Build(secret); + Assert.True(caller.VerifyWebhookSignature(body, ts, hex)); + } + + [Fact] + public void VerifyWebhookSignature_WrongSecret_ReturnsFalse() + { + const string ts = "1234"; + const string body = "{\"ev\":\"x\"}"; + var sig = ComputeRevolutSig("correct_secret", ts, body); + var caller = Build("wrong_secret"); + Assert.False(caller.VerifyWebhookSignature(body, ts, sig)); + } +} + +// ─── AdyenServiceCaller ─────────────────────────────────────────────────────── + +/// Unit testy dla AdyenServiceCaller — czyste funkcje (bez HTTP). +public class AdyenServiceCallerTests +{ + private static AdyenServiceCaller Build(string hmacKeyHex) => + new(Options.Create(new AdyenServiceOptions + { + ApiKey = "AQE...", + MerchantAccount = "TestMerchant", + NotificationHmacKey = hmacKeyHex, + CheckoutUrl = "https://checkout-test.adyen.com/v71", + Environment = "test", + }), CallerHelper.DummyFactory()); + + private static (string hex, string b64) ComputeAdyenHmac(string hexKey, string payload) + { + var keyBytes = Convert.FromHexString(hexKey); + var dataBytes = Encoding.UTF8.GetBytes(payload); + var raw = HMACSHA256.HashData(keyBytes, dataBytes); + return (Convert.ToHexString(raw).ToLowerInvariant(), Convert.ToBase64String(raw)); + } + + [Fact] + public void VerifyNotificationHmac_Valid_ReturnsTrue() + { + const string hexKey = "4142434445464748494a4b4c4d4e4f50"; // 16 bytes + const string payload = "{\"notif\":\"test\"}"; + var (_, b64) = ComputeAdyenHmac(hexKey, payload); + var caller = Build(hexKey); + Assert.True(caller.VerifyNotificationHmac(payload, b64)); + } + + [Fact] + public void VerifyNotificationHmac_InvalidSig_ReturnsFalse() + { + var caller = Build("4142434445464748494a4b4c4d4e4f50"); + Assert.False(caller.VerifyNotificationHmac("{}", "badsignature==")); + } + + [Fact] + public void VerifyNotificationHmac_InvalidHexKey_ReturnsFalse() + { + var caller = Build("not-valid-hex!"); + Assert.False(caller.VerifyNotificationHmac("{}", "anything==")); + } + + [Fact] + public void VerifyNotificationHmac_WrongKey_ReturnsFalse() + { + const string payload = "{\"data\":\"abc\"}"; + var (_, b64) = ComputeAdyenHmac("4142434445464748494a4b4c4d4e4f50", payload); + var caller = Build("5152535455565758595a5b5c5d5e5f60"); // different key + Assert.False(caller.VerifyNotificationHmac(payload, b64)); + } + + [Fact] + public void VerifyNotificationHmac_DifferentPayload_ReturnsFalse() + { + const string hexKey = "4142434445464748494a4b4c4d4e4f50"; + var (_, b64) = ComputeAdyenHmac(hexKey, "{\"original\":true}"); + var caller = Build(hexKey); + Assert.False(caller.VerifyNotificationHmac("{\"tampered\":true}", b64)); + } +} + +// ─── TpayServiceCaller ──────────────────────────────────────────────────────── + +/// Unit testy dla TpayServiceCaller — czyste funkcje (bez HTTP). +public class TpayServiceCallerTests +{ + private static TpayServiceCaller Build(string securityCode) => + new(Options.Create(new TpayServiceOptions + { + SecurityCode = securityCode, + ClientId = "client_1", + ClientSecret = "secret", + ServiceUrl = "https://openapi.sandbox.tpay.com", + }), CallerHelper.DummyFactory()); + + [Fact] + public void VerifyNotification_ValidSig_ReturnsTrue() + { + const string code = "tpay_security"; + const string body = "{\"id\":\"txn_1\",\"status\":\"paid\"}"; + var caller = Build(code); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body + code))).ToLowerInvariant(); + Assert.True(caller.VerifyNotification(body, hash)); + } + + [Fact] + public void VerifyNotification_InvalidSig_ReturnsFalse() + { + var caller = Build("tpay_sec"); + Assert.False(caller.VerifyNotification("{}", "badsig")); + } + + [Fact] + public void VerifyNotification_WrongCode_ReturnsFalse() + { + const string body = "{\"status\":\"paid\"}"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body + "correct_code"))).ToLowerInvariant(); + var caller = Build("wrong_code"); + Assert.False(caller.VerifyNotification(body, hash)); + } + + [Fact] + public void VerifyNotification_EmptySig_ReturnsFalse() + { + var caller = Build("code"); + Assert.False(caller.VerifyNotification("{}", "")); + } +} + +// ─── Przelewy24ServiceCaller ────────────────────────────────────────────────── + +/// Unit testy dla Przelewy24ServiceCaller — czyste funkcje (bez HTTP). +public class Przelewy24ServiceCallerTests +{ + private static Przelewy24ServiceCaller Build(string crcKey, int merchantId = 12345) => + new(Options.Create(new Przelewy24ServiceOptions + { + CrcKey = crcKey, + MerchantId = merchantId, + PosId = merchantId, + ApiKey = "api_key", + ServiceUrl = "https://sandbox.przelewy24.pl", + NotifyUrl = "https://example.com/notify", + ReturnUrl = "https://example.com/return", + }), CallerHelper.DummyFactory()); + + private static string ComputeP24Sign(string sessionId, int merchantId, long amount, string currency, string crcKey) + { + var json = JsonSerializer.Serialize(new + { + sessionId, + merchantId, + amount, + currency, + crc = crcKey, + }); + return Convert.ToHexString(SHA384.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant(); + } + + [Fact] + public void ComputeSign_ReturnsCorrectSHA384() + { + const string crc = "p24_crc_key"; + var caller = Build(crc, 12345); + var sign = caller.ComputeSign("sess_1", 12345, 999, "PLN"); + var expected = ComputeP24Sign("sess_1", 12345, 999, "PLN", crc); + Assert.Equal(expected, sign); + } + + [Fact] + public void ComputeSign_DifferentInputs_DifferentSigns() + { + const string crc = "crc"; + var caller = Build(crc); + var s1 = caller.ComputeSign("sess_1", 12345, 999, "PLN"); + var s2 = caller.ComputeSign("sess_2", 12345, 999, "PLN"); + Assert.NotEqual(s1, s2); + } + + [Fact] + public void VerifyNotification_ValidSign_ReturnsTrue() + { + const string crc = "p24crc"; + const int merchant = 12345; + var caller = Build(crc, merchant); + + const string sessionId = "test_sess"; + const int orderId = 99; + const long amount = 1000L; + const string currency = "PLN"; + + var json = JsonSerializer.Serialize(new + { + sessionId, + orderId, + merchantId = merchant, + amount, + currency, + crc, + }); + var sign = Convert.ToHexString(SHA384.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant(); + + var body = JsonSerializer.Serialize(new + { + sessionId, + orderId, + merchantId = merchant, + amount, + currency, + sign, + }); + + Assert.True(caller.VerifyNotification(body)); + } + + [Fact] + public void VerifyNotification_WrongSign_ReturnsFalse() + { + var caller = Build("crc"); + var body = JsonSerializer.Serialize(new + { + sessionId = "s", + orderId = 1, + merchantId = 12345, + amount = 100L, + currency = "PLN", + sign = "wrongsignature", + }); + Assert.False(caller.VerifyNotification(body)); + } + + [Fact] + public void VerifyNotification_MissingSign_ReturnsFalse() + { + var caller = Build("crc"); + var body = JsonSerializer.Serialize(new { sessionId = "s", orderId = 1 }); + Assert.False(caller.VerifyNotification(body)); + } + + [Fact] + public void VerifyNotification_MalformedJson_ReturnsFalse() + { + var caller = Build("crc"); + Assert.False(caller.VerifyNotification("not-json-at-all")); + } +}