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);
+ }
+}
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/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"));
+ }
+}
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